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..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" +} diff --git a/docs/governance/CANONICAL_PRODUCT_VERSIONING_ADOPTION.md b/docs/governance/CANONICAL_PRODUCT_VERSIONING_ADOPTION.md new file mode 100644 index 0000000..88491e3 --- /dev/null +++ b/docs/governance/CANONICAL_PRODUCT_VERSIONING_ADOPTION.md @@ -0,0 +1,56 @@ +# Canonical product versioning adoption + +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 +is not publication evidence and does not alter Workspace architecture, future +EP adapter contracts or external protocols. + +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 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. + +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. diff --git a/product-version.json b/product-version.json new file mode 100644 index 0000000..c1c9ef0 --- /dev/null +++ b/product-version.json @@ -0,0 +1,5 @@ +{ + "product": "workspace", + "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..453beee --- /dev/null +++ b/scripts/advance_product_version.py @@ -0,0 +1,232 @@ +#!/usr/bin/env python3 +"""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 +import json +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-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]: + 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 _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(f"{description} is unreadable") from error + if not isinstance(payload, dict): + 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: + 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 _atomic_write(path: Path, text: str) -> None: + 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) + except BaseException: + try: + os.unlink(temporary) + except FileNotFoundError: + pass + raise + + +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 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") + 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 = actual + 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 _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}") + 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 + + +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"] + # 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 _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: + 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: + 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("--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") + 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, 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: + 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("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 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/test_product_version_operations.py b/scripts/test_product_version_operations.py new file mode 100644 index 0000000..e769ce8 --- /dev/null +++ b/scripts/test_product_version_operations.py @@ -0,0 +1,110 @@ +#!/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_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_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 + # 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_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) + 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 55a8473..f514af0 100755 --- a/scripts/validate.sh +++ b/scripts/validate.sh @@ -6,6 +6,9 @@ for file in "${required[@]}"; do test -s "$file" done +python3 scripts/advance_product_version.py --check +python3 scripts/test_product_version_operations.py + python3 docs/ai-development/validate_projection.py \ --profile workspace \ --source-commit ec070e399ff4dbd92e760370002995fe4f4d52d6 \