diff --git a/.github/workflows/auto-merge.yml b/.github/workflows/auto-merge.yml index f5b5bbc98..f4c6194b2 100644 --- a/.github/workflows/auto-merge.yml +++ b/.github/workflows/auto-merge.yml @@ -1,7 +1,7 @@ # Auto-merge — nobody is in the merge loop. # # Green, ready PRs merge themselves and deploy themselves. The policy lives in -# ONE place for the whole fleet — catomean/dotfiles, +# ONE place for the whole fleet — bitbaum/dotfiles, # scripts/ci/auto-merge-sweep.sh — and this file only says "run it, with these # settings". # @@ -42,7 +42,7 @@ permissions: jobs: sweep: - uses: catomean/dotfiles/.github/workflows/auto-merge-sweep.yml@master + uses: bitbaum/dotfiles/.github/workflows/auto-merge-sweep.yml@master with: base_branch: main ci_workflow: ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a2b4c5369..28637ab7f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,6 +41,23 @@ 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 deploy in + # this repo: `maonakamoto` → `catomean` (2026-08-26), then the move to + # the `bitbaum` org (2026-08-28). REST and git follow a rename redirect, + # so every normal way of checking says the reference is fine; the + # Actions resolver does not follow it and dies before any step exists, + # with no readable log. PRs stay green and clean the whole time — the + # red run is on main, under a workflow nobody opens. + # + # Deliberately NOT part of `npm run verify`: verify is the local/CI SSOT + # bundle and must stay offline and deterministic. This one needs the API + # to answer what a repo is really called, so it lives here and no-ops + # 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 @@ -273,7 +290,11 @@ jobs: fetch-depth: 0 - name: Secret scan (gitleaks) - # Free for public repos; no license needed. Fails the job on any finding. + # Runs the MIT-licensed gitleaks CLI directly. gitleaks-ACTION is free + # for personal accounts only: when these repos moved into the `bitbaum` + # organisation it began refusing to run ("License key is required") and + # this job went red on every PR — blocking the merge queue while + # scanning nothing at all. The CLI has no such restriction. # # Skipped on workflow_dispatch, and only there. On push and pull_request # the action scans the incoming commits; a dispatched run has no such @@ -290,9 +311,7 @@ jobs: # triggers nothing), so without this every automated merge left main red # and CD, which chains off green CI, never deployed. if: github.event_name != 'workflow_dispatch' - uses: gitleaks/gitleaks-action@v3 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: bash scripts/ci/secret-scan.sh - name: Setup Node.js uses: actions/setup-node@v7 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.`); diff --git a/scripts/ci/secret-scan.sh b/scripts/ci/secret-scan.sh new file mode 100644 index 000000000..63157b77b --- /dev/null +++ b/scripts/ci/secret-scan.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# +# Scan the commits this run is actually about for leaked secrets. +# +# WHY NOT gitleaks-action: it is free for personal accounts only. When these +# repos moved into the `bitbaum` organisation the action began refusing to run +# — "[bitbaum] is an organization. License key is required." — and the job went +# red on every PR, blocking the whole merge queue while scanning nothing. The +# gitleaks CLI is MIT and has no such restriction, so this runs the scanner +# directly and keeps the coverage the action used to give. +# +# THE TRAP THIS GUARDS: `gitleaks` exits 0 on an EMPTY commit range. A range +# that comes out empty — a force-push, a missing base ref, a shallow clone — +# therefore reports success having examined nothing, which is the worst possible +# outcome for a security gate: not "no secrets", but "no look", wearing a green +# tick. So the range is computed first and an empty one is a hard failure. +# +# Deliberately scans a RANGE, not all of history. There are ~173 pre-existing +# findings in 2025-era commits that only a history rewrite can clear; scanning +# everything would make this permanently red and teach everyone to ignore it. +# Coverage is unaffected — every commit passes through a PR run before it can +# merge. +set -euo pipefail + +GITLEAKS_VERSION="${GITLEAKS_VERSION:-8.30.1}" + +case "${GITHUB_EVENT_NAME:-}" in + pull_request) + base="$(jq -r '.pull_request.base.sha' "$GITHUB_EVENT_PATH")" + head="$(jq -r '.pull_request.head.sha' "$GITHUB_EVENT_PATH")" + ;; + push) + base="$(jq -r '.before' "$GITHUB_EVENT_PATH")" + head="${GITHUB_SHA}" + # A new branch (and a first push) reports an all-zero "before". There is no + # range to compute, so scan just the tip commit rather than inventing one. + if [ -z "$base" ] || [ "$base" = "null" ] || [ "$base" = "0000000000000000000000000000000000000000" ]; then + base="${head}~1" + fi + ;; + *) + echo "secret-scan: unsupported event '${GITHUB_EVENT_NAME:-none}' — refusing to guess a range" >&2 + exit 2 + ;; +esac + +if ! git cat-file -e "${base}^{commit}" 2>/dev/null; then + echo "secret-scan: base commit ${base} is not in this clone (needs fetch-depth: 0)" >&2 + exit 2 +fi + +count="$(git rev-list --count "${base}..${head}")" +echo "secret-scan: ${count} commit(s) in ${base:0:8}..${head:0:8}" + +if [ "$count" -eq 0 ]; then + # Never let "nothing to scan" read as "nothing found". + echo "secret-scan: EMPTY range — gitleaks would exit 0 having scanned nothing" >&2 + exit 2 +fi + +curl -sSfL "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \ + | tar -xz -C /tmp gitleaks +chmod +x /tmp/gitleaks + +/tmp/gitleaks git . \ + --log-opts="${base}..${head}" \ + --redact \ + --verbose \ + --exit-code 1