diff --git a/.github/workflows/canonical-versioning.yml b/.github/workflows/canonical-versioning.yml new file mode 100644 index 0000000..607c9b7 --- /dev/null +++ b/.github/workflows/canonical-versioning.yml @@ -0,0 +1,22 @@ +name: Canonical product versioning + +on: + pull_request: + push: + branches: ['**'] + +permissions: + contents: read + +jobs: + validate-version-source: + 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/.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" +} diff --git a/docs/architecture/CANONICAL_PRODUCT_VERSIONING.md b/docs/architecture/CANONICAL_PRODUCT_VERSIONING.md new file mode 100644 index 0000000..2ba6482 --- /dev/null +++ b/docs/architecture/CANONICAL_PRODUCT_VERSIONING.md @@ -0,0 +1,78 @@ +# 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 +repository remains authoritative for its own committed `product-version.json`; +Forge Platform does not rewrite another product's version. + +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. + +- 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. +- 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. + +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. + +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 +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/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/docs/architecture/COMPONENT_MANIFEST_CONTRACT.md b/docs/architecture/COMPONENT_MANIFEST_CONTRACT.md index bbf3977..32ba945 100644 --- a/docs/architecture/COMPONENT_MANIFEST_CONTRACT.md +++ b/docs/architecture/COMPONENT_MANIFEST_CONTRACT.md @@ -52,3 +52,8 @@ The same rule applies to Forge Server, Workspace Server, Workspace Client, Engin The component manifest records a qualified release composition. It is not Forge's Living Mission Graph and does not carry the engineering execution DAG that produced the release. Action dependency and replanning authority remain Forge-owned; execution/admission evidence remains EP-owned. The accompanying [JSON Schema](../../schemas/component-manifest.schema.json) is a structural contract, not a production manifest. It must preserve the distinction between source revision and artifact identity/digest. + +Where a product version-preparation operation exists, its identity may be carried +as provenance but is not release authorization. Product version, API/protocol +version, policy version, source revision and artifact digest remain separate +identities; matching version strings are not compatibility or publication proof. diff --git a/product-version.json b/product-version.json new file mode 100644 index 0000000..52c99b1 --- /dev/null +++ b/product-version.json @@ -0,0 +1,5 @@ +{ + "product": "forge-platform", + "schema_version": 1, + "version": "2.3.0" +} diff --git a/schemas/component-manifest.schema.json b/schemas/component-manifest.schema.json index 067f249..9afc0f6 100644 --- a/schemas/component-manifest.schema.json +++ b/schemas/component-manifest.schema.json @@ -18,6 +18,7 @@ "identity": {"enum": ["forge-runtime", "workspace-server", "workspace-client", "engineering-platform-server", "engineering-platform-project-agent"]}, "version": {"type": "string", "minLength": 1}, "source_revision": {"type": "string", "minLength": 1}, + "version_operation_id": {"type": "string", "minLength": 8}, "artifact": { "type": "object", "additionalProperties": false, @@ -42,6 +43,7 @@ } }, "protocol_compatibility": {"type": "object", "additionalProperties": {"type": "string"}}, + "supported_contract_versions": {"type": "object", "additionalProperties": {"type": "string"}}, "dependencies": { "type": "object", "required": ["required", "optional"], diff --git a/scripts/advance_product_version.py b/scripts/advance_product_version.py new file mode 100644 index 0000000..895881a --- /dev/null +++ b/scripts/advance_product_version.py @@ -0,0 +1,241 @@ +#!/usr/bin/env python3 +"""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 +import json +import os +from pathlib import Path +import re +import tempfile +import subprocess +import hashlib +from typing import Any + +PRODUCT = "forge-platform" +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" +OPERATIONS_DIRECTORY = ".product-version-operations" + + +def version_file(root: Path) -> Path: + return root.resolve() / MANIFEST_PATH + +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, Any], tuple[int, int, int]]: + target = version_file(root) + 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 _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 _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") + 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") + + +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") + 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]} + + +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 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()) + parser.add_argument("--operation-id") + parser.add_argument("--event-lineage") + parser.add_argument("--expected-head") + 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") + 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, 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)): + 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 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/validate.sh b/scripts/validate.sh index b538cc6..e57cf25 100644 --- a/scripts/validate.sh +++ b/scripts/validate.sh @@ -5,6 +5,8 @@ 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 \ --source-commit 4a39841a0c85b0e9962c85a74a3fd49d9803c13d \ diff --git a/tests/foundation/test_product_version_operations.py b/tests/foundation/test_product_version_operations.py new file mode 100644 index 0000000..2b7644a --- /dev/null +++ b/tests/foundation/test_product_version_operations.py @@ -0,0 +1,66 @@ +#!/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_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: + 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()