From c444d24b4d7a598ab3f9410ae47663ce8e3ae7e5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 11:47:27 +0000 Subject: [PATCH] chore(scripts): refuse an expression naming a context its workflow key cannot see check-workflow-context-availability transcribes GitHub's context-availability table (Contexts reference) and refuses any expression under a table key that names a context or special function the row does not list -- the shape that makes GitHub reject the whole workflow file and create no run, so the PR's check list reads green while the gate workflow never ran. Chained into the existing check:workflow-status-functions script line, so the Lint & Repo Gates step carries it with no workflow edit. Claude-Session: https://claude.ai/code/session_01QSeKYkorXJzrG4kmSQV2Y7 Co-authored-by: Claude --- package.json | 2 +- .../check-workflow-context-availability.mjs | 710 ++++++++++++++++++ 2 files changed, 711 insertions(+), 1 deletion(-) create mode 100644 scripts/check-workflow-context-availability.mjs diff --git a/package.json b/package.json index a0c7eb67709..d236a931bfa 100644 --- a/package.json +++ b/package.json @@ -160,7 +160,7 @@ "check:release-index-currency-sync": "node scripts/sync-release-index-currency.mjs --self-test", "check:node-version": "node scripts/check-node-version.mjs --self-test && node scripts/check-node-version.mjs", "check:pnpm-acquisition": "node scripts/check-pnpm-acquisition.mjs --self-test && node scripts/check-pnpm-acquisition.mjs", - "check:workflow-status-functions": "node scripts/check-workflow-status-functions.mjs --self-test && node scripts/check-workflow-status-functions.mjs", + "check:workflow-status-functions": "node scripts/check-workflow-status-functions.mjs --self-test && node scripts/check-workflow-status-functions.mjs && node scripts/check-workflow-context-availability.mjs --self-test && node scripts/check-workflow-context-availability.mjs", "check:workflow-step-name-quoting": "node scripts/check-workflow-step-name-quoting.mjs --self-test && node scripts/check-workflow-step-name-quoting.mjs", "check:select-shard-packages": "bash scripts/ci/select-shard-packages.selftest.sh", "check:select-gate-families": "bash scripts/ci/select-gate-families.selftest.sh", diff --git a/scripts/check-workflow-context-availability.mjs b/scripts/check-workflow-context-availability.mjs new file mode 100644 index 00000000000..1b296d7eb54 --- /dev/null +++ b/scripts/check-workflow-context-availability.mjs @@ -0,0 +1,710 @@ +#!/usr/bin/env node +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// check-workflow-context-availability -- an expression may name only the +// contexts (and special functions) GitHub makes available at the workflow key +// it sits in. +// +// node scripts/check-workflow-context-availability.mjs +// node scripts/check-workflow-context-availability.mjs --self-test # the checker itself +// +// Wired into the SAME `check:workflow-status-functions` script line in +// package.json as its sibling, so the existing `Lint & Repo Gates` step runs it +// with no workflow edit. +// +// ## Why a context in the wrong key is worse than a red check +// +// GitHub validates a workflow file before it creates a run. An expression that +// names a context the key cannot see -- `${{ runner.temp }}` in a JOB-level +// `env:`, say, where `runner` does not exist yet because no runner has been +// picked -- makes the whole file invalid. GitHub then creates NO run for that +// workflow on a pull_request: the required contexts it would have reported never +// appear, every OTHER workflow on the head is green, and the PR's check list +// reads green while the workflow that carries the gates never ran. The only +// trace is a push run named by its file path with 0 jobs. PR #27212 at head +// 191e9d5e is the recorded specimen (`LIVE_BACKEND_DIR: ${{ runner.temp }}/...` +// in job-level `env:`). This gate refuses that shape before push. +// +// ## The table is GitHub's, transcribed -- not remembered +// +// `CONTEXT_AVAILABILITY` below is the "Context availability" table of GitHub's +// Contexts reference (docs.github.com, Actions -> Contexts reference -> Context +// availability), transcribed row for row from the docs source +// `content/actions/reference/workflows-and-actions/contexts.md` in github/docs, +// read 2026-10-28. Each row: a workflow key, the contexts available there, the +// special functions available there. Two consequences of reading it rather than +// recalling it: +// +// - `matrix` IS listed for job-level `runs-on`, `env`, `timeout-minutes`, +// `continue-on-error` (and `name`, `container`, `services`, ...). The table +// states no `strategy.matrix` condition, so this gate imposes none -- a job +// without a matrix that reads `matrix.*` there gets an empty value from +// GitHub, not a rejected file, and is not this gate's business. +// - `matrix` is NOT listed for job-level `if:` or `strategy`, and `runner`, +// `steps`, `job`, `env` and `secrets` are not listed for job-level `if:`, +// `runs-on`, `timeout-minutes`, `name`, `concurrency`, ... -- those refuse. +// +// "Unless listed below, a function can be used anywhere": the special functions +// (`always`, `cancelled`, `success`, `failure`, `hashFiles`) are the ones the +// table restricts, so naming one where its row does not list it refuses too. +// +// When GitHub changes the table, change the rows here and the self-test cases +// that pin them, in the same edit. ⛔ Never widen a row to green a workflow: +// GitHub, not this gate, is what rejects the file. +// +// ## What is judged, and what deliberately is not +// +// - Every string scalar under a key the table names, at any depth below it +// (`jobs..env.FOO`, `jobs..steps[*].with.bar`, a `runs-on` list +// item). The MOST SPECIFIC row wins: `jobs..container.env.` beats +// `jobs..container`. +// - In an `if:` (job or step) the whole value is an expression even without +// `${{ }}`, as GitHub reads it; everywhere else only `${{ ... }}` spans are. +// - Keys the table does not name (`permissions`, `uses`, `id`, `shell`, `needs`, +// ...) are not judged: the table is the authority and it is silent there. +// The count of judged expressions is printed so "nothing judged" is visible. +// - A named value is an identifier that starts an expression term: not after a +// `.`, not followed by `(` (a function), not inside a '...' string literal, +// not one of the literals true/false/null/NaN/Infinity. Case-insensitive, as +// the expression language is. An identifier that is no context at all is +// refused too -- GitHub rejects an unrecognized named-value the same way. +// +// ## Missing input is a failure, never a pass +// +// `.github/workflows/` absent, zero workflow files, a file that does not parse, +// a workflow with no `jobs:` map, an unterminated `${{` -- each exits non-zero. +// A checker that could not read its input has verified nothing (the sibling +// gate's header carries the history). + +import { execFileSync } from 'node:child_process'; +import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { requireDependency } from './import-prerequisite.mjs'; +const { LineCounter, isMap, isScalar, isSeq, parseDocument } = await requireDependency('yaml', () => import('yaml'), import.meta.url); +import { isEntrypoint } from './invoked-as.mjs'; + +const WORKFLOW_DIR = '.github/workflows'; + +/** Every context GitHub's expression language knows. */ +export const KNOWN_CONTEXTS = Object.freeze([ + 'github', 'env', 'vars', 'job', 'jobs', 'steps', 'runner', 'secrets', 'strategy', 'matrix', 'needs', 'inputs', +]); + +/** The functions the table restricts; any other function may be used anywhere. */ +export const SPECIAL_FUNCTIONS = Object.freeze(['always', 'cancelled', 'success', 'failure', 'hashfiles']); + +const LITERALS = new Set(['true', 'false', 'null', 'nan', 'infinity']); + +const STATUS = ['always', 'cancelled', 'success', 'failure']; +const STEP_CONTEXTS = 'github, needs, strategy, matrix, job, runner, env, vars, secrets, steps, inputs'; + +/** + * GitHub's Context availability table, row for row (see header). `key` uses + * `*` for an id segment; sequence indices never appear in a path. + */ +export const CONTEXT_AVAILABILITY = Object.freeze( + [ + ['run-name', 'github, inputs, vars', []], + ['concurrency', 'github, inputs, vars', []], + ['env', 'github, secrets, inputs, vars', []], + ['jobs.*.concurrency', 'github, needs, strategy, matrix, inputs, vars', []], + ['jobs.*.container', 'github, needs, strategy, matrix, vars, inputs', []], + ['jobs.*.container.credentials', 'github, needs, strategy, matrix, env, vars, secrets, inputs', []], + ['jobs.*.container.env.*', 'github, needs, strategy, matrix, job, runner, env, vars, secrets, inputs', []], + ['jobs.*.container.image', 'github, needs, strategy, matrix, vars, inputs', []], + ['jobs.*.continue-on-error', 'github, needs, strategy, vars, matrix, inputs', []], + ['jobs.*.defaults.run', 'github, needs, strategy, matrix, env, vars, inputs', []], + ['jobs.*.env', 'github, needs, strategy, matrix, vars, secrets, inputs', []], + ['jobs.*.environment', 'github, needs, strategy, matrix, vars, inputs', []], + ['jobs.*.environment.url', 'github, needs, strategy, matrix, job, runner, env, vars, steps, inputs', []], + ['jobs.*.if', 'github, needs, vars, inputs', STATUS], + ['jobs.*.name', 'github, needs, strategy, matrix, vars, inputs', []], + ['jobs.*.outputs.*', 'github, needs, strategy, matrix, job, runner, env, vars, secrets, steps, inputs', []], + ['jobs.*.runs-on', 'github, needs, strategy, matrix, vars, inputs', []], + ['jobs.*.secrets.*', 'github, needs, strategy, matrix, secrets, inputs, vars', []], + ['jobs.*.services', 'github, needs, strategy, matrix, vars, inputs', []], + ['jobs.*.services.*.credentials', 'github, needs, strategy, matrix, env, vars, secrets, inputs', []], + ['jobs.*.services.*.env.*', 'github, needs, strategy, matrix, job, runner, env, vars, secrets, inputs', []], + ['jobs.*.steps.continue-on-error', STEP_CONTEXTS, ['hashFiles']], + ['jobs.*.steps.env', STEP_CONTEXTS, ['hashFiles']], + ['jobs.*.steps.if', 'github, needs, strategy, matrix, job, runner, env, vars, steps, inputs', [...STATUS, 'hashFiles']], + ['jobs.*.steps.name', STEP_CONTEXTS, ['hashFiles']], + ['jobs.*.steps.run', STEP_CONTEXTS, ['hashFiles']], + ['jobs.*.steps.timeout-minutes', STEP_CONTEXTS, ['hashFiles']], + ['jobs.*.steps.with', STEP_CONTEXTS, ['hashFiles']], + ['jobs.*.steps.working-directory', STEP_CONTEXTS, ['hashFiles']], + ['jobs.*.strategy', 'github, needs, vars, inputs', []], + ['jobs.*.timeout-minutes', 'github, needs, strategy, matrix, vars, inputs', []], + ['jobs.*.with.*', 'github, needs, strategy, matrix, inputs, vars', []], + ['on.workflow_call.inputs.*.default', 'github, inputs, vars', []], + ['on.workflow_call.outputs.*.value', 'github, jobs, vars, inputs', []], + ].map(([key, contexts, functions]) => + Object.freeze({ + key, + segments: key.split('.'), + contexts: Object.freeze(contexts.split(',').map((c) => c.trim())), + functions: Object.freeze(functions.map((f) => f.toLowerCase())), + }), + ), +); + +/** The most specific row whose key is a prefix of `path`, or null. */ +export function rowFor(path) { + let best = null; + for (const row of CONTEXT_AVAILABILITY) { + if (row.segments.length > path.length) continue; + if (!row.segments.every((s, i) => s === '*' || s === path[i])) continue; + if (!best || row.segments.length > best.segments.length) best = row; + } + return best; +} + +// ── Expression reading ────────────────────────────────────────────────────── + +/** + * The `${{ ... }}` bodies in a string. Returns null when a `${{` is never + * closed -- an input problem, not a pass. A `}}` inside a '...' literal does not + * close the span. + */ +export function expressionSpans(text) { + const spans = []; + let i = 0; + for (;;) { + const open = text.indexOf('${{', i); + if (open === -1) return spans; + let j = open + 3; + let inString = false; + let closed = -1; + while (j < text.length) { + const ch = text[j]; + if (inString) { + if (ch === "'") { + if (text[j + 1] === "'") j++; + else inString = false; + } + } else if (ch === "'") inString = true; + else if (ch === '}' && text[j + 1] === '}') { + closed = j; + break; + } + j++; + } + if (closed === -1) return null; + spans.push(text.slice(open + 3, closed)); + i = closed + 2; + } +} + +/** + * The named values and function calls an expression body uses, lower-cased. + * @returns {{ names: string[], functions: string[] }} + */ +export function referencedNames(expression) { + const names = []; + const functions = []; + let i = 0; + let prev = ''; + while (i < expression.length) { + const ch = expression[i]; + if (ch === "'") { + i++; + while (i < expression.length) { + if (expression[i] === "'" && expression[i + 1] === "'") i += 2; + else if (expression[i] === "'") break; + else i++; + } + i++; + prev = "'"; + continue; + } + if (/[0-9]/.test(ch)) { + while (i < expression.length && /[0-9A-Za-z_.]/.test(expression[i])) i++; + prev = '0'; + continue; + } + if (/[A-Za-z_]/.test(ch)) { + const start = i; + while (i < expression.length && /[A-Za-z0-9_-]/.test(expression[i])) i++; + const word = expression.slice(start, i).toLowerCase(); + let k = i; + while (k < expression.length && /\s/.test(expression[k])) k++; + if (prev === '.') { + // a property name, never a context + } else if (expression[k] === '(') functions.push(word); + else if (!LITERALS.has(word)) names.push(word); + prev = 'w'; + continue; + } + if (!/\s/.test(ch)) prev = ch; + i++; + } + return { names, functions }; +} + +// ── Scanning ──────────────────────────────────────────────────────────────── + +/** + * Every expression under a table key in the workflow directory, judged. + * + * @param {string} root repository root (or, in --self-test, a fixture root) + */ +export function scan(root) { + const violations = []; + const problems = []; + let jobs = 0; + let expressions = 0; + + 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, problems, files: 0, jobs, expressions }; + } + const files = readdirSync(dir) + .filter((f) => f.endsWith('.yml') || f.endsWith('.yaml')) + .sort(); + if (files.length === 0) { + problems.push(`${WORKFLOW_DIR}/ holds no .yml/.yaml file -- nothing was scanned, so nothing was verified.`); + return { violations, problems, files: 0, jobs, expressions }; + } + + for (const name of files) { + const rel = `${WORKFLOW_DIR}/${name}`; + const lineCounter = new LineCounter(); + let doc; + try { + doc = parseDocument(readFileSync(join(dir, name), 'utf8'), { lineCounter }); + } catch (err) { + problems.push(`${rel}: YAML parse threw -- ${err.message}`); + continue; + } + if (doc.errors.length > 0) { + problems.push(`${rel}: YAML parse error -- ${doc.errors[0].message}`); + continue; + } + const jobsNode = doc.get('jobs'); + if (!isMap(jobsNode)) { + problems.push(`${rel}: no \`jobs:\` mapping -- a workflow this gate cannot read is a workflow it cannot clear.`); + continue; + } + jobs += jobsNode.items.length; + + const judge = (path, node) => { + const row = rowFor(path); + if (!row) return; + const line = lineCounter.linePos(node.range[0]).line; + const text = String(node.value); + const isIf = path.length === row.segments.length && row.segments[row.segments.length - 1] === 'if'; + let bodies = expressionSpans(text); + if (bodies === null) { + problems.push(`${rel}:${line}: \`${path.join('.')}\` opens a \${{ that is never closed.`); + return; + } + if (isIf && bodies.length === 0 && text.trim() !== '') bodies = [text]; + for (const body of bodies) { + expressions++; + const { names, functions } = referencedNames(body); + const refused = [ + ...new Set(names.filter((n) => !row.contexts.includes(n))), + ].map((n) => ({ kind: KNOWN_CONTEXTS.includes(n) ? 'context' : 'unknown', name: n })); + for (const fn of new Set(functions)) { + if (SPECIAL_FUNCTIONS.includes(fn) && !row.functions.includes(fn)) refused.push({ kind: 'function', name: fn }); + } + for (const r of refused) { + violations.push({ file: rel, line, key: row.key, path: path.join('.'), ...r, expression: body.replace(/\s+/g, ' ').trim() }); + } + } + }; + + const walk = (path, node) => { + if (isMap(node)) { + for (const pair of node.items) walk([...path, String(pair.key?.value ?? pair.key)], pair.value); + } else if (isSeq(node)) { + for (const item of node.items) walk(path, item); + } else if (isScalar(node) && typeof node.value === 'string') { + judge(path, node); + } + }; + walk([], doc.contents); + } + + return { violations, problems, files: files.length, jobs, expressions }; +} + +// ── Reporting ─────────────────────────────────────────────────────────────── + +function escapeData(value) { + return String(value).replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A'); +} + +function escapeProperty(value) { + return escapeData(value).replace(/:/g, '%3A').replace(/,/g, '%2C'); +} + +export function describe(v) { + const row = CONTEXT_AVAILABILITY.find((r) => r.key === v.key); + const what = + v.kind === 'function' + ? `the special function \`${v.name}()\`` + : v.kind === 'unknown' + ? `\`${v.name}\`, which is no context GitHub knows,` + : `the \`${v.name}\` context`; + return ( + `\`${v.path}\` uses ${what} -- GitHub's context-availability table allows only ` + + `${row.contexts.join(', ')}${row.functions.length ? ` (functions: ${row.functions.join(', ')})` : ''} ` + + `at \`${v.key}\`. GitHub rejects the whole file and creates no run for it.` + ); +} + +export function annotate(v) { + const title = escapeProperty(`Expression uses ${v.name} where GitHub does not make it available`); + return `::error file=${escapeProperty(v.file)},line=${v.line},title=${title}::${escapeData(describe(v))}`; +} + +const PRESCRIPTION = ` +Move the value to a key whose row lists the context. The usual case: a job-level +\`env:\` value that reads \`runner\`, \`steps\` or \`job\` belongs in the \`env:\` of +the step that uses it (step-level keys see every context). Table and scope: +the header of scripts/check-workflow-context-availability.mjs.`; + +function repoRoot() { + return execFileSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8' }).trim(); +} + +function summarise({ files, jobs, expressions }) { + return `scanned ${files} workflow file(s), ${jobs} job(s); judged ${expressions} expression(s) under context-availability table keys`; +} + +function main() { + const result = scan(repoRoot()); + const { violations, problems } = result; + if (problems.length > 0) { + console.error(`check-workflow-context-availability: ${problems.length} input problem(s) -- the scan is NOT a pass\n`); + for (const p of problems) console.error(` • ${p}`); + process.exit(1); + } + if (violations.length === 0) { + console.log(`check-workflow-context-availability: OK (${summarise(result)}).`); + process.exit(0); + } + console.error(`check-workflow-context-availability: ${violations.length} expression use(s) GitHub's context-availability table forbids\n`); + for (const v of violations) { + console.error(` • ${v.file}:${v.line} ${describe(v)}`); + console.error(` \${{ ${v.expression} }}`); + console.log(annotate(v)); + } + console.error(PRESCRIPTION); + process.exit(1); +} + +// ── Self-test ──────────────────────────────────────────────────────────────── +// +// Fixture workflows in temp dirs, run through the SAME scan() main() calls. +// Roster, floor and handshake copied from check-workflow-status-functions.mjs +// (AGENTS.md, "Writing a --self-test"). + +const SELF_TEST_VERDICT = 'check-workflow-context-availability self-test reached its verdict'; + +// Battery name -> minimum case count. A FLOOR, not an equality. +const SELF_TEST_BATTERIES = Object.freeze({ + '1. The PR #27212 head 191e9d5e shape is refused': 6, + '2. The same key at step level passes': 3, + '3. runner/steps/job/matrix x job-level env/if/timeout-minutes/runs-on, judged by the table': 17, + '4. The matrix rows GitHub allows pass, and job-level if: still refuses matrix': 4, + '5. Expression reading: literals, properties, functions, case, implicit if': 8, + '6. Special functions are held to their rows': 3, + '7. Workflow-level and workflow_call rows': 4, + '8. Missing input must go red': 5, + '9. The real repository is what this gate actually guards': 3, +}); + +const SELF_TEST_BATTERY_FLOOR = 9; + +const UNATTRIBUTED_BATTERY = '(no battery open)'; + +function selfTest() { + const seen = new Map(); + let openBattery = null; + const battery = (name) => { + openBattery = name; + }; + const failures = []; + let checked = 0; + const assert = (cond, msg) => { + const b = openBattery ?? UNATTRIBUTED_BATTERY; + seen.set(b, (seen.get(b) ?? 0) + 1); + checked++; + if (!cond) failures.push(msg); + }; + + const roots = []; + const makeRoot = (files) => { + const dir = mkdtempSync(join(tmpdir(), 'check-workflow-context-')); + roots.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 workflow = (body) => makeRoot({ '.github/workflows/w.yml': `name: W\non: [push]\n${body}` }); + + try { + // ── 1. The recorded specimen ───────────────────────────────────────────── + battery('1. The PR #27212 head 191e9d5e shape is refused'); + const specimen = workflow(`jobs: + live-e2e-ui: + runs-on: ubuntu-latest + env: + LIVE_BACKEND_DIR: \${{ runner.temp }}/live-backend + steps: + - run: echo "$LIVE_BACKEND_DIR" +`); + const red = scan(specimen); + assert(red.problems.length === 0, `the specimen is well-formed, got ${red.problems[0]}`); + assert(red.violations.length === 1, `the specimen is flagged once, got ${red.violations.length}`); + assert(red.violations[0]?.name === 'runner' && red.violations[0]?.kind === 'context', `runner is the refused context, got ${red.violations[0]?.name}`); + assert(red.violations[0]?.key === 'jobs.*.env', `the governing row is jobs.*.env, got ${red.violations[0]?.key}`); + assert(red.violations[0]?.line === 7, `the line points at the value, got ${red.violations[0]?.line}`); + const line = red.violations[0] ? annotate(red.violations[0]) : '(no violation to annotate)'; + assert( + line.startsWith('::error file=.github/workflows/w.yml,line=7,title=') && !/[\r\n]/.test(line), + `the annotation is one line carrying file= and line=, got ${line.slice(0, 80)}`, + ); + + // ── 2. The same key one level down ─────────────────────────────────────── + battery('2. The same key at step level passes'); + const stepLevel = scan(workflow(`jobs: + live-e2e-ui: + runs-on: ubuntu-latest + steps: + - env: + LIVE_BACKEND_DIR: \${{ runner.temp }}/live-backend + run: echo "$LIVE_BACKEND_DIR" +`)); + assert(stepLevel.problems.length === 0, `the step-level fixture is well-formed, got ${stepLevel.problems[0]}`); + assert(stepLevel.expressions === 1, `the step-level expression was judged, not skipped, got ${stepLevel.expressions}`); + assert(stepLevel.violations.length === 0, `runner.temp in step-level env: passes, got ${stepLevel.violations.length}`); + + // ── 3. The issue's minimum grid, verdicts read off the table ───────────── + battery('3. runner/steps/job/matrix x job-level env/if/timeout-minutes/runs-on, judged by the table'); + const grid = { + env: (e) => ` env:\n X: \${{ ${e} }}\n`, + if: (e) => ` if: \${{ ${e} }}\n`, + 'timeout-minutes': (e) => ` timeout-minutes: \${{ ${e} }}\n`, + 'runs-on': (e) => ` runs-on: \${{ ${e} }}\n`, + }; + const expected = { + runner: { env: false, if: false, 'timeout-minutes': false, 'runs-on': false }, + steps: { env: false, if: false, 'timeout-minutes': false, 'runs-on': false }, + job: { env: false, if: false, 'timeout-minutes': false, 'runs-on': false }, + matrix: { env: true, if: false, 'timeout-minutes': true, 'runs-on': true }, + }; + let refusedCount = 0; + for (const [ctx, perKey] of Object.entries(expected)) { + for (const [key, allowed] of Object.entries(perKey)) { + const body = `jobs:\n j:\n strategy:\n matrix:\n os: [ubuntu-latest]\n${grid[key](`${ctx}.x`)}${key === 'runs-on' ? '' : ' runs-on: ubuntu-latest\n'} steps:\n - run: echo\n`; + const r = scan(workflow(body)); + const flagged = r.problems.length === 0 && r.violations.some((v) => v.name === ctx && v.key === `jobs.*.${key}`); + if (!allowed) refusedCount++; + assert( + r.problems.length === 0 && flagged === !allowed, + `${ctx}.x in job-level ${key}: expected ${allowed ? 'pass' : 'refuse'}, got ${flagged ? 'refuse' : 'pass'} (${r.problems[0] ?? 'no problems'})`, + ); + } + } + assert(refusedCount === 13, `the table refuses 13 of the 16 cells, the grid encoded ${refusedCount}`); + + // ── 4. matrix where the table lists it ─────────────────────────────────── + battery('4. The matrix rows GitHub allows pass, and job-level if: still refuses matrix'); + const matrixOk = scan(workflow(`jobs: + j: + strategy: + matrix: + os: [ubuntu-latest, macos-latest] + budget: [30] + runs-on: \${{ matrix.os }} + timeout-minutes: \${{ matrix.budget }} + continue-on-error: \${{ matrix.os == 'macos-latest' }} + env: + TARGET: \${{ matrix.os }} + steps: + - run: echo "$TARGET" +`)); + assert(matrixOk.problems.length === 0, `the matrix fixture is well-formed, got ${matrixOk.problems[0]}`); + assert(matrixOk.expressions === 4, `all four matrix reads were judged, got ${matrixOk.expressions}`); + assert(matrixOk.violations.length === 0, `matrix in runs-on/timeout-minutes/continue-on-error/env passes, got ${matrixOk.violations.map((v) => v.path).join(', ')}`); + const matrixIf = scan(workflow(`jobs: + j: + strategy: + matrix: + os: [ubuntu-latest] + if: matrix.os == 'ubuntu-latest' + runs-on: ubuntu-latest + steps: + - run: echo +`)); + assert(matrixIf.violations.length === 1 && matrixIf.violations[0].name === 'matrix', 'matrix in a job-level if: refuses even with a strategy.matrix'); + + // ── 5. Reading expressions the way GitHub does ─────────────────────────── + battery('5. Expression reading: literals, properties, functions, case, implicit if'); + const read = (e) => referencedNames(e); + assert(read("github.job == 'runner.temp'").names.join() === 'github', "a property named job and a 'string' literal are not contexts"); + assert(read("format('{0}}}', github.sha)").names.join() === 'github', "a '}}' inside a literal does not end the read"); + assert(read("contains(fromJSON('[1]'), 1) && true").names.length === 0, 'function names and literals are not contexts'); + assert(read("steps['build'].outputs.x").names.join() === 'steps', 'the index spelling still names its context'); + assert(read('Runner.OS').names.join() === 'runner', 'context names are case-insensitive'); + assert(expressionSpans("a ${{ x }} b ${{ format('}}', y) }} c").length === 2, 'two spans, the second carrying a }} literal'); + const implicit = scan(workflow(`jobs: + j: + if: runner.os == 'Linux' + runs-on: ubuntu-latest + steps: + - if: runner.os == 'Linux' + run: echo +`)); + assert(implicit.violations.length === 1 && implicit.violations[0].key === 'jobs.*.if', 'an if: without ${{ }} is read as an expression, and only the job-level one refuses'); + const unknown = scan(workflow(`jobs: + j: + runs-on: \${{ runners.label }} + steps: + - run: echo +`)); + assert(unknown.violations[0]?.kind === 'unknown', 'a name that is no context is refused as unknown'); + + // ── 6. Special functions ───────────────────────────────────────────────── + battery('6. Special functions are held to their rows'); + const fns = scan(workflow(`jobs: + j: + if: \${{ always() }} + runs-on: ubuntu-latest + env: + KEY: \${{ hashFiles('**/lock') }} + steps: + - if: \${{ failure() && hashFiles('x') != '' }} + env: + KEY: \${{ hashFiles('**/lock') }} + run: echo +`)); + assert(fns.violations.length === 1, `only the job-level hashFiles() refuses, got ${fns.violations.map((v) => `${v.path}:${v.name}`).join(', ')}`); + assert(fns.violations[0]?.kind === 'function' && fns.violations[0]?.name === 'hashfiles', 'hashFiles() in job-level env: is refused as a function'); + const statusInEnv = scan(workflow(`jobs: + j: + runs-on: ubuntu-latest + steps: + - env: + OK: \${{ success() }} + run: echo +`)); + assert(statusInEnv.violations[0]?.name === 'success', 'a status function outside an if: refuses'); + + // ── 7. Workflow-level rows ─────────────────────────────────────────────── + battery('7. Workflow-level and workflow_call rows'); + const top = scan(makeRoot({ + '.github/workflows/top.yml': `name: Top +run-name: \${{ secrets.X }} +on: + workflow_call: + inputs: + dir: + type: string + default: \${{ github.workspace }} + outputs: + result: + value: \${{ jobs.j.outputs.result }} +env: + TMP: \${{ runner.temp }} + OK: \${{ secrets.TOKEN }} +jobs: + j: + runs-on: ubuntu-latest + outputs: + result: \${{ steps.s.outputs.r }} + steps: + - id: s + run: echo "r=1" >> "$GITHUB_OUTPUT" +`, + })); + const at = (p) => top.violations.filter((v) => v.path === p).map((v) => v.name).join(); + assert(at('run-name') === 'secrets', `secrets in run-name: refuses, got ${at('run-name')}`); + assert(at('env.TMP') === 'runner', `runner in workflow-level env: refuses, got ${at('env.TMP')}`); + assert(at('env.OK') === '' && at('on.workflow_call.outputs.result.value') === '', 'secrets in workflow env: and jobs in a workflow_call output pass'); + assert(top.violations.length === 2, `exactly the two refusals, got ${top.violations.length}`); + + // ── 8. Missing input ───────────────────────────────────────────────────── + battery('8. Missing input must go red'); + assert(scan(makeRoot({ 'README.md': '#\n' })).problems.length === 1, `a missing ${WORKFLOW_DIR}/ is an input problem`); + assert(scan(makeRoot({ '.github/workflows/.gitkeep': '' })).problems.length === 1, `an empty ${WORKFLOW_DIR}/ is an input problem`); + const broken = scan(makeRoot({ '.github/workflows/broken.yml': 'name: B\non: [push]\njobs:\n a:\n - x\n b: [\n' })); + assert(broken.problems.some((p) => p.includes('broken.yml')), 'an unparseable file is an input problem that names the file'); + assert(scan(makeRoot({ '.github/workflows/j.yml': 'name: J\non: [push]\n' })).problems.length === 1, 'a workflow with no jobs: is an input problem'); + const unclosed = scan(workflow(`jobs: + j: + runs-on: \${{ matrix.os + steps: + - run: echo +`)); + assert(unclosed.problems.length === 1, 'an unterminated ${{ is an input problem, not a pass'); + + // ── 9. The real tree ───────────────────────────────────────────────────── + battery('9. The real repository is what this gate actually guards'); + const real = scan(repoRoot()); + assert(real.problems.length === 0, `the repo's own workflows parse cleanly, got ${real.problems[0]}`); + assert(real.files > 0 && real.jobs > 0, 'the repo scan actually reads workflows'); + assert(real.expressions > 0, 'the repo scan judged at least one expression -- 0 means the gate guards nothing'); + } finally { + for (const dir of roots) rmSync(dir, { recursive: true, force: true }); + } + + // ── The floor: every declared battery ran, and ran its cases ───────────── + const declared = Object.keys(SELF_TEST_BATTERIES); + let floorBreached = false; + if (declared.length < SELF_TEST_BATTERY_FLOOR) { + floorBreached = true; + failures.push(`SELF_TEST_BATTERIES declares ${declared.length} batteries, below the pinned ${SELF_TEST_BATTERY_FLOOR}.`); + } + for (const [name, count] of seen) { + if (declared.includes(name)) continue; + floorBreached = true; + failures.push(`self-test battery "${name}" registered ${count} case(s) but is not declared in SELF_TEST_BATTERIES.`); + } + for (const name of declared) { + const count = seen.get(name) ?? 0; + if (count >= SELF_TEST_BATTERIES[name]) continue; + floorBreached = true; + failures.push( + count === 0 + ? `self-test battery "${name}" DID NOT RUN -- 0 cases registered, ${SELF_TEST_BATTERIES[name]} pinned.` + : `self-test battery "${name}" registered ${count} case(s), below its pinned floor of ${SELF_TEST_BATTERIES[name]}.`, + ); + } + if (floorBreached) { + failures.push('A battery below its floor means cases STOPPED RUNNING -- find what stopped registering and restore it.'); + } + if (failures.length) { + console.error(`✗ check-workflow-context-availability --self-test -- ${failures.length} failure(s)\n`); + for (const f of failures) console.error(` • ${f}`); + process.exit(1); + } + console.log(`✓ check-workflow-context-availability --self-test: ${checked} assertions over temp fixture roots (real scan() path)`); + + return SELF_TEST_VERDICT; +} + +const invokedDirectly = isEntrypoint(import.meta.url); + +if (!invokedDirectly) { + // imported as a module — expose the exports and do nothing else +} else if (process.argv.includes('--self-test')) { + if (selfTest() !== SELF_TEST_VERDICT) { + console.error( + '\n✗ check-workflow-context-availability self-test: selfTest() returned without reaching its verdict,\n' + + 'so no success line was printed. Exiting 0 here would report a self-test\n' + + 'that never finished as a self-test that passed.\n', + ); + process.exit(1); + } +} else { + main(); +}