You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
fix(ci): derive a gate's population through composite actions, not just workflows (#19284)
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 e6a03e6 exit 0 1866 cases pass 613 s
AFTER this branch bc16625 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 ee5794e == 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 #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](https://claude.ai/code)_
Co-authored-by: Claude <noreply@anthropic.com>
0 commit comments