diff --git a/scripts/dev-prepush.sh b/scripts/dev-prepush.sh index 2d96bb56c..5144e50dd 100755 --- a/scripts/dev-prepush.sh +++ b/scripts/dev-prepush.sh @@ -43,14 +43,28 @@ fi CHANGED=$(git diff --name-only "$BASE"...HEAD) CHANGED_GO=$(printf '%s\n' "$CHANGED" | grep -E '\.go$' || true) -step "Canonical completion guard (HEAD commit, CI mode)" -if [ "$AHEAD" -gt 1 ]; then - echo "Note: $AHEAD commits ahead; the guard is checked for HEAD only here, CI checks each commit." -fi -if ! git diff --name-only HEAD~1 HEAD | \ - python3 scripts/release_control/canonical_completion_guard.py --files-from-stdin --diff-base HEAD~1; then - fail "canonical completion guard" -fi +step "Canonical completion guard (per commit, CI mode)" +while IFS= read -r commit; do + if [ -z "$commit" ]; then + continue + fi + if ! git rev-parse --verify --quiet "${commit}^" >/dev/null; then + echo "Skipping root commit $commit (no parent to diff against)." + continue + fi + + # Match canonical-governance.yml exactly: the prepare-commit-msg hook + # persists an intentional local bypass as a trailer, so recover that + # reason when validating already-created commits before push. + reason=$(git log -1 --format='%(trailers:key=Contract-Neutral,valueonly,separator=; )' "$commit" | tr '\n' ' ') + echo "Checking $commit" + 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}^"; then + fail "canonical completion guard @ $commit" + fi +done < <(git rev-list --reverse --no-merges "$BASE"..HEAD) if printf '%s\n' "$CHANGED" | grep -q 'docs/release-control/v6/internal/subsystems/registry.json'; then step "Registry snapshot tests (registry.json changed)" diff --git a/scripts/release_control/canonical_completion_guard_test.py b/scripts/release_control/canonical_completion_guard_test.py index 156bda7db..4a40e025d 100644 --- a/scripts/release_control/canonical_completion_guard_test.py +++ b/scripts/release_control/canonical_completion_guard_test.py @@ -1,6 +1,7 @@ import io import os import subprocess +import tempfile import unittest from contextlib import redirect_stderr from pathlib import Path @@ -3746,5 +3747,73 @@ class ReleaseCycleArtifactGuardTest(unittest.TestCase): ) +class DevPrepushScriptTest(unittest.TestCase): + def test_checks_every_commit_with_its_contract_neutral_trailer(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + repo = Path(temp_dir) + (repo / "scripts" / "release_control").mkdir(parents=True) + (repo / "scripts" / "dev-prepush.sh").write_text( + (REPO_ROOT / "scripts" / "dev-prepush.sh").read_text(encoding="utf-8"), + encoding="utf-8", + ) + (repo / "scripts" / "release_control" / "canonical_completion_guard.py").write_text( + """#!/usr/bin/env python3 +import os +import sys + +files = ",".join(line.strip() for line in sys.stdin if line.strip()) +with open(os.environ["PULSE_TEST_GUARD_LOG"], "a", encoding="utf-8") as log: + reason = os.environ.get("PULSE_ALLOW_CONTRACT_NEUTRAL_COMMIT", "").strip() + log.write(f"{reason}\\t{files}\\n") +""", + encoding="utf-8", + ) + + def git(*args: str) -> None: + subprocess.run( + ["git", *args], + cwd=repo, + check=True, + capture_output=True, + text=True, + ) + + git("init", "-q", "-b", "main") + git("config", "user.name", "Pulse Test") + git("config", "user.email", "pulse-test@example.invalid") + git("add", "scripts") + git("commit", "-q", "-m", "base") + git("tag", "base") + + (repo / "first.txt").write_text("first\n", encoding="utf-8") + git("add", "first.txt") + git( + "commit", + "-q", + "-m", + "first change\n\nContract-Neutral: dependency-only test", + ) + (repo / "second.txt").write_text("second\n", encoding="utf-8") + git("add", "second.txt") + git("commit", "-q", "-m", "second change") + + guard_log = repo / "guard.log" + env = os.environ.copy() + env["PULSE_TEST_GUARD_LOG"] = str(guard_log) + result = subprocess.run( + ["bash", "scripts/dev-prepush.sh", "base"], + cwd=repo, + env=env, + capture_output=True, + text=True, + ) + + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertEqual( + guard_log.read_text(encoding="utf-8").splitlines(), + ["dependency-only test\tfirst.txt", "\tsecond.txt"], + ) + + if __name__ == "__main__": unittest.main()