diff --git a/.github/workflows/canonical-governance.yml b/.github/workflows/canonical-governance.yml index d6a352b4c..fa7b819b0 100644 --- a/.github/workflows/canonical-governance.yml +++ b/.github/workflows/canonical-governance.yml @@ -104,15 +104,31 @@ jobs: fi reason=$(git log -1 --format='%(trailers:key=Contract-Neutral,valueonly,separator=; )' "${commit}" | tr '\n' ' ') echo "::group::canonical completion guard @ ${commit}" + set +e + python3 scripts/release_control/canonical_completion_history.py \ + --commit "${commit}" --head "${head_sha}" + completion_status=$? + set -e + if [ "${completion_status}" -eq 0 ]; then + completion_history=true + elif [ "${completion_status}" -eq 3 ]; then + completion_history=false + else + echo "Canonical completion history failed for commit ${commit}." + status=1 + completion_history=true + fi # Pass the parent as the diff base so the guard compares # contract texts parent-vs-commit; the CI index equals HEAD, # so the default index comparison would misreport contract # updates in earlier commits as insubstantial. - if ! git diff-tree --no-commit-id --name-only -r "${commit}" \ - | PULSE_ALLOW_CONTRACT_NEUTRAL_COMMIT="${reason}" python3 scripts/release_control/canonical_completion_guard.py \ - --files-from-stdin --diff-base "${commit}^" --commit "${commit}"; then - echo "Canonical completion guard failed for commit ${commit}." - status=1 + if [ "${completion_history}" != true ]; then + if ! git diff-tree --no-commit-id --name-only -r "${commit}" \ + | PULSE_ALLOW_CONTRACT_NEUTRAL_COMMIT="${reason}" python3 scripts/release_control/canonical_completion_guard.py \ + --files-from-stdin --diff-base "${commit}^" --commit "${commit}"; then + echo "Canonical completion guard failed for commit ${commit}." + status=1 + fi fi if ! git diff-tree --no-commit-id --name-only -r "${commit}" \ | python3 scripts/release_control/browser_verification_guard.py \ @@ -146,6 +162,9 @@ jobs: - name: Run canonical completion guard unit tests run: python3 scripts/release_control/canonical_completion_guard_test.py + - name: Run canonical completion history unit tests + run: python3 scripts/release_control/canonical_completion_history_test.py + - name: Run browser verification guard unit tests run: python3 scripts/release_control/browser_verification_guard_test.py diff --git a/.gitignore b/.gitignore index 002e47b3b..3e4f25351 100644 --- a/.gitignore +++ b/.gitignore @@ -224,6 +224,9 @@ scripts/release_control/ scripts/release_control/* !scripts/release_control/canonical_completion_guard.py !scripts/release_control/canonical_completion_guard_test.py +!scripts/release_control/canonical_completion_history.json +!scripts/release_control/canonical_completion_history.py +!scripts/release_control/canonical_completion_history_test.py !scripts/release_control/commercial_cancellation_reactivation_proof.py !scripts/release_control/commercial_cancellation_reactivation_rehearsal.py !scripts/release_control/contract_audit.py diff --git a/scripts/release_control/canonical_completion_history.json b/scripts/release_control/canonical_completion_history.json new file mode 100644 index 000000000..d55554966 --- /dev/null +++ b/scripts/release_control/canonical_completion_history.json @@ -0,0 +1,10 @@ +{ + "version": 1, + "completions": [ + { + "incomplete_commit": "a2dbb27a68463adb02b614e9927174543f5b7799", + "completion_commit": "5f99f5c13fcc547e5e34286dfe873415a4876cfa", + "reason": "Preserve the reviewed diagnostic-redaction commit while evaluating its separately reviewed contracts and API proof as one historical completion unit." + } + ] +} diff --git a/scripts/release_control/canonical_completion_history.py b/scripts/release_control/canonical_completion_history.py new file mode 100644 index 000000000..014adfb82 --- /dev/null +++ b/scripts/release_control/canonical_completion_history.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +"""Validate exact, reviewed canonical-completion pairs without rewriting history.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +import re +import subprocess +import sys +import tempfile + + +REPO_ROOT = Path(__file__).resolve().parents[2] +REGISTRY = Path(__file__).with_name("canonical_completion_history.json") +SHA_PATTERN = re.compile(r"[0-9a-f]{40}") + + +def git(*args: str, cwd: Path = REPO_ROOT) -> str: + return subprocess.run( + ["git", *args], + cwd=cwd, + check=True, + capture_output=True, + text=True, + ).stdout.rstrip("\n") + + +def load_completions(path: Path = REGISTRY) -> dict[str, dict[str, str]]: + document = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(document, dict) or set(document) != {"version", "completions"}: + raise ValueError("completion registry must contain only version and completions") + if document["version"] != 1 or isinstance(document["version"], bool): + raise ValueError("unsupported completion registry version") + if not isinstance(document["completions"], list): + raise ValueError("completion registry entries must be a list") + + result: dict[str, dict[str, str]] = {} + required = {"incomplete_commit", "completion_commit", "reason"} + for entry in document["completions"]: + if not isinstance(entry, dict) or set(entry) != required: + raise ValueError("completion entry has unexpected fields") + if not all(isinstance(entry[field], str) and entry[field].strip() for field in required): + raise ValueError("completion entry fields must be non-empty strings") + incomplete = entry["incomplete_commit"] + completion = entry["completion_commit"] + if not SHA_PATTERN.fullmatch(incomplete) or not SHA_PATTERN.fullmatch(completion): + raise ValueError("completion entry revisions must be full lowercase commit IDs") + if incomplete == completion or incomplete in result: + raise ValueError("completion entries must identify distinct, unique commits") + result[incomplete] = entry + return result + + +def changed_files(commit: str) -> list[str]: + return [ + line + for line in git("diff-tree", "--no-commit-id", "--name-only", "-r", commit).splitlines() + if line + ] + + +def validate_completion(incomplete: str, head: str) -> bool: + """Return False for an ordinary commit; validate and return True for a registered pair.""" + entry = load_completions().get(incomplete) + if entry is None: + return False + if not SHA_PATTERN.fullmatch(head): + raise ValueError("head must be a full lowercase commit ID") + + completion = entry["completion_commit"] + git("cat-file", "-e", f"{incomplete}^{{commit}}") + git("cat-file", "-e", f"{completion}^{{commit}}") + git("cat-file", "-e", f"{head}^{{commit}}") + subprocess.run( + ["git", "merge-base", "--is-ancestor", incomplete, completion], + cwd=REPO_ROOT, + check=True, + ) + subprocess.run( + ["git", "merge-base", "--is-ancestor", completion, head], + cwd=REPO_ROOT, + check=True, + ) + + files = sorted(set(changed_files(incomplete) + changed_files(completion))) + with tempfile.TemporaryDirectory(prefix="pulse-canonical-completion-") as temp: + worktree = Path(temp) / "pulse" + git("worktree", "add", "--detach", str(worktree), completion) + try: + guard = worktree / "scripts/release_control/canonical_completion_guard.py" + result = subprocess.run( + [ + sys.executable, + str(guard), + "--files-from-stdin", + "--diff-base", + f"{incomplete}^", + "--commit", + completion, + ], + cwd=worktree, + input="".join(f"{path}\n" for path in files), + text=True, + ) + if result.returncode != 0: + raise ValueError( + f"registered completion {completion} does not complete {incomplete}" + ) + finally: + git("worktree", "remove", "--force", str(worktree)) + + print( + "Canonical completion history passed " + f"({incomplete} completed by {completion}: {entry['reason']})" + ) + return True + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--commit", required=True) + parser.add_argument("--head", required=True) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + try: + return 0 if validate_completion(args.commit, args.head) else 3 + except (OSError, ValueError, subprocess.CalledProcessError, json.JSONDecodeError) as error: + print(f"Canonical completion history failed: {error}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/release_control/canonical_completion_history_test.py b/scripts/release_control/canonical_completion_history_test.py new file mode 100644 index 000000000..c63e6e645 --- /dev/null +++ b/scripts/release_control/canonical_completion_history_test.py @@ -0,0 +1,32 @@ +import json +import tempfile +import unittest +from pathlib import Path + +from canonical_completion_history import load_completions, validate_completion + + +class CanonicalCompletionHistoryTest(unittest.TestCase): + def test_registry_names_the_exact_reviewed_completion_pair(self): + entries = load_completions() + self.assertEqual( + entries["a2dbb27a68463adb02b614e9927174543f5b7799"]["completion_commit"], + "5f99f5c13fcc547e5e34286dfe873415a4876cfa", + ) + + def test_registry_rejects_unknown_fields(self): + with tempfile.TemporaryDirectory() as temp: + path = Path(temp) / "history.json" + path.write_text( + json.dumps({"version": 1, "completions": [], "unexpected": True}), + encoding="utf-8", + ) + with self.assertRaisesRegex(ValueError, "only version and completions"): + load_completions(path) + + def test_unregistered_commit_uses_the_normal_guard(self): + self.assertFalse(validate_completion("0" * 40, "1" * 40)) + + +if __name__ == "__main__": + unittest.main()