Skip to content

Commit 6bfd617

Browse files
ci(#159): make trigger/ref contract fail closed
1 parent 98fb4ad commit 6bfd617

1 file changed

Lines changed: 72 additions & 9 deletions

File tree

.github/scripts/check_agent_branch_triggers.py

Lines changed: 72 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
ROOT / ".github" / "workflows" / "quality-contract.yml",
2323
)
2424
MERGE_SMOKE_WORKFLOW = ROOT / ".github" / "workflows" / "ci-reusable-pilot.yml"
25+
MERGE_SMOKE_CALLEE = ROOT / ".github" / "workflows" / "ci-python-local.yml"
2526
PR_HEAD_REF = "${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}"
2627
TASK_PREFIX_RE = re.compile(r"`([A-Za-z0-9_-]+)/<task>`")
2728

@@ -63,28 +64,84 @@ def _push_branches(path: Path) -> set[str]:
6364
raise ValueError(f"{path}: push trigger has no branches list")
6465

6566

67+
def _yaml_code(line: str) -> str:
68+
"""Return the structural part of a simple repository workflow line."""
69+
return line.split("#", 1)[0].rstrip()
70+
71+
6672
def _pull_request_is_unfiltered(path: Path) -> bool:
67-
"""Return True iff pull_request exists and has no base-branch filter."""
73+
"""Require default PR activity coverage with no base/path suppression."""
6874
lines = path.read_text(encoding="utf-8").splitlines()
6975
pr_index = next(
70-
(i for i, line in enumerate(lines) if line.rstrip() == " pull_request:"),
76+
(
77+
i
78+
for i, line in enumerate(lines)
79+
if _yaml_code(line).rstrip() == " pull_request:"
80+
),
7181
None,
7282
)
7383
if pr_index is None:
7484
return False
85+
86+
forbidden = ("branches:", "branches-ignore:", "paths:", "paths-ignore:", "types:")
7587
for line in lines[pr_index + 1 :]:
76-
if line and not line.startswith(" "):
88+
code = _yaml_code(line)
89+
if not code.strip():
90+
continue
91+
indent = len(code) - len(code.lstrip(" "))
92+
if indent < 4:
7793
break
78-
stripped = line.strip()
79-
if stripped.startswith("branches:") or stripped.startswith("branches-ignore:"):
94+
stripped = code.strip()
95+
if stripped.startswith(forbidden):
8096
return False
8197
return True
8298

8399

100+
def _checkout_ref_values(path: Path) -> list[str | None]:
101+
"""Return with.ref for every actions/checkout step, None if absent."""
102+
lines = path.read_text(encoding="utf-8").splitlines()
103+
refs: list[str | None] = []
104+
for i, line in enumerate(lines):
105+
code = _yaml_code(line)
106+
stripped = code.strip()
107+
if not stripped.startswith("- uses: actions/checkout@"):
108+
continue
109+
step_indent = len(code) - len(code.lstrip(" "))
110+
in_with = False
111+
ref_value: str | None = None
112+
for next_line in lines[i + 1 :]:
113+
next_code = _yaml_code(next_line)
114+
if not next_code.strip():
115+
continue
116+
indent = len(next_code) - len(next_code.lstrip(" "))
117+
next_stripped = next_code.strip()
118+
if indent <= step_indent:
119+
break
120+
if indent == step_indent + 2:
121+
in_with = next_stripped == "with:"
122+
continue
123+
if in_with and indent >= step_indent + 4 and next_stripped.startswith("ref:"):
124+
ref_value = next_stripped.split(":", 1)[1].strip().strip(chr(34)).strip(chr(39))
125+
refs.append(ref_value)
126+
return refs
127+
128+
84129
def _checks_out_exact_pr_head(path: Path) -> bool:
85-
"""Require PR jobs to checkout the submitted head SHA, not only merge ref."""
86-
text = path.read_text(encoding="utf-8")
87-
return f"ref: {PR_HEAD_REF}" in text
130+
"""Every checkout in an exact-head workflow must bind with.ref."""
131+
refs = _checkout_ref_values(path)
132+
return bool(refs) and all(ref == PR_HEAD_REF for ref in refs)
133+
134+
135+
def _merge_smoke_keeps_default_merge_ref() -> bool:
136+
"""Pilot calls known callee; its checkout must omit with.ref."""
137+
if not MERGE_SMOKE_WORKFLOW.exists() or not MERGE_SMOKE_CALLEE.exists():
138+
return False
139+
caller = MERGE_SMOKE_WORKFLOW.read_text(encoding="utf-8")
140+
if "uses: ./.github/workflows/ci-python-local.yml" not in caller:
141+
return False
142+
refs = _checkout_ref_values(MERGE_SMOKE_CALLEE)
143+
return bool(refs) and all(ref is None for ref in refs)
144+
88145

89146
def main() -> int:
90147
errors: list[str] = []
@@ -132,7 +189,13 @@ def main() -> int:
132189
elif not _pull_request_is_unfiltered(MERGE_SMOKE_WORKFLOW):
133190
errors.append(
134191
f"{MERGE_SMOKE_WORKFLOW.relative_to(ROOT)}: merge-smoke pull_request "
135-
"must cover arbitrary base branches"
192+
"must cover arbitrary base branches and default PR activity types"
193+
)
194+
if not _merge_smoke_keeps_default_merge_ref():
195+
errors.append(
196+
"merge-smoke contract drifted: ci-reusable-pilot.yml must call "
197+
"ci-python-local.yml and that callee actions/checkout step must "
198+
"omit with.ref so the synthetic PR merge ref is exercised"
136199
)
137200

138201
if errors:

0 commit comments

Comments
 (0)