fix(ci): derive a gate's population through composite actions, not just workflows - #19284
Merged
os-elon-musk merged 1 commit intoSep 20, 2026
Merged
Conversation
…st workflows Six gates rooted their population at `.github/workflows` and none read `.github/actions/**`, so a command executed through a composite action was audited by nothing while every one of them printed a scope line that reads as coverage. The positive control is not vacuous: `.github/actions/setup-pnpm/action.yml` already carries six `run:` steps. - `scripts/pm/dispatch-gates.mjs` now follows `uses: ./.github/actions/NAME` out of a workflow and reads that action's `runs:` steps, recursively and cycle-safe, attributing the invocation to the CALLING workflow (which is what CI schedules) with the action file carried beside it as `viaAction`. - The other five gates are judged from their own sources and extended where the population genuinely belongs: the Node pin census, the step-name quoting scan, the shared self-test population two gates consume, and the `bash -e` masking scan all read both roots now. - Four comments in `.github/**` that named the old blind spot as a constraint are corrected, because this diff is what makes them false. Co-Authored-By: Claude <noreply@anthropic.com>
This was referenced Sep 20, 2026
Closed
os-elon-musk
marked this pull request as ready for review
September 20, 2026 13:41
os-elon-musk
enabled auto-merge
September 20, 2026 13:42
os-elon-musk
deleted the
claude/issue-19229-derive-through-composite-actions
branch
September 20, 2026 14:18
This was referenced Sep 20, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #19229
Authored by Claude Code, session
session_017ef78bLdybu3AffehKkhfk.Six gates rooted their population at
.github/workflowsand none read.github/actions/**, so a command executed through a composite action was audited by nothing while every one of them printed a scope line that reads as coverage. The positive control is not vacuous:.github/actions/setup-pnpm/action.ymlcarries sixrun:steps today, and that file's own header named this gap as the reason it deliberately holds nosetup-nodestep.What the repair is
scripts/pm/dispatch-gates.mjsnow followsuses: ./.github/actions/NAMEout of a workflow and reads that action'sruns:steps, so a command executed through a composite action is derived exactly as one executed inline.paths:filter narrows. An action declares noon:block at all, so attributing to it would invent a schedule nobody wrote. The action file rides alongside asviaActionprovenance a reader can open.uses:a sibling; a one-hop follow would re-open the same blind spot one level down.uses: ./…with no action file behind it is a refusal, not a skip. GitHub refuses to start such a job, so a derivation that dropped it quietly would describe a CI this repo does not have../.github/actions/**is followed. A third-party action's steps are not in this tree. A local action outside that root is a MISSING lead and is refused deliberately: the module's declared inherited population has to stay exactly equal to the trees it really opens, and a follow that could open any directory a workflow names could not be declared at all.The repair the card forbids was not taken: the four live-specimen CONTROL assertions on the sweeper family are untouched — no assertion was re-pointed at a different value-bearing family.
The other five gates — each judged from its source, not from the card's grep
scripts/check-node-version.mjsuses: actions/setup-node@steps, and a composite action is a legal place to write one. The cost was already being paid in the tree rather than merely risked:setup-pnpm/action.ymland three workflows carry comments shaping the composition around this gate's blind spot. Both roots are read; both counts are printed.scripts/check-workflow-step-name-quoting.mjs#inside an unquoted- name:plain scalar. A composite action's steps carry- name:scalars parsed by the same YAML, in the same repo, under the same house style of writing issue numbers into step names.setup-pnpm/action.ymlalone carried eight step names this gate could not see.scripts/check-self-test-wired.mjs.github/actions/**is run by CI in the calling job exactly as an inline one is, so rooting the corpus at.github/workflowsalone made a directory boundary into a coverage boundary — and every#4690floor here fires on an EMPTY population, never on one that is complete-minus-one.scripts/check-self-test-workflow-commands.mjscollectPopulationfrom the gate above and adds no walk of its own (its own-source pin forbids one). What it owns is the DECLARATION: it now declares and pins.github/actionsbeside.github/workflows, so the live coupling covers both roots instead of naming half the corpus to the dispatch derivation.scripts/check-step-collectors.mjsjoin('.github', 'workflows')(line 210 on the filing tree), which is why a literal grep found zero — the population was workflows-only all along. It genuinely belongs: the runner writes a composite action'srun:body to a file and executesbash -eon it, so the abort-on-first-failure masking is the same defect in the same shell. The judgement is shared and only the walk to the steps differs —stepGroups()readsjobs.JOB_ID.stepsandruns.stepsalike..github/actions/absent is not a refusal in any of the five: a repo may legitimately hold no composite action. What keeps the second root from going quiet is a LIVE assertion in each gate's own--self-test(and, forcheck-node-version.mjs, which ships no self-test, the firing/dark control pair recorded below).Firing and dark controls
Each extension has both: the hazard placed inside a composite action is flagged, and the SAME tree with the action file removed is green — which is what makes the first a reading about the second root rather than about the fixture.
check-workflow-step-name-quoting— battery 7, five cases: the#hazard insideaction.ymlis flagged and named by its own path; the same tree with no.github/actions/is green AND not a refusal; aREADME.mdbeside an action is not an action.check-self-test-wired— batterythe composite action corpus, six cases: a--self-testrun only inside an action counts as WIRED and the attribution names the action FILE; the same tree without it reports exactly oneself-test-not-runfinding.check-step-collectors— the bare sequence inside a composite action is flagged asruns (composite); the same pair routed through a collector is green; the real root walk finds a NESTEDaction.ymland names its path.check-node-version— no--self-testships, so the pair was driven by hand in a throwaway git tree (recorded here, ⛔ no temporary file left in the repo):dispatch-gates— a fixture caller that invokes no check of its own derives two families only because the action's steps were read; with the reader answeringnullfor that directory, zero families are derived and the absence is NAMED. Plus the live reading: the discovery pass really opens.github/actions/setup-pnpm/action.ymland really reads its sixrun:steps.A script path that reaches its command through a step
env:value is derived by NEITHER spelling — written inline in a workflow, or written in a composite action. The composite follow makes an action's step read exactly like an inline step, including where an inline step is already not derived. That is a different blind spot and it is pinned here so a green follow is not read as coverage of it.This matters for the card's own beneficiary. Measured against PR #19225 at its head
68ca79ec9(read-only; that PR was not touched):The action spells the sweeper as
node "$SWEEPER"withSWEEPER: ${{ steps.sources.outputs.root }}/scripts/pm/check-half-states.mjsin the step'senv:. The follow reaches the steps; the matcher cannot name a script whose path is an unresolvable expression. So the four pinned CONTROL assertions on that family are restored by this change only if the invocation is spelled so a reader can see it — that is #19225's own repair to make, and it is reported rather than taken here.Tests
All readings taken on this branch at
bc1662577.The long battery, before and after.
node scripts/pm/dispatch-gates.mjs --self-test, run detached withtail --pidand the exit code captured by redirect, never through a pipe:Reverse verification — the new cases can fail. The composite follow was neutralised in the PRODUCTION path (an early return inside
followCompositeActions), the mutation proved on disk before the reading was taken, and the file restored fromHEADafterwards:⛔ No temporary file is left in the repo: the ablation ran from a script outside it, carried a shell
trapon EXIT, INT and TERM that restores the file, and restored withgit checkout HEAD -- PATH(never a baregit checkout --, which restores from the index).Derived gate families.
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackover the real change set (10 paths, three-dot vs merge basee6a03e649) derived 51 families; all 51 were run with the exit code captured before any pipe, and reconciled back through--ran:Every one exited 0, including all five extended gates and their self-tests,
check:nul-bytes,check:watch-hint-literal,check:declared-population-liveandcheck:pm-dispatch-gates.pnpm lintis CI's repo-wide run, not this PR's.Acceptance notes
.github/actions/setup-pnpm/action.ymland three workflows carried comments naming the old blind spot as a constraint. This diff is what makes them false, so they are corrected in it. ⛔ The separation itself is kept — the pins are already in place and moving them buys nothing — it is simply no longer forced.action.ymlcarries the same now-stale sentence aboutcheck-node-version.mjs. Not touched: that file belongs to an open PR.skip-changeset: measured, not assumed — 70 published packages, zerofiles[]entries namingscripts/or.github/, root packageprivate: true.Generated by Claude Code