2323)
2424MERGE_SMOKE_WORKFLOW = ROOT / ".github" / "workflows" / "ci-reusable-pilot.yml"
2525MERGE_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}
3031PR_HEAD_REF = "${{ github.event.pull_request.head.sha }}"
3132PR_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+
134210def _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
144220def _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