Skip to content

Commit c334ba0

Browse files
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>
1 parent d69f7e1 commit c334ba0

10 files changed

Lines changed: 1052 additions & 85 deletions

.github/actions/setup-pnpm/action.yml

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -47,11 +47,15 @@
4747
# this action materialised the pin, with an assertion in between -- is what
4848
# makes a job structurally unable to donate an unpinned manager to the cache.
4949
#
50-
# Deliberately NOT in here: `actions/setup-node`. `scripts/check-node-version.mjs`
51-
# scans `.github/workflows/*.yml` ONLY, and reports how many setup-node steps it
52-
# audited. Moving those steps into this composite would drop them from its census
53-
# and it would still print OK -- a gate silently auditing less than it says.
54-
# Callers keep their own `setup-node` step, with its literal `node-version` pin.
50+
# Deliberately NOT in here: `actions/setup-node`. The reason was a gate's blind
51+
# spot -- `scripts/check-node-version.mjs` scanned `.github/workflows/*.yml`
52+
# only, so a setup-node step moved into this composite would have dropped out of
53+
# its census while it still printed OK, a gate silently auditing less than it
54+
# says. That blind spot is CLOSED (#19229): the census now reads
55+
# `.github/actions/**` as well, and a step's Node pin is audited wherever it is
56+
# written. The separation is kept anyway, because the callers' own pins are
57+
# already in place and moving them buys nothing -- ⛔ it is no longer a
58+
# constraint, and a future composition is free to hold one.
5559

5660
name: Setup pnpm
5761
description: >-

.github/workflows/checklist-status.yml

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -114,10 +114,11 @@ jobs:
114114
- name: Checkout repository
115115
uses: actions/checkout@v7
116116

117-
# Kept as this job's own step rather than folded into the composite below:
118-
# `scripts/check-node-version.mjs` scans `.github/workflows/*.yml` only and
119-
# reports how many setup-node steps it audited, so a step moved out of
120-
# sight would silently shrink its census.
117+
# Kept as this job's own step rather than folded into the composite below.
118+
# ⛔ No longer because a gate cannot see it: `scripts/check-node-version.mjs`
119+
# reads `.github/actions/**` too since #19229, so its census follows a
120+
# setup-node step wherever it is written. Kept because the pin is already
121+
# here and moving it buys nothing.
121122
- name: Setup Node.js
122123
uses: actions/setup-node@v7
123124
with:

.github/workflows/platform-checklist-watchdog.yml

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -193,9 +193,10 @@ jobs:
193193
uses: actions/checkout@v7
194194

195195
# Kept as this job's own step rather than folded into the composite
196-
# below: `scripts/check-node-version.mjs` scans `.github/workflows/*.yml`
197-
# only and reports how many setup-node steps it audited, so a step moved
198-
# out of sight would silently shrink its census.
196+
# below. ⛔ No longer because a gate cannot see it:
197+
# `scripts/check-node-version.mjs` reads `.github/actions/**` too since
198+
# #19229, so its census follows a setup-node step wherever it is written.
199+
# Kept because the pin is already here and moving it buys nothing.
199200
- name: Setup Node.js
200201
uses: actions/setup-node@v7
201202
with:

.github/workflows/test-nightly-tiers.yml

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -161,9 +161,10 @@ jobs:
161161
uses: actions/checkout@v7
162162

163163
# Kept as this job's own step rather than folded into the composite
164-
# below: `scripts/check-node-version.mjs` scans `.github/workflows/*.yml`
165-
# only and reports how many setup-node steps it audited, so a step moved
166-
# out of sight would silently shrink its census.
164+
# below. ⛔ No longer because a gate cannot see it:
165+
# `scripts/check-node-version.mjs` reads `.github/actions/**` too since
166+
# #19229, so its census follows a setup-node step wherever it is written.
167+
# Kept because the pin is already here and moving it buys nothing.
167168
- name: Setup Node.js
168169
uses: actions/setup-node@v7
169170
with:

scripts/check-node-version.mjs

Lines changed: 54 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -32,12 +32,36 @@
3232
// Deliberately NOT checked: `engines.node` in package.json. That is a promise
3333
// to users about what the published packages support, which is independent of
3434
// what CI validates on, and tightening it is a breaking change. See #3825.
35+
//
36+
// ## The population is BOTH `.github/workflows/` and `.github/actions/` (#19229)
37+
//
38+
// A `uses: actions/setup-node@` step decides which Node a job runs on wherever
39+
// it is written, and a composite action is a legal place to write one. Rooting
40+
// the census at `.github/workflows` alone made that a place the pin could drift
41+
// unwatched -- and the drift would have been invisible in exactly this gate's
42+
// own signature, because the OK line reports how many steps it audited and a
43+
// step that moved out of the census simply stops being counted.
44+
//
45+
// The cost was already being paid in the tree rather than merely risked:
46+
// `.github/actions/setup-pnpm/action.yml` carries a comment declaring that it
47+
// deliberately does NOT hold a `setup-node` step, and names THIS gate's
48+
// workflows-only census as the reason. That is a real composition being shaped
49+
// around a gate's blind spot, which is the strongest evidence a population is
50+
// wrong. With both roots read, the constraint is gone: put the step wherever
51+
// the composition wants it.
52+
//
53+
// A missing `.github/actions/` is not an error -- a repo may hold no composite
54+
// action at all -- and both counts are printed separately so the scope line
55+
// says what was read rather than implying it.
3556

