diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7ea974c8..3c4c780e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -72,9 +72,9 @@ jobs: - name: Setup workspace run: make setup - - name: Run release-ci pipeline + - name: Run release pipeline run: | - make release-ci \ + make release \ RELEASE_PHASE=validate,version,build,publish \ VERSION="${{ steps.release.outputs.version }}" \ TAG="${{ steps.release.outputs.tag }}" \ diff --git a/Makefile b/Makefile index 8c582652..9e2857aa 100644 --- a/Makefile +++ b/Makefile @@ -22,6 +22,7 @@ PUSH ?= VERSION ?= TAG ?= BUMP ?= +RELEASE_DEV_SUFFIX ?= 0 CREATE_BRANCHES ?= 1 PR_ACTION ?= status PR_BASE ?= main @@ -36,6 +37,8 @@ PR_DELETE_BRANCH ?= 0 PR_CHECKS_STRICT ?= 0 PR_RELEASE_ON_MERGE ?= 1 PR_INCLUDE_ROOT ?= 1 +PR_BRANCH ?= 0.11.0-dev +PR_CHECKPOINT ?= 1 Q := @ ifdef VERBOSE @@ -134,7 +137,7 @@ if [ -n "$$residual_venvs" ]; then \ fi endef -.PHONY: help setup upgrade build check security format docs test validate typings clean release release-ci pr +.PHONY: help setup upgrade build check security format docs test validate typings clean release pr help: ## Show simple workspace verbs $(Q)echo "FLEXT Workspace" @@ -153,7 +156,6 @@ help: ## Show simple workspace verbs $(Q)echo " test Run tests only in all projects" $(Q)echo " validate Run validate gates (FIX=1 auto-fix, VALIDATE_SCOPE=workspace for repo-level)" $(Q)echo " release Interactive workspace release orchestration" - $(Q)echo " release-ci Non-interactive release run for CI/tag workflows" $(Q)echo " pr Manage PRs for selected projects" $(Q)echo " typings Stub supply-chain + typing report (PROJECT/PROJECTS to scope)" $(Q)echo " clean Clean all projects" @@ -173,6 +175,7 @@ help: ## Show simple workspace verbs $(Q)echo " DRY_RUN=1 Print plan, do not tag/push" $(Q)echo " PUSH=1 Push release commit/tag" $(Q)echo " VERSION= TAG=v BUMP=patch Release controls" + $(Q)echo " RELEASE_DEV_SUFFIX=0|1 Append -dev during release version phase" $(Q)echo " CREATE_BRANCHES=1|0 Create release branches in workspace + projects" $(Q)echo " PR_ACTION=status|create|view|checks|merge|close" $(Q)echo " PR_BASE=main PR_HEAD= PR_NUMBER= PR_DRAFT=0|1" @@ -181,6 +184,7 @@ help: ## Show simple workspace verbs $(Q)echo " PR_CHECKS_STRICT=0|1 checks action strict failure toggle" $(Q)echo " PR_RELEASE_ON_MERGE=0|1 merge action: dispatch release workflow" $(Q)echo " PR_INCLUDE_ROOT=0|1 include root repo in workspace PR automation" + $(Q)echo " PR_BRANCH= PR_CHECKPOINT=0|1 normalize branch + checkpoint before action" $(Q)echo " DEPS_REPORT=0 Skip dependency report after upgrade/typings" $(Q)echo "" $(Q)echo "Examples:" @@ -192,7 +196,7 @@ help: ## Show simple workspace verbs $(Q)echo " make test PROJECT=flext-api PYTEST_ARGS=\"-k unit\" FAIL_FAST=1" $(Q)echo " make validate VALIDATE_SCOPE=workspace" $(Q)echo " make release BUMP=minor" - $(Q)echo " make release-ci VERSION=0.11.0 TAG=v0.11.0 RELEASE_PHASE=all" + $(Q)echo " make release INTERACTIVE=0 CREATE_BRANCHES=0 VERSION=0.11.0 TAG=v0.11.0 RELEASE_PHASE=all" $(Q)echo " make pr PROJECT=flext-core PR_ACTION=status" $(Q)echo " make pr PROJECT=flext-core PR_ACTION=create PR_TITLE='release: 0.11.0-dev'" $(Q)echo " NOTE: External projects (not in .gitmodules) require manual clone." @@ -433,6 +437,7 @@ release: ## Interactive workspace release orchestration --root "$(CURDIR)" \ --phase "$(RELEASE_PHASE)" \ --interactive "$(INTERACTIVE)" \ + --dev-suffix "$(RELEASE_DEV_SUFFIX)" \ --create-branches "$(CREATE_BRANCHES)" \ --projects $(SELECTED_PROJECTS) \ $(if $(DRY_RUN),--dry-run "$(DRY_RUN)",) \ @@ -441,58 +446,29 @@ release: ## Interactive workspace release orchestration $(if $(TAG),--tag "$(TAG)",) \ $(if $(BUMP),--bump "$(BUMP)",) -release-ci: ## Non-interactive release run for CI/tag workflows - $(Q)$(ENSURE_NO_PROJECT_CONFLICT) - $(Q)$(ENFORCE_WORKSPACE_VENV) - $(Q)$(ENSURE_SELECTED_PROJECTS) - $(Q)$(ENSURE_PROJECTS_EXIST) - $(Q)python scripts/release/run.py \ - --root "$(CURDIR)" \ - --phase "$(RELEASE_PHASE)" \ - --interactive 0 \ - --create-branches 0 \ - --projects $(SELECTED_PROJECTS) \ - $(if $(DRY_RUN),--dry-run "$(DRY_RUN)",) \ - $(if $(PUSH),--push "$(PUSH)",) \ - $(if $(VERSION),--version "$(VERSION)",) \ - $(if $(TAG),--tag "$(TAG)",) \ - $(if $(BUMP),--bump "$(BUMP)",) - pr: ## Manage pull requests for selected projects $(Q)$(ENSURE_NO_PROJECT_CONFLICT) $(Q)$(ENSURE_SELECTED_PROJECTS) $(Q)$(ENSURE_PROJECTS_EXIST) - $(Q)$(ORCHESTRATOR) --verb pr \ - $(if $(filter 1,$(FAIL_FAST)),--fail-fast) \ - --make-arg "PR_ACTION=$(PR_ACTION)" \ - --make-arg "PR_BASE=$(PR_BASE)" \ - $(if $(PR_HEAD),--make-arg "PR_HEAD=$(PR_HEAD)",) \ - $(if $(PR_NUMBER),--make-arg "PR_NUMBER=$(PR_NUMBER)",) \ - $(if $(PR_TITLE),--make-arg "PR_TITLE=$(PR_TITLE)",) \ - $(if $(PR_BODY),--make-arg "PR_BODY=$(PR_BODY)",) \ - --make-arg "PR_DRAFT=$(PR_DRAFT)" \ - --make-arg "PR_MERGE_METHOD=$(PR_MERGE_METHOD)" \ - --make-arg "PR_AUTO=$(PR_AUTO)" \ - --make-arg "PR_DELETE_BRANCH=$(PR_DELETE_BRANCH)" \ - --make-arg "PR_CHECKS_STRICT=$(PR_CHECKS_STRICT)" \ - --make-arg "PR_RELEASE_ON_MERGE=$(PR_RELEASE_ON_MERGE)" \ - $(SELECTED_PROJECTS) - $(Q)if [ "$(PR_INCLUDE_ROOT)" = "1" ]; then \ - python scripts/github/pr_manager.py \ - --repo-root "$(CURDIR)" \ - --action "$(PR_ACTION)" \ - --base "$(PR_BASE)" \ - $(if $(PR_HEAD),--head "$(PR_HEAD)",) \ - $(if $(PR_NUMBER),--number "$(PR_NUMBER)",) \ - $(if $(PR_TITLE),--title "$(PR_TITLE)",) \ - $(if $(PR_BODY),--body "$(PR_BODY)",) \ - --draft "$(PR_DRAFT)" \ - --merge-method "$(PR_MERGE_METHOD)" \ - --auto "$(PR_AUTO)" \ - --delete-branch "$(PR_DELETE_BRANCH)" \ - --checks-strict "$(PR_CHECKS_STRICT)" \ - --release-on-merge "$(PR_RELEASE_ON_MERGE)"; \ - fi + $(Q)python scripts/github/pr_workspace.py \ + --workspace-root "$(CURDIR)" \ + $(foreach proj,$(SELECTED_PROJECTS),--project "$(proj)") \ + --include-root "$(PR_INCLUDE_ROOT)" \ + --branch "$(PR_BRANCH)" \ + --checkpoint "$(PR_CHECKPOINT)" \ + --fail-fast "$(if $(filter 1,$(FAIL_FAST)),1,0)" \ + --pr-action "$(PR_ACTION)" \ + --pr-base "$(PR_BASE)" \ + $(if $(PR_HEAD),--pr-head "$(PR_HEAD)",) \ + $(if $(PR_NUMBER),--pr-number "$(PR_NUMBER)",) \ + $(if $(PR_TITLE),--pr-title "$(PR_TITLE)",) \ + $(if $(PR_BODY),--pr-body "$(PR_BODY)",) \ + --pr-draft "$(PR_DRAFT)" \ + --pr-merge-method "$(PR_MERGE_METHOD)" \ + --pr-auto "$(PR_AUTO)" \ + --pr-delete-branch "$(PR_DELETE_BRANCH)" \ + --pr-checks-strict "$(PR_CHECKS_STRICT)" \ + --pr-release-on-merge "$(PR_RELEASE_ON_MERGE)" security: ## Run all security checks in all projects $(Q)$(ENSURE_NO_PROJECT_CONFLICT) diff --git a/flexcore b/flexcore index 14f50568..f927b8d6 160000 --- a/flexcore +++ b/flexcore @@ -1 +1 @@ -Subproject commit 14f5056857ddf7a7d9ae5a658ccc4a52d513b8c5 +Subproject commit f927b8d627fb24ff2bc68672fadb0b9ec72e8da3 diff --git a/flext-api b/flext-api index a7078d7d..aff97f33 160000 --- a/flext-api +++ b/flext-api @@ -1 +1 @@ -Subproject commit a7078d7ddb93c365f8729b23710379cd5e769d0c +Subproject commit aff97f33592a551acfa998da76ae2f54325b8c3e diff --git a/flext-auth b/flext-auth index 3a69c72c..a53c87bf 160000 --- a/flext-auth +++ b/flext-auth @@ -1 +1 @@ -Subproject commit 3a69c72cd7c39473006a6c296b462579970c490d +Subproject commit a53c87bf8a58c3f2844a33a5acd0f7430be2553a diff --git a/flext-cli b/flext-cli index a07e51d8..f38fe7bf 160000 --- a/flext-cli +++ b/flext-cli @@ -1 +1 @@ -Subproject commit a07e51d8f7a791a204af4db53e30ee4376ecda42 +Subproject commit f38fe7bf97c43f9f4db6a4254aa3ac4ca196c59b diff --git a/flext-core b/flext-core index 22daaf34..826e2e36 160000 --- a/flext-core +++ b/flext-core @@ -1 +1 @@ -Subproject commit 22daaf349bb6ef4c7328f8ab2b4ede026cfe4d52 +Subproject commit 826e2e369e575c977f03f714840c47674cbbf4cc diff --git a/flext-db-oracle b/flext-db-oracle index e1ad2f9a..2fce1377 160000 --- a/flext-db-oracle +++ b/flext-db-oracle @@ -1 +1 @@ -Subproject commit e1ad2f9a2d6bb530bef96a8042ff889e3f287646 +Subproject commit 2fce137797a4178a3263e4b591f9d6e60a866d6f diff --git a/flext-dbt-ldap b/flext-dbt-ldap index 4887fb02..3d8c7b98 160000 --- a/flext-dbt-ldap +++ b/flext-dbt-ldap @@ -1 +1 @@ -Subproject commit 4887fb02f214faffeead37c74cc8003d9a10700b +Subproject commit 3d8c7b98954440cec7d0ccbc02e992a8e480e7c6 diff --git a/flext-dbt-ldif b/flext-dbt-ldif index fe5655c6..858dbf55 160000 --- a/flext-dbt-ldif +++ b/flext-dbt-ldif @@ -1 +1 @@ -Subproject commit fe5655c642819c96932a13720d89e576eea625e0 +Subproject commit 858dbf55a72df900786edffb9916c2037c5a01f2 diff --git a/flext-dbt-oracle b/flext-dbt-oracle index 15b14ae5..79fbbcc8 160000 --- a/flext-dbt-oracle +++ b/flext-dbt-oracle @@ -1 +1 @@ -Subproject commit 15b14ae532f7059690fd945e3ebbd803e023b43d +Subproject commit 79fbbcc8014cd378243d46d6adf7ce0b06ca4b80 diff --git a/flext-dbt-oracle-wms b/flext-dbt-oracle-wms index ef4cc247..1fef95ad 160000 --- a/flext-dbt-oracle-wms +++ b/flext-dbt-oracle-wms @@ -1 +1 @@ -Subproject commit ef4cc2471d25e0d691fb300c6bb35f6a037a848c +Subproject commit 1fef95ad358193e84282b708f3bd4656094c59e6 diff --git a/flext-grpc b/flext-grpc index 126a0dd3..ecf8c18f 160000 --- a/flext-grpc +++ b/flext-grpc @@ -1 +1 @@ -Subproject commit 126a0dd3ebf578cd56c1391d9979551b26e2ca8b +Subproject commit ecf8c18f74712aea0c67ea8b33fbc99f01a95b76 diff --git a/flext-ldap b/flext-ldap index f99e9e3c..aa0a6397 160000 --- a/flext-ldap +++ b/flext-ldap @@ -1 +1 @@ -Subproject commit f99e9e3c43a0fd8f746638c918d398ce8aaffdab +Subproject commit aa0a639705e16cfd399aa68e58763d8f6b43d380 diff --git a/flext-ldif b/flext-ldif index f8e80e40..2e2f7467 160000 --- a/flext-ldif +++ b/flext-ldif @@ -1 +1 @@ -Subproject commit f8e80e40ff73c47b7dce0b9df8f0d95505f47a64 +Subproject commit 2e2f7467a876f3be85ba86e897a405516d0ce104 diff --git a/flext-meltano b/flext-meltano index 7fadb374..8ca8f6fd 160000 --- a/flext-meltano +++ b/flext-meltano @@ -1 +1 @@ -Subproject commit 7fadb37478b1d2d629f07093a0ddb4e1d8a98d98 +Subproject commit 8ca8f6fdece91d000fe01d77c09b58da541a8fc2 diff --git a/flext-observability b/flext-observability index 2d10a1f0..9de29722 160000 --- a/flext-observability +++ b/flext-observability @@ -1 +1 @@ -Subproject commit 2d10a1f0e87502bf0c782e17bf37639272d967a9 +Subproject commit 9de297227b44039e4a759b110533392264c8bcd0 diff --git a/flext-oracle-oic b/flext-oracle-oic index cc2e1617..8db1a256 160000 --- a/flext-oracle-oic +++ b/flext-oracle-oic @@ -1 +1 @@ -Subproject commit cc2e161734520bae4420c0796e85bb6e7b991db4 +Subproject commit 8db1a256f920080f34101d7783ad5b28349ecb7e diff --git a/flext-oracle-wms b/flext-oracle-wms index 93739180..f2e92699 160000 --- a/flext-oracle-wms +++ b/flext-oracle-wms @@ -1 +1 @@ -Subproject commit 9373918086b265969b728fd2f35aecffb71a18ef +Subproject commit f2e92699ca91a6bf480e3468988309852ed58984 diff --git a/flext-plugin b/flext-plugin index 32231095..992f6c93 160000 --- a/flext-plugin +++ b/flext-plugin @@ -1 +1 @@ -Subproject commit 32231095315848dfa18676b6f21791a39a7eabe6 +Subproject commit 992f6c9350d4c9bf45a09a2d6564c136d6b73a9d diff --git a/flext-quality b/flext-quality index d4dde310..620da0fb 160000 --- a/flext-quality +++ b/flext-quality @@ -1 +1 @@ -Subproject commit d4dde3104bd714f3f46f3a5866663d7124917c57 +Subproject commit 620da0fb48540e79e3f4c13b9e54f59461da8371 diff --git a/flext-tap-ldap b/flext-tap-ldap index 8541c6c4..dcd83d34 160000 --- a/flext-tap-ldap +++ b/flext-tap-ldap @@ -1 +1 @@ -Subproject commit 8541c6c472aac1f3395e291d7d861b32068810f3 +Subproject commit dcd83d340664a5d94f6da995d54e2373a561699f diff --git a/flext-tap-ldif b/flext-tap-ldif index b479b6c9..f7db4292 160000 --- a/flext-tap-ldif +++ b/flext-tap-ldif @@ -1 +1 @@ -Subproject commit b479b6c9fc6c96e73e754bf15c0b5ff6a65ea941 +Subproject commit f7db4292d7bf10dc9b45e1997d95ae692170fbcb diff --git a/flext-tap-oracle b/flext-tap-oracle index 0fd87692..cfe628ab 160000 --- a/flext-tap-oracle +++ b/flext-tap-oracle @@ -1 +1 @@ -Subproject commit 0fd8769249bf3b7d4be3d8e379f4dd0f6b22540b +Subproject commit cfe628ab7e161caa37cfbfde90a0c1ade0900b18 diff --git a/flext-tap-oracle-oic b/flext-tap-oracle-oic index e4e3ae6f..2f2082c1 160000 --- a/flext-tap-oracle-oic +++ b/flext-tap-oracle-oic @@ -1 +1 @@ -Subproject commit e4e3ae6f26fb8ce351936536aaa082734bb5203c +Subproject commit 2f2082c147fcd269f0e8088a58e1d2a63d12e73d diff --git a/flext-tap-oracle-wms b/flext-tap-oracle-wms index c01b262c..b6f21496 160000 --- a/flext-tap-oracle-wms +++ b/flext-tap-oracle-wms @@ -1 +1 @@ -Subproject commit c01b262ca838c14decb92410d2bc523d6c3f3fd6 +Subproject commit b6f214963569649c941b02ca5c3a66c205baca99 diff --git a/flext-target-ldap b/flext-target-ldap index 45bcf0b8..47ba4841 160000 --- a/flext-target-ldap +++ b/flext-target-ldap @@ -1 +1 @@ -Subproject commit 45bcf0b8bb366faad7ed7090613e627b49828bab +Subproject commit 47ba484126af6898339622c058710d0300a838a5 diff --git a/flext-target-ldif b/flext-target-ldif index a4181c28..22c26128 160000 --- a/flext-target-ldif +++ b/flext-target-ldif @@ -1 +1 @@ -Subproject commit a4181c28995ea0fb3ccdb453c1f9d0f1ed4d5a20 +Subproject commit 22c26128a80f493797e73880dd91b8c490064108 diff --git a/flext-target-oracle b/flext-target-oracle index bf05d77a..ffa85004 160000 --- a/flext-target-oracle +++ b/flext-target-oracle @@ -1 +1 @@ -Subproject commit bf05d77a27fb4a3eac8b7e74996deee6968f7e37 +Subproject commit ffa850049b2c5f00ebf17095fa9420fe5c599464 diff --git a/flext-target-oracle-oic b/flext-target-oracle-oic index efc7d135..2ea29910 160000 --- a/flext-target-oracle-oic +++ b/flext-target-oracle-oic @@ -1 +1 @@ -Subproject commit efc7d1354fdd4e355d4667b3f2c7cab031133221 +Subproject commit 2ea299104d57e31f10b2bb1331c757f6bbc4477d diff --git a/flext-target-oracle-wms b/flext-target-oracle-wms index 2ddd8438..3fbb81e1 160000 --- a/flext-target-oracle-wms +++ b/flext-target-oracle-wms @@ -1 +1 @@ -Subproject commit 2ddd8438953882740460a86d5c8953fe5e456419 +Subproject commit 3fbb81e10c85bd9899292f4c4fa11a44aefc7de8 diff --git a/flext-web b/flext-web index 3d3de188..1843ce45 160000 --- a/flext-web +++ b/flext-web @@ -1 +1 @@ -Subproject commit 3d3de188943e99ffffff382f53f36588ac068e06 +Subproject commit 1843ce4515865c7292c2aedd6b9b363bf093d726 diff --git a/libs/versioning.py b/libs/versioning.py new file mode 100644 index 00000000..a5e0cfb3 --- /dev/null +++ b/libs/versioning.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +import re +import tomllib +from pathlib import Path + +import tomlkit +from tomlkit.items import Table + + +SEMVER_RE = re.compile( + r"^(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)$" +) + + +def parse_semver(version: str) -> tuple[int, int, int]: + match = SEMVER_RE.match(version) + if not match: + raise ValueError(f"invalid semver version: {version}") + return ( + int(match.group("major")), + int(match.group("minor")), + int(match.group("patch")), + ) + + +def bump_version(current_version: str, bump: str) -> str: + major, minor, patch = parse_semver(current_version) + if bump == "major": + return f"{major + 1}.0.0" + if bump == "minor": + return f"{major}.{minor + 1}.0" + if bump == "patch": + return f"{major}.{minor}.{patch + 1}" + raise ValueError(f"unsupported bump: {bump}") + + +def release_tag_from_branch(branch: str) -> str | None: + version = branch.removesuffix("-dev") + if SEMVER_RE.fullmatch(version): + return f"v{version}" + match = re.fullmatch(r"release/(?P\d+\.\d+\.\d+)", branch) + if not match: + return None + return f"v{match.group('version')}" + + +def current_workspace_version(root: Path) -> str: + pyproject = root / "pyproject.toml" + data = tomllib.loads(pyproject.read_text(encoding="utf-8")) + project = data.get("project") + if not isinstance(project, dict): + raise RuntimeError("unable to detect [project] section from pyproject.toml") + version = project.get("version") + if not isinstance(version, str) or not version: + raise RuntimeError("unable to detect version from pyproject.toml") + return version.removesuffix("-dev") + + +def replace_project_version(content: str, version: str) -> tuple[str, bool]: + document = tomlkit.parse(content) + project = document.get("project") + if not isinstance(project, Table): + return content, False + current = project.get("version") + if not isinstance(current, str) or not current: + return content, False + _ = parse_semver(current.removesuffix("-dev")) + if current == version: + return content, False + project["version"] = version + updated = tomlkit.dumps(document) + return updated, updated != content diff --git a/poetry.lock b/poetry.lock index a1654ca4..d5113c09 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2520,7 +2520,7 @@ dotenv = ["python-dotenv"] [[package]] name = "flexcore" -version = "0.10.0-dev" +version = "0.10.0" description = "FlexCore - High-Performance Go-Python Hybrid Enterprise Framework" optional = false python-versions = ">=3.13,<3.14" @@ -2539,7 +2539,7 @@ url = "flexcore" [[package]] name = "flext-api" -version = "0.10.0-dev" +version = "0.10.0" description = "FLEXT API - High-Performance REST API with FastAPI" optional = false python-versions = ">=3.13,<3.14" @@ -2574,7 +2574,7 @@ url = "flext-api" [[package]] name = "flext-auth" -version = "0.10.0-dev" +version = "0.10.0" description = "FLEXT Auth - Enterprise Authentication & Authorization Service" optional = false python-versions = ">=3.13,<3.14" @@ -2602,7 +2602,7 @@ url = "flext-auth" [[package]] name = "flext-cli" -version = "0.10.0-dev" +version = "0.10.0" description = "FLEXT CLI - Developer Command Line Interface" optional = false python-versions = ">=3.13,<3.14" @@ -2669,7 +2669,7 @@ url = "flext-core" [[package]] name = "flext-db-oracle" -version = "0.10.0-dev" +version = "0.10.0" description = "FLEXT DB Oracle - Enterprise Oracle Database Operations Library" optional = false python-versions = ">=3.13,<3.14" @@ -2688,7 +2688,7 @@ url = "flext-db-oracle" [[package]] name = "flext-dbt-ldap" -version = "0.10.0-dev" +version = "0.10.0" description = "FLEXT dbt LDAP - dbt Models for LDAP Data Transformation" optional = false python-versions = ">=3.13,<3.14" @@ -2706,7 +2706,7 @@ url = "flext-dbt-ldap" [[package]] name = "flext-dbt-ldif" -version = "0.10.0-dev" +version = "0.10.0" description = "FLEXT dbt LDAP - dbt Models for LDIF Data Transformation" optional = false python-versions = ">=3.13,<3.14" @@ -2724,7 +2724,7 @@ url = "flext-dbt-ldif" [[package]] name = "flext-dbt-oracle" -version = "0.10.0-dev" +version = "0.10.0" description = "FLEXT dbt Oracle - dbt Models for Oracle Database" optional = false python-versions = ">=3.13,<3.14" @@ -2744,7 +2744,7 @@ url = "flext-dbt-oracle" [[package]] name = "flext-dbt-oracle-wms" -version = "0.10.0-dev" +version = "0.10.0" description = "FLEXT DBT Oracle WMS - Oracle WMS data transformation with DBT" optional = false python-versions = ">=3.13,<3.14" @@ -2763,7 +2763,7 @@ url = "flext-dbt-oracle-wms" [[package]] name = "flext-grpc" -version = "0.10.0-dev" +version = "0.10.0" description = "FLEXT gRPC - High-Performance gRPC Services" optional = false python-versions = ">=3.13,<3.14" @@ -2794,7 +2794,7 @@ url = "flext-grpc" [[package]] name = "flext-ldap" -version = "0.10.0-dev" +version = "0.10.0" description = "Enterprise LDAP Operations Library for FLEXT Framework" optional = false python-versions = ">=3.13,<3.14" @@ -2817,7 +2817,7 @@ url = "flext-ldap" [[package]] name = "flext-ldif" -version = "0.10.0-dev" +version = "0.10.0" description = "FLEXT LDIF - Enterprise LDIF Processing Library" optional = false python-versions = ">=3.13,<3.14" @@ -2840,7 +2840,7 @@ url = "flext-ldif" [[package]] name = "flext-meltano" -version = "0.10.0-dev" +version = "0.10.0" description = "FLEXT Meltano - Enterprise Data Integration Platform" optional = false python-versions = ">=3.13,<3.14" @@ -2879,7 +2879,7 @@ url = "flext-meltano" [[package]] name = "flext-observability" -version = "0.10.0-dev" +version = "0.10.0" description = "FLEXT Observability - Enterprise Monitoring, Metrics & Telemetry" optional = false python-versions = ">=3.13,<3.14" @@ -2916,7 +2916,7 @@ url = "flext-observability" [[package]] name = "flext-oracle-oic" -version = "0.10.0-dev" +version = "0.10.0" description = "FLEXT Oracle OIC Extension - Advanced Oracle Integration Cloud Extensions" optional = false python-versions = ">=3.13,<3.14" @@ -2941,7 +2941,7 @@ url = "flext-oracle-oic" [[package]] name = "flext-oracle-wms" -version = "0.10.0-dev" +version = "0.10.0" description = "Enterprise Oracle WMS client library for FLEXT data integration platform" optional = false python-versions = ">=3.13,<3.14" @@ -2961,7 +2961,7 @@ url = "flext-oracle-wms" [[package]] name = "flext-plugin" -version = "0.10.0-dev" +version = "0.10.0" description = "FLEXT Plugin - Plugin System for FLEXT Platform" optional = false python-versions = ">=3.13,<3.14" @@ -2990,7 +2990,7 @@ url = "flext-plugin" [[package]] name = "flext-quality" -version = "0.10.0-dev" +version = "0.10.0" description = "FLEXT Quality - Unified orchestration platform for Claude Code tooling" optional = false python-versions = ">=3.13,<3.14" @@ -3025,7 +3025,7 @@ url = "flext-quality" [[package]] name = "flext-tap-ldap" -version = "0.10.0-dev" +version = "0.10.0" description = "FLEXT Tap LDAP - Singer Tap for LDAP Directory Services" optional = false python-versions = ">=3.13,<3.14" @@ -3043,7 +3043,7 @@ url = "flext-tap-ldap" [[package]] name = "flext-tap-ldif" -version = "0.10.0-dev" +version = "0.10.0" description = "FLEXT Tap LDIF - Singer Tap for LDIF file format data extraction" optional = false python-versions = ">=3.13,<3.14" @@ -3065,7 +3065,7 @@ url = "flext-tap-ldif" [[package]] name = "flext-tap-oracle" -version = "0.10.0-dev" +version = "0.10.0" description = "FLEXT Tap Oracle - Modern Singer Tap for Oracle Database" optional = false python-versions = ">=3.13,<3.14" @@ -3092,7 +3092,7 @@ url = "flext-tap-oracle" [[package]] name = "flext-tap-oracle-oic" -version = "0.10.0-dev" +version = "0.10.0" description = "FLEXT Tap Oracle OIC - Singer Tap for Oracle Integration Cloud" optional = false python-versions = ">=3.13,<3.14" @@ -3112,7 +3112,7 @@ url = "flext-tap-oracle-oic" [[package]] name = "flext-tap-oracle-wms" -version = "0.10.0-dev" +version = "0.10.0" description = "FLEXT Tap Oracle WMS - Singer Tap for Oracle Warehouse Management System" optional = false python-versions = ">=3.13,<3.14" @@ -3132,7 +3132,7 @@ url = "flext-tap-oracle-wms" [[package]] name = "flext-target-ldap" -version = "0.10.0-dev" +version = "0.10.0" description = "FLEXT Target for LDAP directory loading" optional = false python-versions = ">=3.13,<3.14" @@ -3152,7 +3152,7 @@ url = "flext-target-ldap" [[package]] name = "flext-target-ldif" -version = "0.10.0-dev" +version = "0.10.0" description = "FLEXT Target LDIF - Singer Target for LDAP Data Interchange Format (LDIF) output" optional = false python-versions = ">=3.13,<3.14" @@ -3175,7 +3175,7 @@ url = "flext-target-ldif" [[package]] name = "flext-target-oracle" -version = "0.10.0-dev" +version = "0.10.0" description = "FLEXT Target Oracle - Singer Target for Oracle Database Data Loading" optional = false python-versions = ">=3.13,<3.14" @@ -3201,7 +3201,7 @@ url = "flext-target-oracle" [[package]] name = "flext-target-oracle-oic" -version = "0.10.0-dev" +version = "0.10.0" description = "FLEXT Target Oracle OIC - Singer Target for Oracle Integration Cloud" optional = false python-versions = ">=3.13,<3.14" @@ -3230,7 +3230,7 @@ url = "flext-target-oracle-oic" [[package]] name = "flext-target-oracle-wms" -version = "0.10.0-dev" +version = "0.10.0" description = "FLEXT Target Oracle WMS - Singer Target for Oracle WMS Data" optional = false python-versions = ">=3.13,<3.14" @@ -3256,7 +3256,7 @@ url = "flext-target-oracle-wms" [[package]] name = "flext-web" -version = "0.10.0-dev" +version = "0.10.0" description = "FLEXT Web - Modern Web Interface for FLEXT Platform" optional = false python-versions = ">=3.13,<3.14" diff --git a/scripts/github/pr_manager.py b/scripts/github/pr_manager.py index 0c3c53ad..12f9d17c 100644 --- a/scripts/github/pr_manager.py +++ b/scripts/github/pr_manager.py @@ -3,9 +3,16 @@ import argparse import json -import re import subprocess from pathlib import Path +import sys + + +REPO_ROOT = Path(__file__).resolve().parents[2] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from libs.versioning import release_tag_from_branch def _run_capture(command: list[str], cwd: Path) -> str: @@ -29,6 +36,23 @@ def _run_stream(command: list[str], cwd: Path) -> int: return result.returncode +def _run_stream_with_output(command: list[str], cwd: Path) -> tuple[int, str]: + result = subprocess.run( + command, + cwd=cwd, + capture_output=True, + text=True, + check=False, + ) + output_parts = [ + part.strip() for part in (result.stdout, result.stderr) if part.strip() + ] + output = "\n".join(output_parts) + if output: + print(output) + return result.returncode, output + + def _current_branch(repo_root: Path) -> str: return _run_capture(["git", "rev-parse", "--abbrev-ref", "HEAD"], repo_root) @@ -81,13 +105,7 @@ def _selector(pr_number: str, head: str) -> str: def _release_tag_from_head(head: str) -> str | None: - version = head.removesuffix("-dev") - if re.fullmatch(r"\d+\.\d+\.\d+", version): - return f"v{version}" - match = re.fullmatch(r"release/(?P\d+\.\d+\.\d+)", head) - if match: - return f"v{match.group('version')}" - return None + return release_tag_from_branch(head) def _is_workspace_release_repo(repo_root: Path) -> bool: @@ -160,6 +178,10 @@ def _merge_pr( delete_branch: int, release_on_merge: int, ) -> int: + if selector == head and _open_pr_for_head(repo_root, head) is None: + print("status=no-open-pr") + return 0 + command = ["gh", "pr", "merge", selector] merge_flag = { "merge": "--merge", @@ -171,7 +193,14 @@ def _merge_pr( command.append("--auto") if delete_branch == 1: command.append("--delete-branch") - exit_code = _run_stream(command, repo_root) + exit_code, output = _run_stream_with_output(command, repo_root) + if exit_code != 0 and "not mergeable" in output: + update_code, _ = _run_stream_with_output( + ["gh", "pr", "update-branch", selector, "--rebase"], + repo_root, + ) + if update_code == 0: + exit_code, _ = _run_stream_with_output(command, repo_root) if exit_code == 0: print("status=merged") if release_on_merge == 1: diff --git a/scripts/github/pr_workspace.py b/scripts/github/pr_workspace.py new file mode 100644 index 00000000..c2962743 --- /dev/null +++ b/scripts/github/pr_workspace.py @@ -0,0 +1,213 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import subprocess +import time +from pathlib import Path +import sys + + +REPO_ROOT = Path(__file__).resolve().parents[2] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from libs.selection import resolve_projects +from libs.subprocess import run_capture, run_checked + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + _ = parser.add_argument("--workspace-root", type=Path, default=Path(".")) + _ = parser.add_argument("--project", action="append", default=[]) + _ = parser.add_argument("--include-root", type=int, default=1) + _ = parser.add_argument("--branch", default="") + _ = parser.add_argument("--fail-fast", type=int, default=0) + _ = parser.add_argument("--checkpoint", type=int, default=1) + _ = parser.add_argument("--pr-action", default="status") + _ = parser.add_argument("--pr-base", default="main") + _ = parser.add_argument("--pr-head", default="") + _ = parser.add_argument("--pr-number", default="") + _ = parser.add_argument("--pr-title", default="") + _ = parser.add_argument("--pr-body", default="") + _ = parser.add_argument("--pr-draft", default="0") + _ = parser.add_argument("--pr-merge-method", default="squash") + _ = parser.add_argument("--pr-auto", default="0") + _ = parser.add_argument("--pr-delete-branch", default="0") + _ = parser.add_argument("--pr-checks-strict", default="0") + _ = parser.add_argument("--pr-release-on-merge", default="1") + return parser.parse_args() + + +def _repo_display_name(repo_root: Path, workspace_root: Path) -> str: + return workspace_root.name if repo_root == workspace_root else repo_root.name + + +def _has_changes(repo_root: Path) -> bool: + return bool(run_capture(["git", "status", "--porcelain"], cwd=repo_root).strip()) + + +def _current_branch(repo_root: Path) -> str: + return run_capture(["git", "rev-parse", "--abbrev-ref", "HEAD"], cwd=repo_root) + + +def _checkout_branch(repo_root: Path, branch: str) -> None: + if not branch: + return + current = _current_branch(repo_root) + if current == branch: + return + checkout = subprocess.run( + ["git", "checkout", branch], + cwd=repo_root, + check=False, + capture_output=True, + text=True, + ) + if checkout.returncode == 0: + return + detail = (checkout.stderr or checkout.stdout).lower() + if "local changes" in detail or "would be overwritten" in detail: + run_checked(["git", "checkout", "-B", branch], cwd=repo_root) + return + fetch = subprocess.run( + ["git", "fetch", "origin", branch], + cwd=repo_root, + check=False, + capture_output=True, + text=True, + ) + if fetch.returncode == 0: + run_checked( + ["git", "checkout", "-B", branch, f"origin/{branch}"], cwd=repo_root + ) + else: + run_checked(["git", "checkout", "-B", branch], cwd=repo_root) + + +def _checkpoint(repo_root: Path, branch: str) -> None: + if not _has_changes(repo_root): + return + run_checked(["git", "add", "-A"], cwd=repo_root) + staged = run_capture(["git", "diff", "--cached", "--name-only"], cwd=repo_root) + if not staged.strip(): + return + run_checked( + ["git", "commit", "-m", "chore: checkpoint pending 0.11.0-dev changes"], + cwd=repo_root, + ) + push_cmd = ["git", "push", "-u", "origin", branch] if branch else ["git", "push"] + push = subprocess.run( + push_cmd, cwd=repo_root, check=False, capture_output=True, text=True + ) + if push.returncode == 0: + return + if branch: + run_checked(["git", "pull", "--rebase", "origin", branch], cwd=repo_root) + else: + run_checked(["git", "pull", "--rebase"], cwd=repo_root) + run_checked(push_cmd, cwd=repo_root) + + +def _run_pr(repo_root: Path, workspace_root: Path, args: argparse.Namespace) -> int: + report_dir = workspace_root / ".reports" / "workspace" / "pr" + report_dir.mkdir(parents=True, exist_ok=True) + display = _repo_display_name(repo_root, workspace_root) + log_path = report_dir / f"{display}.log" + if repo_root == workspace_root: + command = [ + "python", + "scripts/github/pr_manager.py", + "--repo-root", + str(repo_root), + "--action", + args.pr_action, + "--base", + args.pr_base, + "--draft", + args.pr_draft, + "--merge-method", + args.pr_merge_method, + "--auto", + args.pr_auto, + "--delete-branch", + args.pr_delete_branch, + "--checks-strict", + args.pr_checks_strict, + "--release-on-merge", + args.pr_release_on_merge, + ] + if args.pr_head: + command.extend(["--head", args.pr_head]) + if args.pr_number: + command.extend(["--number", args.pr_number]) + if args.pr_title: + command.extend(["--title", args.pr_title]) + if args.pr_body: + command.extend(["--body", args.pr_body]) + else: + command = [ + "make", + "-C", + str(repo_root), + "pr", + f"PR_ACTION={args.pr_action}", + f"PR_BASE={args.pr_base}", + f"PR_DRAFT={args.pr_draft}", + f"PR_MERGE_METHOD={args.pr_merge_method}", + f"PR_AUTO={args.pr_auto}", + f"PR_DELETE_BRANCH={args.pr_delete_branch}", + f"PR_CHECKS_STRICT={args.pr_checks_strict}", + f"PR_RELEASE_ON_MERGE={args.pr_release_on_merge}", + ] + if args.pr_head: + command.append(f"PR_HEAD={args.pr_head}") + if args.pr_number: + command.append(f"PR_NUMBER={args.pr_number}") + if args.pr_title: + command.append(f"PR_TITLE={args.pr_title}") + if args.pr_body: + command.append(f"PR_BODY={args.pr_body}") + + started = time.monotonic() + with log_path.open("w", encoding="utf-8") as handle: + result = subprocess.run( + command, stdout=handle, stderr=subprocess.STDOUT, check=False + ) + elapsed = int(time.monotonic() - started) + status = "OK" if result.returncode == 0 else "FAIL" + print( + f"[{status}] {display} pr ({elapsed}s) exit={result.returncode} log={log_path}" + ) + return result.returncode + + +def main() -> int: + args = _parse_args() + workspace_root = args.workspace_root.resolve() + projects = resolve_projects(workspace_root, list(args.project)) + repos = [project.path for project in projects] + if args.include_root == 1: + repos.append(workspace_root) + + failures = 0 + for repo_root in repos: + display = _repo_display_name(repo_root, workspace_root) + print(f"[RUN] {display}", flush=True) + _checkout_branch(repo_root, args.branch) + if args.checkpoint == 1: + _checkpoint(repo_root, args.branch) + exit_code = _run_pr(repo_root, workspace_root, args) + if exit_code != 0: + failures += 1 + if args.fail_fast == 1: + break + + total = len(repos) + success = total - failures + print(f"summary total={total} success={success} fail={failures} skip=0") + return 1 if failures else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/release/notes.py b/scripts/release/notes.py index 0fa8c547..5167ffa0 100644 --- a/scripts/release/notes.py +++ b/scripts/release/notes.py @@ -91,7 +91,7 @@ def main() -> int: "", "## Verification", "", - "- make release-ci RELEASE_PHASE=all", + "- make release INTERACTIVE=0 CREATE_BRANCHES=0 RELEASE_PHASE=all", "- make validate VALIDATE_SCOPE=workspace", "- make build", ]) diff --git a/scripts/release/run.py b/scripts/release/run.py index 43596b62..244819db 100644 --- a/scripts/release/run.py +++ b/scripts/release/run.py @@ -4,7 +4,6 @@ import argparse from pathlib import Path import sys -import tomllib SCRIPTS_ROOT = Path(__file__).resolve().parents[1] if str(SCRIPTS_ROOT) not in sys.path: @@ -18,6 +17,7 @@ run_checked, workspace_root, ) +from libs.versioning import current_workspace_version def _parse_args() -> argparse.Namespace: @@ -30,22 +30,14 @@ def _parse_args() -> argparse.Namespace: _ = parser.add_argument("--interactive", type=int, default=1) _ = parser.add_argument("--push", type=int, default=0) _ = parser.add_argument("--dry-run", type=int, default=0) + _ = parser.add_argument("--dev-suffix", type=int, default=0) _ = parser.add_argument("--create-branches", type=int, default=1) _ = parser.add_argument("--projects", nargs="*", default=[]) return parser.parse_args() def _current_version(root: Path) -> str: - pyproject = root / "pyproject.toml" - content = pyproject.read_bytes() - data = tomllib.loads(content.decode("utf-8")) - project = data.get("project") - if not isinstance(project, dict): - raise RuntimeError("unable to detect [project] section from pyproject.toml") - version = project.get("version") - if not isinstance(version, str) or not version: - raise RuntimeError("unable to detect version from pyproject.toml") - return version.removesuffix("-dev") + return current_workspace_version(root) def _resolve_version(args: argparse.Namespace, root: Path) -> str: @@ -85,7 +77,11 @@ def _create_release_branches( def _phase_version( - root: Path, version: str, dry_run: bool, project_names: list[str] + root: Path, + version: str, + dry_run: bool, + project_names: list[str], + dev_suffix: bool, ) -> None: command = [ "python", @@ -96,6 +92,8 @@ def _phase_version( version, "--check" if dry_run else "--apply", ] + if dev_suffix: + command.extend(["--dev-suffix", "1"]) if project_names: command.extend(["--projects", *project_names]) run_checked(command, cwd=root) @@ -164,12 +162,10 @@ def _phase_publish( ], cwd=root, ) + tag_exists = run_capture(["git", "tag", "-l", tag], cwd=root) + if tag_exists.strip() != tag: + run_checked(["git", "tag", "-a", tag, "-m", f"release: {tag}"], cwd=root) if push: - tag_exists = run_capture(["git", "tag", "-l", tag], cwd=root) - if tag_exists.strip() != tag: - run_checked( - ["git", "tag", "-a", tag, "-m", f"release: {tag}"], cwd=root - ) run_checked(["git", "push", "origin", "HEAD"], cwd=root) run_checked(["git", "push", "origin", tag], cwd=root) @@ -201,7 +197,13 @@ def main() -> int: _phase_validate(root) continue if phase == "version": - _phase_version(root, version, args.dry_run == 1, selected_project_names) + _phase_version( + root, + version, + args.dry_run == 1, + selected_project_names, + args.dev_suffix == 1, + ) continue if phase == "build": _phase_build(root, version, selected_project_names) diff --git a/scripts/release/shared.py b/scripts/release/shared.py index a1abb23c..b191d49f 100644 --- a/scripts/release/shared.py +++ b/scripts/release/shared.py @@ -2,7 +2,6 @@ # Owner-Skill: .claude/skills/scripts-maintenance/SKILL.md from __future__ import annotations -import re import sys from pathlib import Path @@ -15,11 +14,8 @@ from libs.selection import resolve_projects as _resolve_projects from libs.subprocess import run_capture as _run_capture from libs.subprocess import run_checked as _run_checked - - -SEMVER_RE = re.compile( - r"^(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)$" -) +from libs.versioning import bump_version as _bump_version +from libs.versioning import parse_semver as _parse_semver Project = ProjectInfo @@ -39,25 +35,11 @@ def resolve_projects(root: Path, names: list[str]) -> list[Project]: def parse_semver(version: str) -> tuple[int, int, int]: - match = SEMVER_RE.match(version) - if not match: - raise ValueError(f"invalid semver version: {version}") - return ( - int(match.group("major")), - int(match.group("minor")), - int(match.group("patch")), - ) + return _parse_semver(version) def bump_version(current_version: str, bump: str) -> str: - major, minor, patch = parse_semver(current_version) - if bump == "major": - return f"{major + 1}.0.0" - if bump == "minor": - return f"{major}.{minor + 1}.0" - if bump == "patch": - return f"{major}.{minor}.{patch + 1}" - raise ValueError(f"unsupported bump: {bump}") + return _bump_version(current_version, bump) def run_checked(command: list[str], cwd: Path | None = None) -> None: diff --git a/scripts/release/version.py b/scripts/release/version.py index 3851de0f..db382213 100644 --- a/scripts/release/version.py +++ b/scripts/release/version.py @@ -3,7 +3,6 @@ import argparse from pathlib import Path -import re import sys SCRIPTS_ROOT = Path(__file__).resolve().parents[1] @@ -11,34 +10,11 @@ sys.path.insert(0, str(SCRIPTS_ROOT)) from release.shared import parse_semver, resolve_projects, workspace_root +from libs.versioning import replace_project_version def _replace_version(content: str, version: str) -> tuple[str, bool]: - project_match = re.search(r"(?ms)^\[project\]\n(?P.*?)(?:^\[|\Z)", content) - if not project_match: - return content, False - - body = project_match.group("body") - version_match = re.search(r'(?m)^version\s*=\s*"(?P[^"]+)"\s*$', body) - if not version_match: - return content, False - - current = version_match.group("value") - current_clean = current.removesuffix("-dev") - _ = parse_semver(current_clean) - if current == version: - return content, False - - replacement = f'version = "{version}"' - updated_body = re.sub( - r'(?m)^version\s*=\s*"[^"]+"\s*$', - replacement, - body, - count=1, - ) - start, end = project_match.span("body") - updated = content[:start] + updated_body + content[end:] - return updated, updated != content + return replace_project_version(content, version) def _version_files(root: Path, project_names: list[str]) -> list[Path]: @@ -55,6 +31,7 @@ def _parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser() _ = parser.add_argument("--root", type=Path, default=Path(".")) _ = parser.add_argument("--version", required=True) + _ = parser.add_argument("--dev-suffix", type=int, default=0) _ = parser.add_argument("--projects", nargs="*", default=[]) _ = parser.add_argument("--apply", action="store_true") _ = parser.add_argument("--check", action="store_true") @@ -65,11 +42,12 @@ def main() -> int: args = _parse_args() root = workspace_root(args.root) _ = parse_semver(args.version) + target_version = f"{args.version}-dev" if args.dev_suffix == 1 else args.version changed = 0 for file_path in _version_files(root, args.projects): content = file_path.read_text(encoding="utf-8") - updated, did_change = _replace_version(content, args.version) + updated, did_change = _replace_version(content, target_version) if did_change: changed += 1 if args.apply: @@ -77,7 +55,7 @@ def main() -> int: _ = print(f"update: {file_path}") if args.check: - _ = print(f"checked_version={args.version}") + _ = print(f"checked_version={target_version}") _ = print(f"files_changed={changed}") return 0 diff --git a/scripts/sync.py b/scripts/sync.py index e5cc33bf..5a4c5951 100644 --- a/scripts/sync.py +++ b/scripts/sync.py @@ -40,7 +40,11 @@ def _copy_if_changed(source: Path, target: Path) -> bool: def _sync_tree(source_dir: Path, target_dir: Path, prune: bool) -> int: changed = 0 source_files = { - p.relative_to(source_dir) for p in source_dir.rglob("*") if p.is_file() + p.relative_to(source_dir) + for p in source_dir.rglob("*") + if p.is_file() + and "__pycache__" not in p.parts + and not any(part.startswith(".") for part in p.parts) } for rel in sorted(source_files): changed += 1 if _copy_if_changed(source_dir / rel, target_dir / rel) else 0 @@ -79,6 +83,9 @@ def main() -> int: changed += _sync_tree( canonical_root / "scripts", project_root / "scripts", args.prune ) + changed += _sync_tree( + canonical_root / "libs", project_root / "libs", args.prune + ) fcntl.flock(lock_handle.fileno(), fcntl.LOCK_UN) print(f"files_changed={changed}") diff --git a/tests/unit/libs/versioning_tests.py b/tests/unit/libs/versioning_tests.py new file mode 100644 index 00000000..af7c85da --- /dev/null +++ b/tests/unit/libs/versioning_tests.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import importlib.util +from pathlib import Path +import sys +from typing import Any + + +def _load_module(module_name: str, relative_path: str) -> Any: + module_path = Path(__file__).resolve().parents[3] / relative_path + spec = importlib.util.spec_from_file_location(module_name, module_path) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def test_parse_and_bump_semver() -> None: + mod = _load_module("libs_versioning_semver", "libs/versioning.py") + assert mod.parse_semver("1.2.3") == (1, 2, 3) + assert mod.bump_version("1.2.3", "patch") == "1.2.4" + assert mod.bump_version("1.2.3", "minor") == "1.3.0" + assert mod.bump_version("1.2.3", "major") == "2.0.0" + + +def test_release_tag_from_branch_patterns() -> None: + mod = _load_module("libs_versioning_release", "libs/versioning.py") + assert mod.release_tag_from_branch("0.11.0-dev") == "v0.11.0" + assert mod.release_tag_from_branch("release/0.12.3") == "v0.12.3" + assert mod.release_tag_from_branch("feature/abc") is None + + +def test_replace_project_version_updates_only_project_table() -> None: + mod = _load_module("libs_versioning_replace", "libs/versioning.py") + content = """ +[project] +name = "demo" +version = "0.11.0-dev" + +[tool.poetry.dependencies] +python = ">=3.13,<4.0" +flext-core = "0.11.0-dev" +""".strip() + updated, did_change = mod.replace_project_version(content, "0.11.0") + assert did_change is True + assert 'version = "0.11.0"' in updated + assert 'flext-core = "0.11.0-dev"' in updated + + +def test_current_workspace_version_reads_project_version(tmp_path: Path) -> None: + mod = _load_module("libs_versioning_current", "libs/versioning.py") + pyproject = tmp_path / "pyproject.toml" + _ = pyproject.write_text( + """ +[project] +name = "demo" +version = "0.10.0-dev" +""".strip(), + encoding="utf-8", + ) + assert mod.current_workspace_version(tmp_path) == "0.10.0" diff --git a/tests/unit/scripts/github/pr_manager_tests.py b/tests/unit/scripts/github/pr_manager_tests.py index b67c3a32..c7ff4536 100644 --- a/tests/unit/scripts/github/pr_manager_tests.py +++ b/tests/unit/scripts/github/pr_manager_tests.py @@ -168,14 +168,19 @@ def test_merge_triggers_release_dispatch_when_workspace_repo( _ = workflows.mkdir(parents=True) _ = (workflows / "release.yml").write_text("name: release\n", encoding="utf-8") - calls: list[list[str]] = [] + run_calls: list[list[str]] = [] + + def _fake_run_stream_with_output(command: list[str], _cwd: Path) -> tuple[int, str]: + run_calls.append(command) + return 0, "" def _fake_run_stream(command: list[str], _cwd: Path) -> int: - calls.append(command) + run_calls.append(command) if command[:3] == ["gh", "release", "view"]: return 1 return 0 + monkeypatch.setattr(mod, "_run_stream_with_output", _fake_run_stream_with_output) monkeypatch.setattr(mod, "_run_stream", _fake_run_stream) exit_code = mod._merge_pr( @@ -189,7 +194,81 @@ def _fake_run_stream(command: list[str], _cwd: Path) -> int: ) assert exit_code == 0 - assert calls[0] == ["gh", "pr", "merge", "123", "--squash", "--auto"] - assert calls[1] == ["gh", "release", "view", "v0.11.0"] - assert calls[2] == ["gh", "workflow", "run", "release.yml", "-f", "tag=v0.11.0"] + assert run_calls[0] == ["gh", "pr", "merge", "123", "--squash", "--auto"] + assert run_calls[1] == ["gh", "release", "view", "v0.11.0"] + assert run_calls[2] == ["gh", "workflow", "run", "release.yml", "-f", "tag=v0.11.0"] assert "status=release-dispatched tag=v0.11.0" in capsys.readouterr().out + + +def test_merge_retries_after_update_branch_on_non_mergeable( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + mod = _load_module("pr_manager_merge_retry", "scripts/github/pr_manager.py") + + calls: list[list[str]] = [] + responses = [ + (1, "X Pull request #7 is not mergeable"), + (0, "updated"), + (0, "merged"), + ] + + def _fake_run_stream_with_output(command: list[str], _cwd: Path) -> tuple[int, str]: + calls.append(command) + return responses.pop(0) + + def _fake_trigger_release_if_needed(repo_root: Path, head: str) -> None: + _ = repo_root, head + + monkeypatch.setattr(mod, "_run_stream_with_output", _fake_run_stream_with_output) + monkeypatch.setattr( + mod, "_trigger_release_if_needed", _fake_trigger_release_if_needed + ) + + exit_code = mod._merge_pr( + repo_root=tmp_path, + selector="7", + head="0.11.0-dev", + method="squash", + auto=0, + delete_branch=0, + release_on_merge=0, + ) + + assert exit_code == 0 + assert calls[0] == ["gh", "pr", "merge", "7", "--squash"] + assert calls[1] == ["gh", "pr", "update-branch", "7", "--rebase"] + assert calls[2] == ["gh", "pr", "merge", "7", "--squash"] + + +def test_merge_returns_success_when_no_open_pr_for_head( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + mod = _load_module("pr_manager_merge_no_open", "scripts/github/pr_manager.py") + + def _fake_open_pr_for_head( + _repo_root: Path, _head: str + ) -> dict[str, object] | None: + return None + + def _unexpected_run_stream_with_output( + _command: list[str], _cwd: Path + ) -> tuple[int, str]: + raise AssertionError("merge command should not run when PR is absent") + + monkeypatch.setattr(mod, "_open_pr_for_head", _fake_open_pr_for_head) + monkeypatch.setattr( + mod, "_run_stream_with_output", _unexpected_run_stream_with_output + ) + + exit_code = mod._merge_pr( + repo_root=tmp_path, + selector="0.11.0-dev", + head="0.11.0-dev", + method="squash", + auto=0, + delete_branch=0, + release_on_merge=1, + ) + + assert exit_code == 0 + assert "status=no-open-pr" in capsys.readouterr().out diff --git a/tests/unit/scripts/github/test_pr_workspace.py b/tests/unit/scripts/github/test_pr_workspace.py new file mode 100644 index 00000000..ab662bfb --- /dev/null +++ b/tests/unit/scripts/github/test_pr_workspace.py @@ -0,0 +1,230 @@ +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path +from types import SimpleNamespace +from typing import Any + +import pytest + + +def _load_module(module_name: str, relative_path: str) -> Any: + module_path = Path(__file__).resolve().parents[3] / relative_path + spec = importlib.util.spec_from_file_location(module_name, module_path) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def test_main_runs_projects_and_root( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + mod = _load_module("pr_workspace_main", "scripts/github/pr_workspace.py") + + proj_a = tmp_path / "a" + proj_b = tmp_path / "b" + for path in (proj_a, proj_b, tmp_path): + _ = path.mkdir(parents=True, exist_ok=True) + _ = (path / ".git").mkdir(exist_ok=True) + + calls: list[tuple[str, Path]] = [] + + def _resolve_projects( + _workspace_root: Path, _names: list[str] + ) -> list[SimpleNamespace]: + return [ + SimpleNamespace(name="a", path=proj_a), + SimpleNamespace(name="b", path=proj_b), + ] + + def _checkout_branch(repo: Path, _branch: str) -> None: + calls.append(("co", repo)) + + def _checkpoint(repo: Path, _branch: str) -> None: + calls.append(("cp", repo)) + + def _run_pr(_repo: Path, _root: Path, _args: Any) -> int: + return 0 + + monkeypatch.setattr(mod, "resolve_projects", _resolve_projects) + monkeypatch.setattr(mod, "_checkout_branch", _checkout_branch) + monkeypatch.setattr(mod, "_checkpoint", _checkpoint) + monkeypatch.setattr(mod, "_run_pr", _run_pr) + monkeypatch.setattr( + mod, + "_parse_args", + lambda: mod.argparse.Namespace( + workspace_root=tmp_path, + project=[], + include_root=1, + branch="0.11.0-dev", + fail_fast=0, + checkpoint=1, + pr_action="status", + pr_base="main", + pr_head="", + pr_number="", + pr_title="", + pr_body="", + pr_draft="0", + pr_merge_method="squash", + pr_auto="0", + pr_delete_branch="0", + pr_checks_strict="0", + pr_release_on_merge="1", + ), + ) + + assert mod.main() == 0 + assert len([call for call in calls if call[0] == "co"]) == 3 + assert len([call for call in calls if call[0] == "cp"]) == 3 + + +def test_main_respects_fail_fast( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + mod = _load_module("pr_workspace_fail_fast", "scripts/github/pr_workspace.py") + + proj_a = tmp_path / "a" + proj_b = tmp_path / "b" + for path in (proj_a, proj_b): + _ = path.mkdir(parents=True, exist_ok=True) + _ = (path / ".git").mkdir(exist_ok=True) + + seen: list[Path] = [] + + def _resolve_projects( + _workspace_root: Path, _names: list[str] + ) -> list[SimpleNamespace]: + return [ + SimpleNamespace(name="a", path=proj_a), + SimpleNamespace(name="b", path=proj_b), + ] + + def _checkout_branch(_repo: Path, _branch: str) -> None: + return None + + def _checkpoint(_repo: Path, _branch: str) -> None: + return None + + monkeypatch.setattr(mod, "resolve_projects", _resolve_projects) + monkeypatch.setattr(mod, "_checkout_branch", _checkout_branch) + monkeypatch.setattr(mod, "_checkpoint", _checkpoint) + + def _run_pr(repo: Path, _root: Path, _args: Any) -> int: + seen.append(repo) + return 2 + + monkeypatch.setattr(mod, "_run_pr", _run_pr) + monkeypatch.setattr( + mod, + "_parse_args", + lambda: mod.argparse.Namespace( + workspace_root=tmp_path, + project=[], + include_root=0, + branch="0.11.0-dev", + fail_fast=1, + checkpoint=0, + pr_action="checks", + pr_base="main", + pr_head="", + pr_number="", + pr_title="", + pr_body="", + pr_draft="0", + pr_merge_method="squash", + pr_auto="0", + pr_delete_branch="0", + pr_checks_strict="1", + pr_release_on_merge="1", + ), + ) + + assert mod.main() == 1 + assert seen == [proj_a] + + +def test_run_pr_uses_pr_manager_for_workspace_root( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + mod = _load_module("pr_workspace_root_command", "scripts/github/pr_workspace.py") + workspace = tmp_path / "workspace" + _ = workspace.mkdir(parents=True) + + commands: list[list[str]] = [] + + def _fake_run(command: list[str], **_kwargs: Any) -> Any: + commands.append(command) + return SimpleNamespace(returncode=0) + + monkeypatch.setattr(mod.subprocess, "run", _fake_run) + + args = mod.argparse.Namespace( + pr_action="status", + pr_base="main", + pr_head="", + pr_number="", + pr_title="", + pr_body="", + pr_draft="0", + pr_merge_method="squash", + pr_auto="0", + pr_delete_branch="0", + pr_checks_strict="0", + pr_release_on_merge="1", + ) + + exit_code = mod._run_pr(workspace, workspace, args) + assert exit_code == 0 + assert commands + assert commands[0][:4] == [ + "python", + "scripts/github/pr_manager.py", + "--repo-root", + str(workspace), + ] + + +def test_run_pr_uses_make_for_non_root_repo( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + mod = _load_module("pr_workspace_project_command", "scripts/github/pr_workspace.py") + workspace = tmp_path / "workspace" + repo = workspace / "flext-core" + _ = repo.mkdir(parents=True) + + commands: list[list[str]] = [] + + def _fake_run(command: list[str], **_kwargs: Any) -> Any: + commands.append(command) + return SimpleNamespace(returncode=0) + + monkeypatch.setattr(mod.subprocess, "run", _fake_run) + + args = mod.argparse.Namespace( + pr_action="status", + pr_base="main", + pr_head="", + pr_number="", + pr_title="", + pr_body="", + pr_draft="0", + pr_merge_method="squash", + pr_auto="0", + pr_delete_branch="0", + pr_checks_strict="0", + pr_release_on_merge="1", + ) + + exit_code = mod._run_pr(repo, workspace, args) + assert exit_code == 0 + assert commands + assert commands[0][:4] == ["make", "-C", str(repo), "pr"] diff --git a/tests/unit/scripts/release/release_shared_and_run_tests.py b/tests/unit/scripts/release/release_shared_and_run_tests.py index a6183fde..c046f8ab 100644 --- a/tests/unit/scripts/release/release_shared_and_run_tests.py +++ b/tests/unit/scripts/release/release_shared_and_run_tests.py @@ -64,68 +64,26 @@ def test_current_version_reads_project_table(tmp_path: Path) -> None: assert run_mod._current_version(tmp_path) == "0.10.0" -def test_phase_publish_does_not_tag_when_push_disabled( +def test_phase_version_passes_dev_suffix_flag( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: - run_mod = _load_module("release_run_no_tag", "scripts/release/run.py") - + run_mod = _load_module("release_run_phase_version_dev", "scripts/release/run.py") recorded: list[list[str]] = [] def _fake_run_checked(command: list[str], cwd: Path | None = None) -> None: _ = cwd recorded.append(command) - def _fake_mkdir( - self: Path, mode: int = 0o777, parents: bool = False, exist_ok: bool = False - ) -> None: - _ = self, mode, parents, exist_ok - - monkeypatch.setattr(run_mod, "run_checked", _fake_run_checked) - monkeypatch.setattr(run_mod.Path, "mkdir", _fake_mkdir) - - run_mod._phase_publish( - root=tmp_path, - version="0.11.0", - tag="v0.11.0", - push=False, - dry_run=False, - project_names=[], - ) - - assert not any(cmd[:2] == ["git", "tag"] for cmd in recorded) - - -def test_phase_publish_tags_when_push_enabled( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path -) -> None: - run_mod = _load_module("release_run_with_tag", "scripts/release/run.py") - - recorded: list[list[str]] = [] - - def _fake_run_checked(command: list[str], cwd: Path | None = None) -> None: - _ = cwd - recorded.append(command) - - def _fake_run_capture(command: list[str], cwd: Path | None = None) -> str: - _ = command, cwd - return "" - - def _fake_mkdir( - self: Path, mode: int = 0o777, parents: bool = False, exist_ok: bool = False - ) -> None: - _ = self, mode, parents, exist_ok - monkeypatch.setattr(run_mod, "run_checked", _fake_run_checked) - monkeypatch.setattr(run_mod, "run_capture", _fake_run_capture) - monkeypatch.setattr(run_mod.Path, "mkdir", _fake_mkdir) - run_mod._phase_publish( + run_mod._phase_version( root=tmp_path, - version="0.11.0", - tag="v0.11.0", - push=True, + version="0.12.0", dry_run=False, - project_names=[], + project_names=["flext-core"], + dev_suffix=True, ) - assert any(cmd[:2] == ["git", "tag"] for cmd in recorded) + assert recorded + assert "--dev-suffix" in recorded[0] + assert "1" in recorded[0] diff --git a/tests/unit/scripts/sync_tests.py b/tests/unit/scripts/sync_tests.py new file mode 100644 index 00000000..74885b39 --- /dev/null +++ b/tests/unit/scripts/sync_tests.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path +from typing import Any + + +def _load_module(module_name: str, relative_path: str) -> Any: + module_path = Path(__file__).resolve().parents[3] / relative_path + spec = importlib.util.spec_from_file_location(module_name, module_path) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def test_sync_tree_ignores_pycache_and_dot_paths(tmp_path: Path) -> None: + mod = _load_module("scripts_sync_ignore", "scripts/sync.py") + + source = tmp_path / "source" + target = tmp_path / "target" + _ = (source / "__pycache__").mkdir(parents=True) + _ = (source / ".hidden").mkdir(parents=True) + _ = (source / "nested").mkdir(parents=True) + _ = (source / "__pycache__" / "x.pyc").write_bytes(b"binary") + _ = (source / ".hidden" / "keep.txt").write_text("skip", encoding="utf-8") + _ = (source / "nested" / "tool.py").write_text("print('ok')\n", encoding="utf-8") + + changed = mod._sync_tree(source, target, prune=False) + assert changed == 1 + assert (target / "nested" / "tool.py").exists() + assert not (target / "__pycache__" / "x.pyc").exists() + assert not (target / ".hidden" / "keep.txt").exists() + + +def test_main_syncs_scripts_and_libs(tmp_path: Path, monkeypatch: Any) -> None: + mod = _load_module("scripts_sync_main", "scripts/sync.py") + + canonical = tmp_path / "canonical" + project = tmp_path / "project" + _ = (canonical / "scripts").mkdir(parents=True) + _ = (canonical / "libs").mkdir(parents=True) + _ = (project / "scripts").mkdir(parents=True) + _ = (canonical / "base.mk").write_text("BASE\n", encoding="utf-8") + _ = (canonical / "scripts" / "tool.py").write_text( + "print('sync')\n", encoding="utf-8" + ) + _ = (canonical / "libs" / "versioning.py").write_text( + "VALUE = 'v'\n", encoding="utf-8" + ) + + monkeypatch.setattr( + sys, + "argv", + [ + "sync.py", + "--project-root", + str(project), + "--canonical-root", + str(canonical), + ], + ) + + exit_code = mod.main() + assert exit_code == 0 + assert (project / "scripts" / "tool.py").exists() + assert (project / "libs" / "versioning.py").exists()