Skip to content

Commit bc16625

Browse files
committed
fix(ci): derive a gate's population through composite actions, not just 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>
1 parent e6a03e6 commit bc16625

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)