From 9bdf3a29b8829c2442610d45b8e3c4729e62ed9f Mon Sep 17 00:00:00 2001 From: Georgy Butaev <41178744+g-but@users.noreply.github.com> Date: Fri, 28 Aug 2026 08:49:52 +0200 Subject: [PATCH] ci: fail when a `uses:` points at a redirect, so there is no third time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Twice in two days an owner change stopped every merge and every deploy in this repo: `maonakamoto` → `catomean` on 2026-08-26, then the move to the `bitbaum` org, fixed in #800. Each fix pointed at a name that became a redirect the next time. Both outages were invisible in every place anyone looks. GitHub redirects a renamed owner for the REST API and for git remotes, so `gh api` answers and `git push` works; the Actions resolver is the one consumer that does not follow the redirect. It fails before any step exists, with "This run likely failed because of a workflow file issue" and no readable log, while pull requests stay green, clean and mergeable. The red run is on main, under a workflow nobody opens. Work simply stops shipping. Two instances is where the rule says stop fixing instances. This asks the REST API what each referenced repo is really called and fails when that disagrees with what the workflow says — that disagreement IS the bug, because REST resolves the redirect and Actions does not. A static allowlist could not catch it: after a rename the workflow file and the allowlist would both hold the same stale name and agree with each other. Only asking GitHub what the repo is called today can tell. Lives in CI rather than in `npm run verify`, which is the offline SSOT bundle and must stay deterministic; without a token this skips rather than passing quietly. A non-200 that is not a 404 exits 2 — could-not-look is never reported as looks-fine. Proven by mutation: restoring `catomean/dotfiles` fails with the canonical name to use. Also fixes the last CLAUDE.md reference to the 2026-08-26 owner. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012dpTLxh5GJWeWTF1UEvcD5 --- .github/workflows/ci.yml | 22 ++++++ CLAUDE.md | 2 +- scripts/ci/check-workflow-refs.mjs | 108 +++++++++++++++++++++++++++++ 3 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 scripts/ci/check-workflow-refs.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b9a9bbf5d..a0036b8f1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,6 +41,28 @@ jobs: - name: Install dependencies run: npm ci + - name: Every external `uses:` names a canonical owner, not a redirect + # Twice in two days an owner rename stopped every merge and every deploy + # in this repo: `maonakamoto` → `catomean` (2026-08-26), then the move to + # the `bitbaum` org (2026-08-28, #800). Both times nothing looked wrong. + # REST and git follow a rename redirect, so every normal way of checking + # says the reference is fine; the Actions resolver is the one consumer + # that does not follow it, and dies before any step exists with no + # readable log. Pull requests stay green, clean and mergeable — the red + # run is on main, under a workflow nobody opens. + # + # #800 fixed the second instance. This is the check, so there is no + # third: it asks the API what each referenced repo is really called and + # fails when that disagrees with the workflow. A static allowlist cannot + # do it — after a rename the workflow and the allowlist hold the same + # stale name and agree with each other. + # + # Deliberately NOT in `npm run verify`: verify is the offline SSOT + # bundle. This needs the API, so it lives here and skips without a token. + env: + GITHUB_TOKEN: ${{ github.token }} + run: node scripts/ci/check-workflow-refs.mjs + - name: Verify (docs + type-check + routes + lint + unit — same as local) # SSOT for "green": the pre-build gate is defined ONCE as the `verify` # npm script and called verbatim here, so `npm run verify` locally and CI diff --git a/CLAUDE.md b/CLAUDE.md index e4a91c423..1d3e0ed45 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -74,7 +74,7 @@ push branch → open PR → CI green → auto-merge.yml squash-merges it `.github/workflows/auto-merge.yml` calls the fleet's canonical sweep — the policy no longer lives in this repo. It is defined once in -`maonakamoto/dotfiles`, `scripts/ci/auto-merge-sweep.sh`, and a fix made to a +`bitbaum/dotfiles`, `scripts/ci/auto-merge-sweep.sh`, and a fix made to a local copy here would reach nobody. The sweep merges **one** PR per sweep, and only when: it is not a draft, carries no hold label, every check has finished green, GitHub calls it cleanly mergeable, and main's own CI is currently green. One car per sweep is deliberate — a PR's diff --git a/scripts/ci/check-workflow-refs.mjs b/scripts/ci/check-workflow-refs.mjs new file mode 100644 index 000000000..1e2df7821 --- /dev/null +++ b/scripts/ci/check-workflow-refs.mjs @@ -0,0 +1,108 @@ +#!/usr/bin/env node +/** + * Every `uses:` that points at another repository must name that repository's + * CANONICAL owner — not a name that merely redirects to it. + * + * This exists because the same outage happened twice in two days: + * + * 2026-08-26 the account `maonakamoto` was renamed to `catomean` + * 2026-08-28 the repos moved to the organisation `bitbaum` + * + * Both times every merge and every deploy in this repo stopped, and both times + * nothing looked wrong. GitHub redirects a renamed owner for the REST API and + * for git remotes — `gh api repos//dotfiles` still answers, `git push` to + * the old remote still works — so every way a human normally checks says the + * reference is fine. The Actions resolver is the one consumer that does NOT + * follow the redirect. It fails before any step exists, with "This run likely + * failed because of a workflow file issue" and no readable log. + * + * The signal is the dangerous shape: pull requests stay GREEN, mergeable and + * clean. The red run is on main, under a workflow nobody opens. Work simply + * stops shipping. + * + * THE CHECK: ask the REST API what each referenced repo is really called. That + * is precisely the discrepancy — REST resolves the redirect and reports the + * canonical `full_name`, Actions does not resolve it at all. If those two + * disagree, the workflow is already broken, whether or not it has run yet. + * + * A static allowlist could not do this: after a rename the workflow file and + * the allowlist would both hold the same stale name and agree with each other. + * Only asking GitHub what the repo is called today can tell. + * + * Runs in CI, where GITHUB_TOKEN is present. Skips (exit 0) without a token so + * a local `npm run verify` does not depend on the network — CI is where this + * has to hold. + */ + +import { readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +const WORKFLOW_DIR = '.github/workflows'; +const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN; + +if (!token) { + console.log('[check-workflow-refs] no GITHUB_TOKEN — skipping (this gate runs in CI)'); + process.exit(0); +} + +/** + * `uses: owner/repo/path@ref` and `uses: owner/repo@ref`. + * Local (`./.github/...`) and container (`docker://`) references have no owner + * to be wrong about, so they are not matched. + */ +const USES = /^\s*uses:\s*([A-Za-z0-9][\w.-]*)\/([\w.-]+)(?:\/[^@\s]+)?@/gm; + +const refs = new Map(); // "owner/repo" -> Set of workflow files + +for (const file of readdirSync(WORKFLOW_DIR).filter(f => /\.ya?ml$/.test(f))) { + const text = readFileSync(join(WORKFLOW_DIR, file), 'utf8'); + for (const [, owner, repo] of text.matchAll(USES)) { + const key = `${owner}/${repo}`; + refs.set(key, (refs.get(key) ?? new Set()).add(file)); + } +} + +const problems = []; +let checked = 0; + +for (const [ref, files] of refs) { + const res = await fetch(`https://api.github.com/repos/${ref}`, { + headers: { + authorization: `Bearer ${token}`, + accept: 'application/vnd.github+json', + 'user-agent': 'orangecat-check-workflow-refs', + }, + }); + + if (res.status === 404) { + problems.push(`${ref} does not exist (referenced by ${[...files].join(', ')})`); + continue; + } + + if (!res.ok) { + // Rate limiting or an outage is "could not look", never "looks fine". + console.error(`[check-workflow-refs] could not resolve ${ref}: HTTP ${res.status}`); + process.exit(2); + } + + const { full_name: canonical } = await res.json(); + checked += 1; + + if (canonical.toLowerCase() !== ref.toLowerCase()) { + problems.push( + `${ref} is a REDIRECT to ${canonical} — REST and git follow it, the Actions ` + + `resolver does NOT, so ${[...files].join(', ')} will fail to load with no ` + + `usable log. Change the reference to ${canonical}.` + ); + } +} + +if (problems.length > 0) { + console.error('[check-workflow-refs] FAIL'); + for (const p of problems) { + console.error(` ${p}`); + } + process.exit(1); +} + +console.log(`[check-workflow-refs] OK — ${checked} external workflow reference(s), all canonical.`);