Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .github/workflows/canonical-versioning.yml
Original file line number Diff line number Diff line change
@@ -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
9 changes: 9 additions & 0 deletions .version-preparation.json
Original file line number Diff line number Diff line change
@@ -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"
}
65 changes: 65 additions & 0 deletions docs/governance/CANONICAL_PRODUCT_VERSIONING_ADOPTION.md
Original file line number Diff line number Diff line change
@@ -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
<exact-sha> --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.
5 changes: 5 additions & 0 deletions product-version.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"product": "forge",
"schema_version": 1,
"version": "2.3.0"
}
289 changes: 289 additions & 0 deletions scripts/advance_product_version.py
Original file line number Diff line number Diff line change
@@ -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())
1 change: 1 addition & 0 deletions scripts/validate.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down
Loading