mirror of
https://github.com/Comfy-Org/github-workflows.git
synced 2026-11-05 00:17:37 +00:00
[PR #195] [MERGED] feat(workflow-pins): follow a ref: through a step output so the never-fail idiom is linted (BE-8130) #203
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#203
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/195
Author: @mattmillerai
Created: 8/20/2026
Status: ✅ Merged
Merged: 8/20/2026
Merged by: @mattmillerai
Base:
main← Head:matt/be-8130-workflow-pins-steps-output-ref📝 Commits (2)
12eb051feat(workflow-pins): follow aref:through a step output (BE-8130)a8017d8fix(workflow-pins): accept a quotedif:scalar, pin the resolver's never-fail (BE-8130)📊 Changes
3 files changed (+558 additions, -56 deletions)
View changed files
📝
.github/workflow-pins/README.md(+31 -0)📝
.github/workflow-pins/check_workflow_pins.py(+279 -9)📝
.github/workflow-pins/tests/test_check_workflow_pins.py(+248 -47)📄 Description
ELI-5
The pin lint asks every
actions/checkoutin this repo's reusable workflows: "are you checking out atworkflows_ref, and if so, did something make sure that ref isn't empty first?" It answered that by reading theref:line and looking for the wordsinputs.workflows_ref.cursor-review.yml's ledger job (a job that must never fail, so it can't run the usual fail-closed guard) resolves the ref one step earlier and writesref: ${{ steps.resolve_ref.outputs.ref }}— noinputs.workflows_refon the line, so the lint stopped seeing that checkout at all. This teaches the lint to follow theref:back one step: if the producing step is the one holdingworkflows_ref, the checkout is in scope again, and it passes only when the resolver itself rejects an empty value or the consuming step carries exactly theif: … != ''that skips it.What changed
ref_checkoutsnow walks a job carrying a smallresolversmap alongside the existing guard flags: a step whoseenv:binds the input (_GUARD_BINDING_RE) and that carries anid:at its own key column is recorded, along with whether it is a fail-closed guard. A laterref:readingsteps.<id>.outputs.<name>(block, flow, orref: >-continuation) is then a ref use, and it is covered when either that resolver is a hard guard, or the consuming step carries exactlyif: steps.<id>.outputs.<name> != ''— bare or${{ … }}-wrapped, either spelling optionally double-quoted, nothing else. An OR-widened... != '' || always()runs the checkout precisely when the output is empty, so it is refused; so is anif:on a different output, and a job-levelif:(which skips the resolver too, so it can never tell the two states apart). Aref:whose producing step never touchesworkflows_refis not this lint's subject and is left alone. Sites reached this way are recorded as non-fallback: theif:tests the resolved value, so guard strength (bare vs|| job.workflow_sha) is moot on this path.check_dirgained a third error message for the shape, because "copy theRequire a pinned workflows_refstep in ahead of it" is advice a never-fail job cannot take — the missing piece there is the consumer's ownif:. That requiredref_checkoutsto carry a fourth field (via_step_output); five existing test unpacks moved fromfor _, fb, _tofor _, fb, _, _.cursor-review.ymlis not touched by this PR — the fix is entirely in the lint, and the ledger job already satisfies it.Verification
python3 -m unittest discover -s .github/workflow-pins/tests -p 'test_*.py'— 118 passed, 0 failed (102 onmainbefore this change; the ticket's "90" baseline predates two other merges tomain).python3 .github/workflow-pins/check_workflow_pins.py— green: 10 workflows checked, every ref checkout guarded.cursor-review.yml(the ticket's acceptance): (1) delete the ledger consumer'sif:line →check_workflow_pins.pyitself exits 1 with the new BE-8130 annotation; (2) widen it to... != '' || always()→ exits 1 the same way; (3) restore → exits 0. The suite goes red for both mutations too.cursor-review.ymlwas restored byte-for-byte afterwards (git statusclean for it)..github/workflows, there is exactly 1ref:line reading a step output — the ledger checkout — and it is now covered. 0 step-outputref:sites are left outside coverage today. The population this lint deliberately does not claim (aref:resolved from a step unrelated toworkflows_ref) is currently empty here, which is why the new tests carry a syntheticgit rev-parsefixture for it.ref_checkoutsrather than a per-lineis_ref_usescan — a step-output site cannot be judged from a single line, since the answer depends on what the job walked past.Judgment calls
resolvers[id]stores a bool, not the{"fallback": …, "is_guard": …}dict the plan sketched. Thefallbackhalf would have been written and never read: the plan's own next point makes guard strength irrelevant on this path, and a lint carrying a field nothing consults is a comment pretending to be data. The rationale is written where the value is recorded.test_the_ledger_resolver_carries_the_shape_check, mirroring its groom twintest_every_groom_guard_carries_the_shape_check, rather than the..._rejects_rather_than_sanitizesthe plan offered — the plan asked for a name consistent with the twin._step_boundsanswersNoneat a step's own key column by design, which is exactly wherewith: {…, ref: …}puts theref:._consuming_step_boundsre-asks it from a copy of that line pushed one column deeper, rather than growing a second copy of the step-boundary logic that could drift from the first. Worst case it still returnsNone, and the site is then reported rather than credited.Known residual — why this is
Refs, notClosesThe resolver is recognized through
_GUARD_BINDING_RE, which matches onlyWORKFLOWS_REF: ${{ inputs.workflows_ref }}and the|| job.workflow_shafallback. A copy of the idiom whose resolver binds${{ inputs.workflows_ref || 'main' }}therefore registers as no resolver at all, and its consuming checkout drops back out of coverage as "not this lint's subject" — the same "copies inherit the blind spot" concern, one spelling over. Forcursor-review.ymlitself that is still covered, by the hand assertion this PR deliberately keeps (WORKFLOWS_REF: ${{ inputs.workflows_ref || job.workflow_sha }}must be the resolver's binding); for a copy in another workflow it is not. Widening the resolver test to_ENV_ALIAS_REwould pull those in, but it would also demand an empty-refif:of checkouts whose ref is a mutable branch by construction — a different judgment than this change makes, so it is not folded in here.Refs BE-8130rather thanCloses: the lint now covers the shape the ticket names, and this named gap in the same file stays open.Unexercised artifacts
is_ref_useisFalseoncursor-review.yml's ledgerref:line,_GUARD_BINDING_REmatches the resolver's binding withfallback=True, andis_guard_stepisFalsethere because ofcontinue-on-error: true. All three reproduced.find_unguarded_ref_checkoutsto follow aref:through a step output" — is what this implements.Review follow-ups (
a8017d8)Three CodeRabbit findings on the first commit, all addressed:
if:scalar was failed.condkept the YAML quotes, soif: "steps.x.outputs.ref != ''"— a consumer with the remedy already applied — was rejected. One matching double-quote pair is now unwrapped before the${{ … }}unwrap. The comparison after it is still character-exact, so the only string the strip can newly accept is the wanted condition wearing quotes; an OR-widened condition stays refused, quoted or not, and a test pins that. Single-quoted scalars stay out on purpose: YAML escapes their inner'as'', and that spelling fails LOUDLY with the remedy naming the accepted form rather than passing something unguarded.if:, so deletingcontinue-on-error: truefromresolve_refand exiting non-zero would have kept the lint green while making the ledger job able to fail — and a failing ledger job takes the whole review matrix with it.test_the_ledger_resolver_carries_the_shape_checknow asserts it, verified load-bearing by mutation.|| 'main'registers as no resolver — the "Known residual" above, now written where the idiom is copied from) and that the resolver must be an earlier step in the same job. Both were verified against the code:_GUARD_BINDING_REmatches only the two bindings, andresolversis reset per job and populated as the walk goes.Refs BE-8130
Provenance
python3 -m unittest discover -s .github/workflow-pins/tests -p 'test_*.py': 118 passed, 0 failed;python3 .github/workflow-pins/check_workflow_pins.py --workflows-dir .github/workflows: green (10 workflows, every ref checkout guarded, 0 exempt); both CI smoke tests run by hand (reintroduced default → exit 1, unguarded checkout → exit 1); 3 hand mutation checks oncursor-review.yml: delete-if:→ lint exit 1,|| always()→ lint exit 1, restore → exit 0; 2 further mutations fora8017d8: reverting the quote unwrap fails exactly the 2 new positive tests, deletingcontinue-on-error: truefromresolve_reffails exactlytest_the_ledger_resolver_carries_the_shape_check.cursor-review.ymlrestored byte-for-byte after each (git statusclean).a8017d8, which documents it rather than closing it. The single-quoted YAML spelling of the skipif:is knowingly not accepted, for the reason given under "Review follow-ups".🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.
ref:expression the step-output reader cannot parse (BE-8253) #211