Skip to content
Closed
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
4 changes: 2 additions & 2 deletions .github/workflows/auto-merge.yml
Original file line number Diff line number Diff line change
@@ -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".
#
Expand Down Expand Up @@ -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
Expand Down
27 changes: 23 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
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.`);
69 changes: 69 additions & 0 deletions scripts/ci/secret-scan.sh
Original file line number Diff line number Diff line change
@@ -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