diff --git a/scripts/ci/fleet-refs-audit-lib.mjs b/scripts/ci/fleet-refs-audit-lib.mjs index a57f4cba..6928ef2e 100644 --- a/scripts/ci/fleet-refs-audit-lib.mjs +++ b/scripts/ci/fleet-refs-audit-lib.mjs @@ -28,5 +28,35 @@ export function retiredHandleMatches(text, retiredHandles) { } /** `uses: owner/repo/path@ref` and `uses: owner/repo@ref`. Local (`./…`) and - * container (`docker://`) references have no owner to be wrong about. */ + * container (`docker://`) references have no owner to be wrong about. + * + * LIMITATION, stated rather than assumed: this matches by line shape, not by + * YAML structural position. A `run: |` block whose FIRST physical line + * (after indentation) happens to read literally `uses: owner/repo@ref` — + * e.g. example text in an echoed usage message — would be treated as a real + * `uses:` key. Narrow and not observed in this fleet; a real YAML parse + * would close it at the cost of a dependency this script deliberately has + * none of. */ export const USES = /^\s*uses:\s*([A-Za-z0-9][\w.-]*)\/([\w.-]+)(?:\/[^@\s]+)?@/gm; + +/** + * This is the actual mechanism that catches the outage: REST resolves a + * rename/transfer redirect and returns the canonical `full_name`; Actions' + * `uses:` resolver does not resolve it at all. `real` is what `resolve()` in + * fleet-refs-audit.mjs already returned for `slug` — this function makes no + * network call, so it is fully testable without a token. + * + * real === undefined → the lookup itself failed (rate limit, 5xx, etc.) — + * report as unreadable, never as clean and never as + * stale. Silence here would be worse than either. + * real === null → the repo does not exist under this name at all + * real !== slug → it exists, but Actions will resolve a DIFFERENT + * name than what's written — the exact redirect gap + * real === slug → the reference is already canonical + */ +export function verdictFor(slug, real) { + if (real === undefined) return { kind: 'unreadable', message: `${slug} (lookup failed)` }; + if (real === null) return { kind: 'stale', message: `uses ${slug} — DOES NOT EXIST` }; + if (real !== slug) return { kind: 'stale', message: `uses ${slug} — canonical is ${real} (Actions will NOT follow this)` }; + return { kind: 'ok' }; +} diff --git a/scripts/ci/fleet-refs-audit.mjs b/scripts/ci/fleet-refs-audit.mjs index 0cfbd8f1..f9d87744 100755 --- a/scripts/ci/fleet-refs-audit.mjs +++ b/scripts/ci/fleet-refs-audit.mjs @@ -34,7 +34,7 @@ * broken for exactly that reason, and had no auto-merge.yml to notice. */ -import { retiredHandleMatches, USES } from './fleet-refs-audit-lib.mjs'; +import { retiredHandleMatches, USES, verdictFor } from './fleet-refs-audit-lib.mjs'; const ORG = process.env.FLEET_ORG || 'bitbaum'; const RETIRED = (process.env.RETIRED_HANDLES || 'maonakamoto').split(',').map(s => s.trim()).filter(Boolean); @@ -97,12 +97,9 @@ for (const repo of repos) { for (const [, owner, name] of text.matchAll(USES)) { const slug = `${owner}/${name}`; const real = await resolve(slug); - if (real === undefined) { unreadable.push(`${slug} (lookup failed)`); continue; } - if (real === null) { - stale.push(`${repo.full_name}/.github/workflows/${f.name}: uses ${slug} — DOES NOT EXIST`); - } else if (real !== slug) { - stale.push(`${repo.full_name}/.github/workflows/${f.name}: uses ${slug} — canonical is ${real} (Actions will NOT follow this)`); - } + const verdict = verdictFor(slug, real); + if (verdict.kind === 'unreadable') unreadable.push(verdict.message); + else if (verdict.kind === 'stale') stale.push(`${repo.full_name}/.github/workflows/${f.name}: ${verdict.message}`); } } } diff --git a/scripts/test/fleet-refs-audit.ts b/scripts/test/fleet-refs-audit.ts index 29bab0a4..13eeca19 100644 --- a/scripts/test/fleet-refs-audit.ts +++ b/scripts/test/fleet-refs-audit.ts @@ -4,9 +4,15 @@ * its first real run (2026-08-28) — the workflow that exists to catch a * retired-owner reference failed on the one line that DEFINES what a retired * owner is, not a line that USES one. + * + * Also covers USES and verdictFor — the mechanism that actually caught the + * three real outages this audit exists for (2026-08-26/27/28), and which had + * zero test coverage until this file: only the newer, less consequential + * retired-handle check was pinned. A regex bug in USES would silently miss + * exactly the class of breakage the whole tool was built to catch. */ import assert from "node:assert/strict"; -import { retiredHandleMatches } from "../ci/fleet-refs-audit-lib.mjs"; +import { retiredHandleMatches, USES, verdictFor } from "../ci/fleet-refs-audit-lib.mjs"; const RETIRED = ["maonakamoto"]; @@ -72,4 +78,68 @@ assert.deepEqual( "a file with no retired handle anywhere must report nothing" ); -console.log("OK: 5 assertions passed"); +// --- USES: what actually gets checked against GitHub ----------------------- +const usesOf = (text: string) => [...text.matchAll(USES)].map(([, owner, name]) => `${owner}/${name}`); + +assert.deepEqual( + usesOf("jobs:\n x:\n uses: bitbaum/fleetcrown@main\n"), + ["bitbaum/fleetcrown"], + "a plain owner/repo@ref uses: line is matched" +); + +assert.deepEqual( + usesOf("jobs:\n x:\n uses: bitbaum/fleetcrown/.github/workflows/selfhost-deploy.yml@main\n"), + ["bitbaum/fleetcrown"], + "the owner/repo is extracted even with a path and filename after it" +); + +assert.deepEqual( + usesOf("jobs:\n x:\n uses: ./.github/actions/local-thing\n"), + [], + "a local action (no owner, no @ref) is not matched" +); + +assert.deepEqual( + usesOf("jobs:\n x:\n uses: docker://ghcr.io/owner/image:tag\n"), + [], + "a docker:// reference has no owner/repo to be wrong about and must not match" +); + +assert.deepEqual( + usesOf("jobs:\n a:\n uses: bitbaum/one@v1\n b:\n uses: bitbaum/two@v2\n"), + ["bitbaum/one", "bitbaum/two"], + "every uses: line in a file is matched independently" +); + +assert.deepEqual( + usesOf(" uses: bitbaum/fleetcrown@main\n"), + ["bitbaum/fleetcrown"], + "indentation before uses: does not prevent a match" +); + +// --- verdictFor: the actual redirect-detection decision -------------------- +assert.deepEqual( + verdictFor("bitbaum/fleetcrown", "bitbaum/fleetcrown"), + { kind: "ok" }, + "a reference already naming its canonical owner is fine" +); + +assert.deepEqual( + verdictFor("catomean/fleetcrown", "bitbaum/fleetcrown"), + { kind: "stale", message: "uses catomean/fleetcrown — canonical is bitbaum/fleetcrown (Actions will NOT follow this)" }, + "REST resolving a DIFFERENT canonical name is the exact redirect gap Actions falls into" +); + +assert.deepEqual( + verdictFor("catomean/does-not-exist", null), + { kind: "stale", message: "uses catomean/does-not-exist — DOES NOT EXIST" }, + "a 404 from REST is reported as stale, not silently skipped" +); + +assert.deepEqual( + verdictFor("bitbaum/fleetcrown", undefined), + { kind: "unreadable", message: "bitbaum/fleetcrown (lookup failed)" }, + "a failed lookup (rate limit, 5xx) must be unreadable — never reported as clean, never as a false stale" +); + +console.log("OK: 15 assertions passed");