Skip to content

fix(ci): derive a gate's population through composite actions, not just workflows - #19284

Merged
os-elon-musk merged 1 commit into
mainfrom
claude/issue-19229-derive-through-composite-actions
Sep 20, 2026
Merged

os-elon-musk merged 1 commit into
mainfrom
claude/issue-19229-derive-through-composite-actions

Conversation

@os-try-charles

@os-try-charles os-try-charles commented Sep 20, 2026

Copy link
Copy Markdown
Collaborator

Fixes #19229

Authored by Claude Code, session session_017ef78bLdybu3AffehKkhfk.

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 carries six run: steps today, and that file's own header named this gap as the reason it deliberately holds no setup-node step.

What the repair is

scripts/pm/dispatch-gates.mjs now follows uses: ./.github/actions/NAME out of a workflow and reads that action's runs: steps, so a command executed through a composite action is derived exactly as one executed inline.

  • Attribution never moves. The invocation is attributed to the CALLING workflow, because the caller is what CI schedules and what a paths: filter narrows. An action declares no on: block at all, so attributing to it would invent a schedule nobody wrote. The action file rides alongside as viaAction provenance a reader can open.
  • Recursive, cycle-safe. An action may uses: a sibling; a one-hop follow would re-open the same blind spot one level down.
  • A 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.
  • Only ./.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

gate verdict reading
scripts/check-node-version.mjs extended Its population is uses: 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.yml and 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 extended Its subject is # 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.yml alone carried eight step names this gate could not see.
scripts/check-self-test-wired.mjs extended Its subject is "a script CI RUNS whose self-test CI must run too". A step in .github/actions/** is run by CI in the calling job exactly as an inline one is, so rooting the corpus at .github/workflows alone made a directory boundary into a coverage boundary — and every #4690 floor here fires on an EMPTY population, never on one that is complete-minus-one.
scripts/check-self-test-workflow-commands.mjs extended It consumes collectPopulation from 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/actions beside .github/workflows, so the live coupling covers both roots instead of naming half the corpus to the dispatch derivation.
scripts/check-step-collectors.mjs UNJUDGED row traced, then extended The card recorded this row as UNJUDGED because it names neither path spelling. Traced: its root is assembled as join('.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's run: body to a file and executes bash -e on 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() reads jobs.JOB_ID.steps and runs.steps alike.

.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, for check-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 inside action.yml is flagged and named by its own path; the same tree with no .github/actions/ is green AND not a refusal; a README.md beside an action is not an action.
  • check-self-test-wired — battery the composite action corpus, six cases: a --self-test run only inside an action counts as WIRED and the attribution names the action FILE; the same tree without it reports exactly one self-test-not-run finding.
  • check-step-collectors — the bare sequence inside a composite action is flagged as runs (composite); the same pair routed through a collector is green; the real root walk finds a NESTED action.yml and names its path.
  • check-node-version — no --self-test ships, so the pair was driven by hand in a throwaway git tree (recorded here, ⛔ no temporary file left in the repo):
FIRING  .github/actions/fixture/nested/action.yml pins node-version '20' against .nvmrc 22
        -> exit 1, "• .github/actions/fixture/nested/action.yml:8 -- pins Node 20, but .nvmrc says 22"
DARK    same tree, .github/actions removed
        -> exit 0, "OK (1 setup-node step(s) across 1 workflow(s) and 0 composite action(s))"
  • 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 answering null for 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.yml and really reads its six run: steps.

⚠️ A measured boundary, stated rather than implied

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):

uses found            .github/actions/half-state-patrol
runs: steps read      6
families via action   (none)

The action spells the sweeper as node "$SWEEPER" with SWEEPER: ${{ steps.sources.outputs.root }}/scripts/pm/check-half-states.mjs in the step's env:. 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 with tail --pid and the exit code captured by redirect, never through a pipe:

BEFORE  origin/main e6a03e649   exit 0   1866 cases pass   613 s
AFTER   this branch bc1662577   exit 0   1883 cases pass   633 s      (+17 new cases)

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 from HEAD afterwards:

on-disk proof   OS_ABLATION_19229 occurrences 0 -> 1
blob            HEAD=ee5794e17f01e0f64ef97d204aeaccbebed27b33  mutated=4ba2eb4b6d4e096dbad65212efc0372c55955c87
live reading    discoverFamilies().compositeActions  ->  []        (was ['.github/actions/setup-pnpm/action.yml'])
ablated run     exit 1 — 7 of 1883 case(s) failed
                  the command executed THROUGH a composite action is derived / the CALLING-workflow attribution
                  the absence is NAMED / the follow recurses / a cycle terminates
                  the live discovery really opens the tree / really reads the steps in it
restored        blob ee5794e17f01e0f64ef97d204aeaccbebed27b33 == HEAD, `git diff HEAD` empty

⛔ No temporary file is left in the repo: the ablation ran from a script outside it, carried a shell trap on EXIT, INT and TERM that restores the file, and restored with git checkout HEAD -- PATH (never a bare git checkout --, which restores from the index).

Derived gate families. node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack over the real change set (10 paths, three-dot vs merge base e6a03e649) derived 51 families; all 51 were run with the exit code captured before any pipe, and reconciled back through --ran:

✓ dispatch-gates --ran: 51 derived famil(ies) accounted for — 51 run,
  0 NOT-MEASURED (a DERIVED zero — all 51 recorded an exit code and none of them is 3).

Every one exited 0, including all five extended gates and their self-tests, check:nul-bytes, check:watch-hint-literal, check:declared-population-live and check:pm-dispatch-gates.

pnpm lint is CI's repo-wide run, not this PR's.

Acceptance notes

  • .github/actions/setup-pnpm/action.yml and 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.
  • PR ci(pm): make the half-state patrol callable instead of copied #19225's action.yml carries the same now-stale sentence about check-node-version.mjs. Not touched: that file belongs to an open PR.
  • skip-changeset: measured, not assumed — 70 published packages, zero files[] entries naming scripts/ or .github/, root package private: true.

Generated by Claude Code

…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>
@os-try-charles os-try-charles added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 20, 2026 — with Claude
This was referenced Sep 20, 2026
@os-elon-musk
os-elon-musk marked this pull request as ready for review September 20, 2026 13:41
@os-elon-musk
os-elon-musk added this pull request to the merge queue Sep 20, 2026
Merged via the queue into main with commit c334ba0 Sep 20, 2026
56 checks passed
@os-elon-musk
os-elon-musk deleted the claude/issue-19229-derive-through-composite-actions branch September 20, 2026 14:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cd size/xl skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

3 participants