From 3ce6241429c8cd7bbff622031ba5d06e7610c2fd Mon Sep 17 00:00:00 2001 From: pcvantol Date: Tue, 8 Sep 2026 08:25:42 +0200 Subject: [PATCH 01/11] ci: adopt canonical product versioning --- .github/workflows/canonical-versioning.yml | 48 ++++++++++++++ .../CANONICAL_PRODUCT_VERSIONING.md | 26 ++++++++ docs/architecture/COMPATIBILITY.md | 2 +- product-version.json | 5 ++ scripts/advance_product_version.py | 62 +++++++++++++++++++ scripts/validate.sh | 1 + 6 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/canonical-versioning.yml create mode 100644 docs/architecture/CANONICAL_PRODUCT_VERSIONING.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/architecture/CANONICAL_PRODUCT_VERSIONING.md b/docs/architecture/CANONICAL_PRODUCT_VERSIONING.md new file mode 100644 index 0000000..c0187dd --- /dev/null +++ b/docs/architecture/CANONICAL_PRODUCT_VERSIONING.md @@ -0,0 +1,26 @@ +# Canonical product versioning + +**Status:** Adopted cross-product policy v1 + +Forge Platform owns this policy as the composition authority. Each product +repository remains authoritative for its own committed `product-version.json`; +Forge Platform does not rewrite another product's version. + +Every product begins with the stable SemVer baseline `0.1.0`. The product-local +`scripts/advance_product_version.py` validates the only version source and is +the only CI mutation mechanism. + +- The first non-bot push to a non-`main`, non-`release-*` branch creates one + patch bump commit. +- Every non-bot push to `main` creates one minor bump commit and resets patch + to zero. +- Writes are serialized per Git ref. The bot never writes a protected branch + unless repository policy explicitly permits its scoped `contents: write` + token; denial fails visibly. +- The versioning workflow validates the resulting manifest before it commits. + It does not publish, tag, deploy, or alter compatibility declarations. + +The policy governs Forge, Workspace and Forge Platform. Engineering Platform +uses the same event semantics with its richer multi-file package-version +projection. A Forge Platform release remains a separately qualified +composition of those independently versioned artifacts. diff --git a/docs/architecture/COMPATIBILITY.md b/docs/architecture/COMPATIBILITY.md index d0df605..0d83a99 100644 --- a/docs/architecture/COMPATIBILITY.md +++ b/docs/architecture/COMPATIBILITY.md @@ -2,4 +2,4 @@ Forge Platform owns cross-product compatibility declarations: Forge version, Workspace version, Engineering Platform version, Agent version, and supported protocol versions. Product repositories remain authoritative for their own protocol implementation and compatibility guarantees. -A Forge Platform release is a tested composition of independently versioned artifacts, not a source-monorepo release. A future release may pair one qualified Forge Runtime version with independently qualified Workspace and Engineering Platform versions. This foundation intentionally chooses no release numbers. +A Forge Platform release is a tested composition of independently versioned artifacts, not a source-monorepo release. A future release may pair one qualified Forge Runtime version with independently qualified Workspace and Engineering Platform versions. Product version mutation is governed by [Canonical product versioning](CANONICAL_PRODUCT_VERSIONING.md); compatibility declarations remain a separate, explicit composition decision. diff --git a/product-version.json b/product-version.json new file mode 100644 index 0000000..aa81286 --- /dev/null +++ b/product-version.json @@ -0,0 +1,5 @@ +{ + "product": "forge-platform", + "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..132b01d --- /dev/null +++ b/scripts/advance_product_version.py @@ -0,0 +1,62 @@ +#!/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 version_file(root: Path) -> Path: + return root.resolve() / "product-version.json" + + +def current(root: Path) -> tuple[Path, dict[str, object], tuple[int, int, int]]: + target = version_file(root) + 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) + if component == "patch": + version = f"{major}.{minor}.{patch + 1}" + elif component == "minor": + version = f"{major}.{minor + 1}.0" + else: + 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 b538cc6..3933a3c 100644 --- a/scripts/validate.sh +++ b/scripts/validate.sh @@ -5,6 +5,7 @@ root_dir=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) cd "$root_dir" python3 tests/foundation/test_foundation.py +python3 scripts/advance_product_version.py --check python3 docs/ai-development/validate_projection.py \ --profile forge-platform \ --source-commit 4a39841a0c85b0e9962c85a74a3fd49d9803c13d \ From ed875e2f5efc8a15c474f28ee72405a458663980 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:51 +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 aa81286..b6686fc 100644 --- a/product-version.json +++ b/product-version.json @@ -1,5 +1,5 @@ { "product": "forge-platform", "schema_version": 1, - "version": "0.1.0" + "version": "0.1.1" } From dee34d2685037ae8621ef878c19626fcc9723625 Mon Sep 17 00:00:00 2001 From: pcvantol Date: Tue, 8 Sep 2026 08:31:07 +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 b6686fc..52c99b1 100644 --- a/product-version.json +++ b/product-version.json @@ -1,5 +1,5 @@ { "product": "forge-platform", "schema_version": 1, - "version": "0.1.1" + "version": "2.3.0" } From 0f474b3059d61654f8858f43a433924704ef7348 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.md | 30 +++++---- scripts/advance_product_version.py | 63 ++++++++++++++----- 3 files changed, 67 insertions(+), 65 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/architecture/CANONICAL_PRODUCT_VERSIONING.md b/docs/architecture/CANONICAL_PRODUCT_VERSIONING.md index c0187dd..0952fe1 100644 --- a/docs/architecture/CANONICAL_PRODUCT_VERSIONING.md +++ b/docs/architecture/CANONICAL_PRODUCT_VERSIONING.md @@ -6,19 +6,25 @@ Forge Platform owns this policy as the composition authority. Each product repository remains authoritative for its own committed `product-version.json`; Forge Platform does not rewrite another product's version. -Every product begins with the stable SemVer baseline `0.1.0`. The product-local -`scripts/advance_product_version.py` validates the only version source and is -the only CI mutation mechanism. +The approved implementation baseline for the four repositories is `2.3.0`; +it is not evidence that any product was published. Each product can advance +independently. The product-local helper validates its only version source and +can apply an explicit patch/minor or exact release target with a stale-baseline +guard; it never decides compatibility, publication, or a major release. -- The first non-bot push to a non-`main`, non-`release-*` branch creates one - patch bump commit. -- Every non-bot push to `main` creates one minor bump commit and resets patch - to zero. -- Writes are serialized per Git ref. The bot never writes a protected branch - unless repository policy explicitly permits its scoped `contents: write` - token; denial fails visibly. -- The versioning workflow validates the resulting manifest before it commits. - It does not publish, tag, deploy, or alter compatibility declarations. +- First qualifying feature-branch lineage events are allocated one patch, and + qualifying non-versioning main source events one minor with patch reset. + `release-X.Y.Z` is an explicit exact target; major requires an explicit + applicable release/architecture decision. Candidate numbering is not a + compatibility or release-GO decision. +- Current workflows are deliberately read-only guards. A protected + version-preparation route must persist an operation ID, policy revision, + event/lineage, expected head/baseline, requested and determined target, paths + and resulting commit before it can enable allocation. Per-ref Actions + concurrency and a commit subject are not that authority. +- A build reads the committed version and never allocates it. Publication binds + approved exact source, target version, artifact bytes/digest and qualification; + a repeated identity with different bytes is a conflict. The policy governs Forge, Workspace and Forge Platform. Engineering Platform uses the same event semantics with its richer multi-file package-version diff --git a/scripts/advance_product_version.py b/scripts/advance_product_version.py index 132b01d..e643dac 100644 --- a/scripts/advance_product_version.py +++ b/scripts/advance_product_version.py @@ -1,44 +1,72 @@ #!/usr/bin/env python3 -"""Validate or advance Forge-family product semantic versions.""" +"""Validate or explicitly apply Forge Platform's canonical product version.""" from __future__ import annotations import argparse import json +import os from pathlib import Path import re +import tempfile +PRODUCT = "forge-platform" VERSION = re.compile(r"^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$") def version_file(root: Path) -> Path: return root.resolve() / "product-version.json" +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 = version_file(root) 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) - if component == "patch": + 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("version component must be patch or minor") + 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 @@ -46,15 +74,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 16f08a1542aaad9ad9001f557b95c2fbe3bb5ace Mon Sep 17 00:00:00 2001 From: pcvantol Date: Tue, 8 Sep 2026 16:07:42 +0200 Subject: [PATCH 05/11] feat: persist product version operations safely --- .../CANONICAL_PRODUCT_VERSIONING.md | 21 ++- .../COMPONENT_MANIFEST_CONTRACT.md | 12 +- schemas/component-manifest.schema.json | 5 +- scripts/advance_product_version.py | 134 +++++++++++++++--- scripts/validate.sh | 1 + .../test_product_version_operations.py | 47 ++++++ 6 files changed, 190 insertions(+), 30 deletions(-) create mode 100644 tests/foundation/test_product_version_operations.py diff --git a/docs/architecture/CANONICAL_PRODUCT_VERSIONING.md b/docs/architecture/CANONICAL_PRODUCT_VERSIONING.md index 0952fe1..b9904b7 100644 --- a/docs/architecture/CANONICAL_PRODUCT_VERSIONING.md +++ b/docs/architecture/CANONICAL_PRODUCT_VERSIONING.md @@ -17,11 +17,16 @@ guard; it never decides compatibility, publication, or a major release. `release-X.Y.Z` is an explicit exact target; major requires an explicit applicable release/architecture decision. Candidate numbering is not a compatibility or release-GO decision. -- Current workflows are deliberately read-only guards. A protected - version-preparation route must persist an operation ID, policy revision, - event/lineage, expected head/baseline, requested and determined target, paths - and resulting commit before it can enable allocation. Per-ref Actions - concurrency and a commit subject are not that authority. +- The current workflow is deliberately a read-only guard. A protected delivery + route invokes `scripts/advance_product_version.py --plan` and then `--apply` + with an explicit operation ID, event lineage and expected source HEAD. Apply + persists a repository-local operation receipt with policy revision, baseline, + target, allowed projection path and before/after manifest digests before a + delivery commit is made. The same ID and inputs recover the same target; + changed inputs conflict; a new operation at a stale HEAD fails closed. + The receipt and the one changed projection must be committed together and the + resulting commit recorded by the authorized delivery system once available. + Per-ref Actions concurrency and a commit subject are not that authority. - A build reads the committed version and never allocates it. Publication binds approved exact source, target version, artifact bytes/digest and qualification; a repeated identity with different bytes is a conflict. @@ -30,3 +35,9 @@ The policy governs Forge, Workspace and Forge Platform. Engineering Platform uses the same event semantics with its richer multi-file package-version projection. A Forge Platform release remains a separately qualified composition of those independently versioned artifacts. + +`--check` and `--plan` write nothing. `--apply` is interruption-safe only at +the per-file level: it records the allocation before atomically replacing the +single manifest, so a retry can complete or refuse the same operation without +allocating another version. It is not a multi-file Git transaction and it never +pushes, publishes, or turns a candidate number into release approval. diff --git a/docs/architecture/COMPONENT_MANIFEST_CONTRACT.md b/docs/architecture/COMPONENT_MANIFEST_CONTRACT.md index c0a34d5..94132e9 100644 --- a/docs/architecture/COMPONENT_MANIFEST_CONTRACT.md +++ b/docs/architecture/COMPONENT_MANIFEST_CONTRACT.md @@ -6,11 +6,19 @@ For each platform release and component, it must identify: - Forge Platform release version; - component identity and version; +- qualified source revision and version-preparation operation identity where + applicable; - artifact URI or source reference; -- artifact digest; +- artifact name, SHA-256 digest and exact-byte identity; - signature and provenance evidence; - supported operating systems and architectures; - protocol compatibility; and - required and optional dependencies. -The accompanying [JSON Schema](../../schemas/component-manifest.schema.json) is a structural contract, not a production manifest. Artifact locations and credentials are resolved only by future qualified release processes. +The accompanying [JSON Schema](../../schemas/component-manifest.schema.json) is a +structural descriptor boundary, not a production manifest or installer input. +It carries no invented artifact URL, SHA, qualification, or component pin. +Artifact locations and credentials are resolved only by future qualified release +processes. Product version, API/protocol version, policy version, source revision +and artifact digest are separate identities; equality of version strings is not +compatibility or publication evidence. diff --git a/schemas/component-manifest.schema.json b/schemas/component-manifest.schema.json index 9ee2b5b..26834de 100644 --- a/schemas/component-manifest.schema.json +++ b/schemas/component-manifest.schema.json @@ -17,9 +17,12 @@ "properties": { "identity": {"enum": ["forge-runtime", "workspace-server", "workspace-client", "engineering-platform-server", "engineering-platform-project-agent"]}, "version": {"type": "string", "minLength": 1}, - "artifact": {"type": "object", "additionalProperties": false, "required": ["source", "digest"], "properties": {"source": {"type": "string", "minLength": 1}, "digest": {"type": "string", "pattern": "^[A-Za-z0-9._:+=-]+$"}, "signature": {"type": "string"}, "provenance": {"type": "string"}}}, + "source_revision": {"type": "string", "minLength": 1}, + "version_operation_id": {"type": "string", "minLength": 8}, + "artifact": {"type": "object", "additionalProperties": false, "required": ["name", "source", "sha256"], "properties": {"name": {"type": "string", "minLength": 1}, "source": {"type": "string", "minLength": 1}, "sha256": {"type": "string", "pattern": "^[a-f0-9]{64}$"}, "signature": {"type": "string"}, "provenance": {"type": "string"}, "qualification": {"type": "string"}}}, "platforms": {"type": "array", "minItems": 1, "items": {"type": "object", "required": ["os", "architecture"], "properties": {"os": {"type": "string"}, "architecture": {"type": "string"}}, "additionalProperties": false}}, "protocol_compatibility": {"type": "object", "additionalProperties": {"type": "string"}}, + "supported_contract_versions": {"type": "object", "additionalProperties": {"type": "string"}}, "dependencies": {"type": "object", "required": ["required", "optional"], "properties": {"required": {"type": "array", "items": {"type": "string"}}, "optional": {"type": "array", "items": {"type": "string"}}}, "additionalProperties": false} } } diff --git a/scripts/advance_product_version.py b/scripts/advance_product_version.py index e643dac..0ce671b 100644 --- a/scripts/advance_product_version.py +++ b/scripts/advance_product_version.py @@ -1,5 +1,9 @@ #!/usr/bin/env python3 -"""Validate or explicitly apply Forge Platform's canonical product version.""" +"""Plan, apply, or inspect Forge Platform's canonical product version. + +This is a product-local preparation helper. It deliberately does not commit, +push, publish, or decide release compatibility. +""" from __future__ import annotations import argparse @@ -8,13 +12,20 @@ from pathlib import Path import re import tempfile +import subprocess +import hashlib +from typing import Any PRODUCT = "forge-platform" +POLICY_REVISION = "FORGE_FAMILY_REPOSITORY_SEMVER_V1" 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}$") +MANIFEST_PATH = "product-version.json" +OPERATIONS_DIRECTORY = ".product-version-operations" def version_file(root: Path) -> Path: - return root.resolve() / "product-version.json" + return root.resolve() / MANIFEST_PATH def _pairs(pairs: list[tuple[str, object]]) -> dict[str, object]: result: dict[str, object] = {} @@ -23,7 +34,7 @@ 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]]: +def current(root: Path) -> tuple[Path, dict[str, Any], tuple[int, int, int]]: target = version_file(root) try: payload = json.loads(target.read_text(encoding="utf-8"), object_pairs_hook=_pairs) @@ -39,7 +50,7 @@ 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 0o644 fd, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) try: with os.fdopen(fd, "w", encoding="utf-8") as handle: @@ -50,40 +61,119 @@ def _atomic_write(path: Path, text: str) -> None: 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}") - if (component is None) == (exact is None): raise RuntimeError("provide exactly one requested bump or exact target version") +def _head(root: Path) -> str: + try: + return subprocess.run(["git", "-C", str(root), "rev-parse", "HEAD"], check=True, text=True, + stdout=subprocess.PIPE, stderr=subprocess.PIPE).stdout.strip() + except (OSError, subprocess.CalledProcessError) as error: + raise RuntimeError("version apply requires a Git checkout with HEAD") from error + + +def _target(actual: str, bump: str | None, exact: str | None) -> str: + major, minor, patch = (int(part) for part in actual.split(".")) + if (bump 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 + return exact + if bump == "patch": return f"{major}.{minor}.{patch + 1}" + if bump == "minor": return f"{major}.{minor + 1}.0" + raise RuntimeError("major requires explicit release authority") + + +def _operation_path(root: Path, operation_id: str) -> Path: + if OPERATION_ID.fullmatch(operation_id) is None: + raise RuntimeError("operation ID must be 8-128 safe identifier characters") + return root / OPERATIONS_DIRECTORY / f"{operation_id}.json" + + +def _operation(operation_id: str, lineage: str, expected_head: str, baseline: str, + bump: str | None, exact: str | None, target: str) -> dict[str, Any]: + if not lineage.strip(): raise RuntimeError("event lineage is required") + return {"schema_version": 1, "operation_id": operation_id, "product": PRODUCT, + "component": PRODUCT, "policy_revision": POLICY_REVISION, "event_lineage": lineage, + "expected_source_revision": expected_head, "baseline_version": baseline, + "requested_bump": bump, "requested_exact_version": exact, "target_version": target, + "allowed_projection_paths": [MANIFEST_PATH]} + + +def _same(existing: dict[str, Any], requested: dict[str, Any]) -> bool: + return all(existing.get(key) == value for key, value in requested.items()) + + +def plan(root: Path, operation_id: str, lineage: str, expected_head: str, + bump: str | None, exact: str | None) -> dict[str, Any]: + _, payload, _ = current(root) + requested = _operation(operation_id, lineage, expected_head, payload["version"], bump, exact, + _target(payload["version"], bump, exact)) + path = _operation_path(root, operation_id) + if not path.exists(): return requested + existing = json.loads(path.read_text(encoding="utf-8"), object_pairs_hook=_pairs) + if not isinstance(existing, dict) or not _same(existing, requested): + raise RuntimeError("operation ID conflict: existing operation has different input") + return existing + + +def apply(root: Path, operation_id: str, lineage: str, expected_head: str, + bump: str | None, exact: str | None) -> dict[str, Any]: + target_file, payload, _ = current(root) + actual = payload["version"] + path = _operation_path(root, operation_id) + if path.exists(): + operation = json.loads(path.read_text(encoding="utf-8"), object_pairs_hook=_pairs) + if not isinstance(operation, dict): raise RuntimeError("version operation must be an object") + # A retry recognizes durable provenance before evaluating a newer checkout HEAD. + requested = _operation(operation_id, lineage, expected_head, operation.get("baseline_version"), bump, + exact, operation.get("target_version")) + if not _same(operation, requested): + raise RuntimeError("operation ID conflict: existing operation has different input") + target = operation["target_version"] + if actual not in (operation["baseline_version"], target): + raise RuntimeError("operation recovery conflict: canonical version is neither baseline nor target") + else: + if _head(root) != expected_head: + raise RuntimeError("stale source head: refresh and requalify the candidate") + target = _target(actual, bump, exact) + operation = _operation(operation_id, lineage, expected_head, actual, bump, exact, target) + operation["state"] = "prepared" + operation["manifest_sha256_before"] = hashlib.sha256(target_file.read_bytes()).hexdigest() + path.parent.mkdir(mode=0o755, exist_ok=True) + # Persist allocation first: a crash is recoverable with this operation ID, never a new bump. + _atomic_write(path, json.dumps(operation, indent=2, sort_keys=True) + "\n") + if actual != target: + payload["version"] = target + _atomic_write(target_file, json.dumps(payload, indent=2, sort_keys=True) + "\n") + operation["state"] = "applied" + operation["manifest_sha256_after"] = hashlib.sha256(target_file.read_bytes()).hexdigest() + _atomic_write(path, json.dumps(operation, indent=2, sort_keys=True) + "\n") + return operation def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser() parser.add_argument("--source-root", type=Path, default=Path.cwd()) + parser.add_argument("--operation-id") + parser.add_argument("--event-lineage") + parser.add_argument("--expected-head") 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("--apply", action="store_true") parser.add_argument("--check", action="store_true") args = parser.parse_args(argv) + if sum((args.check, args.plan, args.apply)) != 1: parser.error("provide exactly one of --check, --plan, or --apply") if args.check: - if args.bump or args.set_version: parser.error("--check cannot change a version") + if args.bump or args.set_version or args.operation_id or args.event_lineage or args.expected_head: parser.error("--check only reads the version source") _, payload, _ = current(args.source_root) print(f"PRODUCT_VERSION=PASS version={payload['version']}") else: - print(f"PRODUCT_VERSION={advance(args.source_root, args.bump, args.set_version, args.expected_version)}") + if args.expected_version is not None: parser.error("use expected source head, not --expected-version") + if not all((args.operation_id, args.event_lineage, args.expected_head)): + parser.error("--plan/--apply require --operation-id, --event-lineage, and --expected-head") + operation = (plan if args.plan else apply)(args.source_root, args.operation_id, args.event_lineage, + args.expected_head, args.bump, args.set_version) + print(json.dumps(operation, sort_keys=True)) return 0 diff --git a/scripts/validate.sh b/scripts/validate.sh index 3933a3c..e57cf25 100644 --- a/scripts/validate.sh +++ b/scripts/validate.sh @@ -5,6 +5,7 @@ root_dir=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) cd "$root_dir" python3 tests/foundation/test_foundation.py +python3 tests/foundation/test_product_version_operations.py python3 scripts/advance_product_version.py --check python3 docs/ai-development/validate_projection.py \ --profile forge-platform \ diff --git a/tests/foundation/test_product_version_operations.py b/tests/foundation/test_product_version_operations.py new file mode 100644 index 0000000..01b567a --- /dev/null +++ b/tests/foundation/test_product_version_operations.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +"""Regression checks for product-local version preparation.""" +from __future__ import annotations +import importlib.util +import json +from pathlib import Path +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[2] +SPEC = importlib.util.spec_from_file_location("versioning", ROOT / "scripts/advance_product_version.py") +assert SPEC and SPEC.loader +versioning = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(versioning) + +class OperationsTests(unittest.TestCase): + def repo(self): + temporary = tempfile.TemporaryDirectory(); root = Path(temporary.name) + (root / "product-version.json").write_text('{"product":"forge-platform","schema_version":1,"version":"2.3.0"}\n') + subprocess.run(["git", "init", "-q", str(root)], check=True) + subprocess.run(["git", "-C", str(root), "add", "."], check=True) + subprocess.run(["git", "-C", str(root), "-c", "user.name=t", "-c", "user.email=t@x", "commit", "-qm", "base"], check=True) + return temporary, root, versioning._head(root) + def test_plan_read_only_and_retry_idempotent(self): + temporary, root, head = self.repo() + with temporary: + self.assertEqual(versioning.plan(root, "operation-0001", "feature@a", head, "patch", None)["target_version"], "2.3.1") + self.assertFalse((root / versioning.OPERATIONS_DIRECTORY).exists()) + versioning.apply(root, "operation-0001", "feature@a", head, "patch", None) + self.assertEqual(versioning.apply(root, "operation-0001", "feature@a", head, "patch", None)["target_version"], "2.3.1") + def test_conflict_and_stale_head_fail_closed(self): + temporary, root, head = self.repo() + with temporary: + with self.assertRaisesRegex(RuntimeError, "stale source head"): + versioning.apply(root, "operation-0002", "feature@a", "stale", "patch", None) + versioning.apply(root, "operation-0002", "feature@a", head, "patch", None) + with self.assertRaisesRegex(RuntimeError, "operation ID conflict"): + versioning.apply(root, "operation-0002", "feature@b", head, "patch", None) + def test_exact_release_and_invalid_manifest(self): + temporary, root, head = self.repo() + with temporary: + self.assertEqual(versioning.apply(root, "operation-0003", "release", head, None, "2.4.0")["target_version"], "2.4.0") + (root / "product-version.json").write_text('{"product":"forge-platform","schema_version":true,"version":"02.4.0"}') + with self.assertRaises(RuntimeError): versioning.current(root) + +if __name__ == "__main__": unittest.main() From 2987e29eb6bcf608382c1ac6edb9712271807657 Mon Sep 17 00:00:00 2001 From: pcvantol Date: Tue, 8 Sep 2026 16:14:13 +0200 Subject: [PATCH 06/11] feat: qualify exact version preparation candidates --- .github/workflows/canonical-versioning.yml | 7 +++ .../CANONICAL_PRODUCT_VERSIONING.md | 18 ++++++ scripts/advance_product_version.py | 58 ++++++++++++++++++- .../test_product_version_operations.py | 13 +++++ 4 files changed, 95 insertions(+), 1 deletion(-) diff --git a/.github/workflows/canonical-versioning.yml b/.github/workflows/canonical-versioning.yml index 4aef7ca..607c9b7 100644 --- a/.github/workflows/canonical-versioning.yml +++ b/.github/workflows/canonical-versioning.yml @@ -1,6 +1,7 @@ name: Canonical product versioning on: + pull_request: push: branches: ['**'] @@ -12,4 +13,10 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v5 + with: + # Pull-request default checkout is a synthetic merge commit; qualification + # evidence must bind to the candidate that could actually be delivered. + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + fetch-depth: 2 - run: python3 scripts/advance_product_version.py --check + - run: python3 scripts/advance_product_version.py --verify-operation --candidate-head "${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}" diff --git a/docs/architecture/CANONICAL_PRODUCT_VERSIONING.md b/docs/architecture/CANONICAL_PRODUCT_VERSIONING.md index b9904b7..43b8bdd 100644 --- a/docs/architecture/CANONICAL_PRODUCT_VERSIONING.md +++ b/docs/architecture/CANONICAL_PRODUCT_VERSIONING.md @@ -41,3 +41,21 @@ the per-file level: it records the allocation before atomically replacing the single manifest, so a retry can complete or refuse the same operation without allocating another version. It is not a multi-file Git transaction and it never pushes, publishes, or turns a candidate number into release approval. + +## Candidate qualification and release boundary + +This repository has protected-main pull-request gates but no authorized release +delivery API, GitHub App, or publication route. A delivery operator therefore +creates a dedicated version-preparation candidate from the recorded expected +source revision, commits only `product-version.json` and its receipt, and opens +that candidate for the ordinary protected route. The canonical-version workflow +checks out the exact PR head (never GitHub's synthetic merge ref) and runs +`--verify-operation --candidate-head `. It rejects a receipt whose parent, +projection digest, target, or changed paths differ. Thus the new candidate gets +its own qualification evidence; an older review/check cannot be repurposed. + +`release-X.Y.Z` is parsed only as an explicit exact target by the helper; it is +not a branch authorization. No release workflow is configured here. Until an +authorized route can bind an approved exact candidate, compatibility decision, +artifact bytes/digest and publication identity, release preparation and +publication remain unsupported and fail closed by absence rather than a bypass. diff --git a/scripts/advance_product_version.py b/scripts/advance_product_version.py index 0ce671b..935f71e 100644 --- a/scripts/advance_product_version.py +++ b/scripts/advance_product_version.py @@ -149,6 +149,55 @@ def apply(root: Path, operation_id: str, lineage: str, expected_head: str, return operation +def verify_operation(root: Path, candidate_head: str) -> None: + """Verify an already-committed, isolated preparation candidate read-only. + + Qualification is deliberately tied to the exact candidate SHA, not a prior + green source SHA or a merge ref synthesized by GitHub Actions. + """ + actual_head = _head(root) + if actual_head != candidate_head: + raise RuntimeError("candidate head mismatch: qualification must inspect the exact candidate SHA") + operation_root = root / OPERATIONS_DIRECTORY + if not operation_root.exists(): + print("PRODUCT_VERSION_OPERATION=NOT_APPLICABLE no version-preparation receipt") + return + operations = sorted(operation_root.glob("*.json")) + if len(operations) != 1: + raise RuntimeError("version-preparation candidate must contain exactly one operation receipt") + operation = json.loads(operations[0].read_text(encoding="utf-8"), object_pairs_hook=_pairs) + if not isinstance(operation, dict) or operation.get("state") != "applied": + raise RuntimeError("version operation receipt is not an applied object") + required = ("operation_id", "product", "component", "policy_revision", "event_lineage", + "expected_source_revision", "baseline_version", "target_version", + "allowed_projection_paths", "manifest_sha256_before", "manifest_sha256_after") + if any(key not in operation for key in required): + raise RuntimeError("version operation receipt is incomplete") + if operation["product"] != PRODUCT or operation["component"] != PRODUCT: + raise RuntimeError("version operation receipt has the wrong product identity") + if operation["policy_revision"] != POLICY_REVISION or operation["allowed_projection_paths"] != [MANIFEST_PATH]: + raise RuntimeError("version operation receipt has unsupported policy or projection paths") + _operation_path(root, operation["operation_id"]) + _, payload, _ = current(root) + if payload["version"] != operation["target_version"]: + raise RuntimeError("version operation target does not match the canonical projection") + if hashlib.sha256(version_file(root).read_bytes()).hexdigest() != operation["manifest_sha256_after"]: + raise RuntimeError("version operation result digest does not match the canonical projection") + try: + parent = subprocess.run(["git", "-C", str(root), "rev-parse", f"{candidate_head}^"], check=True, + text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE).stdout.strip() + changed = subprocess.run(["git", "-C", str(root), "diff", "--name-only", parent, candidate_head], + check=True, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE).stdout.splitlines() + except (OSError, subprocess.CalledProcessError) as error: + raise RuntimeError("candidate must have an inspectable single parent") from error + if parent != operation["expected_source_revision"]: + raise RuntimeError("candidate parent does not equal the operation's expected source revision") + expected_paths = {MANIFEST_PATH, f"{OPERATIONS_DIRECTORY}/{operation['operation_id']}.json"} + if set(changed) != expected_paths: + raise RuntimeError("version-preparation candidate changes paths outside its declared operation") + print(f"PRODUCT_VERSION_OPERATION=PASS operation_id={operation['operation_id']} candidate={candidate_head}") + + def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser() parser.add_argument("--source-root", type=Path, default=Path.cwd()) @@ -160,13 +209,20 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument("--expected-version") parser.add_argument("--plan", action="store_true") parser.add_argument("--apply", action="store_true") + parser.add_argument("--verify-operation", action="store_true") + parser.add_argument("--candidate-head") parser.add_argument("--check", action="store_true") args = parser.parse_args(argv) - if sum((args.check, args.plan, args.apply)) != 1: parser.error("provide exactly one of --check, --plan, or --apply") + if sum((args.check, args.plan, args.apply, args.verify_operation)) != 1: parser.error("provide exactly one operation mode") if args.check: if args.bump or args.set_version or args.operation_id or args.event_lineage or args.expected_head: parser.error("--check only reads the version source") _, payload, _ = current(args.source_root) print(f"PRODUCT_VERSION=PASS version={payload['version']}") + elif args.verify_operation: + if not args.candidate_head: parser.error("--verify-operation requires --candidate-head") + if any((args.operation_id, args.event_lineage, args.expected_head, args.bump, args.set_version, args.expected_version)): + parser.error("--verify-operation only inspects a committed candidate") + verify_operation(args.source_root, args.candidate_head) else: if args.expected_version is not None: parser.error("use expected source head, not --expected-version") if not all((args.operation_id, args.event_lineage, args.expected_head)): diff --git a/tests/foundation/test_product_version_operations.py b/tests/foundation/test_product_version_operations.py index 01b567a..e29e1de 100644 --- a/tests/foundation/test_product_version_operations.py +++ b/tests/foundation/test_product_version_operations.py @@ -43,5 +43,18 @@ def test_exact_release_and_invalid_manifest(self): self.assertEqual(versioning.apply(root, "operation-0003", "release", head, None, "2.4.0")["target_version"], "2.4.0") (root / "product-version.json").write_text('{"product":"forge-platform","schema_version":true,"version":"02.4.0"}') with self.assertRaises(RuntimeError): versioning.current(root) + def test_qualification_binds_the_exact_preparation_commit(self): + temporary, root, head = self.repo() + with temporary: + versioning.apply(root, "operation-0004", "feature@a", head, "patch", None) + subprocess.run(["git", "-C", str(root), "add", "."], check=True) + subprocess.run(["git", "-C", str(root), "-c", "user.name=t", "-c", "user.email=t@x", "commit", "-qm", "prepare"], check=True) + candidate = versioning._head(root) + versioning.verify_operation(root, candidate) + (root / "unrelated.txt").write_text("not a preparation candidate") + subprocess.run(["git", "-C", str(root), "add", "."], check=True) + subprocess.run(["git", "-C", str(root), "-c", "user.name=t", "-c", "user.email=t@x", "commit", "-qm", "extra"], check=True) + with self.assertRaisesRegex(RuntimeError, "candidate parent"): + versioning.verify_operation(root, versioning._head(root)) if __name__ == "__main__": unittest.main() From ca6807e073cd29d850bcdb08fee2ca7bfb363884 Mon Sep 17 00:00:00 2001 From: pcvantol Date: Tue, 8 Sep 2026 18:07:36 +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..a750318 --- /dev/null +++ b/.version-preparation.json @@ -0,0 +1,9 @@ +{ + "contract_version": "1", + "product_id": "forge-platform", + "repository_id": "pcvantol/forge-platform", + "helper_path": "scripts/advance_product_version.py", + "receipt_directory": ".product-version-operations", + "allowed_projection_paths": ["product-version.json"], + "policy_revision": "FORGE_FAMILY_REPOSITORY_SEMVER_V1" +} From db99c615865409ccb72943eb3d26e20519e08d31 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/architecture/CANONICAL_PRODUCT_VERSIONING.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/architecture/CANONICAL_PRODUCT_VERSIONING.md b/docs/architecture/CANONICAL_PRODUCT_VERSIONING.md index 43b8bdd..fb4633b 100644 --- a/docs/architecture/CANONICAL_PRODUCT_VERSIONING.md +++ b/docs/architecture/CANONICAL_PRODUCT_VERSIONING.md @@ -42,6 +42,13 @@ single manifest, so a retry can complete or refuse the same operation without allocating another version. It is not a multi-file Git transaction and it never pushes, publishes, or turns a candidate number into release approval. +Engineering Platform PR [#105](https://github.com/pcvantol/engineering-platform/pull/105) +is the pending source-level bounded adapter for product-owned prepared +operations. It verifies declared helper identity, an isolated allowlisted +candidate and exact-head qualification evidence. It does not establish an +installed writer, an active authorization grant, merge authority, artifact +publication or universal-installer readiness. + ## Candidate qualification and release boundary This repository has protected-main pull-request gates but no authorized release From 0df549bf690c1069a01c895a731540b9b45bd0e8 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 | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/scripts/advance_product_version.py b/scripts/advance_product_version.py index 935f71e..895881a 100644 --- a/scripts/advance_product_version.py +++ b/scripts/advance_product_version.py @@ -17,7 +17,7 @@ from typing import Any PRODUCT = "forge-platform" -POLICY_REVISION = "FORGE_FAMILY_REPOSITORY_SEMVER_V1" +POLICY_REVISION = "forge-platform-bootstrap-release-cadence-v2" 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}$") MANIFEST_PATH = "product-version.json" @@ -76,6 +76,7 @@ def _target(actual: str, bump: str | None, exact: str | None) -> str: 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 + if bump == "none": return actual if bump == "patch": return f"{major}.{minor}.{patch + 1}" if bump == "minor": return f"{major}.{minor + 1}.0" raise RuntimeError("major requires explicit release authority") @@ -90,10 +91,13 @@ def _operation_path(root: Path, operation_id: str) -> Path: def _operation(operation_id: str, lineage: str, expected_head: str, baseline: str, bump: str | None, exact: str | None, target: str) -> dict[str, Any]: if not lineage.strip(): raise RuntimeError("event lineage is required") + release_class = "EXACT" if exact is not None else {"none": "NO_BUMP", "patch": "PATCH", "minor": "MINOR"}.get(bump) + 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": lineage, "expected_source_revision": expected_head, "baseline_version": baseline, "requested_bump": bump, "requested_exact_version": exact, "target_version": target, + "release_class": release_class, "classification_rationale": lineage, "allowed_projection_paths": [MANIFEST_PATH]} @@ -204,7 +208,7 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument("--operation-id") parser.add_argument("--event-lineage") parser.add_argument("--expected-head") - 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("--plan", action="store_true") From 837f1b61beaf2731163ba13bef92f4d8d2f57ad0 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/architecture/CANONICAL_PRODUCT_VERSIONING.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/architecture/CANONICAL_PRODUCT_VERSIONING.md b/docs/architecture/CANONICAL_PRODUCT_VERSIONING.md index fb4633b..2ba6482 100644 --- a/docs/architecture/CANONICAL_PRODUCT_VERSIONING.md +++ b/docs/architecture/CANONICAL_PRODUCT_VERSIONING.md @@ -1,5 +1,15 @@ # Canonical product versioning +## Bootstrap release cadence V2 + +`forge-platform-bootstrap-release-cadence-v2` replaces push- and main-event +allocation for new operations. A bounded engineering increment defaults to one +`PATCH`; documentation-only work is explicit `NO_BUMP`; only an explicit +capability/release boundary is `MINOR`; `MAJOR` and `EXACT` require applicable +release authority. Repair, requalification and protected merge are evidence for +the same operation and cannot allocate a second version. V1 receipts remain +immutable historical evidence; CI validates but never writes versions. + **Status:** Adopted cross-product policy v1 Forge Platform owns this policy as the composition authority. Each product From b93e00ee5bbd9e88e896bd84fcb2cf1bb92c716f Mon Sep 17 00:00:00 2001 From: pcvantol Date: Tue, 8 Sep 2026 19:02:14 +0200 Subject: [PATCH 11/11] test: cover docs-only version operation --- tests/foundation/test_product_version_operations.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/foundation/test_product_version_operations.py b/tests/foundation/test_product_version_operations.py index e29e1de..2b7644a 100644 --- a/tests/foundation/test_product_version_operations.py +++ b/tests/foundation/test_product_version_operations.py @@ -37,6 +37,12 @@ def test_conflict_and_stale_head_fail_closed(self): versioning.apply(root, "operation-0002", "feature@a", head, "patch", None) with self.assertRaisesRegex(RuntimeError, "operation ID conflict"): versioning.apply(root, "operation-0002", "feature@b", head, "patch", None) + def test_docs_only_operation_does_not_allocate(self): + temporary, root, head = self.repo() + with temporary: + self.assertEqual(versioning.apply(root, "operation-docs-01", "increment:docs", head, "none", None)["target_version"], "2.3.0") + with self.assertRaisesRegex(RuntimeError, "operation ID conflict"): + versioning.apply(root, "operation-docs-01", "increment:docs", head, "patch", None) def test_exact_release_and_invalid_manifest(self): temporary, root, head = self.repo() with temporary: