Skip to content

Commit 0b7773f

Browse files
ci(#159): bind head and merge evidence to concrete PR jobs
1 parent 5544bc4 commit 0b7773f

1 file changed

Lines changed: 85 additions & 24 deletions

File tree

.github/scripts/check_agent_branch_triggers.py

Lines changed: 85 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,10 @@
2323
)
2424
MERGE_SMOKE_WORKFLOW = ROOT / ".github" / "workflows" / "ci-reusable-pilot.yml"
2525
MERGE_SMOKE_CALLEE = ROOT / ".github" / "workflows" / "ci-python-local.yml"
26-
DUAL_EVIDENCE_WORKFLOWS = {
27-
ROOT / ".github" / "workflows" / "ci-qutip.yml",
28-
ROOT / ".github" / "workflows" / "quality-contract.yml",
26+
EVIDENCE_JOBS = {
27+
"ci.yml": ("test", "test-head"),
28+
"ci-qutip.yml": ("qutip-cross-check", "qutip-head-cross-check"),
29+
"quality-contract.yml": ("contract", "contract-head"),
2930
}
3031
PR_HEAD_REF = "${{ github.event.pull_request.head.sha }}"
3132
PR_ONLY_IF = "${{ github.event_name == 'pull_request' }}"
@@ -131,6 +132,81 @@ def _checkout_ref_values(path: Path) -> list[str | None]:
131132
refs.append(ref_value)
132133
return refs
133134

135+
def _job_specs(path: Path) -> dict[str, dict[str, object]]:
136+
"""Return job-level if/uses plus checkout refs, excluding scalar payload."""
137+
lines = _structural_lines(path)
138+
jobs: dict[str, dict[str, object]] = {}
139+
current: str | None = None
140+
in_jobs = False
141+
for i, (_, indent, stripped) in enumerate(lines):
142+
if indent == 0:
143+
in_jobs = stripped == "jobs:"
144+
current = None
145+
continue
146+
if not in_jobs:
147+
continue
148+
if indent == 2 and stripped.endswith(":") and not stripped.startswith("-"):
149+
current = stripped[:-1]
150+
jobs[current] = {"if": None, "uses": [], "refs": []}
151+
continue
152+
if current is None:
153+
continue
154+
if indent == 4 and stripped.startswith("if:"):
155+
jobs[current]["if"] = stripped.split(":", 1)[1].strip()
156+
continue
157+
if indent == 4 and stripped.startswith("uses:"):
158+
uses = jobs[current]["uses"]
159+
assert isinstance(uses, list)
160+
uses.append(stripped.split(":", 1)[1].strip().strip(chr(34)).strip(chr(39)))
161+
continue
162+
short_form = stripped.startswith("- uses: actions/checkout@")
163+
named_form = stripped.startswith("uses: actions/checkout@")
164+
if not (short_form or named_form):
165+
continue
166+
property_indent = indent + 2 if short_form else indent
167+
in_with = False
168+
ref_value: str | None = None
169+
for _, next_indent, child in lines[i + 1 :]:
170+
if next_indent < property_indent:
171+
break
172+
if next_indent == property_indent:
173+
if child == "with:":
174+
in_with = True
175+
continue
176+
in_with = False
177+
if child.startswith("- "):
178+
break
179+
continue
180+
if in_with and next_indent > property_indent and child.startswith("ref:"):
181+
ref_value = child.split(":", 1)[1].strip().strip(chr(34)).strip(chr(39))
182+
refs = jobs[current]["refs"]
183+
assert isinstance(refs, list)
184+
refs.append(ref_value)
185+
return jobs
186+
187+
188+
def _evidence_job_errors(path: Path) -> list[str]:
189+
errors: list[str] = []
190+
jobs = _job_specs(path)
191+
required_id, head_id = EVIDENCE_JOBS[path.name]
192+
required = jobs.get(required_id)
193+
if required is None:
194+
errors.append(f"{path.relative_to(ROOT)}: missing required merge job {required_id}")
195+
else:
196+
if required["if"] is not None:
197+
errors.append(f"{path.relative_to(ROOT)}:{required_id}: required job must not be conditional")
198+
if required["refs"] != [None]:
199+
errors.append(f"{path.relative_to(ROOT)}:{required_id}: required job must have one default-ref checkout")
200+
head = jobs.get(head_id)
201+
if head is None:
202+
errors.append(f"{path.relative_to(ROOT)}: missing exact-head job {head_id}")
203+
else:
204+
if head["if"] != PR_ONLY_IF:
205+
errors.append(f"{path.relative_to(ROOT)}:{head_id}: exact-head job must be PR-only")
206+
if head["refs"] != [PR_HEAD_REF]:
207+
errors.append(f"{path.relative_to(ROOT)}:{head_id}: exact-head checkout is not bound to PR head")
208+
return errors
209+
134210
def _checks_out_exact_pr_head(path: Path) -> bool:
135211
"""At least one checkout must bind to the submitted PR head SHA."""
136212
return PR_HEAD_REF in _checkout_ref_values(path)
@@ -142,18 +218,12 @@ def _also_checks_out_proposed_merge(path: Path) -> bool:
142218

143219

144220
def _job_level_uses_values(path: Path) -> list[str]:
145-
"""Return workflow-level job ``uses`` targets, excluding steps/comments."""
221+
"""Return actual job-level reusable-workflow calls."""
146222
values: list[str] = []
147-
for line in path.read_text(encoding="utf-8").splitlines():
148-
code = _yaml_code(line)
149-
if not code.strip():
150-
continue
151-
indent = len(code) - len(code.lstrip(" "))
152-
stripped = code.strip()
153-
# In this repository a reusable-workflow job property is indented four
154-
# spaces under its job id. Step-level uses entries are deeper/list items.
155-
if indent == 4 and stripped.startswith("uses:"):
156-
values.append(stripped.split(":", 1)[1].strip().strip(chr(34)).strip(chr(39)))
223+
for spec in _job_specs(path).values():
224+
uses = spec["uses"]
225+
assert isinstance(uses, list)
226+
values.extend(str(value) for value in uses)
157227
return values
158228

159229

@@ -204,16 +274,7 @@ def main() -> int:
204274
"branches (no branches/branches-ignore filter), so stacked PRs "
205275
"receive exact-head CI"
206276
)
207-
if not _checks_out_exact_pr_head(path):
208-
errors.append(
209-
f"{path.relative_to(ROOT)}: at least one checkout must use the exact "
210-
f"PR head ref {PR_HEAD_REF!r}"
211-
)
212-
if path in DUAL_EVIDENCE_WORKFLOWS and not _also_checks_out_proposed_merge(path):
213-
errors.append(
214-
f"{path.relative_to(ROOT)}: must also contain a checkout with no "
215-
"with.ref so the proposed pull_request merge is exercised"
216-
)
277+
errors.extend(_evidence_job_errors(path))
217278

218279
if not MERGE_SMOKE_WORKFLOW.exists():
219280
errors.append(

0 commit comments

Comments
 (0)