From bc1662577942421b20a1ed22a40a864ef6c2610f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 20 Sep 2026 09:34:40 +0000 Subject: [PATCH] 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 --- .github/actions/setup-pnpm/action.yml | 14 +- .github/workflows/checklist-status.yml | 9 +- .../workflows/platform-checklist-watchdog.yml | 7 +- .github/workflows/test-nightly-tiers.yml | 7 +- scripts/check-node-version.mjs | 61 ++- scripts/check-self-test-wired.mjs | 181 ++++++- scripts/check-self-test-workflow-commands.mjs | 34 +- scripts/check-step-collectors.mjs | 193 +++++++- scripts/check-workflow-step-name-quoting.mjs | 189 +++++++- scripts/pm/dispatch-gates.mjs | 442 +++++++++++++++++- 10 files changed, 1052 insertions(+), 85 deletions(-) diff --git a/.github/actions/setup-pnpm/action.yml b/.github/actions/setup-pnpm/action.yml index cc95d84fd32..30ef22c1bbe 100644 --- a/.github/actions/setup-pnpm/action.yml +++ b/.github/actions/setup-pnpm/action.yml @@ -47,11 +47,15 @@ # this action materialised the pin, with an assertion in between -- is what # makes a job structurally unable to donate an unpinned manager to the cache. # -# Deliberately NOT in here: `actions/setup-node`. `scripts/check-node-version.mjs` -# scans `.github/workflows/*.yml` ONLY, and reports how many setup-node steps it -# audited. Moving those steps into this composite would drop them from its census -# and it would still print OK -- a gate silently auditing less than it says. -# Callers keep their own `setup-node` step, with its literal `node-version` pin. +# Deliberately NOT in here: `actions/setup-node`. The reason was a gate's blind +# spot -- `scripts/check-node-version.mjs` scanned `.github/workflows/*.yml` +# only, so a setup-node step moved into this composite would have dropped out of +# its census while it still printed OK, a gate silently auditing less than it +# says. That blind spot is CLOSED (#19229): the census now reads +# `.github/actions/**` as well, and a step's Node pin is audited wherever it is +# written. The separation is kept anyway, because the callers' own pins are +# already in place and moving them buys nothing -- ⛔ it is no longer a +# constraint, and a future composition is free to hold one. name: Setup pnpm description: >- diff --git a/.github/workflows/checklist-status.yml b/.github/workflows/checklist-status.yml index 26729c9aab8..6cc537d62ce 100644 --- a/.github/workflows/checklist-status.yml +++ b/.github/workflows/checklist-status.yml @@ -114,10 +114,11 @@ jobs: - name: Checkout repository uses: actions/checkout@v7 - # Kept as this job's own step rather than folded into the composite below: - # `scripts/check-node-version.mjs` scans `.github/workflows/*.yml` only and - # reports how many setup-node steps it audited, so a step moved out of - # sight would silently shrink its census. + # Kept as this job's own step rather than folded into the composite below. + # ⛔ No longer because a gate cannot see it: `scripts/check-node-version.mjs` + # reads `.github/actions/**` too since #19229, so its census follows a + # setup-node step wherever it is written. Kept because the pin is already + # here and moving it buys nothing. - name: Setup Node.js uses: actions/setup-node@v7 with: diff --git a/.github/workflows/platform-checklist-watchdog.yml b/.github/workflows/platform-checklist-watchdog.yml index a16ce34df5f..fa0ede86726 100644 --- a/.github/workflows/platform-checklist-watchdog.yml +++ b/.github/workflows/platform-checklist-watchdog.yml @@ -193,9 +193,10 @@ jobs: uses: actions/checkout@v7 # Kept as this job's own step rather than folded into the composite - # below: `scripts/check-node-version.mjs` scans `.github/workflows/*.yml` - # only and reports how many setup-node steps it audited, so a step moved - # out of sight would silently shrink its census. + # below. ⛔ No longer because a gate cannot see it: + # `scripts/check-node-version.mjs` reads `.github/actions/**` too since + # #19229, so its census follows a setup-node step wherever it is written. + # Kept because the pin is already here and moving it buys nothing. - name: Setup Node.js uses: actions/setup-node@v7 with: diff --git a/.github/workflows/test-nightly-tiers.yml b/.github/workflows/test-nightly-tiers.yml index 75a166b715f..bca2a3cbca5 100644 --- a/.github/workflows/test-nightly-tiers.yml +++ b/.github/workflows/test-nightly-tiers.yml @@ -161,9 +161,10 @@ jobs: uses: actions/checkout@v7 # Kept as this job's own step rather than folded into the composite - # below: `scripts/check-node-version.mjs` scans `.github/workflows/*.yml` - # only and reports how many setup-node steps it audited, so a step moved - # out of sight would silently shrink its census. + # below. ⛔ No longer because a gate cannot see it: + # `scripts/check-node-version.mjs` reads `.github/actions/**` too since + # #19229, so its census follows a setup-node step wherever it is written. + # Kept because the pin is already here and moving it buys nothing. - name: Setup Node.js uses: actions/setup-node@v7 with: diff --git a/scripts/check-node-version.mjs b/scripts/check-node-version.mjs index eaf3f021e21..42d8f2d6a7c 100644 --- a/scripts/check-node-version.mjs +++ b/scripts/check-node-version.mjs @@ -32,12 +32,36 @@ // Deliberately NOT checked: `engines.node` in package.json. That is a promise // to users about what the published packages support, which is independent of // what CI validates on, and tightening it is a breaking change. See #3825. +// +// ## The population is BOTH `.github/workflows/` and `.github/actions/` (#19229) +// +// A `uses: actions/setup-node@` step decides which Node a job runs on wherever +// it is written, and a composite action is a legal place to write one. Rooting +// the census at `.github/workflows` alone made that a place the pin could drift +// unwatched -- and the drift would have been invisible in exactly this gate's +// own signature, because the OK line reports how many steps it audited and a +// step that moved out of the census simply stops being counted. +// +// The cost was already being paid in the tree rather than merely risked: +// `.github/actions/setup-pnpm/action.yml` carries a comment declaring that it +// deliberately does NOT hold a `setup-node` step, and names THIS gate's +// workflows-only census as the reason. That is a real composition being shaped +// around a gate's blind spot, which is the strongest evidence a population is +// wrong. With both roots read, the constraint is gone: put the step wherever +// the composition wants it. +// +// A missing `.github/actions/` is not an error -- a repo may hold no composite +// action at all -- and both counts are printed separately so the scope line +// says what was read rather than implying it. import { execFileSync } from 'node:child_process'; -import { readFileSync, readdirSync } from 'node:fs'; +import { existsSync, readFileSync, readdirSync } from 'node:fs'; import { join } from 'node:path'; const WORKFLOW_DIR = '.github/workflows'; +const ACTION_DIR = '.github/actions'; +/** The file names GitHub accepts for a local action, in the order it resolves them. */ +const ACTION_FILES = ['action.yml', 'action.yaml']; const PIN_FILE = '.nvmrc'; const root = execFileSync('git', ['rev-parse', '--show-toplevel'], { @@ -123,10 +147,32 @@ if (daysLeft <= WARN_WITHIN_DAYS) { ); } -const files = readdirSync(join(root, WORKFLOW_DIR)) +const workflowFiles = readdirSync(join(root, WORKFLOW_DIR)) .filter((f) => f.endsWith('.yml') || f.endsWith('.yaml')) .sort(); +// Every `action.yml` / `action.yaml` under `.github/actions/`, walked rather +// than read one level deep because a local action may be nested +// (`uses: ./.github/actions/a/b`). An absent directory answers []. +function actionFilesUnder(dir, prefix = '') { + const out = []; + if (!existsSync(dir)) return out; + for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) { + if (entry.isDirectory()) out.push(...actionFilesUnder(join(dir, entry.name), `${prefix}${entry.name}/`)); + else if (ACTION_FILES.includes(entry.name)) out.push(`${prefix}${entry.name}`); + } + return out; +} +const actionFiles = actionFilesUnder(join(root, ACTION_DIR)); + +// One list, each entry carrying the path it will be REPORTED under, so an +// offender names the file a reader can open rather than a name that is only +// unique inside one of the two roots. +const files = [ + ...workflowFiles.map((f) => ({ rel: `${WORKFLOW_DIR}/${f}`, abs: join(root, WORKFLOW_DIR, f) })), + ...actionFiles.map((f) => ({ rel: `${ACTION_DIR}/${f}`, abs: join(root, ACTION_DIR, f) })), +]; + // A step ends at the next YAML list item; `with:` keys live between the // `uses: actions/setup-node` line and that boundary. const SETUP_NODE = /^\s*(?:-\s+)?uses:\s*actions\/setup-node@/; @@ -139,8 +185,8 @@ const unquote = (v) => v.replace(/^['"]|['"]$/g, '').trim(); const offenders = []; let steps = 0; -for (const file of files) { - const lines = readFileSync(join(root, WORKFLOW_DIR, file), 'utf8').split('\n'); +for (const { rel: where, abs } of files) { + const lines = readFileSync(abs, 'utf8').split('\n'); for (let i = 0; i < lines.length; i++) { if (!SETUP_NODE.test(lines[i])) continue; steps++; @@ -161,7 +207,6 @@ for (const file of files) { } } - const where = `${WORKFLOW_DIR}/${file}`; if (!found) { // No pin at all: the step silently inherits whatever Node the runner // image ships, which GitHub bumps without telling us. @@ -214,7 +259,8 @@ for (const file of files) { if (offenders.length === 0) { const phase = inMaintenance ? 'maintenance' : 'active LTS'; console.log( - `check-node-version: OK (${steps} setup-node step(s) across ${files.length} workflow(s), all on Node ${pin}).\n` + + `check-node-version: OK (${steps} setup-node step(s) across ${workflowFiles.length} workflow(s) ` + + `and ${actionFiles.length} composite action(s), all on Node ${pin}).\n` + ` Node ${major} is in ${phase}; supported until ${lifecycle.end} (${daysLeft} days).`, ); process.exit(0); @@ -234,5 +280,6 @@ newer one can abort the test worker mid-run, which vitest reports as a PASSING suite with silently missing cases (#3812). To move the whole repo to a new Node version, edit ${PIN_FILE} and then update -every step this guard lists.`); +every step this guard lists. The census covers ${WORKFLOW_DIR}/ and +${ACTION_DIR}/ alike -- a setup-node step is a Node pin wherever it is written.`); process.exit(1); diff --git a/scripts/check-self-test-wired.mjs b/scripts/check-self-test-wired.mjs index 461096f8536..c36931a5fe0 100644 --- a/scripts/check-self-test-wired.mjs +++ b/scripts/check-self-test-wired.mjs @@ -64,9 +64,28 @@ * * ## Population, and the one thing it deliberately over-selects * - * A script is IN when a workflow names it -- directly, or through a root - * `package.json` alias a workflow names -- and its code, with comments masked, - * contains the literal `--self-test`. The mask is load-bearing in both + * A script is IN when a workflow names it -- directly, through a root + * `package.json` alias a workflow names, or through a LOCAL COMPOSITE ACTION a + * workflow `uses:` (#19229) -- and its code, with comments masked, contains the + * literal `--self-test`. + * + * The third source landed because the first two made a directory boundary into + * a coverage boundary. This gate's subject is "a script CI RUNS whose self-test + * CI must run too", and a step inside `.github/actions/**` is run by CI in the + * calling job exactly as an inline one is. With the corpus rooted at + * `.github/workflows` alone, moving a step into a composite action dropped its + * script out of the population -- and the scope line still counted confidently, + * because every `#4690` floor here fires on an EMPTY population, never on one + * that is complete-minus-one. That is the same failure mode #15414 records one + * paragraph down, arriving through a different door. + * + * ⛔ The action corpus is read from the tree, not followed out of a `uses:` + * line. Every `action.yml` under `.github/actions/` is read whether a workflow + * reaches it or not, which is the safe direction here: this gate asks whether a + * self-test is RUN anywhere in CI, so a reachability rule could only ever + * SUBTRACT members -- and it would subtract them silently. An unreferenced + * action's steps are dead code, which is a different finding and a different + * gate's. The mask is load-bearing in both * directions: `pnpm check:platform-checklist` appears in `lint.yml` only inside * a comment (it is maintainer-run by ruling), and counting that would fabricate * a member; a gate's header naming its own flag is likewise prose, not code. @@ -97,8 +116,9 @@ * merely implied by the operators. */ -import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs'; -import { join, relative, sep } from 'node:path'; +import { readFileSync, readdirSync, existsSync, statSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, relative, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; import { isEntrypoint } from './invoked-as.mjs'; @@ -106,6 +126,12 @@ import { maskComments } from './js-comment-mask.mjs'; const ROOT = join(fileURLToPath(new URL('.', import.meta.url)), '..'); const WORKFLOW_DIR = '.github/workflows'; +// The SECOND corpus root (#19229) -- local composite actions. Absent is a real +// state and never a refusal: a repo may hold no composite action at all. What +// keeps it from going quiet on a repo that DOES is the live battery below. +const ACTION_DIR = '.github/actions'; +/** The file names GitHub accepts for a local action, in the order it resolves them. */ +const ACTION_FILES = ['action.yml', 'action.yaml']; // The token every gate in this farm writes when it names a path belonging to a // maintainer rather than to the landing author (#8435). Declared per gate by @@ -432,6 +458,25 @@ function walkScripts(dir, root = ROOT, out = []) { return out; } +/** + * Every `action.yml` / `action.yaml` under `/.github/actions/`, as paths + * relative to the REPO root -- the spelling a finding names, so a reader can + * open the file the attribution points at. + * + * Walked rather than read one level deep, because a local action may be nested + * (`uses: ./.github/actions/a/b`). A missing directory answers `[]`: absence is + * a real state for this root, never a broken reader (#19229). + */ +function walkActionFiles(dir, root, out = []) { + if (!existsSync(dir) || !statSync(dir).isDirectory()) return out; + for (const entry of readdirSync(dir).sort()) { + const full = join(dir, entry); + if (statSync(full).isDirectory()) walkActionFiles(full, root, out); + else if (ACTION_FILES.includes(entry)) out.push(relative(root, full).split(sep).join('/')); + } + return out; +} + /** * ⛔ THE `#4690` FLOORS, as a pure function over a COMPLETED reading (#15414). * @@ -511,9 +556,11 @@ export function collectPopulation({ root = ROOT } = {}) { named: new Map(), selfTested: new Map(), workflows: [], + actions: [], population: [], packageLocal: [], workflowDir: WORKFLOW_DIR, + actionDir: ACTION_DIR, sourceOf, ...over, }); @@ -539,6 +586,16 @@ export function collectPopulation({ root = ROOT } = {}) { .sort() .map((name) => ({ name, text: readFileSync(join(workflowDir, name), 'utf8') })); + // The composite action corpus (#19229). Named by its REPO-RELATIVE path + // rather than by a bare file name: `action.yml` is the same string in every + // action directory, and an attribution a reader cannot open is not an + // attribution. Kept in its own array so the `#4690` floors above keep asking + // about the WORKFLOW corpus, which is the one this tree cannot legally be + // without. + const actions = walkActionFiles(join(root, ACTION_DIR), root) + .sort() + .map((name) => ({ name, text: readFileSync(join(root, name), 'utf8') })); + let pkgScripts = null; try { pkgScripts = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).scripts ?? {}; @@ -546,7 +603,9 @@ export function collectPopulation({ root = ROOT } = {}) { return blank('the root package.json could not be read or parsed.', { files, walked, sources, rootCarriers, workflows }); } - const { named, selfTested } = collectInvocations(workflows, pkgScripts); + // ONE corpus into the extraction: a step CI runs is a step CI runs, whichever + // of the two files it is written in. + const { named, selfTested } = collectInvocations([...workflows, ...actions], pkgScripts); // The population's SECOND source: the package-local gate lane (#15342). // @@ -604,9 +663,11 @@ export function collectPopulation({ root = ROOT } = {}) { named, selfTested, workflows, + actions, population, packageLocal, workflowDir: WORKFLOW_DIR, + actionDir: ACTION_DIR, sourceOf, }; return { ...reading, refusal: refusalFor({ ...reading, pkgScriptCount: Object.keys(pkgScripts).length }) }; @@ -623,7 +684,7 @@ function main() { // sibling gate consumes the SAME answer rather than a second one (#15414). const read = collectPopulation(); if (read.refusal) refuse(read.refusal); - const { files, carriers, named, selfTested, population, packageLocal, workflows, sourceOf } = read; + const { files, carriers, named, selfTested, population, packageLocal, workflows, actions, sourceOf } = read; const findings = [ ...auditPopulation({ carriers, named, selfTested, ledger: SELF_TEST_RUN_OTHERWISE }), @@ -634,7 +695,8 @@ function main() { const scope = ` scope: ${files.length} file(s) under scripts/, ${carriers.size} carrying \`--self-test\` in code ` + `(comments masked, ${packageLocal.length} of them package-local gate(s) CI names by path); ` + - `${population.length} of those are run by ${workflows.length} workflow(s); ` + + `${population.length} of those are run by ${workflows.length} workflow(s) and ` + + `${actions.length} composite action(s); ` + `${wired.length} have their self-test run through the flag, ${SELF_TEST_RUN_OTHERWISE.length} through a recorded route.`; if (findings.length > 0) { @@ -705,12 +767,17 @@ const SELF_TEST_BATTERIES = Object.freeze({ 'ledger hygiene': 9, 'live ledger': 4, 'the exported population': 8, + // #19229: the composite-action corpus. A firing control (a self-test wired + // ONLY inside an action counts as wired), a dark control (with the action + // file gone the same tree reports it unwired), the absence case that must NOT + // become a refusal, and the live reading that keeps the root from going quiet. + 'the composite action corpus': 6, }); // DELETING an entry silences that battery's floor exactly as effectively as // zeroing it, so the registry's own size is pinned too. Adding a battery raises // this number; removing one is the same ⛔ deliberate edit as lowering a count. -const SELF_TEST_BATTERY_FLOOR = 10; +const SELF_TEST_BATTERY_FLOOR = 11; // The key an assertion is filed under when no battery is open. It is not a // declared battery, so it reds by the same set difference rather than silently @@ -1179,6 +1246,102 @@ function selfTest() { ); } + // ── The composite action corpus (#19229) ──────────────────────────────── + // + // A step written in `.github/actions/**` is run by CI in the calling job + // exactly as an inline step is, so a `--self-test` executed there is WIRED. + // Rooting the corpus at `.github/workflows` alone made that a coverage + // boundary: the script dropped out of `selfTested` and this gate reported it + // unwired, while a scope line counted confidently past the gap. + // + // Driven on a fixture tree rather than on the repo, because the repo cannot + // hold the defect on purpose. The dark control is the same tree with the + // action file removed -- it is what makes the firing control a reading about + // the second root rather than about the fixture. + battery('the composite action corpus'); + { + const fixtureRoots = []; + const makeRoot = (files) => { + const dir = mkdtempSync(join(tmpdir(), 'check-self-test-wired-')); + fixtureRoots.push(dir); + for (const [rel, contents] of Object.entries(files)) { + const full = join(dir, rel); + mkdirSync(dirname(full), { recursive: true }); + writeFileSync(full, contents); + } + return dir; + }; + const GATE_SOURCE = "if (process.argv.includes('--self-test')) { process.exit(0); }\n"; + const CALLER_WF = `name: Lint +on: [push] +jobs: + lint: + runs-on: ubuntu-latest + steps: + - run: node scripts/g.mjs + - uses: ./.github/actions/fixture-gate +`; + const ACTION_YML = `name: Fixture gate +description: runs the gate's own self-test +runs: + using: composite + steps: + - shell: bash + run: node scripts/g.mjs --self-test +`; + const BASE = { 'scripts/g.mjs': GATE_SOURCE, 'package.json': '{"scripts":{"noop":"true"}}\n', '.github/workflows/lint.yml': CALLER_WF }; + try { + // ⭐ FIRING control. + const withAction = collectPopulation({ + root: makeRoot({ ...BASE, '.github/actions/fixture-gate/action.yml': ACTION_YML }), + }); + ok( + withAction.refusal === null && withAction.population.includes('scripts/g.mjs'), + `the fixture tree did not produce a population (${withAction.refusal ?? 'empty'}), so the cases below prove nothing (#4690)`, + ); + ok( + withAction.selfTested.has('scripts/g.mjs'), + 'a `--self-test` CI runs INSIDE a composite action does not count as run. The step executes in the ' + + 'calling job exactly as an inline one does, so this gate would demand a wiring that is already there (#19229)', + ); + ok( + [...(withAction.selfTested.get('scripts/g.mjs') ?? [])].join('|') === '.github/actions/fixture-gate/action.yml', + 'the attribution does not name the action FILE. `action.yml` is the same string in every action ' + + 'directory, so a bare file name is an attribution a reader cannot open', + ); + ok( + withAction.actions.length === 1 && withAction.actionDir === ACTION_DIR, + `the reading does not carry its second corpus root, got ${withAction.actions.length} action(s) under ${withAction.actionDir}`, + ); + // ⭐ DARK control: the SAME tree with the action file gone. The script is + // still named by the workflow, so this is not a refusal — it is the + // finding the firing control must be absent from. + const withoutAction = collectPopulation({ root: makeRoot(BASE) }); + ok( + withoutAction.refusal === null + && withoutAction.actions.length === 0 + && !withoutAction.selfTested.has('scripts/g.mjs') + && auditPopulation({ + carriers: withoutAction.carriers, + named: withoutAction.named, + selfTested: withoutAction.selfTested, + ledger: [], + }).length === 1, + 'with no composite action behind it the same tree is NOT reported unwired, so the case above is ' + + 'passing for some other reason than the second root being read', + ); + // The live reading, so the root cannot go quiet on the repo it guards. + const liveActions = collectPopulation().actions; + ok( + liveActions.length > 0 && liveActions.every((a) => a.name.startsWith(`${ACTION_DIR}/`)), + 'this repo holds composite actions and the shared reading sees none — a corpus root that stopped ' + + 'being read, which absence-is-not-a-refusal cannot tell apart from a repo that has none (#19229)', + ); + } finally { + for (const dir of fixtureRoots) rmSync(dir, { recursive: true, force: true }); + } + } + // ── The floor: every declared battery RAN, and ran its cases ───────────── // // Evaluated here, after every battery has had its chance and BEFORE the diff --git a/scripts/check-self-test-workflow-commands.mjs b/scripts/check-self-test-workflow-commands.mjs index fab01010eb1..c0041b48d34 100644 --- a/scripts/check-self-test-workflow-commands.mjs +++ b/scripts/check-self-test-workflow-commands.mjs @@ -146,6 +146,22 @@ const ROOT = join(fileURLToPath(new URL('.', import.meta.url)), '..'); */ const WORKFLOW_DIR = '.github/workflows'; +/** + * The SECOND corpus root the shared reader derives this gate's population from + * (#19229) — local composite actions. + * + * Declared here for both of the reasons the workflow root is, and for a third + * that is this card's: a step written in `.github/actions/**` is run by CI in + * the calling job exactly as an inline step is, so it decides which self-tests + * are in the population. A gate that reads a corpus and names only half of it + * to the dispatch derivation is derived onto the wrong cards — and silently, + * which is the shape #19229 measured across six gates at once. + * + * PINNED against `read.actionDir` in `main()` below, exactly as `WORKFLOW_DIR` + * is: a live coupling rather than a decoration. + */ +const ACTION_DIR = '.github/actions'; + /** * POPULATION DECLARATION — the `scripts/` corpus this gate's verdict is ABOUT, * in the subtree spelling `scripts/pm/dispatch-gates.mjs` compares in. @@ -312,11 +328,11 @@ function main() { // replaced was a second walk that agreed for a while and then quietly did not. const read = collectPopulation(); if (read.refusal) refuse(read.refusal); - if (read.workflowDir !== WORKFLOW_DIR) { + if (read.workflowDir !== WORKFLOW_DIR || read.actionDir !== ACTION_DIR) { refuse( - `the shared population was read from \`${read.workflowDir}\`, but this gate declares ` + - `\`${WORKFLOW_DIR}\` to the dispatch derivation. One of the two moved, and a gate naming a ` + - 'corpus it no longer depends on is derived onto the wrong cards in silence.', + `the shared population was read from \`${read.workflowDir}\` + \`${read.actionDir}\`, but this gate ` + + `declares \`${WORKFLOW_DIR}\` + \`${ACTION_DIR}\` to the dispatch derivation. One of them moved, and a ` + + 'gate naming a corpus it no longer depends on is derived onto the wrong cards in silence.', ); } const { population, packageLocal, sources } = read; @@ -415,7 +431,7 @@ const SELF_TEST_BATTERIES = Object.freeze({ 'innocent output': 4, 'prefilter reads CODE, never prose': 5, 'end to end on the real defect site': 5, - 'the population is imported, never re-walked': 8, + 'the population is imported, never re-walked': 9, 'the scripts/ population is declared here': 6, }); @@ -569,10 +585,16 @@ function selfTest() { + 'terms for every member, and its VALUE is a measurement of that file, deliberately not pinned here', ); ok( - live.workflowDir === WORKFLOW_DIR, + live.workflowDir === WORKFLOW_DIR && live.actionDir === ACTION_DIR, 'the shared reader derives the population from a corpus root this gate does not declare, so the ' + 'dispatch derivation would name this gate for the wrong cards', ); + ok( + Array.isArray(live.actions) && live.actions.length > 0, + 'the shared reading carries no composite action at all, on a repo that holds them — the second ' + + 'corpus root stopped being read, and absence-is-not-a-refusal cannot tell that apart from a ' + + 'repo that has none (#19229)', + ); // Own-source. The needles are ASSEMBLED: spelled out, they would be found // in this very fixture and the pin would red on itself forever. diff --git a/scripts/check-step-collectors.mjs b/scripts/check-step-collectors.mjs index 681ce9af02d..74cc3cf5fe5 100644 --- a/scripts/check-step-collectors.mjs +++ b/scripts/check-step-collectors.mjs @@ -208,6 +208,20 @@ import { probeBashCapabilities, unsupportedConstructs } from './check-bash32-flo const HERE = dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = resolve(HERE, '..'); const WORKFLOW_DIR = join('.github', 'workflows'); +// The SECOND root (#19229). A composite action's steps run on the runner under +// the same `bash -e ` a workflow step does -- so the first non-zero exit +// aborts the block and everything after it is neither green nor red, which is +// this gate's whole subject. Rooting the population at `.github/workflows` +// alone made a directory boundary into a coverage boundary; this gate's row in +// #19229's table was UNJUDGED precisely because the root is assembled with +// `join` and a literal grep for `.github/workflows` found nothing here. +// +// ⚠️ Absent is NOT a refusal: a repo may hold no composite action. What keeps +// the root from going quiet on a repo that has them is the live assertion in +// `--self-test`, which is the #4690 floor in the form this root can carry. +const ACTION_DIR = join('.github', 'actions'); +/** The file names GitHub accepts for a local action, in the order it resolves them. */ +const ACTION_FILES = ['action.yml', 'action.yaml']; /** The block this gate requires, recognised by the helper it must define. */ const COLLECTOR_ANCHOR = /^\s*run_self_test\s*\(\)\s*\{/m; @@ -397,8 +411,39 @@ export function collectedCommands(runText) { } /** - * Judge one workflow's text. Pure over the text, so the self-test drives the - * same predicate the gate does rather than a paraphrase of it. + * The step lists a parsed document holds, each with the name a message should + * attribute it to. + * + * TWO document shapes, one predicate (#19229). A workflow declares its steps + * under `jobs..steps`; a composite action declares them under `runs.steps` + * and has no `jobs:` at all. The masking defect is identical in both -- the + * runner writes the block to a file and runs `bash -e` on it either way -- so + * the judgement below is shared and only the walk to the steps differs. A + * document with neither shape contributes nothing, which is what a reusable + * workflow call or a `node20` action does. + * + * @param {unknown} doc + * @returns {{ job: string, steps: unknown[] }[]} + */ +function stepGroups(doc) { + if (!doc || typeof doc !== 'object') return []; + const out = []; + const jobs = doc.jobs; + if (jobs && typeof jobs === 'object') { + for (const [job, body] of Object.entries(jobs)) { + if (Array.isArray(body?.steps)) out.push({ job, steps: body.steps }); + } + } + // A composite action: ONE implicit group, labelled so a finding names the + // shape a reader will find in the file rather than a job id that is not there. + if (Array.isArray(doc.runs?.steps)) out.push({ job: 'runs (composite)', steps: doc.runs.steps }); + return out; +} + +/** + * Judge one workflow's or one composite action's text. Pure over the text, so + * the self-test drives the same predicate the gate does rather than a + * paraphrase of it. * * @param {string} text workflow YAML source * @param {string} file its file name, for messages @@ -415,11 +460,8 @@ export function scanWorkflowText(text, file, parseYaml) { } catch (error) { return { problems: [`${file} does not parse as YAML: ${error.message}`], steps: 0, collectors: [] }; } - const jobs = doc && typeof doc === 'object' ? doc.jobs : undefined; - if (!jobs || typeof jobs !== 'object') return { problems, steps, collectors }; - - for (const [job, body] of Object.entries(jobs)) { - for (const step of Array.isArray(body?.steps) ? body.steps : []) { + for (const { job, steps: group } of stepGroups(doc)) { + for (const step of group) { if (typeof step?.run !== 'string') continue; steps++; const targets = selfTestTargets(step.run); @@ -470,11 +512,29 @@ export function scanWorkflowText(text, file, parseYaml) { } /** - * Scan every checked-in workflow. + * Every `action.yml` / `action.yaml` under `/.github/actions/`, relative + * to that directory. Walked rather than read one level deep, because a local + * action may be nested (`uses: ./.github/actions/a/b`). A missing directory + * answers `[]` -- see ACTION_DIR for why absence here is a state and not a + * refusal. + */ +function actionFilesUnder(dir, prefix = '', out = []) { + if (!existsSync(dir)) return out; + for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) { + if (entry.isDirectory()) actionFilesUnder(join(dir, entry.name), `${prefix}${entry.name}/`, out); + else if (ACTION_FILES.includes(entry.name)) out.push(`${prefix}${entry.name}`); + } + return out; +} + +/** + * Scan every checked-in workflow AND every checked-in composite action. * * Missing input is a failure, never a pass (#4690): no workflow directory, and * no collector found at all, are both problems -- a scan that reads nothing is - * indistinguishable from a scan that found nothing wrong. + * indistinguishable from a scan that found nothing wrong. The SECOND root is + * the one exception and it is declared rather than assumed: `.github/actions/` + * absent is a real state, so it answers zero files instead of a refusal. * * @param {string} root * @param {(source: string) => unknown} parseYaml @@ -482,19 +542,27 @@ export function scanWorkflowText(text, file, parseYaml) { export function scanWorkflows(root, parseYaml) { const dir = join(root, WORKFLOW_DIR); if (!existsSync(dir)) { - return { problems: [`${WORKFLOW_DIR} does not exist -- nothing was verified (see #4690).`], steps: 0, collectors: [], files: 0 }; + return { problems: [`${WORKFLOW_DIR} does not exist -- nothing was verified (see #4690).`], steps: 0, collectors: [], files: 0, actionFiles: 0 }; } - const files = readdirSync(dir) + const workflowNames = readdirSync(dir) .filter((n) => n.endsWith('.yml') || n.endsWith('.yaml')) .sort(); - if (files.length === 0) { - return { problems: [`${WORKFLOW_DIR} holds no workflow files -- nothing was verified (see #4690).`], steps: 0, collectors: [], files: 0 }; + if (workflowNames.length === 0) { + return { problems: [`${WORKFLOW_DIR} holds no workflow files -- nothing was verified (see #4690).`], steps: 0, collectors: [], files: 0, actionFiles: 0 }; } + const actionNames = actionFilesUnder(join(root, ACTION_DIR)).sort(); + // Each entry carries the path it is REPORTED under: `action.yml` is the same + // string in every action directory, so a bare name is an attribution nobody + // can open. + const files = [ + ...workflowNames.map((n) => ({ rel: `${WORKFLOW_DIR}/${n}`, abs: join(dir, n) })), + ...actionNames.map((n) => ({ rel: `${ACTION_DIR}/${n}`, abs: join(root, ACTION_DIR, n) })), + ]; const problems = []; const collectors = []; let steps = 0; - for (const file of files) { - const out = scanWorkflowText(readFileSync(join(dir, file), 'utf8'), file, parseYaml); + for (const { rel, abs } of files) { + const out = scanWorkflowText(readFileSync(abs, 'utf8'), rel, parseYaml); problems.push(...out.problems); collectors.push(...out.collectors); steps += out.steps; @@ -506,7 +574,7 @@ export function scanWorkflows(root, parseYaml) { `(#4690). Recognised enumeration spellings for a discovered set:\n${discoverySpellingHelp()}`, ); } - return { problems, steps, collectors, files: files.length }; + return { problems, steps, collectors, files: workflowNames.length, actionFiles: actionNames.length }; } // -- The dynamic half: drive a real block under a real `bash -e` -------------- @@ -631,14 +699,15 @@ async function loadYamlParser() { async function run() { const parseYaml = await loadYamlParser(); - const { problems, steps, collectors, files } = scanWorkflows(REPO_ROOT, parseYaml); + const { problems, steps, collectors, files, actionFiles } = scanWorkflows(REPO_ROOT, parseYaml); if (problems.length > 0) { console.error(`✗ check-step-collectors -- ${problems.length} problem(s)\n`); for (const p of problems) console.error(` • ${p}\n`); return 1; } console.log( - `✓ check-step-collectors: ${steps} \`run:\` steps across ${files} workflow(s); ` + + `✓ check-step-collectors: ${steps} \`run:\` steps across ${files} workflow(s) and ` + + `${actionFiles} composite action(s); ` + `${collectors.length} step(s) run 2+ independent self-tests, all of them through a collector.`, ); return 0; @@ -735,6 +804,87 @@ async function selfTest() { '#4690: a missing workflow directory is a failure, never a pass', ); + // ---- The composite action root (#19229) ---------------------------------- + // + // The runner writes a composite action's `run:` body to a file and executes + // `bash -e` on it, exactly as it does a workflow step's -- so the masking this + // gate exists to stop is the same defect in the same shell, and the only thing + // that differed was which directory the file sat in. This gate's row in the + // filing card was UNJUDGED rather than clean: its root is assembled with + // `join('.github', 'actions')`, so a literal grep for the path spelling found + // nothing here and said nothing about the population. + const compositeBare = [ + 'name: Fixture gate', + 'description: two independent self-tests, bare', + 'runs:', + ' using: composite', + ' steps:', + ' - name: Two self-tests, bare', + ' shell: bash', + ' run: |', + ' node scripts/alpha.mjs --self-test', + ' node scripts/beta.mjs --self-test', + '', + ].join('\n'); + const compositeFlag = scanWorkflowText(compositeBare, 'fixture-action.yml', parseYaml); + assert( + compositeFlag.steps === 1 && compositeFlag.problems.length === 1, + `the FIRING control: a composite action's own steps are judged (steps=${compositeFlag.steps}, problems=${compositeFlag.problems.length})`, + ); + assert( + (compositeFlag.problems[0] ?? '').includes('runs (composite)') + && (compositeFlag.problems[0] ?? '').includes('2 independent self-tests'), + 'the finding names the shape a reader will find in the file -- an action has no job id to attribute to', + ); + // The DARK control for the judgement: the same two self-tests, routed through + // a collector, are green -- so the flag above is about the bare sequence and + // not about the shape merely being read. + const compositeCollected = compositeBare.replace( + ' run: |\n node scripts/alpha.mjs --self-test\n node scripts/beta.mjs --self-test', + ' run: |\n run_self_test() { "$@"; }\n run_self_test node scripts/alpha.mjs --self-test\n' + + ' run_self_test node scripts/beta.mjs --self-test', + ); + assert( + scanWorkflowText(compositeCollected, 'fixture-action.yml', parseYaml).problems.length === 0 + && scanWorkflowText(compositeCollected, 'fixture-action.yml', parseYaml).collectors.length === 1, + 'the same pair routed through a collector is green inside a composite action too', + ); + // And the same thing through the REAL root walk, which is the half a pure + // text predicate cannot prove: the file has to be FOUND before it is judged. + { + const dir = mkdtempSync(join(tmpdir(), 'os-step-collectors-actions-')); + try { + mkdirSync(join(dir, WORKFLOW_DIR), { recursive: true }); + writeFileSync( + join(dir, WORKFLOW_DIR, 'lint.yml'), + 'jobs:\n lint:\n steps:\n - name: A collector\n run: |\n' + + ' run_self_test() { "$@"; }\n run_self_test node scripts/a.mjs --self-test\n' + + ' run_self_test node scripts/b.mjs --self-test\n', + ); + const withoutActions = scanWorkflows(dir, parseYaml); + assert( + withoutActions.problems.length === 0 && withoutActions.actionFiles === 0, + `the DARK control: a tree with no ${ACTION_DIR}/ is green and NOT a refusal (${withoutActions.problems[0] ?? ''})`, + ); + mkdirSync(join(dir, ACTION_DIR, 'nested', 'gate'), { recursive: true }); + writeFileSync(join(dir, ACTION_DIR, 'nested', 'gate', 'action.yml'), compositeBare); + const withActions = scanWorkflows(dir, parseYaml); + assert( + withActions.actionFiles === 1 + && withActions.problems.length === 1 + && (withActions.problems[0] ?? '').startsWith(`${ACTION_DIR}/nested/gate/action.yml:`), + `the same tree plus one nested action file is flagged, and the finding names the path (${withActions.problems[0] ?? 'none'})`, + ); + writeFileSync(join(dir, ACTION_DIR, 'nested', 'gate', 'README.md'), '- run: node scripts/a.mjs --self-test\n'); + assert( + scanWorkflows(dir, parseYaml).actionFiles === 1, + `only action.yml/action.yaml are read under ${ACTION_DIR}/ -- a README beside one is not an action`, + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + } + // ---- DISCOVERED sets: recognition, both reds, and the driven block ------- // // Pinned by FIXTURES as well as by the tree. The live tree may hold no @@ -943,6 +1093,13 @@ async function selfTest() { // ---- The dynamic half: the LIVE blocks, under a real `bash -e` ------------ const live = scanWorkflows(REPO_ROOT, parseYaml); assert(live.problems.length === 0, `the checked-in workflows pass the static half (${live.problems[0] ?? ''})`); + // The #4690 floor the second root CAN carry: absence there is legal in + // general, so on a repo that HOLDS composite actions a zero is a reader that + // stopped reading rather than a tree that stopped having them (#19229). + assert( + live.actionFiles > 0, + `this repo holds composite actions and the live scan read ${live.actionFiles} of them`, + ); assert(live.collectors.length >= 2, `at least the two known collectors are found (found ${live.collectors.length})`); for (const collector of live.collectors) { diff --git a/scripts/check-workflow-step-name-quoting.mjs b/scripts/check-workflow-step-name-quoting.mjs index 700dc6781d7..6a811053f05 100644 --- a/scripts/check-workflow-step-name-quoting.mjs +++ b/scripts/check-workflow-step-name-quoting.mjs @@ -62,11 +62,29 @@ // reference) errors LOUDLY at YAML-parse time and reds CI on its own; it does // not need a gate to notice. // -// ## Scope: the population is `- name:` lines under .github/workflows/ only +// ## Scope: the population is `- name:` lines under .github/workflows/ AND +// .github/actions/ // -// The population is spelled directly as the WORKFLOW_DIR literal below (the -// check-workflow-status-functions.mjs convention) -- narrow and named, so it -// needs no dispatch-gates population marker of its own. +// The population is spelled directly as the WORKFLOW_DIR and ACTION_DIR +// literals below (the check-workflow-status-functions.mjs convention) -- +// narrow and named, so it needs no dispatch-gates population marker of its own. +// +// The second root landed with #19229, which measured the class: six gates in +// this repo rooted their population at `.github/workflows` and none read +// `.github/actions/**`, while every one printed a scope line that reads as +// coverage. This gate's subject is a YAML quoting hazard in a `- name:` scalar, +// and 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. There is no reading under which the hazard stops at the +// directory boundary -- `.github/actions/setup-pnpm/action.yml` alone carried +// eight step names that this gate could not see. +// +// `.github/actions/` ABSENT is not a refusal: a repo may legitimately hold no +// composite action, and a missing second root is a real state rather than a +// broken reader. `.github/workflows/` absent or empty stays a refusal, because +// this repo cannot be in that state. What keeps the second root from going +// quiet is the live battery below, which asserts the real tree's action files +// are in the scan -- the #4690 floor in the form this root can carry. import { execFileSync } from 'node:child_process'; import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'; @@ -76,6 +94,10 @@ import { fileURLToPath } from 'node:url'; import { isEntrypoint } from './invoked-as.mjs'; const WORKFLOW_DIR = '.github/workflows'; +const ACTION_DIR = '.github/actions'; + +/** The file names GitHub accepts for a local action, in the order it resolves them. */ +const ACTION_FILES = ['action.yml', 'action.yaml']; /** A step-name line: `- name:` at the start of a mapping entry, with the rest of the line captured. */ const STEP_NAME_LINE = /^(\s*)-\s+name:(.*)$/; @@ -108,20 +130,28 @@ export function scan(root) { const dir = join(root, WORKFLOW_DIR); if (!existsSync(dir) || !statSync(dir).isDirectory()) { problems.push(`${WORKFLOW_DIR}/ does not exist -- nothing was scanned, so nothing was verified.`); - return { violations, quoted, unquotedSafe, problems, files: 0, stepNames: 0 }; + return { violations, quoted, unquotedSafe, problems, files: 0, actionFiles: 0, stepNames: 0 }; } - const files = readdirSync(dir) + const workflowFiles = readdirSync(dir) .filter((f) => f.endsWith('.yml') || f.endsWith('.yaml')) .sort(); - if (files.length === 0) { + if (workflowFiles.length === 0) { problems.push(`${WORKFLOW_DIR}/ holds no .yml/.yaml file -- nothing was scanned, so nothing was verified.`); - return { violations, quoted, unquotedSafe, problems, files: 0, stepNames: 0 }; + return { violations, quoted, unquotedSafe, problems, files: 0, actionFiles: 0, stepNames: 0 }; } - for (const fileName of files) { - const rel = `${WORKFLOW_DIR}/${fileName}`; - const source = readFileSync(join(dir, fileName), 'utf8'); + // The SECOND root (#19229). Absent is a real state, never a refusal -- see + // the header. Walked rather than readdir'd at one level, because a local + // action may be nested (`uses: ./.github/actions/a/b`). + const actionFiles = actionFilesUnder(join(root, ACTION_DIR)).sort(); + const files = [ + ...workflowFiles.map((f) => ({ rel: `${WORKFLOW_DIR}/${f}`, abs: join(dir, f) })), + ...actionFiles.map((f) => ({ rel: `${ACTION_DIR}/${f}`, abs: join(root, ACTION_DIR, f) })), + ]; + + for (const { rel, abs } of files) { + const source = readFileSync(abs, 'utf8'); const lines = source.split('\n'); for (let i = 0; i < lines.length; i++) { @@ -159,7 +189,34 @@ export function scan(root) { } } - return { violations, quoted, unquotedSafe, problems, files: files.length, stepNames }; + return { + violations, + quoted, + unquotedSafe, + problems, + files: files.length, + workflowFiles: workflowFiles.length, + actionFiles: actionFiles.length, + stepNames, + }; +} + +/** + * Every `action.yml` / `action.yaml` under `/.github/actions/`, as paths + * relative to that directory. + * + * A missing directory answers `[]` rather than throwing: absence is a real + * state for this root (see the header), and the live self-test battery is what + * keeps a real tree's actions from silently dropping out of the scan. + */ +function actionFilesUnder(dir, prefix = '', out = []) { + if (!existsSync(dir) || !statSync(dir).isDirectory()) return out; + for (const entry of readdirSync(dir).sort()) { + const full = join(dir, entry); + if (statSync(full).isDirectory()) actionFilesUnder(full, `${prefix}${entry}/`, out); + else if (ACTION_FILES.includes(entry)) out.push(`${prefix}${entry}`); + } + return out; } // ── Reporting ─────────────────────────────────────────────────────────────── @@ -168,8 +225,14 @@ function repoRoot() { return execFileSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8' }).trim(); } -function summarise({ files, stepNames, quoted, unquotedSafe }) { - return `scanned ${files} workflow file(s), ${stepNames} step name(s) -- ${quoted.length} already quoted, ${unquotedSafe.length} unquoted-and-safe`; +function summarise({ workflowFiles, actionFiles, stepNames, quoted, unquotedSafe }) { + // Both roots are NAMED and both counts are printed, so the scope line says + // what was read rather than implying it (#19229). A zero on the second root + // is a reading a reader can act on, not a silence. + return ( + `scanned ${workflowFiles} workflow file(s) + ${actionFiles} composite action file(s), ` + + `${stepNames} step name(s) -- ${quoted.length} already quoted, ${unquotedSafe.length} unquoted-and-safe` + ); } function reportProblems(problems) { @@ -264,12 +327,17 @@ const SELF_TEST_BATTERIES = Object.freeze({ // contains ` #`" shape would have gotten wrong. '4. A normal trailing YAML comment on a non-name line stays green': 3, '5. Missing input must go red, in both shapes (#4690)': 2, - '6. The real repository is what this gate actually guards': 3, + '6. The real repository is what this gate actually guards': 5, + // #19229: the second root. A firing control (the same hazard inside a + // composite action IS flagged), a dark control (it is flagged only because + // the action file is read), and the absence case the second root must NOT + // turn into a refusal. + '7. A composite action\'s step names are in the population (#19229)': 5, }); // DELETING an entry silences that battery's floor exactly as effectively as // zeroing it, so the roster's own size is pinned too. -const SELF_TEST_BATTERY_FLOOR = 6; +const SELF_TEST_BATTERY_FLOOR = 7; // The key an assertion is filed under when no battery is open. It is not a // declared battery, so it reds by the same set difference rather than silently @@ -417,6 +485,95 @@ jobs: real.violations.length === 0, `the repo is expected to be clean at self-test time -- got ${real.violations.length}: ${JSON.stringify(real.violations)}`, ); + // ⭐ The #4690 floor the second root CAN carry. Absence there is a legal + // state in general, so it is not a refusal -- which means the only thing + // standing between "this repo's actions are scanned" and a root that + // silently stopped being read is this pair of live assertions. + assert( + real.actionFiles > 0, + `this repo holds composite actions, so a zero here is a reader that stopped reading -- got ${real.actionFiles}`, + ); + assert( + real.quoted.concat(real.unquotedSafe, real.violations).some((e) => e.file.startsWith(`${ACTION_DIR}/`)), + 'and their step names are really in the judged population, not merely their files in the count', + ); + + // ── 7. A composite action's step names are in the population (#19229) ── + // + // The hazard is identical on both roots: ` #` inside an unquoted plain + // scalar begins a YAML comment, so the step's parsed name is the text + // before it. What differed was only which directory the file sat in. + battery("7. A composite action's step names are in the population (#19229)"); + const actionBody = `name: Fixture gate +description: does a thing +runs: + using: composite + steps: + - name: The #13419 name-fold fixture has no non-test loader + shell: bash + run: echo hi + - name: 'A quoted #456 name stays green' + shell: bash + run: echo hi +`; + const withAction = makeRoot({ + '.github/workflows/lint.yml': `name: Lint +on: [push] +jobs: + lint: + runs-on: ubuntu-latest + steps: + - name: Use the action + uses: ./.github/actions/fixture-gate +`, + '.github/actions/fixture-gate/action.yml': actionBody, + }); + const actionResult = scan(withAction); + // FIRING control: the violation exists only inside the action file. + assert( + actionResult.violations.length === 1 + && actionResult.violations[0]?.file === `${ACTION_DIR}/fixture-gate/action.yml`, + `the hazard inside a composite action is flagged, and named by its own path -- got ${JSON.stringify(actionResult.violations)}`, + ); + assert( + actionResult.actionFiles === 1 && actionResult.workflowFiles === 1, + `both roots are counted separately, got ${actionResult.workflowFiles} + ${actionResult.actionFiles}`, + ); + assert( + actionResult.quoted.length === 1 && actionResult.stepNames === 3, + `the action's second (quoted) name and the caller's own name are judged too, got ${actionResult.stepNames} step name(s)`, + ); + // DARK control: the SAME workflow with the action file absent is green, so + // the flag above can only have come from reading the second root. + const withoutAction = makeRoot({ + '.github/workflows/lint.yml': `name: Lint +on: [push] +jobs: + lint: + runs-on: ubuntu-latest + steps: + - name: Use the action + uses: ./.github/actions/fixture-gate +`, + }); + const darkResult = scan(withoutAction); + assert( + darkResult.violations.length === 0 && darkResult.actionFiles === 0 && darkResult.problems.length === 0, + `with no ${ACTION_DIR}/ the same tree is green and NOT a refusal -- got ${darkResult.violations.length} violation(s), ${darkResult.problems.length} problem(s)`, + ); + // A non-action file sitting in the tree is not an action: the two names + // GitHub resolves are the whole population, so a README beside an action + // contributes no step names. + const strayResult = scan( + makeRoot({ + '.github/workflows/lint.yml': "name: Lint\non: [push]\njobs:\n lint:\n runs-on: ubuntu-latest\n steps:\n - name: Build\n run: echo hi\n", + '.github/actions/fixture-gate/README.md': '- name: Not a #123 step at all\n', + }), + ); + assert( + strayResult.actionFiles === 0 && strayResult.violations.length === 0, + `only action.yml/action.yaml are read under ${ACTION_DIR}/, got ${strayResult.actionFiles} file(s)`, + ); } finally { for (const dir of roots) rmSync(dir, { recursive: true, force: true }); } diff --git a/scripts/pm/dispatch-gates.mjs b/scripts/pm/dispatch-gates.mjs index b4d500de912..ee5794e17f0 100644 --- a/scripts/pm/dispatch-gates.mjs +++ b/scripts/pm/dispatch-gates.mjs @@ -177,7 +177,10 @@ * workflow facts into a dispatch prompt (four of six required-context names * lived in a different file than claimed). So this script embeds NO list of * checks and NO map from paths to checks: every run re-reads - * `.github/workflows/*.yml`, resolves each `check:*` script through + * `.github/workflows/*.yml`, follows each `uses: ./.github/actions/…` + * into that action's `runs:` steps (#19229 — a command executed through a + * composite action runs on the runner exactly as an inline one does, so it is + * derived exactly as one), resolves each `check:*` script through * package.json, and scans the check scripts' own sources for the path * literals they operate on. When the farm grows, the next run sees it. * @@ -702,7 +705,7 @@ const maskedHashCommentBody = memoiseMask((source) => // remembered, not a property of this module. The line below makes it the module's own // declaration, read fresh on every run and held to a subset of what this file really spells // — see `declaredInheritedPopulation`. -// dispatch-gates: inherited-population .github/workflows -- the workflow directory this tool readdirs; every other module-body literal here is a package-manifest join base or a tier glob, not a path this file opens (#11556) +// dispatch-gates: inherited-population .github/workflows .github/actions -- the two trees this tool opens: the workflow directory it readdirs and the composite actions those workflows `uses:` (#19229); every other module-body literal here is a package-manifest join base or a tier glob, not a path this file opens (#11556) // --------------------------------------------------------------------------- // Extraction — pure functions over file contents, self-testable offline. @@ -2127,8 +2130,14 @@ function tailBeforeRedirection(tail, nextChar) { * subtree"), so a hint added for this card's convenience would fail the very * gate the card is about. */ -export function extractCheckInvocations(workflowText, workflowFile) { +export function extractCheckInvocations(workflowText, workflowFile, { via = null } = {}) { const out = []; + // WHERE the step this invocation came out of is written (#19229). `null` is + // an inline step of the workflow itself; a path is the composite action file + // the caller `uses:`. The WORKFLOW attribution never moves — CI schedules the + // caller — so this rides alongside as provenance a reader can go check, the + // same split `readEdge` keeps for a read's spelling. + const withVia = (inv) => (via === null ? inv : { ...inv, viaAction: via }); for (const { text: raw, envVariables: stepEnv } of runCommandSteps(workflowText)) { // ONE joined text for all three matchers, so no two of them can disagree // about where a command ends — the discipline `discoverFamilies` follows @@ -2146,14 +2155,14 @@ export function extractCheckInvocations(workflowText, workflowFile) { // `process.env`, which no reader of the command line can see. const carriedEnv = envNamesNotSpelledInCommand(cmd, stepEnv); for (const m of cmd.matchAll(/pnpm\s+(?:--filter\s+(\S+)\s+)?(?:run\s+)?(check:[\w:-]+)/g)) { - out.push({ check: m[2], filter: m[1] ?? null, workflow: workflowFile, envVariables: carriedEnv }); + out.push(withVia({ check: m[2], filter: m[1] ?? null, workflow: workflowFile, envVariables: carriedEnv })); } for (const m of cmd.matchAll(DIRECT_CHECK_INVOCATION)) { const script = m[1]; // The KEY is (script, args), never the path alone — `renderedArgv`'s // docblock carries the measurement and the classification it applies. const argv = renderedArgv(tailBeforeRedirection(m[2], cmd[m.index + m[0].length])); - out.push({ + out.push(withVia({ check: argv ? `${script} ${argv.args}` : script, script, filter: null, @@ -2178,7 +2187,7 @@ export function extractCheckInvocations(workflowText, workflowFile) { // follows in `discoverFamilies`, and `ciOnlyMeasurement`). Before the // key carried the argv there was nothing here to read it off. selfTest: Boolean(argv) && argv.args.split(/[ \t]+/).includes('--self-test'), - }); + })); } for (const m of cmd.matchAll(SELF_TEST_INVOCATION)) { const script = m[1]; @@ -2187,7 +2196,7 @@ export function extractCheckInvocations(workflowText, workflowFile) { // twice, not a second family. The skip is what keeps the two matchers // from disagreeing; the split into two families is done by the key. if (nodePath.basename(script).includes('check-')) continue; - out.push({ + out.push(withVia({ // The flag is part of the KEY because it is part of the runnable // command: `node scripts/pm/bare-root-worklist.mjs` on its own prints // a worklist and exits 0. A dev pasting the key without it runs @@ -2199,11 +2208,183 @@ export function extractCheckInvocations(workflowText, workflowFile) { direct: true, selfTest: true, envVariables: carriedEnv, - }); + })); } } return out; } + +// ── Following a command OUT of a workflow and into a composite action ──────── +// +// Everything above reads a `run:` step out of a workflow file. That was the +// whole population until composite actions started carrying runner-executed +// commands, and the gap it left was measured rather than argued (#19229): six +// gates in this repo root their population at `.github/workflows` and NONE of +// them reads `.github/actions/**`, while every one prints a scope line that +// reads as coverage. The positive control on the tree that filed it: +// `.github/actions/setup-pnpm/action.yml` already carried SIX `run:` steps, so +// the zero was a reading and not an empty query. +// +// A command GitHub executes through `uses: ./.github/actions/NAME` is executed +// on the runner exactly as an inline one is, in the caller's job, under the +// caller's triggers. So it is derived exactly as an inline one is: the steps of +// the action are read into the CALLING workflow's invocation set, keeping the +// caller's file name as the attribution, because the caller is what CI +// schedules and what a `paths:` filter narrows. ⛔ The action file is NOT a +// second workflow with triggers of its own — an action declares no `on:` block +// at all, so attributing an invocation to it would invent a schedule nobody +// wrote. +// +// ⚠️ SCOPE, and the direction each boundary fails in: +// +// - Only a LOCAL action is followed (`uses: ./…`). A third-party action's +// steps are not in this tree, so nothing here could read them and no gate +// in this repo claims to audit them. +// - Only `./.github/actions/**` is followed, which is where GitHub's own +// convention puts them and where every local action in this repo lives. +// A local action landing outside that tree would be followed by nothing — +// a MISSING lead, never a fabricated one — and it is refused deliberately: +// the declared inherited population at the top of this file has to stay +// exactly equal to the trees this module really opens, and a follow that +// could open any directory a workflow names could not be declared at all. +// - A `uses:` naming a directory with no `action.yml`/`action.yaml` in it is +// UNRESOLVED and reported, never skipped. GitHub fails such a job outright, +// so on a tree where it happens the derivation must say so rather than +// derive a smaller answer and print it as a whole one (#4690). +// - The follow is RECURSIVE with a visited set, because an action may itself +// `uses:` a sibling action; a one-hop follow would re-open this card's own +// blind spot one level down. +// +// ⛔ What is deliberately NOT extended here, stated because an unstated +// omission is the shape this card is about: the always-runs tail +// (`alwaysRunSteps`) and the job-filtered tail (`jobFilteredSteps`) below still +// read the workflow's own `jobs:` structure only. A composite action has no +// `jobs:`, so those two walks find nothing in it and their rows are UNDER- +// reported rather than wrong — the safe direction, and the same one +// `extractTriggerPaths` takes for `paths-ignore:`. The size of that deferral is +// measured by this file's own `--self-test` so it goes loud the day it grows. + +/** The tree local composite actions live in — the one extra tree this module opens. */ +export const COMPOSITE_ACTION_DIR = '.github/actions'; + +/** + * A step that `uses:` a LOCAL composite action under `.github/actions/`. + * + * Matched on the `uses:` line rather than parsed, for the reason + * `extractTriggerPaths` states at length: this script is dependency-free by + * design and runs from a bare checkout before `pnpm install`. The value may be + * quoted either way and may carry a trailing comment; a local `uses:` takes no + * `@ref` (GitHub resolves it inside the checked-out tree), so a value carrying + * one is not this shape and is left alone. + */ +const LOCAL_COMPOSITE_USES = + /^[ \t]*(?:-[ \t]+)?uses:[ \t]*(['"]?)\.\/(\.github\/actions\/[\w.-]+(?:\/[\w.-]+)*)\1[ \t]*(?:#.*)?$/gm; + +/** + * Every local composite action a text `uses:`, in declaration order, deduped — + * as repo-relative DIRECTORY paths (`.github/actions/half-state-patrol`). + * + * Works on a workflow and on an action alike, which is what makes the follow + * below recursive without a second reader. + */ +export function localCompositeActionUses(text) { + const out = []; + for (const m of String(text ?? '').matchAll(LOCAL_COMPOSITE_USES)) { + if (!out.includes(m[2])) out.push(m[2]); + } + return out; +} + +/** + * The body of an action file's top-level `runs:` block — the steps a composite + * action executes, and nothing else in the file. + * + * Narrowed to `runs:` rather than handing the whole file to the matchers, so a + * `run:` line appearing inside a top-level `description:` block scalar or an + * input default is not read as a step nobody wrote. The walk is the same + * indentation walk the three `on:` readers above use. + * + * ⛔ NOT gated on `using: composite`. A `node20` or `docker` action's `runs:` + * block declares no `run:` step, so it contributes nothing either way, and a + * gate on the `using:` value would be a second thing to keep true about a file + * this function already reads correctly. + */ +export function compositeActionRunsBlock(actionText) { + const lines = String(actionText ?? '').split('\n'); + const body = []; + let inRuns = false; + for (const line of lines) { + if (line.trim() === '' || /^[ \t]*#/.test(line)) { + if (inRuns) body.push(line); + continue; + } + const indent = /^[ \t]*/.exec(line)[0].length; + if (indent === 0) { + if (inRuns) break; + inRuns = /^runs:\s*$/.test(line.trim()); + continue; + } + if (inRuns) body.push(line); + } + return body.join('\n'); +} + +/** + * Follow every local composite action a workflow reaches, recursively. + * + * `readAction` is a parameter rather than a filesystem call so this whole walk + * is a pure function over text that `--self-test` drives offline — the same + * discipline every extractor above keeps. It is handed a repo-relative + * directory and answers `{ file, text }` for the action file inside it, or + * `null` when there is none. + * + * @param {string} workflowText + * @param {(dir: string) => ({ file: string, text: string } | null)} readAction + * @returns {{ steps: {action: string, dir: string, text: string}[], unresolved: string[] }} + * `steps[].text` is the action's `runs:` body, ready for the same matchers a + * workflow's own text goes through; `unresolved` names every `uses:` target + * with no action file behind it. + */ +export function followCompositeActions(workflowText, readAction) { + const steps = []; + const unresolved = []; + const seen = new Set(); + const queue = localCompositeActionUses(workflowText); + while (queue.length > 0) { + const dir = queue.shift(); + if (seen.has(dir)) continue; + seen.add(dir); + const found = readAction(dir); + if (found === null || found === undefined) { + unresolved.push(dir); + continue; + } + steps.push({ action: found.file, dir, text: compositeActionRunsBlock(found.text) }); + // An action may `uses:` a sibling. Queued from the WHOLE action text rather + // than from the `runs:` body alone, so a `uses:` written beside the steps + // still enters the walk. + for (const next of localCompositeActionUses(found.text)) { + if (!seen.has(next)) queue.push(next); + } + } + return { steps, unresolved }; +} + +/** + * The filesystem half of `followCompositeActions`, rooted at this checkout. + * + * Both extensions GitHub accepts are probed, in the order GitHub resolves them. + */ +export function compositeActionReader(root = ROOT) { + return (dir) => { + for (const name of ['action.yml', 'action.yaml']) { + const rel = `${dir}/${name}`; + const abs = nodePath.join(root, rel); + if (existsSync(abs)) return { file: rel, text: readFileSync(abs, 'utf8') }; + } + return null; + }; +} // ── The "always runs" tail: the steps CI runs whatever your diff is (#13333) ─ // // Everything above discovers CHECK FAMILIES. The four functions below answer @@ -12328,10 +12509,29 @@ function discoverFamiliesPass(tree) { // families it is printed beside, and the whole point of the tail is that it // states what the family list does not cover. const workflowEntries = []; + // The composite actions the workflows reach, read ONCE for the whole pass and + // keyed by the action file, so a helper two workflows `uses:` is opened once + // and cannot arrive as two revisions of itself (#19229). + const readAction = compositeActionReader(); + const compositeActionFiles = new Set(); + const unresolvedCompositeUses = []; for (const wf of workflows) { const text = readFileSync(nodePath.join(wfDir, wf), 'utf8'); - workflowEntries.push({ file: wf, text }); + // The steps this workflow executes THROUGH a composite action. They are + // derived under the caller's name because the caller is what CI schedules; + // the action file rides along as `viaAction` provenance. See + // `followCompositeActions` for the boundaries and the direction each fails + // in. + const followed = followCompositeActions(text, readAction); + for (const dir of followed.unresolved) { + unresolvedCompositeUses.push(`.github/workflows/${wf} uses ./${dir}, which holds no action.yml`); + } + workflowEntries.push({ file: wf, text, composites: followed.steps }); invocations.push(...extractCheckInvocations(text, wf)); + for (const step of followed.steps) { + compositeActionFiles.add(step.action); + invocations.push(...extractCheckInvocations(step.text, wf, { via: step.action })); + } triggerPathsByWorkflow.set(wf, extractTriggerPaths(text)); for (const pop of jobPathPopulations(text, wf)) { for (const check of pop.checks) { @@ -12344,6 +12544,14 @@ function discoverFamiliesPass(tree) { } } if (invocations.length === 0) throw new Error('no check:* invocations found in any workflow'); + // A `uses: ./…` with no action file behind it is a job GitHub refuses to + // start, so a derivation that quietly dropped it would be describing a CI + // this repo does not have. Loud, naming every one (#4690). + if (unresolvedCompositeUses.length > 0) { + throw new Error( + `composite action(s) named by a workflow but absent from the tree:\n ${unresolvedCompositeUses.join('\n ')}`, + ); + } // Dedupe by (check, workflow); resolve each to script files + watch hints. const byCheck = new Map(); @@ -12352,6 +12560,11 @@ function discoverFamiliesPass(tree) { if (!byCheck.has(key)) byCheck.set(key, { ...inv, workflows: new Set(), files: [], hints: [] }); const merged = byCheck.get(key); merged.workflows.add(inv.workflow); + // The composite action file this invocation was read out of, when it was + // not written inline (#19229). A SET because one family may be reached both + // ways, and the union is the honest answer to "where is this command + // written". + if (inv.viaAction) (merged.viaActions ??= new Set()).add(inv.viaAction); // INTERSECTION, not union (#15761). `argvVariables` needs no merge — argv // is part of the KEY, so every invocation under one key spells the same // one. `env:` is NOT part of the key, so two workflows can run the same @@ -12770,7 +12983,11 @@ function discoverFamiliesPass(tree) { ? { variables: workflowValues, envVariables: [...entry.envValues] } : null; } - return { byCheck, workflows, workflowEntries }; + // `compositeActions` is the reading that makes this pass's new tree a + // MEASUREMENT rather than a capability nobody can size (#19229): the action + // files really opened on this run, sorted. A zero here on a tree that holds + // composite actions is a follow that stopped following. + return { byCheck, workflows, workflowEntries, compositeActions: [...compositeActionFiles].sort() }; } /** @@ -15165,7 +15382,7 @@ export function repoIdentity({ cwd = ROOT } = {}) { * a single family, which is what makes this list the right filter and raw * commit distance the wrong one. */ -export const DERIVATION_SURFACE = ['.github/workflows', 'package.json', 'scripts']; +export const DERIVATION_SURFACE = ['.github/workflows', '.github/actions', 'package.json', 'scripts']; /** * How far behind `DEFAULT_BASE_REF` this checkout is — and whether that matters. @@ -20154,6 +20371,197 @@ function selfTest() { t('the flow-sequence spelling is read too', extractTriggerPaths("on:\n pull_request:\n paths: ['a/**', \"b/c\"]\n").join('|') === 'a/**|b/c'); t('pull_request_target is not mistaken for pull_request', extractTriggerPaths("on:\n pull_request_target:\n paths:\n - 'x/**'\n").length === 0); + // ── Derivation THROUGH a composite action (#19229) ───────────────────────── + // + // The card: six gates root their population at `.github/workflows` and none + // reads `.github/actions/**`, so a command executed through a composite + // action was audited by nothing while every scope line read as coverage. The + // repair is `followCompositeActions` + the `viaAction` provenance it carries; + // these cases are the firing control and the dark control for it. + // + // ⛔ The repair the card REFUSES, recorded here because this is where someone + // would take it: re-pointing the four live-specimen CONTROL assertions below + // at a different value-bearing family. That turns the pin green while leaving + // the derivation blind, which is the declaration-without-an-assertion shape + // this whole file exists to refuse. + const compositeCallerWf = [ + 'name: Fixture', + 'on:', + ' pull_request: {}', + 'jobs:', + ' sweep:', + ' runs-on: ubuntu-latest', + ' steps:', + ' - uses: actions/checkout@v7', + ' - name: Through the action', + ' uses: ./.github/actions/fixture-gate', + '', + ].join('\n'); + const compositeActionYml = [ + 'name: Fixture gate', + 'description: >-', + ' A description whose folded body mentions run: and must never be read as a step.', + 'runs:', + ' using: composite', + ' steps:', + ' - name: Run the gate', + ' shell: bash', + ' run: |', + ' node scripts/check-nul-bytes.mjs', + ' pnpm check:agent-model-declared', + '', + ].join('\n'); + const compositeReader = (files) => (dir) => + (Object.hasOwn(files, dir) ? { file: `${dir}/action.yml`, text: files[dir] } : null); + t( + 'a local composite `uses:` is read out of a workflow, in its repo-relative spelling', + localCompositeActionUses(compositeCallerWf).join('|') === '.github/actions/fixture-gate', + ); + t( + 'the quoted spellings and a trailing comment are read too, and a repeat is read once', + localCompositeActionUses( + [ + " - uses: './.github/actions/a'", + ' - uses: "./.github/actions/b" # why', + ' - uses: ./.github/actions/a', + ].join('\n'), + ).join('|') === '.github/actions/a|.github/actions/b', + ); + t( + 'a third-party action and a local path outside .github/actions are NOT followed — a missing lead, never a fabricated one', + localCompositeActionUses( + [' - uses: actions/checkout@v7', ' - uses: ./tools/some-action', ' - uses: ./.github/workflows/x.yml'].join('\n'), + ).length === 0, + ); + t( + "an action's `runs:` body is what is read — a `run:` mentioned in a top-level description block scalar is not a step", + runCommandSteps(compositeActionRunsBlock(compositeActionYml)).length === 1 + && !compositeActionRunsBlock(compositeActionYml).includes('description'), + ); + // ⭐ THE FIRING CONTROL. The caller invokes no check of its own; both families + // exist only because the action's steps were read, and both are attributed to + // the CALLER, which is what CI schedules. + const compositeFollowed = followCompositeActions( + compositeCallerWf, + compositeReader({ '.github/actions/fixture-gate': compositeActionYml }), + ); + const compositeVia = compositeFollowed.steps.flatMap((s) => + extractCheckInvocations(s.text, 'fixture.yml', { via: s.action })); + t( + 'the caller itself invokes no check family, so the families below can only come from the action', + extractCheckInvocations(compositeCallerWf, 'fixture.yml').length === 0, + ); + t( + '⭐ a command executed THROUGH a composite action is derived exactly as an inline one is' + + ` (${compositeVia.map((i) => i.check).join(', ') || 'none'})`, + compositeVia.map((i) => i.check).sort().join('|') + === 'check:agent-model-declared|scripts/check-nul-bytes.mjs', + ); + t( + '…attributed to the CALLING workflow, with the action file carried beside it as provenance', + compositeVia.length > 0 + && compositeVia.every((i) => i.workflow === 'fixture.yml' + && i.viaAction === '.github/actions/fixture-gate/action.yml'), + ); + t( + 'and an INLINE invocation carries no viaAction at all, so the two spellings stay legible', + extractCheckInvocations(' - run: node scripts/check-nul-bytes.mjs\n', 'fixture.yml') + .every((i) => i.viaAction === undefined), + ); + // ⭐ THE DARK CONTROL, both halves: with the action file gone the families + // disappear (so they really came from it), and the absence is REPORTED rather + // than skipped — GitHub refuses to start a job whose `uses: ./…` resolves to + // nothing, so a derivation that dropped it quietly would describe a CI this + // repo does not have. + const compositeDark = followCompositeActions(compositeCallerWf, compositeReader({})); + t( + 'the dark control fires — with no action file behind the `uses:`, not one family is derived', + compositeDark.steps.length === 0 + && compositeDark.steps.flatMap((s) => extractCheckInvocations(s.text, 'fixture.yml')).length === 0, + ); + t( + '…and the absence is NAMED, never skipped (#4690)', + compositeDark.unresolved.join('|') === '.github/actions/fixture-gate', + ); + // An action may `uses:` a sibling. A one-hop follow would re-open this card's + // own blind spot one level down, so the walk recurses — and terminates on a + // cycle rather than spinning, which a fixture asserts rather than a comment. + const nestedOuter = ['runs:', ' using: composite', ' steps:', ' - uses: ./.github/actions/inner', ''].join('\n'); + const nestedInner = ['runs:', ' using: composite', ' steps:', ' - shell: bash', ' run: node scripts/check-nul-bytes.mjs', ''].join('\n'); + const nested = followCompositeActions( + ' - uses: ./.github/actions/outer\n', + compositeReader({ '.github/actions/outer': nestedOuter, '.github/actions/inner': nestedInner }), + ); + t( + 'the follow recurses — a gate an action reaches through a SECOND action is derived too', + nested.steps.map((s) => s.dir).join('|') === '.github/actions/outer|.github/actions/inner' + && nested.steps.flatMap((s) => extractCheckInvocations(s.text, 'fixture.yml')).length === 1, + ); + const cyclicA = ['runs:', ' using: composite', ' steps:', ' - uses: ./.github/actions/b', ''].join('\n'); + const cyclicB = ['runs:', ' using: composite', ' steps:', ' - uses: ./.github/actions/a', ''].join('\n'); + t( + 'and a cycle terminates with each action read exactly once, rather than spinning', + followCompositeActions( + ' - uses: ./.github/actions/a\n', + compositeReader({ '.github/actions/a': cyclicA, '.github/actions/b': cyclicB }), + ).steps.map((s) => s.dir).join('|') === '.github/actions/a|.github/actions/b', + ); + // ── LIVE: the card's own positive control, re-taken here ─────────────────── + // + // Fixtures cannot prove the live derivation opens the tree at all. The card's + // control is `.github/actions/setup-pnpm/action.yml` and its `run:` steps — + // audited by nothing on the day the card was filed, and read by the discovery + // pass now. A zero here is a follow that stopped following. + const liveComposites = discoverFamilies().compositeActions ?? []; + t( + `⭐ the live discovery really opens the composite action tree (${liveComposites.join(', ') || 'none'})`, + liveComposites.length > 0 && liveComposites.includes('.github/actions/setup-pnpm/action.yml'), + ); + const liveCompositeRunSteps = liveComposites.reduce( + (n, rel) => n + runCommandSteps(compositeActionRunsBlock(readFileSync(nodePath.join(ROOT, rel), 'utf8'))).length, + 0, + ); + t( + `…and really reads the steps in it — ${liveCompositeRunSteps} \`run:\` step(s) that no gate rooted at` + + ' .github/workflows could see, which is the card\'s positive control', + liveCompositeRunSteps > 0, + ); + // ── The BOUNDARY, measured rather than assumed ───────────────────────────── + // + // A script path that reaches the command through a step `env:` value is + // derived by NEITHER spelling — written inline in a workflow, or written in a + // composite action. That is one blind spot and it is not this one: the + // composite follow makes an action's step read EXACTLY like an inline step, + // including where an inline step is already not derived. Pinned so nobody + // reads a green follow as coverage of the env-carried class, and so the day + // that class is closed it is closed for both spellings at once. + const envCarriedStep = [ + ' - name: Run the sweep', + ' shell: bash', + ' env:', + ' SWEEPER: ${{ steps.sources.outputs.root }}/scripts/pm/check-half-states.mjs', + ' run: node "$SWEEPER" --format=markdown', + '', + ].join('\n'); + const envCarriedAction = ['runs:', ' using: composite', ' steps:', envCarriedStep].join('\n'); + t( + 'an env-carried script path is derived by neither spelling — the composite follow closes the ACTION' + + ' boundary, not the env-carrier one', + extractCheckInvocations(envCarriedStep, 'fixture.yml').length === 0 + && followCompositeActions(' - uses: ./.github/actions/c\n', compositeReader({ '.github/actions/c': envCarriedAction })) + .steps.flatMap((s) => extractCheckInvocations(s.text, 'fixture.yml')).length === 0, + ); + // The second declared deferral, sized rather than described: the always-runs + // tail walks `jobs:` and a composite action has none, so its rows still + // under-report by exactly the composite steps the follow now reads. Under- + // reporting is the safe direction (a MISSING lead), and this number is what + // makes the deferral honest instead of merely convenient. + t( + `the always-runs tail still reads no composite step — ${liveCompositeRunSteps} step(s) deferred, a` + + ' MISSING lead and never a fabricated one; when this number matters, extend that walk', + alwaysRunSteps(discoverFamilies().workflowEntries).rows.every((r) => r.workflow.endsWith('.yml')), + ); + // ── The SCHEDULED-ONLY routing question, measured and answered ZERO (#14899) // // The card: the derivation named `node scripts/pm/check-half-states.mjs` — @@ -22079,10 +22487,16 @@ function selfTest() { const ownPopulation = ownDeclared?.population ?? []; t('this module declares what a follower inherits', (ownDeclared?.reason ?? '').length > 0); t( - 'it declares exactly the workflow tree it readdirs', - ownPopulation.length === 1 && ownPopulation[0] === '.github/workflows', + 'it declares exactly the two trees it opens — the workflow directory it readdirs and the composite actions those workflows use', + ownPopulation.length === 2 + && ownPopulation[0] === '.github/workflows' + && ownPopulation[1] === '.github/actions', ); t('so a follower still reaches the workflow files this tool really opens', covers(ownPopulation, '.github/workflows/lint.yml')); + t( + '…and the composite action files it really opens through them (#19229)', + covers(ownPopulation, '.github/actions/setup-pnpm/action.yml'), + ); // The four fabricating classes the card measured, each pinned as SPELLED but // NOT INHERITED — the two halves have to be asserted together, because the // literal disappearing from the file would also pass "not inherited" while @@ -22188,7 +22602,7 @@ function selfTest() { '.github/workflows/scaffold-e2e.yml:23 no-check-families', 'scripts/cli-build-prerequisite.mjs:111 inherited-population', 'scripts/pm/check-expected-skips.mjs:131 self-test-reads', - 'scripts/pm/dispatch-gates.mjs:705 inherited-population', + 'scripts/pm/dispatch-gates.mjs:708 inherited-population', ].join(' · '), censusRows.join(' · '), );