From dea96487ab88a265bf1f2b1ccaa7bd6da9b8463f Mon Sep 17 00:00:00 2001 From: pcvantol Date: Tue, 8 Sep 2026 08:25:41 +0200 Subject: [PATCH 01/11] ci: adopt canonical product versioning --- .github/workflows/canonical-versioning.yml | 48 ++++++++++++++++ .../CANONICAL_PRODUCT_VERSIONING_ADOPTION.md | 8 +++ product-version.json | 5 ++ scripts/advance_product_version.py | 55 +++++++++++++++++++ scripts/validate.sh | 2 + 5 files changed, 118 insertions(+) create mode 100644 .github/workflows/canonical-versioning.yml create mode 100644 docs/governance/CANONICAL_PRODUCT_VERSIONING_ADOPTION.md create mode 100644 product-version.json create mode 100644 scripts/advance_product_version.py diff --git a/.github/workflows/canonical-versioning.yml b/.github/workflows/canonical-versioning.yml new file mode 100644 index 0000000..b87872d --- /dev/null +++ b/.github/workflows/canonical-versioning.yml @@ -0,0 +1,48 @@ +name: Canonical product versioning + +on: + push: + branches: ['**'] + +concurrency: + group: canonical-product-version-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: write + +jobs: + feature-patch: + if: github.ref_name != 'main' && !startsWith(github.ref_name, 'release-') && github.actor != 'github-actions[bot]' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v5 + with: {fetch-depth: 0} + - id: existing + run: | + git fetch --no-tags origin main + git log --format=%s origin/main..HEAD | grep -Eq '^build: advance canonical product patch version [0-9]+\.[0-9]+\.[0-9]+$' && echo 'present=true' >> "$GITHUB_OUTPUT" || echo 'present=false' >> "$GITHUB_OUTPUT" + - if: steps.existing.outputs.present != 'true' + run: | + set -euo pipefail + version="$(python3 scripts/advance_product_version.py --bump patch | sed -n 's/^PRODUCT_VERSION=//p')" + python3 scripts/advance_product_version.py --check + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add product-version.json + git commit -m "build: advance canonical product patch version ${version}" + git push origin "HEAD:${GITHUB_REF_NAME}" + main-minor: + if: github.ref_name == 'main' && github.actor != 'github-actions[bot]' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v5 + - run: | + set -euo pipefail + version="$(python3 scripts/advance_product_version.py --bump minor | sed -n 's/^PRODUCT_VERSION=//p')" + python3 scripts/advance_product_version.py --check + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add product-version.json + git commit -m "build: advance canonical product minor version ${version}" + git push origin HEAD:main diff --git a/docs/governance/CANONICAL_PRODUCT_VERSIONING_ADOPTION.md b/docs/governance/CANONICAL_PRODUCT_VERSIONING_ADOPTION.md new file mode 100644 index 0000000..e333a14 --- /dev/null +++ b/docs/governance/CANONICAL_PRODUCT_VERSIONING_ADOPTION.md @@ -0,0 +1,8 @@ +# Canonical product versioning adoption + +Workspace adopts Forge Platform's [canonical product versioning policy](https://github.com/pcvantol/forge-platform/blob/main/docs/architecture/CANONICAL_PRODUCT_VERSIONING.md), policy v1. + +`product-version.json` is Workspace's only product-release version source. It +does not alter Workspace architecture, future EP adapter contracts or external +protocols. `scripts/advance_product_version.py` and the scoped `Canonical +product versioning` workflow are the only automatic mutators. diff --git a/product-version.json b/product-version.json new file mode 100644 index 0000000..e533fe6 --- /dev/null +++ b/product-version.json @@ -0,0 +1,5 @@ +{ + "product": "workspace", + "schema_version": 1, + "version": "0.1.0" +} diff --git a/scripts/advance_product_version.py b/scripts/advance_product_version.py new file mode 100644 index 0000000..444e2ee --- /dev/null +++ b/scripts/advance_product_version.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +"""Validate or advance Forge-family product semantic versions.""" +from __future__ import annotations + +import argparse +import json +from pathlib import Path +import re + +VERSION = re.compile(r"^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$") + + +def current(root: Path) -> tuple[Path, dict[str, object], tuple[int, int, int]]: + target = root.resolve() / "product-version.json" + try: + payload = json.loads(target.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + raise RuntimeError("canonical product version manifest is unreadable") from error + value = payload.get("version") if isinstance(payload, dict) else None + if payload.get("schema_version") != 1 or not isinstance(payload.get("product"), str) or not isinstance(value, str): + raise RuntimeError("canonical product version manifest is invalid") + match = VERSION.fullmatch(value) + if match is None: + raise RuntimeError("canonical product version must be stable X.Y.Z") + return target, payload, tuple(int(part) for part in match.groups()) + + +def advance(root: Path, component: str) -> str: + target, payload, (major, minor, patch) = current(root) + version = f"{major}.{minor}.{patch + 1}" if component == "patch" else f"{major}.{minor + 1}.0" if component == "minor" else None + if version is None: + raise RuntimeError("version component must be patch or minor") + payload["version"] = version + target.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") + return version + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--source-root", type=Path, default=Path.cwd()) + parser.add_argument("--bump", choices=("patch", "minor")) + parser.add_argument("--check", action="store_true") + args = parser.parse_args(argv) + if args.check == (args.bump is not None): + parser.error("provide exactly one of --check or --bump") + if args.check: + _, payload, _ = current(args.source_root) + print(f"PRODUCT_VERSION=PASS version={payload['version']}") + else: + print(f"PRODUCT_VERSION={advance(args.source_root, args.bump)}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/validate.sh b/scripts/validate.sh index 55a8473..4d82908 100755 --- a/scripts/validate.sh +++ b/scripts/validate.sh @@ -6,6 +6,8 @@ for file in "${required[@]}"; do test -s "$file" done +python3 scripts/advance_product_version.py --check + python3 docs/ai-development/validate_projection.py \ --profile workspace \ --source-commit ec070e399ff4dbd92e760370002995fe4f4d52d6 \ From 50a992e752e19289d68685ef5bd4e99faf0db0a9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 06:25:48 +0000 Subject: [PATCH 02/11] build: advance canonical product patch version 0.1.1 --- product-version.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/product-version.json b/product-version.json index e533fe6..da2f051 100644 --- a/product-version.json +++ b/product-version.json @@ -1,5 +1,5 @@ { "product": "workspace", "schema_version": 1, - "version": "0.1.0" + "version": "0.1.1" } From fcda180e7e6323c54b54e5b3186ef0056514d52e Mon Sep 17 00:00:00 2001 From: pcvantol Date: Tue, 8 Sep 2026 08:31:05 +0200 Subject: [PATCH 03/11] release: set canonical version 2.3.0 --- product-version.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/product-version.json b/product-version.json index da2f051..c1c9ef0 100644 --- a/product-version.json +++ b/product-version.json @@ -1,5 +1,5 @@ { "product": "workspace", "schema_version": 1, - "version": "0.1.1" + "version": "2.3.0" } From 3f2cd42334f6e4e4640c21ca5e620d9218960f7a Mon Sep 17 00:00:00 2001 From: pcvantol Date: Tue, 8 Sep 2026 15:20:23 +0200 Subject: [PATCH 04/11] fix: guard canonical product version operations --- .github/workflows/canonical-versioning.yml | 39 +---------- .../CANONICAL_PRODUCT_VERSIONING_ADOPTION.md | 20 ++++-- scripts/advance_product_version.py | 68 ++++++++++++++----- 3 files changed, 70 insertions(+), 57 deletions(-) diff --git a/.github/workflows/canonical-versioning.yml b/.github/workflows/canonical-versioning.yml index b87872d..4aef7ca 100644 --- a/.github/workflows/canonical-versioning.yml +++ b/.github/workflows/canonical-versioning.yml @@ -4,45 +4,12 @@ on: push: branches: ['**'] -concurrency: - group: canonical-product-version-${{ github.ref }} - cancel-in-progress: false - permissions: - contents: write + contents: read jobs: - feature-patch: - if: github.ref_name != 'main' && !startsWith(github.ref_name, 'release-') && github.actor != 'github-actions[bot]' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v5 - with: {fetch-depth: 0} - - id: existing - run: | - git fetch --no-tags origin main - git log --format=%s origin/main..HEAD | grep -Eq '^build: advance canonical product patch version [0-9]+\.[0-9]+\.[0-9]+$' && echo 'present=true' >> "$GITHUB_OUTPUT" || echo 'present=false' >> "$GITHUB_OUTPUT" - - if: steps.existing.outputs.present != 'true' - run: | - set -euo pipefail - version="$(python3 scripts/advance_product_version.py --bump patch | sed -n 's/^PRODUCT_VERSION=//p')" - python3 scripts/advance_product_version.py --check - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add product-version.json - git commit -m "build: advance canonical product patch version ${version}" - git push origin "HEAD:${GITHUB_REF_NAME}" - main-minor: - if: github.ref_name == 'main' && github.actor != 'github-actions[bot]' + validate-version-source: runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v5 - - run: | - set -euo pipefail - version="$(python3 scripts/advance_product_version.py --bump minor | sed -n 's/^PRODUCT_VERSION=//p')" - python3 scripts/advance_product_version.py --check - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add product-version.json - git commit -m "build: advance canonical product minor version ${version}" - git push origin HEAD:main + - run: python3 scripts/advance_product_version.py --check diff --git a/docs/governance/CANONICAL_PRODUCT_VERSIONING_ADOPTION.md b/docs/governance/CANONICAL_PRODUCT_VERSIONING_ADOPTION.md index e333a14..6dfb8f1 100644 --- a/docs/governance/CANONICAL_PRODUCT_VERSIONING_ADOPTION.md +++ b/docs/governance/CANONICAL_PRODUCT_VERSIONING_ADOPTION.md @@ -2,7 +2,19 @@ Workspace adopts Forge Platform's [canonical product versioning policy](https://github.com/pcvantol/forge-platform/blob/main/docs/architecture/CANONICAL_PRODUCT_VERSIONING.md), policy v1. -`product-version.json` is Workspace's only product-release version source. It -does not alter Workspace architecture, future EP adapter contracts or external -protocols. `scripts/advance_product_version.py` and the scoped `Canonical -product versioning` workflow are the only automatic mutators. +`product-version.json` (`product=workspace`, `schema_version=1`, `version`) is +Workspace's only product-release version source. Its committed `2.3.0` baseline +is not publication evidence and does not alter Workspace architecture, future +EP adapter contracts or external protocols. + +The helper has a read-only check and explicit patch/minor or exact-release +apply with an optional expected baseline. It rejects wrong product identity, +boolean schema values, malformed SemVer and duplicate JSON keys, and writes the +single manifest through temp-file replacement. It does not commit, push, +publish, qualify a release or decide compatibility. + +The workflow is intentionally read-only. A token-created version commit has a +new SHA that needs its own protected qualification and cannot safely represent +exactly-once processing of push events. Automatic allocation stays disabled +until the existing protected delivery route binds an operation ID, policy +revision, lineage and expected head. Builds consume only the committed version. diff --git a/scripts/advance_product_version.py b/scripts/advance_product_version.py index 444e2ee..1f8c3f1 100644 --- a/scripts/advance_product_version.py +++ b/scripts/advance_product_version.py @@ -1,37 +1,70 @@ #!/usr/bin/env python3 -"""Validate or advance Forge-family product semantic versions.""" +"""Inspect, plan, or explicitly apply Workspace's product-version.json change.""" from __future__ import annotations import argparse import json +import os from pathlib import Path import re +import tempfile +PRODUCT = "workspace" VERSION = re.compile(r"^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$") +def _pairs(pairs: list[tuple[str, object]]) -> dict[str, object]: + result: dict[str, object] = {} + for key, value in pairs: + if key in result: + raise ValueError(f"duplicate manifest key: {key}") + result[key] = value + return result def current(root: Path) -> tuple[Path, dict[str, object], tuple[int, int, int]]: target = root.resolve() / "product-version.json" try: - payload = json.loads(target.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as error: + payload = json.loads(target.read_text(encoding="utf-8"), object_pairs_hook=_pairs) + except (OSError, ValueError, json.JSONDecodeError) as error: raise RuntimeError("canonical product version manifest is unreadable") from error - value = payload.get("version") if isinstance(payload, dict) else None - if payload.get("schema_version") != 1 or not isinstance(payload.get("product"), str) or not isinstance(value, str): - raise RuntimeError("canonical product version manifest is invalid") - match = VERSION.fullmatch(value) - if match is None: + if not isinstance(payload, dict): + raise RuntimeError("canonical product version manifest must be an object") + if payload.get("schema_version") != 1 or isinstance(payload.get("schema_version"), bool): + raise RuntimeError("canonical product version manifest has an unsupported schema") + if payload.get("product") != PRODUCT: + raise RuntimeError(f"canonical product version manifest must identify {PRODUCT}") + value = payload.get("version") + if not isinstance(value, str) or VERSION.fullmatch(value) is None: raise RuntimeError("canonical product version must be stable X.Y.Z") - return target, payload, tuple(int(part) for part in match.groups()) + return target, payload, tuple(int(part) for part in value.split(".")) +def _atomic_write(path: Path, text: str) -> None: + mode = path.stat().st_mode + fd, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) + try: + with os.fdopen(fd, "w", encoding="utf-8") as handle: + handle.write(text); handle.flush(); os.fsync(handle.fileno()) + os.chmod(temporary, mode); os.replace(temporary, path) + except BaseException: + try: os.unlink(temporary) + except FileNotFoundError: pass + raise -def advance(root: Path, component: str) -> str: +def advance(root: Path, component: str | None, exact: str | None = None, expected_version: str | None = None) -> str: target, payload, (major, minor, patch) = current(root) - version = f"{major}.{minor}.{patch + 1}" if component == "patch" else f"{major}.{minor + 1}.0" if component == "minor" else None - if version is None: - raise RuntimeError("version component must be patch or minor") + actual = payload["version"] + if expected_version is not None and expected_version != actual: + raise RuntimeError(f"stale version operation: expected {expected_version}, found {actual}") + if (component is None) == (exact is None): + raise RuntimeError("provide exactly one requested bump or exact target version") + if exact is not None: + if VERSION.fullmatch(exact) is None: raise RuntimeError("the requested release version must be stable X.Y.Z") + version = exact + elif component == "patch": version = f"{major}.{minor}.{patch + 1}" + elif component == "minor": version = f"{major}.{minor + 1}.0" + else: raise RuntimeError("major requires explicit release authority") + if version == actual: return version payload["version"] = version - target.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") + _atomic_write(target, json.dumps(payload, indent=2, sort_keys=True) + "\n") return version @@ -39,15 +72,16 @@ def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser() parser.add_argument("--source-root", type=Path, default=Path.cwd()) parser.add_argument("--bump", choices=("patch", "minor")) + parser.add_argument("--set-version") + parser.add_argument("--expected-version") parser.add_argument("--check", action="store_true") args = parser.parse_args(argv) - if args.check == (args.bump is not None): - parser.error("provide exactly one of --check or --bump") if args.check: + if args.bump or args.set_version: parser.error("--check cannot change a version") _, payload, _ = current(args.source_root) print(f"PRODUCT_VERSION=PASS version={payload['version']}") else: - print(f"PRODUCT_VERSION={advance(args.source_root, args.bump)}") + print(f"PRODUCT_VERSION={advance(args.source_root, args.bump, args.set_version, args.expected_version)}") return 0 From ab1b2258507a81c52af660572872d0d5fffe0abd Mon Sep 17 00:00:00 2001 From: pcvantol Date: Tue, 8 Sep 2026 16:07:54 +0200 Subject: [PATCH 05/11] feat: add idempotent Workspace version operations --- .../CANONICAL_PRODUCT_VERSIONING_ADOPTION.md | 28 +++- scripts/advance_product_version.py | 156 +++++++++++++++--- scripts/test_product_version_operations.py | 88 ++++++++++ scripts/validate.sh | 1 + 4 files changed, 240 insertions(+), 33 deletions(-) create mode 100644 scripts/test_product_version_operations.py diff --git a/docs/governance/CANONICAL_PRODUCT_VERSIONING_ADOPTION.md b/docs/governance/CANONICAL_PRODUCT_VERSIONING_ADOPTION.md index 6dfb8f1..812c2d4 100644 --- a/docs/governance/CANONICAL_PRODUCT_VERSIONING_ADOPTION.md +++ b/docs/governance/CANONICAL_PRODUCT_VERSIONING_ADOPTION.md @@ -7,14 +7,28 @@ Workspace's only product-release version source. Its committed `2.3.0` baseline is not publication evidence and does not alter Workspace architecture, future EP adapter contracts or external protocols. -The helper has a read-only check and explicit patch/minor or exact-release -apply with an optional expected baseline. It rejects wrong product identity, -boolean schema values, malformed SemVer and duplicate JSON keys, and writes the -single manifest through temp-file replacement. It does not commit, push, -publish, qualify a release or decide compatibility. +The helper has a read-only check, a read-only `--plan`, and explicit +patch/minor or exact-release apply. An apply requires an operation ID, policy +revision, source-event lineage, expected source HEAD and expected baseline. It +persists a small tracked `.version-operations/.json` receipt +alongside the only allowed projection, `product-version.json`. Repeating the +same operation is idempotent; changed inputs are a conflict. If interruption +occurs after the manifest replacement but before its receipt, the same +operation can only recover the already-determined result, never derive another +bump. + +The helper rejects wrong product identity, boolean schema values, malformed +SemVer and duplicate JSON keys, and writes each file through temp-file +replacement. It validates the operation before writes, but two replacements +are not a multi-file transaction: no partial local result may be published. +The delivery route must commit the manifest and receipt together, then qualify +that exact SHA. The helper does not commit, push, publish, qualify a release or +decide compatibility. The workflow is intentionally read-only. A token-created version commit has a new SHA that needs its own protected qualification and cannot safely represent exactly-once processing of push events. Automatic allocation stays disabled -until the existing protected delivery route binds an operation ID, policy -revision, lineage and expected head. Builds consume only the committed version. +until the existing protected delivery route commits and qualifies the complete +operation. Builds consume only the committed version. A candidate patch number +is not release compatibility or publication authority; major changes require an +explicit approved exact-release operation. diff --git a/scripts/advance_product_version.py b/scripts/advance_product_version.py index 1f8c3f1..0734c7d 100644 --- a/scripts/advance_product_version.py +++ b/scripts/advance_product_version.py @@ -1,5 +1,10 @@ #!/usr/bin/env python3 -"""Inspect, plan, or explicitly apply Workspace's product-version.json change.""" +"""Inspect, plan, or explicitly apply Workspace's version operation. + +The helper has no commit, push, publication, or qualification authority. A +protected delivery route must commit the manifest and receipt together, then +qualify that exact resulting SHA. +""" from __future__ import annotations import argparse @@ -7,10 +12,13 @@ import os from pathlib import Path import re +import subprocess import tempfile PRODUCT = "workspace" VERSION = re.compile(r"^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$") +OPERATION_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{7,127}$") +POLICY_REVISION = "workspace-product-versioning-v1" def _pairs(pairs: list[tuple[str, object]]) -> dict[str, object]: result: dict[str, object] = {} @@ -20,14 +28,19 @@ def _pairs(pairs: list[tuple[str, object]]) -> dict[str, object]: result[key] = value return result -def current(root: Path) -> tuple[Path, dict[str, object], tuple[int, int, int]]: - target = root.resolve() / "product-version.json" +def _read_object(target: Path, description: str) -> dict[str, object]: try: payload = json.loads(target.read_text(encoding="utf-8"), object_pairs_hook=_pairs) except (OSError, ValueError, json.JSONDecodeError) as error: - raise RuntimeError("canonical product version manifest is unreadable") from error + raise RuntimeError(f"{description} is unreadable") from error if not isinstance(payload, dict): - raise RuntimeError("canonical product version manifest must be an object") + raise RuntimeError(f"{description} must be an object") + return payload + + +def current(root: Path) -> tuple[Path, dict[str, object], tuple[int, int, int]]: + target = root.resolve() / "product-version.json" + payload = _read_object(target, "canonical product version manifest") if payload.get("schema_version") != 1 or isinstance(payload.get("schema_version"), bool): raise RuntimeError("canonical product version manifest has an unsupported schema") if payload.get("product") != PRODUCT: @@ -38,34 +51,110 @@ def current(root: Path) -> tuple[Path, dict[str, object], tuple[int, int, int]]: return target, payload, tuple(int(part) for part in value.split(".")) def _atomic_write(path: Path, text: str) -> None: - mode = path.stat().st_mode + mode = path.stat().st_mode if path.exists() else 0o100644 fd, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) try: with os.fdopen(fd, "w", encoding="utf-8") as handle: - handle.write(text); handle.flush(); os.fsync(handle.fileno()) - os.chmod(temporary, mode); os.replace(temporary, path) + handle.write(text) + handle.flush() + os.fsync(handle.fileno()) + os.chmod(temporary, mode) + os.replace(temporary, path) except BaseException: - try: os.unlink(temporary) - except FileNotFoundError: pass + try: + os.unlink(temporary) + except FileNotFoundError: + pass raise -def advance(root: Path, component: str | None, exact: str | None = None, expected_version: str | None = None) -> str: - target, payload, (major, minor, patch) = current(root) - actual = payload["version"] - if expected_version is not None and expected_version != actual: - raise RuntimeError(f"stale version operation: expected {expected_version}, found {actual}") + +def _head(root: Path) -> str: + result = subprocess.run(["git", "-C", str(root), "rev-parse", "HEAD"], text=True, + capture_output=True, check=False) + if result.returncode: + raise RuntimeError("version operation requires a Git checkout with HEAD") + return result.stdout.strip() + + +def _target(actual: tuple[int, int, int], component: str | None, exact: str | None) -> str: if (component is None) == (exact is None): raise RuntimeError("provide exactly one requested bump or exact target version") if exact is not None: - if VERSION.fullmatch(exact) is None: raise RuntimeError("the requested release version must be stable X.Y.Z") - version = exact - elif component == "patch": version = f"{major}.{minor}.{patch + 1}" - elif component == "minor": version = f"{major}.{minor + 1}.0" - else: raise RuntimeError("major requires explicit release authority") - if version == actual: return version - payload["version"] = version - _atomic_write(target, json.dumps(payload, indent=2, sort_keys=True) + "\n") - return version + if VERSION.fullmatch(exact) is None: + raise RuntimeError("the requested release version must be stable X.Y.Z") + return exact + major, minor, patch = actual + if component == "patch": + return f"{major}.{minor}.{patch + 1}" + if component == "minor": + return f"{major}.{minor + 1}.0" + raise RuntimeError("major requires explicit release authority") + + +def _receipt(root: Path, operation_id: str, event_lineage: str, expected_head: str, + expected_version: str, component: str | None, exact: str | None, + determined: str, policy_revision: str) -> tuple[Path, dict[str, object]]: + if OPERATION_ID.fullmatch(operation_id) is None: + raise RuntimeError("operation ID must be 8-128 safe identifier characters") + if not event_lineage.strip() or re.fullmatch(r"[0-9a-f]{40}", expected_head) is None: + raise RuntimeError("event lineage and a full expected source revision are required") + if policy_revision != POLICY_REVISION: + raise RuntimeError(f"unsupported Workspace version policy revision: {policy_revision}") + requested: dict[str, object] = {"exact_version": exact} if exact is not None else {"bump": component} + value = { + "schema_version": 1, "operation_id": operation_id, "product": PRODUCT, + "policy_revision": policy_revision, "event_lineage": event_lineage, + "expected_source_revision": expected_head, "expected_version": expected_version, + "requested": requested, "determined_version": determined, + "allowed_projection_paths": ["product-version.json"], "result_commit": None, + } + return root / ".version-operations" / f"{operation_id}.json", value + + +def plan(root: Path, component: str | None, exact: str | None, expected_version: str, + operation_id: str, event_lineage: str, expected_head: str, + policy_revision: str = POLICY_REVISION) -> dict[str, object]: + _, payload, parsed = current(root) + actual = payload["version"] + if actual != expected_version: + raise RuntimeError(f"stale version operation: expected {expected_version}, found {actual}") + if _head(root) != expected_head: + raise RuntimeError("stale version operation: source HEAD differs from expected source revision") + determined = _target(parsed, component, exact) + _, value = _receipt(root.resolve(), operation_id, event_lineage, expected_head, expected_version, + component, exact, determined, policy_revision) + return value + + +def apply(root: Path, component: str | None, exact: str | None, expected_version: str, + operation_id: str, event_lineage: str, expected_head: str, + policy_revision: str = POLICY_REVISION) -> str: + root = root.resolve() + target, payload, parsed = current(root) + actual = payload["version"] + if _head(root) != expected_head: + raise RuntimeError("stale version operation: source HEAD differs from expected source revision") + # Calculate from the expected baseline when recovering after a crash, never + # from the already-written target. + if VERSION.fullmatch(expected_version) is None: + raise RuntimeError("expected version must be stable X.Y.Z") + determined = _target(parsed if actual == expected_version else tuple(map(int, expected_version.split("."))), component, exact) + receipt_path, receipt = _receipt(root, operation_id, event_lineage, expected_head, expected_version, + component, exact, determined, policy_revision) + if receipt_path.exists(): + if _read_object(receipt_path, "version operation receipt") != receipt: + raise RuntimeError("operation ID conflict: existing receipt has different meaning") + if actual != determined: + raise RuntimeError("operation ID conflict: receipt and canonical version disagree") + return determined + if actual not in (expected_version, determined): + raise RuntimeError(f"stale version operation: expected {expected_version}, found {actual}") + if actual == expected_version and determined != actual: + payload["version"] = determined + _atomic_write(target, json.dumps(payload, indent=2, sort_keys=True) + "\n") + receipt_path.parent.mkdir(mode=0o755, exist_ok=True) + _atomic_write(receipt_path, json.dumps(receipt, indent=2, sort_keys=True) + "\n") + return determined def main(argv: list[str] | None = None) -> int: @@ -74,14 +163,29 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument("--bump", choices=("patch", "minor")) parser.add_argument("--set-version") parser.add_argument("--expected-version") + parser.add_argument("--operation-id") + parser.add_argument("--event-lineage") + parser.add_argument("--expected-head") + parser.add_argument("--policy-revision", default=POLICY_REVISION) parser.add_argument("--check", action="store_true") + parser.add_argument("--plan", action="store_true") args = parser.parse_args(argv) if args.check: - if args.bump or args.set_version: parser.error("--check cannot change a version") + if any((args.bump, args.set_version, args.operation_id, args.plan)): + parser.error("--check cannot change or plan a version") _, payload, _ = current(args.source_root) print(f"PRODUCT_VERSION=PASS version={payload['version']}") + return 0 + if not all((args.expected_version, args.operation_id, args.event_lineage, args.expected_head)): + parser.error("version operations require --expected-version, --operation-id, --event-lineage and --expected-head") + if args.plan: + print(json.dumps(plan(args.source_root, args.bump, args.set_version, args.expected_version, + args.operation_id, args.event_lineage, args.expected_head, + args.policy_revision), sort_keys=True)) else: - print(f"PRODUCT_VERSION={advance(args.source_root, args.bump, args.set_version, args.expected_version)}") + print("PRODUCT_VERSION=" + apply(args.source_root, args.bump, args.set_version, args.expected_version, + args.operation_id, args.event_lineage, args.expected_head, + args.policy_revision)) return 0 diff --git a/scripts/test_product_version_operations.py b/scripts/test_product_version_operations.py new file mode 100644 index 0000000..019b9fd --- /dev/null +++ b/scripts/test_product_version_operations.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +"""Focused regression tests for the Workspace product-version operation.""" +from __future__ import annotations + +import importlib.util +import json +from pathlib import Path +import subprocess +import tempfile +import unittest + +MODULE = Path(__file__).with_name("advance_product_version.py") +SPEC = importlib.util.spec_from_file_location("version_helper", MODULE) +assert SPEC and SPEC.loader +version_helper = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(version_helper) + + +class ProductVersionOperationTests(unittest.TestCase): + def setUp(self) -> None: + self.temp = tempfile.TemporaryDirectory() + self.root = Path(self.temp.name) + (self.root / "product-version.json").write_text( + '{"product":"workspace","schema_version":1,"version":"2.3.0"}\n', encoding="utf-8" + ) + subprocess.run(["git", "init", "-q", str(self.root)], check=True) + subprocess.run(["git", "-C", str(self.root), "add", "product-version.json"], check=True) + subprocess.run(["git", "-C", str(self.root), "-c", "user.name=test", "-c", "user.email=test@example.invalid", + "commit", "-qm", "baseline"], check=True) + self.head = subprocess.check_output(["git", "-C", str(self.root), "rev-parse", "HEAD"], text=True).strip() + + def tearDown(self) -> None: + self.temp.cleanup() + + def apply(self, operation_id: str = "event-0001", **changes: object) -> str: + arguments: dict[str, object] = dict(component="patch", exact=None, expected_version="2.3.0", + operation_id=operation_id, event_lineage="push:" + self.head, + expected_head=self.head) + arguments.update(changes) + return version_helper.apply(self.root, **arguments) + + def test_plan_is_read_only_and_apply_is_idempotent(self) -> None: + receipt = version_helper.plan(self.root, "patch", None, "2.3.0", "event-0001", "push:" + self.head, self.head) + self.assertEqual("2.3.1", receipt["determined_version"]) + self.assertEqual("2.3.0", json.loads((self.root / "product-version.json").read_text())["version"]) + self.assertEqual("2.3.1", self.apply()) + self.assertEqual("2.3.1", self.apply()) + self.assertTrue((self.root / ".version-operations/event-0001.json").is_file()) + + def test_changed_input_under_an_operation_id_conflicts(self) -> None: + self.apply() + with self.assertRaisesRegex(RuntimeError, "operation ID conflict"): + self.apply(component="minor") + + def test_minor_and_explicit_release_target_are_deterministic(self) -> None: + self.assertEqual("2.4.0", self.apply(operation_id="event-0002", component="minor")) + # An explicit release target may be prepared only from the declared + # baseline; it does not receive an implicit additional bump. + self.assertEqual("3.0.0", version_helper.apply( + self.root, None, "3.0.0", "2.4.0", "event-0003", "release:" + self.head, self.head + )) + + def test_bad_expected_version_and_major_bump_are_rejected(self) -> None: + with self.assertRaisesRegex(RuntimeError, "expected version must be stable"): + self.apply(expected_version="02.3.0") + with self.assertRaisesRegex(RuntimeError, "major requires explicit"): + version_helper.apply(self.root, "major", None, "2.3.0", "event-0004", "push:" + self.head, self.head) + + def test_stale_head_and_baseline_are_rejected(self) -> None: + with self.assertRaisesRegex(RuntimeError, "source HEAD"): + self.apply(expected_head="0" * 40) + with self.assertRaisesRegex(RuntimeError, "stale version operation"): + self.apply(expected_version="2.2.9") + + def test_recovery_after_manifest_write_does_not_allocate_again(self) -> None: + manifest = self.root / "product-version.json" + manifest.write_text('{"product":"workspace","schema_version":1,"version":"2.3.1"}\n', encoding="utf-8") + self.assertEqual("2.3.1", self.apply()) + self.assertEqual("2.3.1", json.loads(manifest.read_text())["version"]) + + def test_invalid_manifest_is_rejected(self) -> None: + (self.root / "product-version.json").write_text("[]\n", encoding="utf-8") + with self.assertRaisesRegex(RuntimeError, "must be an object"): + version_helper.current(self.root) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/validate.sh b/scripts/validate.sh index 4d82908..f514af0 100755 --- a/scripts/validate.sh +++ b/scripts/validate.sh @@ -7,6 +7,7 @@ for file in "${required[@]}"; do done python3 scripts/advance_product_version.py --check +python3 scripts/test_product_version_operations.py python3 docs/ai-development/validate_projection.py \ --profile workspace \ From 3522a5ee9610ad62ee748a42b83dc9f9e81ea414 Mon Sep 17 00:00:00 2001 From: pcvantol Date: Tue, 8 Sep 2026 16:14:48 +0200 Subject: [PATCH 06/11] feat: guard Workspace release source binding --- .../CANONICAL_PRODUCT_VERSIONING_ADOPTION.md | 10 ++++++ scripts/advance_product_version.py | 35 ++++++++++++++++++- scripts/test_product_version_operations.py | 9 +++++ 3 files changed, 53 insertions(+), 1 deletion(-) diff --git a/docs/governance/CANONICAL_PRODUCT_VERSIONING_ADOPTION.md b/docs/governance/CANONICAL_PRODUCT_VERSIONING_ADOPTION.md index 812c2d4..f7987de 100644 --- a/docs/governance/CANONICAL_PRODUCT_VERSIONING_ADOPTION.md +++ b/docs/governance/CANONICAL_PRODUCT_VERSIONING_ADOPTION.md @@ -32,3 +32,13 @@ until the existing protected delivery route commits and qualifies the complete operation. Builds consume only the committed version. A candidate patch number is not release compatibility or publication authority; major changes require an explicit approved exact-release operation. + +For the current foundation-only product there is no package artifact or +installed runtime to verify. `--verify-release-source` is a read-only guard for +a future authorized publication route: it accepts only `release-X.Y.Z`, requires +that exact canonical version, and requires the candidate HEAD to equal (not +merely descend from) the externally approved source revision. It neither +authorizes a branch nor publishes an artifact. Workspace presently has no +repository-local protected version-preparation dispatcher, GitHub App, or EP +qualification integration capable of committing a prepared operation and +binding hosted qualification evidence to its resulting SHA. diff --git a/scripts/advance_product_version.py b/scripts/advance_product_version.py index 0734c7d..ea785f8 100644 --- a/scripts/advance_product_version.py +++ b/scripts/advance_product_version.py @@ -19,6 +19,7 @@ VERSION = re.compile(r"^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$") OPERATION_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{7,127}$") POLICY_REVISION = "workspace-product-versioning-v1" +RELEASE_BRANCH = re.compile(r"^release-((?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*))$") def _pairs(pairs: list[tuple[str, object]]) -> dict[str, object]: result: dict[str, object] = {} @@ -76,6 +77,25 @@ def _head(root: Path) -> str: return result.stdout.strip() +def verify_release_source(root: Path, branch: str, approved_source: str) -> str: + """Verify a release candidate's exact source/version binding, read-only. + + The caller supplies an exact source already approved by its owning route; + ancestry is intentionally insufficient because it permits extra commits. + """ + match = RELEASE_BRANCH.fullmatch(branch) + if match is None: + raise RuntimeError("release branch must be exactly release-X.Y.Z") + if re.fullmatch(r"[0-9a-f]{40}", approved_source) is None: + raise RuntimeError("approved release source must be a full source revision") + _, payload, _ = current(root) + if payload["version"] != match.group(1): + raise RuntimeError("release branch target and canonical product version disagree") + if _head(root.resolve()) != approved_source: + raise RuntimeError("release candidate HEAD is not the exact approved source") + return match.group(1) + + def _target(actual: tuple[int, int, int], component: str | None, exact: str | None) -> str: if (component is None) == (exact is None): raise RuntimeError("provide exactly one requested bump or exact target version") @@ -169,13 +189,26 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument("--policy-revision", default=POLICY_REVISION) parser.add_argument("--check", action="store_true") parser.add_argument("--plan", action="store_true") + parser.add_argument("--verify-release-source", action="store_true") + parser.add_argument("--release-branch") + parser.add_argument("--approved-source") args = parser.parse_args(argv) if args.check: - if any((args.bump, args.set_version, args.operation_id, args.plan)): + if any((args.bump, args.set_version, args.operation_id, args.plan, args.verify_release_source)): parser.error("--check cannot change or plan a version") _, payload, _ = current(args.source_root) print(f"PRODUCT_VERSION=PASS version={payload['version']}") return 0 + if args.verify_release_source: + if any((args.bump, args.set_version, args.plan, args.operation_id, args.expected_version, + args.event_lineage, args.expected_head)): + parser.error("--verify-release-source is a separate read-only operation") + if not args.release_branch or not args.approved_source: + parser.error("release verification requires --release-branch and --approved-source") + print("RELEASE_SOURCE=PASS version=" + verify_release_source( + args.source_root, args.release_branch, args.approved_source + )) + return 0 if not all((args.expected_version, args.operation_id, args.event_lineage, args.expected_head)): parser.error("version operations require --expected-version, --operation-id, --event-lineage and --expected-head") if args.plan: diff --git a/scripts/test_product_version_operations.py b/scripts/test_product_version_operations.py index 019b9fd..29fe8f2 100644 --- a/scripts/test_product_version_operations.py +++ b/scripts/test_product_version_operations.py @@ -66,6 +66,15 @@ def test_bad_expected_version_and_major_bump_are_rejected(self) -> None: with self.assertRaisesRegex(RuntimeError, "major requires explicit"): version_helper.apply(self.root, "major", None, "2.3.0", "event-0004", "push:" + self.head, self.head) + def test_release_guard_requires_exact_branch_version_and_source(self) -> None: + self.assertEqual("2.3.0", version_helper.verify_release_source(self.root, "release-2.3.0", self.head)) + with self.assertRaisesRegex(RuntimeError, "exactly release"): + version_helper.verify_release_source(self.root, "release-02.3.0", self.head) + with self.assertRaisesRegex(RuntimeError, "disagree"): + version_helper.verify_release_source(self.root, "release-2.3.1", self.head) + with self.assertRaisesRegex(RuntimeError, "exact approved"): + version_helper.verify_release_source(self.root, "release-2.3.0", "0" * 40) + def test_stale_head_and_baseline_are_rejected(self) -> None: with self.assertRaisesRegex(RuntimeError, "source HEAD"): self.apply(expected_head="0" * 40) From 96bdb9a1e873cb56ca2737b5980d09f52c95906d Mon Sep 17 00:00:00 2001 From: pcvantol Date: Tue, 8 Sep 2026 18:07:33 +0200 Subject: [PATCH 07/11] docs: declare version preparation helper contract --- .version-preparation.json | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .version-preparation.json diff --git a/.version-preparation.json b/.version-preparation.json new file mode 100644 index 0000000..7a833e6 --- /dev/null +++ b/.version-preparation.json @@ -0,0 +1,9 @@ +{ + "contract_version": "1", + "product_id": "workspace", + "repository_id": "pcvantol/workspace", + "helper_path": "scripts/advance_product_version.py", + "receipt_directory": ".version-operations", + "allowed_projection_paths": ["product-version.json"], + "policy_revision": "workspace-product-versioning-v1" +} From acfad12ffb85329ef38c7cf1b4a8c7c024ce633a Mon Sep 17 00:00:00 2001 From: pcvantol Date: Tue, 8 Sep 2026 18:42:09 +0200 Subject: [PATCH 08/11] docs: clarify pending version delivery seam --- docs/governance/CANONICAL_PRODUCT_VERSIONING_ADOPTION.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/governance/CANONICAL_PRODUCT_VERSIONING_ADOPTION.md b/docs/governance/CANONICAL_PRODUCT_VERSIONING_ADOPTION.md index f7987de..c7c912f 100644 --- a/docs/governance/CANONICAL_PRODUCT_VERSIONING_ADOPTION.md +++ b/docs/governance/CANONICAL_PRODUCT_VERSIONING_ADOPTION.md @@ -42,3 +42,8 @@ authorizes a branch nor publishes an artifact. Workspace presently has no repository-local protected version-preparation dispatcher, GitHub App, or EP qualification integration capable of committing a prepared operation and binding hosted qualification evidence to its resulting SHA. + +Engineering Platform PR [#105](https://github.com/pcvantol/engineering-platform/pull/105) +is a pending source-level bounded adapter for that future integration. It does +not prove an installed writer, an active grant, protected merge delivery or +artifact publication. From 8834af3a13b6b8a8f008e93298758147c94501e0 Mon Sep 17 00:00:00 2001 From: pcvantol Date: Tue, 8 Sep 2026 18:57:54 +0200 Subject: [PATCH 09/11] feat: adopt bootstrap release cadence v2 --- scripts/advance_product_version.py | 10 ++++++++-- scripts/test_product_version_operations.py | 6 ++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/scripts/advance_product_version.py b/scripts/advance_product_version.py index ea785f8..64d7f33 100644 --- a/scripts/advance_product_version.py +++ b/scripts/advance_product_version.py @@ -18,7 +18,7 @@ PRODUCT = "workspace" VERSION = re.compile(r"^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$") OPERATION_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{7,127}$") -POLICY_REVISION = "workspace-product-versioning-v1" +POLICY_REVISION = "workspace-bootstrap-release-cadence-v2" RELEASE_BRANCH = re.compile(r"^release-((?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*))$") def _pairs(pairs: list[tuple[str, object]]) -> dict[str, object]: @@ -104,6 +104,8 @@ def _target(actual: tuple[int, int, int], component: str | None, exact: str | No raise RuntimeError("the requested release version must be stable X.Y.Z") return exact major, minor, patch = actual + if component == "none": + return f"{major}.{minor}.{patch}" if component == "patch": return f"{major}.{minor}.{patch + 1}" if component == "minor": @@ -120,12 +122,16 @@ def _receipt(root: Path, operation_id: str, event_lineage: str, expected_head: s raise RuntimeError("event lineage and a full expected source revision are required") if policy_revision != POLICY_REVISION: raise RuntimeError(f"unsupported Workspace version policy revision: {policy_revision}") + release_class = "EXACT" if exact is not None else {"patch": "PATCH", "minor": "MINOR", "none": "NO_BUMP"}.get(component) + if release_class is None: + raise RuntimeError("unsupported bootstrap release classification") requested: dict[str, object] = {"exact_version": exact} if exact is not None else {"bump": component} value = { "schema_version": 1, "operation_id": operation_id, "product": PRODUCT, "policy_revision": policy_revision, "event_lineage": event_lineage, "expected_source_revision": expected_head, "expected_version": expected_version, "requested": requested, "determined_version": determined, + "release_class": release_class, "classification_rationale": event_lineage, "allowed_projection_paths": ["product-version.json"], "result_commit": None, } return root / ".version-operations" / f"{operation_id}.json", value @@ -180,7 +186,7 @@ def apply(root: Path, component: str | None, exact: str | None, expected_version def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser() parser.add_argument("--source-root", type=Path, default=Path.cwd()) - parser.add_argument("--bump", choices=("patch", "minor")) + parser.add_argument("--bump", choices=("none", "patch", "minor")) parser.add_argument("--set-version") parser.add_argument("--expected-version") parser.add_argument("--operation-id") diff --git a/scripts/test_product_version_operations.py b/scripts/test_product_version_operations.py index 29fe8f2..b57d419 100644 --- a/scripts/test_product_version_operations.py +++ b/scripts/test_product_version_operations.py @@ -52,6 +52,12 @@ def test_changed_input_under_an_operation_id_conflicts(self) -> None: with self.assertRaisesRegex(RuntimeError, "operation ID conflict"): self.apply(component="minor") + def test_docs_only_no_bump_is_durable_and_cannot_be_reclassified(self) -> None: + self.assertEqual("2.3.0", self.apply(operation_id="increment-docs-1", component="none")) + self.assertEqual("2.3.0", self.apply(operation_id="increment-docs-1", component="none")) + with self.assertRaisesRegex(RuntimeError, "operation ID conflict"): + self.apply(operation_id="increment-docs-1", component="patch") + def test_minor_and_explicit_release_target_are_deterministic(self) -> None: self.assertEqual("2.4.0", self.apply(operation_id="event-0002", component="minor")) # An explicit release target may be prepared only from the declared From 206b9283a6b8e311a21164382924d24bf4553658 Mon Sep 17 00:00:00 2001 From: pcvantol Date: Tue, 8 Sep 2026 18:59:55 +0200 Subject: [PATCH 10/11] docs: define bootstrap release cadence v2 --- docs/governance/CANONICAL_PRODUCT_VERSIONING_ADOPTION.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/governance/CANONICAL_PRODUCT_VERSIONING_ADOPTION.md b/docs/governance/CANONICAL_PRODUCT_VERSIONING_ADOPTION.md index c7c912f..88491e3 100644 --- a/docs/governance/CANONICAL_PRODUCT_VERSIONING_ADOPTION.md +++ b/docs/governance/CANONICAL_PRODUCT_VERSIONING_ADOPTION.md @@ -1,6 +1,13 @@ # Canonical product versioning adoption -Workspace adopts Forge Platform's [canonical product versioning policy](https://github.com/pcvantol/forge-platform/blob/main/docs/architecture/CANONICAL_PRODUCT_VERSIONING.md), policy v1. +Workspace adopts `BOOTSTRAP_RELEASE_CADENCE_V2` through +`workspace-bootstrap-release-cadence-v2`. V1 receipts remain immutable +historical evidence and are never reinterpreted. + +A bounded engineering increment defaults to `PATCH`; documentation-only work +is explicit `NO_BUMP`; only an explicit capability boundary is `MINOR`; and +`MAJOR`/`EXACT` require applicable authority. Repair, requalification and the +protected merge are evidence for the same operation, never another allocation. `product-version.json` (`product=workspace`, `schema_version=1`, `version`) is Workspace's only product-release version source. Its committed `2.3.0` baseline From efdee8619f6bb965044e01169ae001a466e2d52a Mon Sep 17 00:00:00 2001 From: pcvantol Date: Tue, 8 Sep 2026 19:03:24 +0200 Subject: [PATCH 11/11] fix: recover version receipt after delivery head changes --- scripts/advance_product_version.py | 4 ++-- scripts/test_product_version_operations.py | 7 +++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/scripts/advance_product_version.py b/scripts/advance_product_version.py index 64d7f33..453beee 100644 --- a/scripts/advance_product_version.py +++ b/scripts/advance_product_version.py @@ -158,8 +158,6 @@ def apply(root: Path, component: str | None, exact: str | None, expected_version root = root.resolve() target, payload, parsed = current(root) actual = payload["version"] - if _head(root) != expected_head: - raise RuntimeError("stale version operation: source HEAD differs from expected source revision") # Calculate from the expected baseline when recovering after a crash, never # from the already-written target. if VERSION.fullmatch(expected_version) is None: @@ -173,6 +171,8 @@ def apply(root: Path, component: str | None, exact: str | None, expected_version if actual != determined: raise RuntimeError("operation ID conflict: receipt and canonical version disagree") return determined + if _head(root) != expected_head: + raise RuntimeError("stale version operation: source HEAD differs from expected source revision") if actual not in (expected_version, determined): raise RuntimeError(f"stale version operation: expected {expected_version}, found {actual}") if actual == expected_version and determined != actual: diff --git a/scripts/test_product_version_operations.py b/scripts/test_product_version_operations.py index b57d419..e769ce8 100644 --- a/scripts/test_product_version_operations.py +++ b/scripts/test_product_version_operations.py @@ -58,6 +58,13 @@ def test_docs_only_no_bump_is_durable_and_cannot_be_reclassified(self) -> None: with self.assertRaisesRegex(RuntimeError, "operation ID conflict"): self.apply(operation_id="increment-docs-1", component="patch") + def test_existing_increment_receipt_survives_a_later_delivery_head(self) -> None: + self.assertEqual("2.3.1", self.apply(operation_id="increment-delivery-1")) + (self.root / "delivery-evidence").write_text("merged", encoding="utf-8") + subprocess.run(["git", "-C", str(self.root), "add", "delivery-evidence"], check=True) + subprocess.run(["git", "-C", str(self.root), "-c", "user.name=test", "-c", "user.email=test@example.invalid", "commit", "-qm", "delivery"], check=True) + self.assertEqual("2.3.1", self.apply(operation_id="increment-delivery-1")) + def test_minor_and_explicit_release_target_are_deterministic(self) -> None: self.assertEqual("2.4.0", self.apply(operation_id="event-0002", component="minor")) # An explicit release target may be prepared only from the declared