From 6c4030d6a3e9df27d2d503e32b8e1bc31693c89f Mon Sep 17 00:00:00 2001 From: pcvantol Date: Tue, 8 Sep 2026 08:25:39 +0200 Subject: [PATCH 01/11] ci: adopt canonical product versioning --- .github/workflows/canonical-versioning.yml | 48 ++++++++++++++++ .../CANONICAL_PRODUCT_VERSIONING_ADOPTION.md | 9 +++ product-version.json | 5 ++ scripts/advance_product_version.py | 55 +++++++++++++++++++ scripts/validate.sh | 1 + 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..4ba8af2 --- /dev/null +++ b/docs/governance/CANONICAL_PRODUCT_VERSIONING_ADOPTION.md @@ -0,0 +1,9 @@ +# Canonical product versioning adoption + +Forge 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 Forge's only product-release version source. Its +version does not change Forge schema, Mission, Producer Contract, Execution +Host Contract or provider compatibility versions. Those remain independent, +explicit contracts. `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..a6ccf95 --- /dev/null +++ b/product-version.json @@ -0,0 +1,5 @@ +{ + "product": "forge", + "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 ecb1184..ba90fdb 100644 --- a/scripts/validate.sh +++ b/scripts/validate.sh @@ -3,6 +3,7 @@ set -euo pipefail python3 -m compileall -q forge tests python3 -m unittest discover -s tests -v +python3 scripts/advance_product_version.py --check python3 docs/ai-development/validate_projection.py \ --profile forge \ --source-commit ec070e399ff4dbd92e760370002995fe4f4d52d6 \ From 4d031972944f557bd3fc844eba77abb49affdc7f 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:47 +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 a6ccf95..2e5f756 100644 --- a/product-version.json +++ b/product-version.json @@ -1,5 +1,5 @@ { "product": "forge", "schema_version": 1, - "version": "0.1.0" + "version": "0.1.1" } From a0602ba702fcf0ec3807599526257c410084b02f Mon Sep 17 00:00:00 2001 From: pcvantol Date: Tue, 8 Sep 2026 08:31:04 +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 2e5f756..3c85c26 100644 --- a/product-version.json +++ b/product-version.json @@ -1,5 +1,5 @@ { "product": "forge", "schema_version": 1, - "version": "0.1.1" + "version": "2.3.0" } From c21d15e7d5b2ed27cd6efa89637b03ca1ba5f4d6 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 | 27 +++++- scripts/advance_product_version.py | 96 +++++++++++++++---- 3 files changed, 104 insertions(+), 58 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 4ba8af2..f01086e 100644 --- a/docs/governance/CANONICAL_PRODUCT_VERSIONING_ADOPTION.md +++ b/docs/governance/CANONICAL_PRODUCT_VERSIONING_ADOPTION.md @@ -2,8 +2,25 @@ Forge 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 Forge's only product-release version source. Its -version does not change Forge schema, Mission, Producer Contract, Execution -Host Contract or provider compatibility versions. Those remain independent, -explicit contracts. `scripts/advance_product_version.py` and the scoped -`Canonical product versioning` workflow are the only automatic mutators. +`product-version.json` (`product=forge`, `schema_version=1`, `version`) is +Forge's only product-release version source. The checked-in baseline is +`2.3.0`; it is not evidence of a published release. Its version does not change +Forge schema, Mission, Producer Contract, Execution Host Contract or provider +compatibility versions. + +The helper separates read-only `--check`, non-mutating planning, and an +explicit guarded apply (`--bump patch|minor` or `--set-version X.Y.Z` with an +optional expected baseline). It performs a single-file atomic replacement, but +does not claim a cross-file transaction, commit, push, qualification, artifact +publication or compatibility approval. Stable release publication remains +blocked unless an explicit compatibility classification, approved exact source, +exact target version and immutable artifact identity are supplied. + +The workflow intentionally has read-only permissions. The former token-pushed +version commit could not prove qualification of its new SHA and could not safely +provide exactly-once event delivery. The required protected version-preparation +delivery route must bind operation ID, event/branch lineage, expected head and +policy revision before automated feature-patch/main-minor allocation is enabled. +Until then this repository has no automatic version writer; builds consume the +committed source only. This is product-owned groundwork, not Forge's future +generic version/release planner. diff --git a/scripts/advance_product_version.py b/scripts/advance_product_version.py index 444e2ee..198178a 100644 --- a/scripts/advance_product_version.py +++ b/scripts/advance_product_version.py @@ -1,37 +1,91 @@ #!/usr/bin/env python3 -"""Validate or advance Forge-family product semantic versions.""" +"""Inspect, plan, or explicitly apply Forge's product-version.json change. + +This repository-local boundary does not commit, publish, or qualify releases. +""" from __future__ import annotations import argparse import json +import os from pathlib import Path import re +import tempfile +PRODUCT = "forge" 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 determine(parsed: 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") + return exact + major, minor, patch = parsed + 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 _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}") + version = determine((major, minor, patch), component, exact) + 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 +93,23 @@ 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("--plan", action="store_true") 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 or args.plan: + parser.error("--check cannot change or plan a version") _, payload, _ = current(args.source_root) print(f"PRODUCT_VERSION=PASS version={payload['version']}") + elif args.plan: + if (args.bump is None) == (args.set_version is None): + parser.error("--plan requires exactly one requested version operation") + _, payload, parsed = current(args.source_root) + print(json.dumps({"product": PRODUCT, "baseline": payload["version"], "target": determine(parsed, args.bump, args.set_version), "writes": []}, sort_keys=True)) 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 f0cda55760919000ced23a4c00cc8e0847196d32 Mon Sep 17 00:00:00 2001 From: pcvantol Date: Tue, 8 Sep 2026 16:07:01 +0200 Subject: [PATCH 05/11] feat: bind Forge version operations to durable receipts --- .../CANONICAL_PRODUCT_VERSIONING_ADOPTION.md | 14 +- scripts/advance_product_version.py | 134 +++++++++++++++++- tests/test_product_version_operations.py | 78 ++++++++++ 3 files changed, 215 insertions(+), 11 deletions(-) create mode 100644 tests/test_product_version_operations.py diff --git a/docs/governance/CANONICAL_PRODUCT_VERSIONING_ADOPTION.md b/docs/governance/CANONICAL_PRODUCT_VERSIONING_ADOPTION.md index f01086e..b37d4a8 100644 --- a/docs/governance/CANONICAL_PRODUCT_VERSIONING_ADOPTION.md +++ b/docs/governance/CANONICAL_PRODUCT_VERSIONING_ADOPTION.md @@ -9,10 +9,16 @@ Forge schema, Mission, Producer Contract, Execution Host Contract or provider compatibility versions. The helper separates read-only `--check`, non-mutating planning, and an -explicit guarded apply (`--bump patch|minor` or `--set-version X.Y.Z` with an -optional expected baseline). It performs a single-file atomic replacement, but -does not claim a cross-file transaction, commit, push, qualification, artifact -publication or compatibility approval. Stable release publication remains +explicit guarded apply (`--bump patch|minor` or `--set-version X.Y.Z`). An +apply requires an operation ID, expected Git head, expected baseline version, +event/branch lineage and policy revision. It writes a committed durable receipt +under `.github/product-version-operations/`; the same operation ID and inputs +return the same target, while altered inputs conflict. The receipt is staged +before the manifest so an interrupted local write can be resumed without +deriving a second bump. Each file uses atomic replacement, but this is not a +cross-file transaction: a caller must commit and qualify the complete resulting +candidate as one delivery boundary. The helper does not commit, push, +qualification, artifact publication or compatibility approval. Stable release publication remains blocked unless an explicit compatibility classification, approved exact source, exact target version and immutable artifact identity are supplied. diff --git a/scripts/advance_product_version.py b/scripts/advance_product_version.py index 198178a..7d14509 100644 --- a/scripts/advance_product_version.py +++ b/scripts/advance_product_version.py @@ -10,10 +10,15 @@ import os from pathlib import Path import re +import subprocess import tempfile +from typing import Any PRODUCT = "forge" 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}$") +OPERATIONS_DIRECTORY = Path(".github/product-version-operations") +POLICY_REVISION = "canonical-product-versioning-policy-v1" def _pairs(pairs: list[tuple[str, object]]) -> dict[str, object]: @@ -59,7 +64,7 @@ def determine(parsed: tuple[int, int, int], component: str | None, exact: str | def _atomic_write(path: Path, text: str) -> None: - mode = path.stat().st_mode + mode = path.stat().st_mode if path.exists() else 0o644 fd, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) try: with os.fdopen(fd, "w", encoding="utf-8") as handle: @@ -76,12 +81,111 @@ def _atomic_write(path: Path, text: str) -> None: 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) +def _git_head(root: Path) -> str: + result = subprocess.run( + ["git", "-C", str(root), "rev-parse", "HEAD"], + check=False, + capture_output=True, + text=True, + ) + if result.returncode: + raise RuntimeError("version operation requires a Git worktree with a resolved HEAD") + return result.stdout.strip() + + +def _operation_path(root: Path, operation_id: str) -> Path: + if OPERATION_ID.fullmatch(operation_id) is None: + raise RuntimeError("operation ID must be a stable, non-path identifier") + return root.resolve() / OPERATIONS_DIRECTORY / f"{operation_id}.json" + + +def _read_operation(path: Path) -> dict[str, Any] | None: + if not path.exists(): + return None + try: + payload = json.loads(path.read_text(encoding="utf-8"), object_pairs_hook=_pairs) + except (OSError, ValueError, json.JSONDecodeError) as error: + raise RuntimeError("version operation receipt is unreadable") from error + if not isinstance(payload, dict): + raise RuntimeError("version operation receipt must be an object") + return payload + + +def _operation_input( + operation_id: str, + expected_head: str, + expected_version: str, + component: str | None, + exact: str | None, + event_lineage: str, + policy_revision: str, + target: str, +) -> dict[str, str | None]: + if not event_lineage.strip(): + raise RuntimeError("version operation requires a non-empty event or branch lineage") + if not policy_revision.strip(): + raise RuntimeError("version operation requires a non-empty policy revision") + return { + "schema_version": "1", + "operation_id": operation_id, + "product": PRODUCT, + "component": "product", + "policy_revision": policy_revision, + "event_lineage": event_lineage, + "expected_head": expected_head, + "baseline_version": expected_version, + "requested_bump": component, + "requested_version": exact, + "target_version": target, + "projection_paths": "product-version.json", + } + + +def _validate_existing_operation(existing: dict[str, Any], requested: dict[str, str | None]) -> None: + # Receipt equality, rather than a commit subject or actor, is the idempotency key. + if existing != requested: + raise RuntimeError("conflicting reuse of version operation ID") + + +def advance( + root: Path, + component: str | None, + exact: str | None = None, + expected_version: str | None = None, + operation_id: str | None = None, + expected_head: str | None = None, + event_lineage: str | None = None, + policy_revision: str = POLICY_REVISION, +) -> str: + target, payload, parsed = 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}") - version = determine((major, minor, patch), component, exact) + if operation_id is None or expected_head is None or expected_version is None or event_lineage is None: + raise RuntimeError("apply requires operation ID, expected head, expected version, and event lineage") + if VERSION.fullmatch(expected_version) is None: + raise RuntimeError("expected version must be stable X.Y.Z") + # Calculate from the declared baseline, never from a source that may have + # been changed by an interrupted first attempt. + version = determine(tuple(int(part) for part in expected_version.split(".")), component, exact) + requested = _operation_input( + operation_id, expected_head, expected_version, component, exact, event_lineage, policy_revision, version + ) + receipt = _operation_path(root, operation_id) + existing = _read_operation(receipt) + if existing is not None: + _validate_existing_operation(existing, requested) + if actual not in (expected_version, version): + raise RuntimeError("version operation receipt conflicts with canonical source") + # A crash after receipt staging but before the source replacement can be + # resumed. It never derives a fresh bump from the partially changed source. + if actual == version: + return version + else: + if _git_head(root) != expected_head: + raise RuntimeError("stale version operation: expected Git head no longer matches") + if expected_version != actual: + raise RuntimeError(f"stale version operation: expected {expected_version}, found {actual}") + receipt.parent.mkdir(parents=True, exist_ok=True) + _atomic_write(receipt, json.dumps(requested, indent=2, sort_keys=True) + "\n") if version == actual: return version payload["version"] = version @@ -95,6 +199,10 @@ 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("--expected-head") + parser.add_argument("--event-lineage") + parser.add_argument("--policy-revision", default=POLICY_REVISION) parser.add_argument("--plan", action="store_true") parser.add_argument("--check", action="store_true") args = parser.parse_args(argv) @@ -109,7 +217,19 @@ def main(argv: list[str] | None = None) -> int: _, payload, parsed = current(args.source_root) print(json.dumps({"product": PRODUCT, "baseline": payload["version"], "target": determine(parsed, args.bump, args.set_version), "writes": []}, sort_keys=True)) else: - print(f"PRODUCT_VERSION={advance(args.source_root, args.bump, args.set_version, args.expected_version)}") + print( + "PRODUCT_VERSION=" + + advance( + args.source_root, + args.bump, + args.set_version, + args.expected_version, + args.operation_id, + args.expected_head, + args.event_lineage, + args.policy_revision, + ) + ) return 0 diff --git a/tests/test_product_version_operations.py b/tests/test_product_version_operations.py new file mode 100644 index 0000000..201b388 --- /dev/null +++ b/tests/test_product_version_operations.py @@ -0,0 +1,78 @@ +"""Regression coverage for the repository-owned product version operation.""" +from __future__ import annotations + +import importlib.util +import json +from pathlib import Path +import subprocess +import tempfile +import unittest + + +SCRIPT = Path(__file__).parents[1] / "scripts" / "advance_product_version.py" +SPEC = importlib.util.spec_from_file_location("advance_product_version", SCRIPT) +assert SPEC and SPEC.loader +versioning = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(versioning) + + +def repository(tmp_path: Path, version: str = "2.3.0") -> tuple[Path, str]: + (tmp_path / "product-version.json").write_text( + json.dumps({"schema_version": 1, "product": "forge", "version": version}) + "\n", + encoding="utf-8", + ) + subprocess.run(["git", "init", "-q", str(tmp_path)], check=True) + subprocess.run(["git", "-C", str(tmp_path), "add", "product-version.json"], check=True) + subprocess.run( + ["git", "-C", str(tmp_path), "-c", "user.email=test@example.invalid", "-c", "user.name=test", "commit", "-qm", "base"], + check=True, + ) + return tmp_path, subprocess.check_output(["git", "-C", str(tmp_path), "rev-parse", "HEAD"], text=True).strip() + + +def apply(root: Path, head: str, operation: str = "version-op-0001", lineage: str = "refs/heads/feature/a") -> str: + return versioning.advance( + root, "patch", expected_version="2.3.0", operation_id=operation, + expected_head=head, event_lineage=lineage, + ) + + +class ProductVersionOperationTests(unittest.TestCase): + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory() + self.root, self.head = repository(Path(self.temporary.name)) + + def tearDown(self) -> None: + self.temporary.cleanup() + + def test_operation_receipt_makes_retry_idempotent(self) -> None: + self.assertEqual(apply(self.root, self.head), "2.3.1") + self.assertEqual(apply(self.root, self.head), "2.3.1") + self.assertEqual(json.loads((self.root / "product-version.json").read_text())["version"], "2.3.1") + self.assertTrue((self.root / ".github/product-version-operations/version-op-0001.json").exists()) + + def test_changed_meaning_for_operation_id_conflicts(self) -> None: + apply(self.root, self.head) + with self.assertRaisesRegex(RuntimeError, "conflicting reuse"): + apply(self.root, self.head, lineage="refs/heads/feature/other") + + def test_stale_expected_head_writes_neither_receipt_nor_version(self) -> None: + (self.root / "unrelated").write_text("changed", encoding="utf-8") + subprocess.run(["git", "-C", str(self.root), "add", "unrelated"], check=True) + subprocess.run(["git", "-C", str(self.root), "-c", "user.email=test@example.invalid", "-c", "user.name=test", "commit", "-qm", "changed"], check=True) + with self.assertRaisesRegex(RuntimeError, "expected Git head"): + apply(self.root, self.head) + self.assertEqual(json.loads((self.root / "product-version.json").read_text())["version"], "2.3.0") + self.assertFalse((self.root / ".github/product-version-operations").exists()) + + def test_staged_receipt_recovers_without_second_bump(self) -> None: + target = "2.3.1" + receipt = self.root / ".github/product-version-operations/version-op-0001.json" + receipt.parent.mkdir(parents=True) + receipt.write_text(json.dumps(versioning._operation_input("version-op-0001", self.head, "2.3.0", "patch", None, "refs/heads/feature/a", versioning.POLICY_REVISION, target)), encoding="utf-8") + self.assertEqual(apply(self.root, self.head), target) + self.assertEqual(json.loads((self.root / "product-version.json").read_text())["version"], target) + + def test_apply_requires_explicit_operation_provenance(self) -> None: + with self.assertRaisesRegex(RuntimeError, "apply requires"): + versioning.advance(self.root, "patch") From 269675b7517ae15c73a850b6b031d99ef544d093 Mon Sep 17 00:00:00 2001 From: pcvantol Date: Tue, 8 Sep 2026 16:14:38 +0200 Subject: [PATCH 06/11] feat: guard Forge release candidates by exact source --- .../CANONICAL_PRODUCT_VERSIONING_ADOPTION.md | 18 +++++++ scripts/advance_product_version.py | 47 ++++++++++++++++++- tests/test_product_version_operations.py | 15 ++++++ 3 files changed, 79 insertions(+), 1 deletion(-) diff --git a/docs/governance/CANONICAL_PRODUCT_VERSIONING_ADOPTION.md b/docs/governance/CANONICAL_PRODUCT_VERSIONING_ADOPTION.md index b37d4a8..9b1ea65 100644 --- a/docs/governance/CANONICAL_PRODUCT_VERSIONING_ADOPTION.md +++ b/docs/governance/CANONICAL_PRODUCT_VERSIONING_ADOPTION.md @@ -30,3 +30,21 @@ policy revision before automated feature-patch/main-minor allocation is enabled. Until then this repository has no automatic version writer; builds consume the committed source only. This is product-owned groundwork, not Forge's future generic version/release planner. + +## Candidate delivery and release guard + +Forge's active `main` ruleset requires a pull request and the exact `Test and +static validation` status. The normal PR workflow checks out the PR head, so a +version-preparation commit pushed to an existing PR receives qualification for +that new candidate rather than borrowing the preceding head's result. The +repository currently has no authorized GitHub App, trusted dispatch route, or +write-capable workflow that can create that preparation commit; the helper and +workflow therefore do not attempt one. + +Before any existing authorized publication route can act, its caller must run +`--verify-release-candidate --release-branch release-X.Y.Z --approved-head + --approved-version X.Y.Z`. This read-only guard requires the +current branch name, canonical source and exact checked-out head to agree. It +does not treat a branch name as approval, establish compatibility, inspect a +registry, create a tag, or publish an artifact. Those facts must be supplied +and recorded by the authorized release route. diff --git a/scripts/advance_product_version.py b/scripts/advance_product_version.py index 7d14509..a1af776 100644 --- a/scripts/advance_product_version.py +++ b/scripts/advance_product_version.py @@ -93,6 +93,18 @@ def _git_head(root: Path) -> str: return result.stdout.strip() +def _git_branch(root: Path) -> str: + result = subprocess.run( + ["git", "-C", str(root), "symbolic-ref", "--quiet", "--short", "HEAD"], + check=False, + capture_output=True, + text=True, + ) + if result.returncode: + raise RuntimeError("release candidate verification requires a named Git branch") + return result.stdout.strip() + + def _operation_path(root: Path, operation_id: str) -> Path: if OPERATION_ID.fullmatch(operation_id) is None: raise RuntimeError("operation ID must be a stable, non-path identifier") @@ -147,6 +159,26 @@ def _validate_existing_operation(existing: dict[str, Any], requested: dict[str, raise RuntimeError("conflicting reuse of version operation ID") +def verify_release_candidate(root: Path, release_branch: str, approved_head: str, approved_version: str) -> str: + """Read-only source guard; approval and publication remain external facts.""" + match = re.fullmatch(r"release-(" + VERSION.pattern.removeprefix("^").removesuffix("$") + r")", release_branch) + if match is None: + raise RuntimeError("release branch must be exactly release-X.Y.Z") + branch_version = match.group(1) + if VERSION.fullmatch(approved_version) is None: + raise RuntimeError("approved release version must be stable X.Y.Z") + if branch_version != approved_version: + raise RuntimeError("release branch version must equal the approved release version") + _, payload, _ = current(root) + if payload["version"] != approved_version: + raise RuntimeError("canonical product version does not equal the approved release version") + if _git_branch(root) != release_branch: + raise RuntimeError("current branch is not the declared release branch") + if _git_head(root) != approved_head: + raise RuntimeError("current Git head is not the approved exact release source") + return approved_version + + def advance( root: Path, component: str | None, @@ -203,10 +235,23 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument("--expected-head") parser.add_argument("--event-lineage") parser.add_argument("--policy-revision", default=POLICY_REVISION) + parser.add_argument("--verify-release-candidate", action="store_true") + parser.add_argument("--release-branch") + parser.add_argument("--approved-head") + parser.add_argument("--approved-version") parser.add_argument("--plan", action="store_true") parser.add_argument("--check", action="store_true") args = parser.parse_args(argv) - if args.check: + if args.verify_release_candidate: + if args.check or args.plan or args.bump or args.set_version: + parser.error("--verify-release-candidate is read-only and cannot combine with version mutation modes") + if not args.release_branch or not args.approved_head or not args.approved_version: + parser.error("release candidate verification requires branch, approved head, and approved version") + print( + "RELEASE_CANDIDATE=PASS version=" + + verify_release_candidate(args.source_root, args.release_branch, args.approved_head, args.approved_version) + ) + elif args.check: if args.bump or args.set_version or args.plan: parser.error("--check cannot change or plan a version") _, payload, _ = current(args.source_root) diff --git a/tests/test_product_version_operations.py b/tests/test_product_version_operations.py index 201b388..b6feb63 100644 --- a/tests/test_product_version_operations.py +++ b/tests/test_product_version_operations.py @@ -76,3 +76,18 @@ def test_staged_receipt_recovers_without_second_bump(self) -> None: def test_apply_requires_explicit_operation_provenance(self) -> None: with self.assertRaisesRegex(RuntimeError, "apply requires"): versioning.advance(self.root, "patch") + + def test_release_candidate_binds_exact_branch_head_and_version(self) -> None: + subprocess.run(["git", "-C", str(self.root), "branch", "-m", "release-2.3.0"], check=True) + self.assertEqual( + versioning.verify_release_candidate(self.root, "release-2.3.0", self.head, "2.3.0"), "2.3.0" + ) + with self.assertRaisesRegex(RuntimeError, "branch version"): + versioning.verify_release_candidate(self.root, "release-2.3.1", self.head, "2.3.0") + with self.assertRaisesRegex(RuntimeError, "approved exact"): + versioning.verify_release_candidate(self.root, "release-2.3.0", "0" * 40, "2.3.0") + + def test_release_candidate_rejects_branch_only_authority(self) -> None: + subprocess.run(["git", "-C", str(self.root), "branch", "-m", "release-2.3.1"], check=True) + with self.assertRaisesRegex(RuntimeError, "canonical product version"): + versioning.verify_release_candidate(self.root, "release-2.3.1", self.head, "2.3.1") From 2292fd737931995c542feab6f658078d86003105 Mon Sep 17 00:00:00 2001 From: pcvantol Date: Tue, 8 Sep 2026 18:07:31 +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..ad2134d --- /dev/null +++ b/.version-preparation.json @@ -0,0 +1,9 @@ +{ + "contract_version": "1", + "product_id": "forge", + "repository_id": "pcvantol/forge", + "helper_path": "scripts/advance_product_version.py", + "receipt_directory": ".github/product-version-operations", + "allowed_projection_paths": ["product-version.json"], + "policy_revision": "canonical-product-versioning-policy-v1" +} From 061e0cc761fe69fd73772c5050ca4788fdde4acf 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 | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/governance/CANONICAL_PRODUCT_VERSIONING_ADOPTION.md b/docs/governance/CANONICAL_PRODUCT_VERSIONING_ADOPTION.md index 9b1ea65..3053208 100644 --- a/docs/governance/CANONICAL_PRODUCT_VERSIONING_ADOPTION.md +++ b/docs/governance/CANONICAL_PRODUCT_VERSIONING_ADOPTION.md @@ -22,6 +22,13 @@ qualification, artifact publication or compatibility approval. Stable release pu blocked unless an explicit compatibility classification, approved exact source, exact target version and immutable artifact identity are supplied. +Engineering Platform PR [#105](https://github.com/pcvantol/engineering-platform/pull/105) +is the pending source-level bounded version-preparation adapter. It validates a +declared product helper, isolates its candidate, verifies its allowlisted +receipt/projection diff and binds exact-head qualification evidence. It is not +yet installed-runtime evidence, a version grant, a protected merge authority or +publication proof; Forge therefore retains the fail-closed boundary below. + The workflow intentionally has read-only permissions. The former token-pushed version commit could not prove qualification of its new SHA and could not safely provide exactly-once event delivery. The required protected version-preparation From 0c770d6421ff85e8ff34552734518f5554b1cb4a Mon Sep 17 00:00:00 2001 From: pcvantol Date: Tue, 8 Sep 2026 18:59:06 +0200 Subject: [PATCH 09/11] feat: adopt bootstrap release cadence v2 --- scripts/advance_product_version.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/scripts/advance_product_version.py b/scripts/advance_product_version.py index a1af776..4054781 100644 --- a/scripts/advance_product_version.py +++ b/scripts/advance_product_version.py @@ -18,7 +18,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}$") OPERATIONS_DIRECTORY = Path(".github/product-version-operations") -POLICY_REVISION = "canonical-product-versioning-policy-v1" +POLICY_REVISION = "forge-bootstrap-release-cadence-v2" def _pairs(pairs: list[tuple[str, object]]) -> dict[str, object]: @@ -56,6 +56,8 @@ def determine(parsed: tuple[int, int, int], component: str | None, exact: str | raise RuntimeError("the requested release version must be stable X.Y.Z") return exact major, minor, patch = parsed + if component == "none": + return f"{major}.{minor}.{patch}" if component == "patch": return f"{major}.{minor}.{patch + 1}" if component == "minor": @@ -137,6 +139,9 @@ def _operation_input( raise RuntimeError("version operation requires a non-empty event or branch lineage") if not policy_revision.strip(): raise RuntimeError("version operation requires a non-empty policy revision") + release_class = "EXACT" if exact is not None else {"none": "NO_BUMP", "patch": "PATCH", "minor": "MINOR"}.get(component) + if release_class is None: + raise RuntimeError("unsupported bootstrap release classification") return { "schema_version": "1", "operation_id": operation_id, @@ -149,6 +154,8 @@ def _operation_input( "requested_bump": component, "requested_version": exact, "target_version": target, + "release_class": release_class, + "classification_rationale": event_lineage, "projection_paths": "product-version.json", } @@ -228,7 +235,7 @@ def advance( 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") From cae844c6a758cbfd73f258629c285fca26b63e72 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 --- .../CANONICAL_PRODUCT_VERSIONING_ADOPTION.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/governance/CANONICAL_PRODUCT_VERSIONING_ADOPTION.md b/docs/governance/CANONICAL_PRODUCT_VERSIONING_ADOPTION.md index 3053208..bac42cd 100644 --- a/docs/governance/CANONICAL_PRODUCT_VERSIONING_ADOPTION.md +++ b/docs/governance/CANONICAL_PRODUCT_VERSIONING_ADOPTION.md @@ -1,6 +1,14 @@ # Canonical product versioning adoption -Forge adopts Forge Platform's [canonical product versioning policy](https://github.com/pcvantol/forge-platform/blob/main/docs/architecture/CANONICAL_PRODUCT_VERSIONING.md), policy v1. +Forge adopts `BOOTSTRAP_RELEASE_CADENCE_V2` through its product-owned +`forge-bootstrap-release-cadence-v2` policy revision. V1 receipts remain +historical and are never reinterpreted. + +For one canonical engineering increment, `PATCH` is the bootstrap default; +documentation-only work is an explicit `NO_BUMP`; a capability boundary is an +explicit `MINOR`; and `MAJOR`/`EXACT` require their applicable release authority. +Repair, requalification and protected merge are delivery evidence for the same +operation and never allocate another version. CI only validates this binding. `product-version.json` (`product=forge`, `schema_version=1`, `version`) is Forge's only product-release version source. The checked-in baseline is From e132097d2122cc6e1e7e45ab87b3fb6e05d56cc1 Mon Sep 17 00:00:00 2001 From: pcvantol Date: Tue, 8 Sep 2026 19:01:56 +0200 Subject: [PATCH 11/11] test: cover docs-only version operation --- tests/test_product_version_operations.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/test_product_version_operations.py b/tests/test_product_version_operations.py index b6feb63..b56c27a 100644 --- a/tests/test_product_version_operations.py +++ b/tests/test_product_version_operations.py @@ -56,6 +56,17 @@ def test_changed_meaning_for_operation_id_conflicts(self) -> None: with self.assertRaisesRegex(RuntimeError, "conflicting reuse"): apply(self.root, self.head, lineage="refs/heads/feature/other") + def test_docs_only_operation_does_not_allocate_or_allow_reclassification(self) -> None: + self.assertEqual( + versioning.advance(self.root, "none", expected_version="2.3.0", operation_id="version-docs-0001", + expected_head=self.head, event_lineage="increment:docs-1"), + "2.3.0", + ) + self.assertEqual(json.loads((self.root / "product-version.json").read_text())["version"], "2.3.0") + with self.assertRaisesRegex(RuntimeError, "conflicting reuse"): + versioning.advance(self.root, "patch", expected_version="2.3.0", operation_id="version-docs-0001", + expected_head=self.head, event_lineage="increment:docs-1") + def test_stale_expected_head_writes_neither_receipt_nor_version(self) -> None: (self.root / "unrelated").write_text("changed", encoding="utf-8") subprocess.run(["git", "-C", str(self.root), "add", "unrelated"], check=True)