diff --git a/.github/workflows/canonical-versioning.yml b/.github/workflows/canonical-versioning.yml new file mode 100644 index 0000000..4aef7ca --- /dev/null +++ b/.github/workflows/canonical-versioning.yml @@ -0,0 +1,15 @@ +name: Canonical product versioning + +on: + push: + branches: ['**'] + +permissions: + contents: read + +jobs: + validate-version-source: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v5 + - run: python3 scripts/advance_product_version.py --check 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" +} diff --git a/docs/governance/CANONICAL_PRODUCT_VERSIONING_ADOPTION.md b/docs/governance/CANONICAL_PRODUCT_VERSIONING_ADOPTION.md new file mode 100644 index 0000000..bac42cd --- /dev/null +++ b/docs/governance/CANONICAL_PRODUCT_VERSIONING_ADOPTION.md @@ -0,0 +1,65 @@ +# Canonical product versioning adoption + +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 +`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`). 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. + +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 +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. + +## 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/product-version.json b/product-version.json new file mode 100644 index 0000000..3c85c26 --- /dev/null +++ b/product-version.json @@ -0,0 +1,5 @@ +{ + "product": "forge", + "schema_version": 1, + "version": "2.3.0" +} diff --git a/scripts/advance_product_version.py b/scripts/advance_product_version.py new file mode 100644 index 0000000..4054781 --- /dev/null +++ b/scripts/advance_product_version.py @@ -0,0 +1,289 @@ +#!/usr/bin/env python3 +"""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 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 = "forge-bootstrap-release-cadence-v2" + + +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"), object_pairs_hook=_pairs) + except (OSError, ValueError, json.JSONDecodeError) as error: + raise RuntimeError("canonical product version manifest is unreadable") from error + 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 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 == "none": + return f"{major}.{minor}.{patch}" + 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 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: + 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 _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 _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") + 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") + 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, + "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, + "release_class": release_class, + "classification_rationale": event_lineage, + "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 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, + 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 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 + _atomic_write(target, json.dumps(payload, indent=2, sort_keys=True) + "\n") + 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=("none", "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("--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.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) + 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( + "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 + + +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 \ diff --git a/tests/test_product_version_operations.py b/tests/test_product_version_operations.py new file mode 100644 index 0000000..b56c27a --- /dev/null +++ b/tests/test_product_version_operations.py @@ -0,0 +1,104 @@ +"""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_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) + 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") + + 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")