Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
108 changes: 108 additions & 0 deletions scripts/ci/check-workflow-refs.mjs
Original file line number Diff line number Diff line change
@@ -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/<old>/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.`);
Loading