mirror of
https://github.com/Comfy-Org/github-workflows.git
synced 2026-11-05 00:17:37 +00:00
[PR #221] [MERGED] fix(workflow-pins): scope the step-id pre-scan to the step key column (BE-8254) #226
Labels
No labels
cursor-review-catalog-drift
groom
groom
pull-request
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
Comfy-Org/github-workflows#226
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
📋 Pull Request Information
Original PR: https://github.com/Comfy-Org/github-workflows/pull/221
Author: @mattmillerai
Created: 8/24/2026
Status: ✅ Merged
Merged: 8/25/2026
Merged by: @mattmillerai
Base:
main← Head:matt/be-8254-workflow-pins-step-id-prescan📝 Commits (4)
3da470efix(workflow-pins): scope the step-id pre-scan to the step key column (BE-8254)a91ec91fix(workflow-pins): read every YAML spelling of a step item in the pre-scan (BE-8254)0062a73fix(workflow-pins): refuse a quoted comma as a flow step boundary (BE-8254)be0b1d0fix(workflow-pins): open a quoted scalar only where a YAML node can start (BE-8254)📊 Changes
3 files changed (+1036 additions, -58 deletions)
View changed files
📝
.github/workflow-pins/README.md(+74 -0)📝
.github/workflow-pins/check_workflow_pins.py(+424 -58)📝
.github/workflow-pins/tests/test_check_workflow_pins.py(+538 -0)📄 Description
ELI-5
The pin checker keeps a list of "step ids that exist in this job" so it can tell a
ref: ${{ steps.x.outputs.ref }}that points at a real earlier step (fine, not our business) from one that points at nothing (broken — at runtime it's'', andactions/checkoutreads''as the default branch, so the checkout silently runs at a mutable ref). It built that list by grepping every line of the job for anything shaped likeid:. So a line that merely looks like a step id — an action input literally namedid, or a line of YAML a shell script prints into a heredoc — got added to the list, and a genuinely broken ref then resolved against that phantom and was reported as nothing at all. Now the checker walks the job's actual step items and reads each id at the column a step declares one, so lines that aren't step declarations can't get in.What changed
_job_step_idsis rewritten from a flat line scan into a walk of the job'ssteps:items:_binding_step_idreads it — at the item's own key column, with a key written on the-marker line normalized back into that column. This excludes both silencing shapes structurally rather than by pattern: YAML puts awith:member and a block scalar's body deeper than the step's own keys either way.run: |,script: >-) are additionally skipped outright — belt-and-braces for odd indentation-indicator edges.{(- {id: x, run: …}), and the continuation lines of one spanning several physical lines (- {uses: x,/id: y}), tracked by unbalanced braces.steps:the walk cannot stand behind — flow-stylesteps: [ … ]on the key line, or opening no-item — returnsNone("unknown", not "empty")._record_steps_outputthen drops that job's sites whose id names no tracked resolver, exactly as this reader did before the dangling check existed. That is every verdict resting on "no step of that id exists":dangling, and thenon-leadingone an absent id also reaches — the same pair a real earlier out-of-scope step drops today. Resolver and guard verdicts are untouched, in that job and every other.Also updated:
.github/workflow-pins/README.md, which documents this subsystem's contract and now describes both new behaviors.Verification
python3 -m unittest discover -s .github/workflow-pins/tests -p 'test_*.py'— 239 passed, 0 failed (216 before; 23 new).python3 .github/workflow-pins/check_workflow_pins.py— green on the real tree, exit 0, 11 workflows checked, 0 exempt.check_workflow_pins.pyfrommain— precisely the silencing shapes. The other 4 are regression pins on behavior that had to be preserved (the two existing flow fixtures, the marker-line id, the caller-job shape).{}answer) and the eight added in review (below). Each fails exactly the test(s) that name it and nothing else — so no branch here is unpinned.Corpus sweep — including the half this does not change
Swept all 48 workflow-shaped YAML files / 69 jobs in the tree, running the old flat scan and the new item walk side by side and diffing their id sets per job:
So this is a verified no-op on today's tree — a latent robustness fix, which matches the read-only investigation it came from ("no phantom shape in today's tree"). The heredoc-heavy workflows the silencing shape would most plausibly hit are covered by that sweep and land identically:
groom.yml(3,308 lines, 7 jobs) andcursor-review.yml(2,133 lines, 9 jobs) produce byte-identical id sets before and after.Review round — every YAML spelling of a step item
The four-model panel found that walking items instead of lines had traded the two phantom shapes for several shapes the walk could no longer read. Every one is the costly direction (a missed id is a false
danglingFAILURE on a compliant workflow, or an escaped job that drops a real finding), so all were reproduced first, then fixed:steps:and- uses: …both at column 4 (valid YAML; Actions accepts it)steps:'s siblings share it)- {run: "echo {"}/- {name: "}"}text.count("{")wedged flow mode open (or closed it early); while open, a heredoc's- id: xregistered the exact phantom this PR removes, and a real- id: resolve_refmatched neither pattern applied there and was lost_flow_brace_delta, quote-aware on the same rules_strip_commentuses, at both update sites- # set upkey_columnto the comment's column; the item's realid:was never read-path and the members below name the column-whose item is a flow mapping on the next line{matched nothing{" test the marker-line branch makes and enters flow tracking{ … }constrains no indentation)flow_deptha residual miscount could still leave stuckTwo findings were answered without changing behavior:
continues ahead of theleadingtest, so it costs thenon-leadingverdict as well asdangling— and three places said "only the dangling verdict". Judgingleadingfirst would be worse: with a readablesteps:, a site whose id names a real earlier out-of-scope step is dropped regardless ofleading, so printingnon-leadingunder the escape trades a dropped site for a false failure. The call-site comment, both docstrings and the README now state the escape's real reach.- {uses: x, with: {id: y}}still registersy. Scoping the flat pattern to the outermost mapping's depth is the under-collection direction; a test now names the shape so any future change here is a deliberate one. 0 occurrences in the tree.Deferred to a follow-up (noted in the README): making the fail-open observable. Reformatting a job's
steps:is an in-band way to switch its dangling check off with nothing in the output saying so — but every path incheck_dirappends toerrors, and routing the escape there turns a deliberate fail-open into a fail-closed. It needs a non-fatalnoticeschannel, which changescheck_dir's return contract, the CLI and the test job; none are in this diff's scope.Re-verified after the review round: 239 tests pass; an eight-mutation battery over the new branches (indentless
marker_column, the sibling-column terminator, the quote-aware brace count at each of its two sites, the comment-stripped marker width, the bare-dash flow entry, the flow-dedent suppression, and its job bound) each fails exactly the test(s) naming it — two of them (the job bound, and the continuation-line brace count) came back green on the first pass, which is how the two tests covering them were found to be too weak and tightened; the corpus sweep re-run against the pre-review implementation shows 0 differences across 50 files / 69 jobs and 0 escapes either way — still a verified no-op on today's tree;check_workflow_pins.pyexit 0; and both CI smoke tests still reject, including the unguarded fixture rewritten in the indentless style.Review round two — the quote scanner itself
The panel came back on the reader that the first review round added. Five findings; four fixed, one deferred with a ticket.
- {name: don't, id: real}, legal YAML (a quote is forbidden only as a plain scalar's FIRST character)id:read as content andrealwas never registered, while the closing}went uncounted and wedgedflow_depthopen — the whole job's id set came back{}, making every site in it a falsedangling_opens_quoted_scalargates every opener on a position a node can actually begin at (start of text, or after{,[,,, a:separator, or a-marker)- {name: "a/id: phantom", …}registered a phantom (silencing a real verdict);- {name: "two/lines", id: real}lost the real id_step_id_value: an Actions step id is[A-Za-z0-9_-], so a stray quote marks the tail of a scalar opened above)- &resolver {id: x, …}{-immediately-after-the-marker gate missed it, the item fell to a block pattern that cannot match a line beginning&resolver {, and its id was lost_after_node_propertiesskips leading&anchor/!tagat both flow-entry sites_outside_quotesrescanned from column 0 per match_quote_maskpass per line, shared by all three readers — 3200 candidates: 1009 ms → 1.7 ms, same ids outA regression the fix itself introduced, caught before it shipped. Applying the scalar-start rule to
_strip_commenttoo — which shares the scan — changed 11 of the tree's 16,359 physical lines, allrun: |script text likeecho "Updated PR #${existing}": strictly, that"follows the plain wordechoand opens nothing, so the#reads as a comment opener and truncates a line whose tail may carry the veryref:this lint looks for._strip_commentis asked about every physical line and cannot tell YAML from shell, so it deliberately keeps the weaker reading and is pinned to it; only the structural readers, never asked about script text, take the strict rule._strip_commentis now byte-identical to before across the whole tree.One finding deferred, with a ticket rather than a reply.
_in_steps_sequencewas never taught the indentless spelling the pre-scan learned in round one, so in such a job every step has no bounds and a step consuming its OWN output is silently dropped — reproduced: the identical job reports[(False, False, 'dangling')]indented and[]indentless. Not fixed here because_in_steps_sequenceis the step-BOUNDS subsystem gating resolver registration andis_guard_step, and this PR's stated invariant is that resolver and guard verdicts are untouched in every job. The obvious fix also opens a fail-open hole (arun: |heredoc emittingsteps:/- id: xat one column would be credited as a real sequence); the follow-up carries the reproduction, that trap, the alternative of keying the boundary off the pre-scan's own item boundaries, and acceptance criteria.Residue that survives, pinned not claimed closed: a flow continuation line whose id value carries no quote at all (
id: phantom, more":) still over-collects. Closing it needs real cross-line quote state, which is a module-wide contract — every reader here is per-line,_strip_commentincluded, and it runs first — so it stays out of scope rather than being half-done in one walk. 0 occurrences in the tree.Re-verified after round two: 255 tests pass (239 before; 16 new, of which 9 fail against the pre-round-two implementation and the rest pin preserved behaviour). Seven-way mutation battery over the new branches — the scalar-start rule, its list-marker case, the node-property skip at each of its two sites, the step-id quote refusal, its matched-quote unwrap, and
_strip_comment's deliberate weaker reading — each mutation failing exactly the test(s) naming it, no survivors (one, the matched-quote unwrap, survived the first pass, which is how the test covering it was found missing and added). Full-pipeline differential across all 50 workflow-shaped files / 69 jobs, run three ways —origin/main, this PR's previous tip, and this commit all produce identicalref_checkoutsandfind_unguarded_ref_checkoutsoutput, 0 diffs, 0 escapes — so this is still a verified no-op on today's tree.check_workflow_pins.pyexit 0, 11 workflows checked. Every other suite green (agents-md-integrity 46, cursor-review 194, groom 365, public-repo-hygiene 165, refresh-reviewers 48).Judgment calls and residuals
marker column + 2was specified; I used the measured marker width instead. The repo's ownWIDE_MARKER_RESOLVERfixture pins- id: resolve_ref(dash + 3), which a hardcoded+ 2reads one column shallow — the exact hole_marker_widthwas extracted to close. Using_marker_widthkeeps this reader agreeing with_binding_step_idand_step_bounds; a new test pins both separation widths.{, not to every marker line. Applied to every marker line it also matches- with: {id: x}, which is the flow spelling of silencing shape 1; the narrowing is pinned by a test covering both the member and marker spellings.- {with: {id: x}, uses: y}still registersx, because_STEP_ID_FLOW_REis a flat pattern that cannot see nesting. This is the tolerated direction by design (over-collection reproduces the old fail-open drop for one site; under-collection manufactures a false CI failure) and it is the contract the rewrite was specified to keep — but it is a residual on_job_step_ids/_STEP_ID_FLOW_RE, so this PR does not claim the pre-scan is exhaustively scoped, and the tracking ticket stays open rather than auto-closing. No occurrence exists in the tree today (0 of 69 jobs).Brace counting for the multi-line flow form is not quote-aware.Fixed in review round one — and the claim that bounded it was wrong: while flow mode is stuck open a real block item- id: resolve_refmatches neither pattern applied there, so it can lose a real id and manufacture a failure._flow_brace_deltanow counts outside quoted scalars.A quote spanning physical lines is still out of scope— also closed in round two, from the value side:_step_id_valuerefuses an id still carrying a quote after its closing punctuation and one matched wrapper are stripped, and the mirror direction falls out of the scalar-start rule. The walk's job bound on a stuckflow_depthremains explicit and test-pinned. One narrower residue survives and is pinned (see round two above).check_workflow_pins.pyover the real tree, exit 0) and the 69-job sweep that 0 jobs reach it — no workflow loses a verdict it has today.check_agents_md.py --root .fails onAGENTS.mdbeing 305 lines (ceiling 200) — identical onorigin/main, and this diff does not touch that file..github/coderabbit-config/testserrors locally on a missingjsonschemadependency — also identical onorigin/main, an environment gap, and outside this diff's path filter.Unexercised artifacts
The source ticket's Provenance block links a Linear issue and a run dashboard/transcript on an internal host; this worker holds no Linear access and those are unreachable from it, so neither was read. The ticket's description carried the full mechanism and the recommended implementation, and every shape it names was reproduced as a failing test before it was fixed, so nothing in those links was needed to establish acceptance. The linked GitHub review-thread comment was likewise not fetched; its content is restated in the ticket body I worked from.
Refs BE-8254
Provenance
python3 -m unittest discover -s .github/workflow-pins/tests -p 'test_*.py': 255 passed, 0 failed (239 before review round two; 16 new);python3 .github/workflow-pins/check_workflow_pins.py: exit 0, 11 workflows checked, 0 exempt; all five round-two findings reproduced as failures BEFORE being fixed (the plain-scalar quote collapsing a job's id set to{}; both cross-line-quote directions; the&anchoritem losing its id; the indentless job dropping a self-consuming step'sdanglingverdict); 9 of the 16 new tests confirmed failing against the pre-round-two implementation, the other 7 pinning preserved behaviour; seven-way mutation battery over the round-two branches, each mutation failing exactly the test(s) naming it and none surviving — one survived the first pass, which is how the missing matched-quote-unwrap test was found and added; full-pipeline differential across all 50 workflow-shaped files / 69 jobs run three ways (origin/main, the PR's previous tip, this commit) — identicalref_checkoutsandfind_unguarded_ref_checkoutsoutput, 0 diffs, 0 escapes, still a verified no-op on today's tree;_strip_commentdifferential over all 16,359 physical lines in the tree — 0 differ, which is how the 11-linerun: |script-text regression an over-broad first attempt introduced was caught and reverted; quote-scan hoist measured at 200/800/3200 flowid:candidates (3.4/59.8/1009 ms → 0.09/0.42/1.7 ms) with identical ids out; every other suite in the repo re-run green (agents-md-integrity 46, cursor-review 194, groom 365, public-repo-hygiene 165, refresh-reviewers 48). Note the earlier review commit message says "ten-mutation battery", which miscounts the eight of that round.marker column + 2(the repo's own dash+3 fixture proves+2wrong); the flow pattern applied only where a flow mapping actually opens rather than to all marker lines (the wider form re-admits the- with: {id: x}silencing shape); and one finding answered in documentation rather than code (the escape costs thenon-leadingverdict too — judgingleadingahead of it would trade a dropped site for a false failure). New in round two:_strip_commentdeliberately does NOT take the scalar-start rule the other readers take, because it is asked aboutrun: |script bodies where the strict reading truncates real content — the split is pinned by its own test and by a mutation. Two items are deferred rather than done: making the fail-open observable (needs a non-fatalnoticeschannelcheck_dirdoes not have), and teaching_in_steps_sequencethe indentless spelling (it is the step-bounds subsystem this PR pledges to leave untouched, and the naive fix opens a heredoc fail-open) — both carry a written plan. No acceptance criterion was left unmet.🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.