3657
import { execFileSync } from 'node:child_process';
37-
import { readFileSync, readdirSync } from 'node:fs';
58+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
3859
import { join } from 'node:path';
3960

4061
const WORKFLOW_DIR = '.github/workflows';
62+
const ACTION_DIR = '.github/actions';
63+
/** The file names GitHub accepts for a local action, in the order it resolves them. */
64+
const ACTION_FILES = ['action.yml', 'action.yaml'];
4165
const PIN_FILE = '.nvmrc';
4266

4367
const root = execFileSync('git', ['rev-parse', '--show-toplevel'], {
@@ -123,10 +147,32 @@ if (daysLeft <= WARN_WITHIN_DAYS) {
123147
);
124148
}
125149

126-
const files = readdirSync(join(root, WORKFLOW_DIR))
150+
const workflowFiles = readdirSync(join(root, WORKFLOW_DIR))
127151
.filter((f) => f.endsWith('.yml') || f.endsWith('.yaml'))
128152
.sort();
129153

154+
// Every `action.yml` / `action.yaml` under `.github/actions/`, walked rather
155+
// than read one level deep because a local action may be nested
156+
// (`uses: ./.github/actions/a/b`). An absent directory answers [].
157+
function actionFilesUnder(dir, prefix = '') {
158+
const out = [];
159+
if (!existsSync(dir)) return out;
160+
for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
161+
if (entry.isDirectory()) out.push(...actionFilesUnder(join(dir, entry.name), `${prefix}${entry.name}/`));
162+
else if (ACTION_FILES.includes(entry.name)) out.push(`${prefix}${entry.name}`);
163+
}
164+
return out;
165+
}
166+
const actionFiles = actionFilesUnder(join(root, ACTION_DIR));
167+
168+
// One list, each entry carrying the path it will be REPORTED under, so an
169+
// offender names the file a reader can open rather than a name that is only
170+
// unique inside one of the two roots.
171+
const files = [
172+
...workflowFiles.map((f) => ({ rel: `${WORKFLOW_DIR}/${f}`, abs: join(root, WORKFLOW_DIR, f) })),
173+
...actionFiles.map((f) => ({ rel: `${ACTION_DIR}/${f}`, abs: join(root, ACTION_DIR, f) })),
174+
];
175+
130176
// A step ends at the next YAML list item; `with:` keys live between the
131177
// `uses: actions/setup-node` line and that boundary.
132178
const SETUP_NODE = /^\s*(?:-\s+)?uses:\s*actions\/setup-node@/;
@@ -139,8 +185,8 @@ const unquote = (v) => v.replace(/^['"]|['"]$/g, '').trim();
139185
const offenders = [];
140186
let steps = 0;
141187

142-
for (const file of files) {
143-
const lines = readFileSync(join(root, WORKFLOW_DIR, file), 'utf8').split('\n');
188+
for (const { rel: where, abs } of files) {
189+
const lines = readFileSync(abs, 'utf8').split('\n');
144190
for (let i = 0; i < lines.length; i++) {
145191
if (!SETUP_NODE.test(lines[i])) continue;
146192
steps++;
@@ -161,7 +207,6 @@ for (const file of files) {
161207
}
162208
}
163209

164-
const where = `${WORKFLOW_DIR}/${file}`;
165210
if (!found) {
166211
// No pin at all: the step silently inherits whatever Node the runner
167212
// image ships, which GitHub bumps without telling us.
@@ -214,7 +259,8 @@ for (const file of files) {
214259
if (offenders.length === 0) {
215260
const phase = inMaintenance ? 'maintenance' : 'active LTS';
216261
console.log(
217-
`check-node-version: OK (${steps} setup-node step(s) across ${files.length} workflow(s), all on Node ${pin}).\n` +
262+
`check-node-version: OK (${steps} setup-node step(s) across ${workflowFiles.length} workflow(s) ` +
263+
`and ${actionFiles.length} composite action(s), all on Node ${pin}).\n` +
218264
` Node ${major} is in ${phase}; supported until ${lifecycle.end} (${daysLeft} days).`,
219265
);
220266
process.exit(0);
@@ -234,5 +280,6 @@ newer one can abort the test worker mid-run, which vitest reports as a PASSING
234280
suite with silently missing cases (#3812).
235281
236282
To move the whole repo to a new Node version, edit ${PIN_FILE} and then update
237-
every step this guard lists.`);
283+
every step this guard lists. The census covers ${WORKFLOW_DIR}/ and
284+
${ACTION_DIR}/ alike -- a setup-node step is a Node pin wherever it is written.`);
238285
process.exit(1);

0 commit comments

Comments
 (0)