mirror of
https://github.com/Comfy-Org/github-workflows.git
synced 2026-11-05 00:17:37 +00:00
[PR #203] [MERGED] fix(workflow-pins): fail loud on a ref: expression the step-output reader cannot parse (BE-8253) #211
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#211
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/203
Author: @mattmillerai
Created: 8/21/2026
Status: ✅ Merged
Merged: 8/21/2026
Merged by: @mattmillerai
Base:
main← Head:matt/be-8253-workflow-pins-unparsed-ref📝 Commits (9)
f481ca5fix(workflow-pins): stop the step-output reader failing compliant spellings (BE-8130)61cbc05fix(workflow-pins): report dangling step-output refs and read the||fallback spelling (BE-8220)b68df60fix(workflow-pins): keep the dangling check to real checkouts, and judge any leading operand (BE-8220)1c66f7ffix(workflow-pins): keep the scope gate ahead of operand order, and read awith:the YAML nests differently (BE-8220)91ab7fbfix(workflow-pins): fail loud on aref:expression the step-output reader cannot parse (BE-8253)f438b1bMerge base into matt/be-8253-workflow-pins-unparsed-ref, resolving semantic conflictd446fc9Merge origin/main into matt/be-8253-workflow-pins-unparsed-ref977bf7aMerge origin/main into matt/be-8253-workflow-pins-unparsed-ref9c9cf9eMerge origin/main into matt/be-8253-workflow-pins-unparsed-ref📊 Changes
3 files changed (+727 additions, -154 deletions)
View changed files
📝
.github/workflow-pins/README.md(+32 -8)📝
.github/workflow-pins/check_workflow_pins.py(+401 -146)📝
.github/workflow-pins/tests/test_check_workflow_pins.py(+294 -0)📄 Description
Update: #202 (this PR's former base) has merged to
main; GitHub retargeted this PR tomaindirectly, so it is no longer stacked — the banner that used to be here is stale and removed. This branch was then merged with the currentmaintip to pick up #202/#197/#200/#196; see the Provenance section below for what that merge verified.ELI-5
The workflow-pins lint follows a checkout's
ref:back to the step that produced it, so a job that must never fail still can't silently check out mutable scripts. It read thatref:with one regex and gave one answer per line — so any spelling it couldn't chew recorded no site at all and the file passed. Now it asks two questions: "does thisref:mention a step output at all?" and "how many of those could I actually read?" If the first number is bigger than the second, the lint says so out loud instead of shrugging. And where aref:names several step outputs, every one of them is judged, not just the first the regex happened to land on.What was wrong
ref_checkouts's else-arm dropped any line_steps_output_matchcouldn't match. Five shapes escaped, all confirmed fail-open through the real CLI (see Evidence):ref: ${{ steps.x.outputs.ref || format('refs/heads/{0}', 'main') }}— the}in{0}breaks the[^}]*?fallback stretch.ref: ${{ (steps.x.outputs.ref) || 'main' }}—)after the output name matches neither||nor}}.ref: ${{ steps.x.outputs.ref && 'main' }}— only||was accepted. Runtime is wrong in both arms: truthy checks out at the mutable literal, falsey resolves to''andactions/checkoutreads''as the default branch.ref: "${{ steps.a.outputs.ref }}${{ steps.b.outputs.ref }}"— the block pattern's greedy.*landed on the LAST interpolation;awas never judged even though both feed the value.ref: ${{ steps.a.outputs.ref || steps.b.outputs.ref }}— the fallback stretch swallowedb; a coveredapassed the whole site.What changed
Two-tier reader. LOOSE counts
steps.<id>.outputs.<out>mentions in the comment-strippedref:value (soref: main # was ${{ steps.x.outputs.ref }}cannot fire it). STRICT runs the existing patterns over every interpolation and every top-level||operand, yielding a list rather than one match. Loose > strict records the site withvia_step_output = 'unparsed'— truthy, sounguarded_ref_checkoutsneeds no change, the same trick'dangling'/'non-leading'already use — andcheck_dirprints a message naming the two spellings the lint can judge. Widening the strict reader to evaluateformat()/&&/ parenthesized expressions is deliberately not the fix: an unknown spelling asks its author for a supported one rather than growing an expression parser inside the lint.All operands judged, strongest requirement wins.
_record_steps_outputtakes the list; each(id, out)must independently pass (declared as an earlier step, covered by a fail-closed resolver or the exact non-emptyif:, reachable by operand order). A covered sibling excuses nothing.'non-leading'outranks'dangling'for the reason it is judged first: no change to the id or the step order fixes operand order._lead_reaches_outputnow falls through a leading step output. An unresolved output is'', which is falsey, soA || Breally can resolve toB. That second operand only became visible with the exhaustive reader, and it is judged in its own right — so nothing is excused by falling through. (Chesterton's-fence note: the check was written in #202 when only one step output per expression was ever visible, so this case could not arise; the falsey-literal behaviour and every_FALSEY_OPERANDSanswer are unchanged, pinned bytest_a_falsey_leading_operand_still_reaches_the_output.)Gated by
_is_ref_input, exactly as the other states are — a job-leveloutputs:mapping or arun:heredoc holding an unparseable expression is not a checkout. That is the false-CI-failure channel BE-8215 already had to close once; mutation-checking confirms reordering the gate breaks a named test.Flow entry boundary is interpolation-aware. The naive
[^,}]bound cut a value mid-expression and hid the rest from both tiers.${{ … }}is atomic now; outside one, the first,/}still ends the entry, so a sibling entry still cannot be misread as the ref (pinned by the pre-existing decoy tests — reverting it fails four of them).Applied to all three spellings (block, flow, continuation under
ref: >-/ref: |).Evidence — the refusal path is empirically justified, not assumed
Before shipping a diff whose user-facing outcome is "this lint refuses your expression", I ran each shape through both CLIs (base = #202 head
b68df60, new = this branch), against a fixture workflow with a real resolver and checkout:ref:value${{ steps.resolve_ref.outputs.ref }}(bare, uncovered)${{ steps.resolve_ref.outputs.ref || format('refs/heads/{0}', 'main') }}${{ (steps.resolve_ref.outputs.ref) || 'main' }}${{ steps.resolve_ref.outputs.ref && 'main' }}"${{ steps.soft.outputs.ref }}${{ steps.resolve_ref.outputs.ref }}"(first operand uncovered)${{ steps.resolve_ref.outputs.ref || steps.soft.outputs.ref }}(second operand uncovered)So the capability being refused genuinely does not exist on any path: these expressions were never judged and approved, they were never read. There is no second lint in this repo covering
ref:guards —pinact/zizmorcheckuses:pins, not this. The refusal redirects to the two spellings that are judged, both of which I verified still pass unchanged.I also read the two review threads the work is built on (r3826102157, r3826102163) — public and fetchable — and they name exactly these shapes.
Tests
15 new tests (154 total in this suite, up from 139), each mutation-checked: revert the branch, a named test fails.
'unparsed'||operands'unparsed'recorded before the_is_ref_inputgate…outside_a_with_input_is_not_a_checkout)…commented_out_step_output_is_not_a_ref_use)any(verdicts)instead ofall(...)[^,}]flow entry bound'unparsed'messageJudgment calls
'non-leading'is answerable from the line alone, so it still names the exact operand.'dangling'is a property of the job, not the line, so a multi-output value falls back to the<id>/<out>placeholders rather than pointing the author at a sibling that is fine — the same fallback theref: >-spelling already takes. Pinned bytest_a_multi_operand_error_does_not_name_the_wrong_step. Threading the offending operand through thefoundtuple would have changed its shape and rippled through #202's tests; not worth it for a shape that has no instance in the tree.with: {ref: "${{ … || 'a,b' }}"}) is now'unparsed'rather than silently dropped —[^,}]is the flow form's real entry boundary, so that spelling genuinely cannot be read there (the block form reads it, unchanged); (b) a continuation value with a literal prefix (ref: >-thenrefs/heads/${{ … }}) is now a judged site, matching what the block form always did. Neither occurs in this repo's tree.steps_output_refis no longer called inside the module —check_dirgoes through the new_reported_step_output. It stays as the module's line-level accessor and is still exercised by #202's decoy tests; deleting it would lose that coverage.Verification
python3 -m unittest discover -s .github/workflow-pins/tests -p 'test_*.py'— 154 passed, 0 failed (139 before).python3 .github/workflow-pins/check_workflow_pins.py— exit 0,OK — 10 workflow(s) declare workflows_ref, none with a default, every ref checkout guarded (0 exempt). Identical to the baseline on the #202 head. The tree's only real step-output site iscursor-review.yml:456(bare spelling); zero new failures there.shellcheck -xclean.Unexercised artifacts
Out of scope
Teaching the strict reader to actually evaluate
format()/&&/ parenthesized expressions. That is the point of the two-tier design, not a gap in it.Refsrather than a closing keyword: the driving ticket's own evidence artifact went unexercised (above), so the ticket stays open for a human to confirm nothing in it was missed.Provenance
main(b92f1df, which squash-landed #202/#197/#200/#196) into this branch to clear aCONFLICTING→CLEANmerge state; the conflicts were git failing to recognize content already combined by a prior merge across a squash-merge (identical tree, different commit hash) — resolved by keeping that already-combined content, verified both BE-8220's and BE-8253's added symbols are present. Post-merge: workflow-pins unittest 160 passed / 0 failed;check_workflow_pins.pyon the real tree exit 0, 10 workflows, 0 unguarded;pr-sizego tests pass except one pre-existing, environment-dependent failure (TestResolveMergeBaseWithAdvancedBaseBranch) reproduced identically on an unmodifiedmaincheckout, unrelated to this change; base-vs-branch CLI comparison on 6 fixtureref:spellings (table above)🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.