Skip to content

Commit da334ca

Browse files
FIX enforce immutability in CI (#1729)
1 parent 654c50c commit da334ca

2 files changed

Lines changed: 67 additions & 12 deletions

File tree

.github/workflows/build_and_test.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,11 @@ jobs:
3333
contents: read
3434
steps:
3535
- uses: actions/checkout@v5
36+
with:
37+
# Full history is required so pre-commit hooks (notably
38+
# enforce_alembic_revision_immutability) can compute merge-bases and
39+
# diff ranges against origin/main.
40+
fetch-depth: 0
3641

3742
- uses: actions/setup-python@v6
3843
with:

build_scripts/enforce_alembic_revision_immutability.py

Lines changed: 62 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,36 +4,86 @@
44
"""
55
Migration history must be immutable. This hook enforces that by preventing deletion or updates to migration scripts.
66
7-
Checks both staged changes (local pre-commit) and the full branch diff against origin/main (CI).
7+
Checks staged changes (local pre-commit), the full branch diff against origin/main (CI PRs),
8+
and the previous commit (CI merge-queue / push-to-main).
89
"""
910

11+
import os
1012
import subprocess
1113
import sys
1214

1315
_VERSIONS_PATH = "pyrit/memory/alembic/versions/"
1416

1517

16-
def _git(*args: str) -> str:
17-
result = subprocess.run(["git", *args], capture_output=True, text=True)
18-
return result.stdout.strip()
18+
def _git(*args: str) -> subprocess.CompletedProcess[str]:
19+
return subprocess.run(["git", *args], capture_output=True, text=True)
1920

2021

21-
def _has_non_add_changes(diff_spec: list[str]) -> bool:
22-
output = _git("diff", "--name-status", *diff_spec, "--", _VERSIONS_PATH)
23-
return any(line and not line.startswith("A") for line in output.splitlines())
22+
def _git_stdout(*args: str) -> str:
23+
return _git(*args).stdout.strip()
24+
25+
26+
def _get_violations(diff_spec: list[str]) -> list[str]:
27+
"""Return lines from ``git diff --name-status`` that are not pure additions."""
28+
output = _git_stdout("diff", "--name-status", *diff_spec, "--", _VERSIONS_PATH)
29+
return [line for line in output.splitlines() if line and not line.startswith("A")]
30+
31+
32+
def _in_ci() -> bool:
33+
return os.environ.get("CI", "").lower() in {"1", "true"} or "GITHUB_ACTIONS" in os.environ
34+
35+
36+
def _fail_ci(reason: str) -> bool:
37+
"""Fail closed in CI when we can't perform the check, pass through locally."""
38+
if _in_ci():
39+
print(f"[ERROR] Cannot verify alembic revision immutability: {reason}")
40+
print(" Ensure the CI checkout has full history (fetch-depth: 0).")
41+
return True
42+
return False
2443

2544

2645
def has_revision_violations() -> bool:
2746
# Local pre-commit: check staged changes
28-
if _has_non_add_changes(["--cached"]):
47+
violations = _get_violations(["--cached"])
48+
if violations:
49+
_report(violations)
2950
return True
3051

31-
# CI: check full branch diff against origin/main
32-
merge_base = _git("merge-base", "origin/main", "HEAD")
33-
return bool(merge_base and _has_non_add_changes([f"{merge_base}...HEAD"]))
52+
# CI (PR): diff branch against its merge-base with origin/main.
53+
# The three-dot syntax (A...B) resolves to ``git diff $(merge-base A B) B``
54+
# automatically, so we don't need a separate merge-base call. When
55+
# origin/main is missing (shallow clone) git exits non-zero.
56+
pr_diff = _git("diff", "--name-status", "origin/main...HEAD", "--", _VERSIONS_PATH)
57+
if pr_diff.returncode == 0:
58+
violations = [line for line in pr_diff.stdout.strip().splitlines() if line and not line.startswith("A")]
59+
if violations:
60+
_report(violations)
61+
return True
62+
elif _fail_ci("origin/main is not available (shallow clone?)"):
63+
return True
64+
65+
# CI (merge-queue / push-to-main): on main the branch *is* origin/main, so
66+
# the diff above is empty. Compare HEAD against its first parent to catch
67+
# deletions or modifications introduced by the merge commit itself.
68+
head_parent = _git("rev-parse", "--verify", "HEAD~1")
69+
if head_parent.returncode == 0:
70+
violations = _get_violations(["HEAD~1..HEAD"])
71+
if violations:
72+
_report(violations)
73+
return True
74+
elif _fail_ci("HEAD~1 is not available (shallow clone?)"):
75+
return True
76+
77+
return False
78+
79+
80+
def _report(violations: list[str]) -> None:
81+
print("[ERROR] Migration scripts can only be added, not modified or deleted.")
82+
print("The following disallowed changes were detected:")
83+
for v in violations:
84+
print(f" {v}")
3485

3586

3687
if __name__ == "__main__":
3788
if has_revision_violations():
38-
print("[ERROR] Migration scripts can only be added, not modified or deleted.")
3989
sys.exit(1)

0 commit comments

Comments
 (0)