Skip to content

CI

CI #2485

Workflow file for this run

name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
# Cancel superseded runs on the same ref — a newer push makes the older build
# obsolete. On main this means only the latest commit's CI (and thus its CD)
# proceeds, which is what we want: deploy the newest, not a stale in-flight one.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
build-and-smoke:
runs-on: ubuntu-latest
timeout-minutes: 25
env:
# Public Supabase vars are baked into the client bundle at build time.
# Use repo secrets when present so P0 auth tests hit the real fixture DB.
# Fall back to dummies for fork PRs where secrets are unavailable.
NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL != '' && secrets.NEXT_PUBLIC_SUPABASE_URL || 'https://dummy-project.supabase.co' }}
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY != '' && secrets.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY || 'dummy' }}
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY != '' && secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY || 'dummy' }}
SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SECRET_KEY != '' && secrets.SUPABASE_SECRET_KEY || '' }}
SUPABASE_SECRET_KEY: ${{ secrets.SUPABASE_SECRET_KEY != '' && secrets.SUPABASE_SECRET_KEY || '' }}
PORT: 3000
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: 24
cache: 'npm'
- 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
# can never drift. The chain (in order): ci:docs → type-check →
# audit:routes → lint → test:unit. Rationale for each check:
# - ci:docs : docs hygiene (slop scan + frontmatter). Fails hard,
# never swallowed with `|| echo` (that was a no-op gate).
# - type-check : tsc --noEmit.
# - audit:routes : every declared route + emitted internal link must
# resolve to a real route file — kills the internal-link
# → 404 class of bug. Static, fast.
# - lint : eslint — catches the undefined-identifier class (e.g.
# the `IntegrationNote` bug that once landed on main).
# - test:unit : Vitest unit suite.
# (Playwright/E2E stays a separate post-build gate below — it needs a
# running server + secrets, so it isn't part of the static verify bundle.)
run: npm run verify
- name: Install Playwright Browsers
run: npx playwright install --with-deps
- name: Cache Next.js build cache
uses: actions/cache@v6
with:
path: ${{ github.workspace }}/.next/cache
# Re-key when deps or source change; restore the newest same-deps cache
# otherwise so incremental compilation is reused across runs.
key: nextjs-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('src/**/*.[jt]s', 'src/**/*.[jt]sx') }}
restore-keys: |
nextjs-${{ hashFiles('**/package-lock.json') }}-
- name: Build (standalone — the exact bundle CD ships)
env:
NODE_ENV: production
# Build the SELF_HOST standalone here so CI tests the same artifact CD
# deploys (tested == shipped). On main this artifact is uploaded and
# shipped verbatim; PRs build it too (dummy env) to catch standalone-only
# breakage. Bake the same client vars CD bakes — public receiving
# addresses; empty on PRs/forks, which is fine for a test build.
SELF_HOST: '1'
NEXT_PUBLIC_LIGHTNING_ADDRESS: ${{ vars.NEXT_PUBLIC_LIGHTNING_ADDRESS }}
NEXT_PUBLIC_BITCOIN_ADDRESS: ${{ vars.NEXT_PUBLIC_BITCOIN_ADDRESS }}
# Cat Credits go-live flag. This build step is the one that bakes the
# client bundle CD ships (artifact download, no rebuild) — a client
# var set only in cd.yml never reaches production.
NEXT_PUBLIC_CAT_CREDITS_LIVE: ${{ vars.NEXT_PUBLIC_CAT_CREDITS_LIVE }}
# Voice input on create forms and onboarding. Same rule as the line
# above, and the reason this feature was dead: the flag existed in the
# code and in nobody's build env, so FEATURES.voiceInput compiled to
# false in every production bundle it ever shipped in.
NEXT_PUBLIC_FEATURE_VOICE_INPUT: ${{ vars.NEXT_PUBLIC_FEATURE_VOICE_INPUT }}
# FleetCrown feedback widget token. Statically prerendered routes
# (e.g. /dashboard/*) evaluate the root layout's env gate at BUILD
# time — without this the widget bakes out of every static page and
# only runtime-rendered routes get it. Public by design (it ships in
# page source), hence a repo variable, not a secret.
FLEETCROWN_FEEDBACK_TOKEN: ${{ vars.FLEETCROWN_FEEDBACK_TOKEN }}
run: npm run build
- name: Assemble standalone (static + public + content)
# Copy the assets into the standalone tree so both the E2E run below and
# the uploaded artifact are complete and runnable as-is. content/ holds
# the blog mdx files read from the filesystem at request time — file
# tracing does not follow request-time fs reads, and shipping without
# them is exactly how every blog post rendered as an empty stub in prod.
run: |
cp -r .next/static .next/standalone/.next/static
[ -d public ] && cp -r public .next/standalone/public || true
[ -d content ] && cp -r content .next/standalone/content || true
- name: Upload standalone artifact (main → shipped by CD)
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
uses: actions/upload-artifact@v4
with:
name: standalone-${{ github.sha }}
path: .next/standalone
include-hidden-files: true # .next/* is hidden; excluded by default
retention-days: 3
- name: Start server (standalone — the shipped entrypoint)
run: |
PORT=3000 node .next/standalone/server.js &
npx wait-on http://localhost:3000
- name: Validate required P0 E2E env
id: check-secrets
env:
E2E_USER_EMAIL: ${{ secrets.E2E_USER_EMAIL }}
E2E_USER_PASSWORD: ${{ secrets.E2E_USER_PASSWORD }}
E2E_PROJECT_ID: ${{ secrets.E2E_PROJECT_ID }}
NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}
run: |
missing=0
for key in E2E_USER_EMAIL E2E_USER_PASSWORD E2E_PROJECT_ID NEXT_PUBLIC_SUPABASE_URL; do
if [ -z "${!key}" ]; then
echo "⚠️ Missing secret: $key"
missing=1
else
echo "✅ Found $key"
fi
done
if [ "$missing" -ne 0 ]; then
# PRs (especially from forks) legitimately lack these secrets → soft-skip.
# But a push/dispatch to main is what CD deploys on: a green build there
# MUST mean the P0 E2E suite actually ran, never "secrets were missing so
# we skipped." Fail hard so the deploy gate can't pass untested.
if [ "${{ github.event_name }}" = "pull_request" ]; then
echo "⚠️ E2E secrets not configured - skipping P0 matrix (PR / fork build)"
echo "has_secrets=false" >> $GITHUB_OUTPUT
else
echo "❌ E2E secrets missing on a ${{ github.event_name }} to ${{ github.ref }}."
echo " Refusing to report a green build with zero end-to-end coverage — CD deploys on this."
exit 1
fi
else
echo "has_secrets=true" >> $GITHUB_OUTPUT
fi
- name: Bootstrap E2E fixture data
if: steps.check-secrets.outputs.has_secrets == 'true'
env:
E2E_USER_EMAIL: ${{ secrets.E2E_USER_EMAIL }}
E2E_USER_PASSWORD: ${{ secrets.E2E_USER_PASSWORD }}
NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY }}
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}
SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SECRET_KEY }}
run: |
node scripts/test-setup/ensure-e2e-fixtures.mjs
node scripts/test-setup/refresh-e2e-reset-tokens.mjs
- name: Run P0 workflow matrix
if: steps.check-secrets.outputs.has_secrets == 'true'
env:
E2E_BASE_URL: http://localhost:3000
BASE_URL: http://localhost:3000
E2E_USER_EMAIL: ${{ secrets.E2E_USER_EMAIL }}
E2E_USER_PASSWORD: ${{ secrets.E2E_USER_PASSWORD }}
E2E_PROJECT_ID: ${{ secrets.E2E_PROJECT_ID }}
# E2E_RESET_* tokens are minted in the bootstrap step (single-use recovery sessions).
# Legacy aliases for global-setup and older specs
E2E_TEST_USER_EMAIL: ${{ secrets.E2E_USER_EMAIL }}
E2E_TEST_USER_PASSWORD: ${{ secrets.E2E_USER_PASSWORD }}
run: npm run test:e2e:matrix:p0
# Runs `verify` a SECOND time, from a git worktree, because a worktree is the
# shape every agent in this fleet actually works in (`_claude_autoworktree_enter`)
# and it is a shape CI otherwise never sees.
#
# The difference that matters: `actions/checkout` + `npm ci` puts node_modules
# at the checkout root, so a script that hardcodes `join(ROOT,'node_modules',…)`
# resolves and CI goes green. A worktree contains ONLY TRACKED FILES — no
# node_modules of its own — and inherits the main checkout's by letting Node
# walk UP the directory chain. So the same code is green here and crashes there.
#
# That is not hypothetical: on 2026-08-24 `npm run verify` — the repo's SSOT for
# "verified" — could not run to completion from any worktree, and CI was green
# throughout. Re-proven by mutation before this job was written: reintroducing
# the hardcoded jscpd path passes a normal checkout (exit 0, "OK") and ENOENTs
# in a worktree.
#
# Two rules this job encodes, both load-bearing:
# 1. The worktree MUST be nested inside the checkout (.claude/worktrees/, which
# is gitignored). That is where the real tooling puts it, and it is what lets
# Node's upward resolution reach <checkout>/node_modules. A sibling path
# would resolve nothing and would be testing a shape nobody works in.
# 2. It runs `npm run verify` VERBATIM, not a hand-picked "worktree-sensitive"
# subset. Of the two real 2026-08-24 failures one was a path-resolution
# script and the other was a unit test — no honest subset would have
# included both, and the subset would drift from `verify` besides.
#
# Scope, stated plainly: this catches the node_modules-location and git-dir-shape
# classes. It does NOT catch "script reads an untracked file" (e.g. .env.local) —
# CI has no untracked files in either shape, so that class is already caught by
# the normal run and is not this job's job.
#
# Runs in parallel with build-and-smoke, so it costs runner minutes, not
# wall-clock on the path to deploy.
verify-in-worktree:
runs-on: ubuntu-latest
timeout-minutes: 20
env:
# Mirror build-and-smoke's env exactly. If this job got different inputs, a
# failure here would be ambiguous — env drift or worktree shape? — and the
# signal this job exists to give would be worthless.
NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL != '' && secrets.NEXT_PUBLIC_SUPABASE_URL || 'https://dummy-project.supabase.co' }}
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY != '' && secrets.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY || 'dummy' }}
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY != '' && secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY || 'dummy' }}
SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SECRET_KEY != '' && secrets.SUPABASE_SECRET_KEY || '' }}
SUPABASE_SECRET_KEY: ${{ secrets.SUPABASE_SECRET_KEY != '' && secrets.SUPABASE_SECRET_KEY || '' }}
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: 24
cache: 'npm'
- name: Install dependencies
# Installs into <checkout>/node_modules and NOWHERE ELSE. The worktree
# below deliberately gets none of its own — that asymmetry is the test.
run: npm ci
- name: Create a git worktree (the shape agents work in)
# --detach avoids inventing a branch name that could collide on re-runs.
# Works from the shallow, detached-HEAD checkout actions/checkout produces.
run: |
git worktree add --detach .claude/worktrees/ci-verify HEAD
test ! -e .claude/worktrees/ci-verify/node_modules \
|| { echo "::error::worktree has its own node_modules — this job would prove nothing"; exit 1; }
- name: Verify from the worktree (same script, no local node_modules)
working-directory: .claude/worktrees/ci-verify
run: npm run verify
# Supply-chain gate: catch committed secrets and known-vuln dependencies before
# they ship. Runs in parallel with build-and-smoke (independent). The
# `security:scan` npm script existed but nothing invoked it — this wires it in.
security:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@v7
with:
# gitleaks scans git history for leaked secrets — needs the full log,
# not the default shallow clone.
fetch-depth: 0
- name: Secret scan (gitleaks)
# The gitleaks CLI, not gitleaks-action. The action is free only for
# PERSONAL accounts; under an organization it refuses to scan at all and
# exits with "missing gitleaks license". Moving this repo into the
# bitbaum org therefore switched secret scanning off while leaving the
# job, its name, and its place in the checks list exactly as they were.
# It happened to fail loudly here — but only because the licence check
# errors; a wrapper that had instead skipped would have reported green
# while scanning nothing. The CLI itself is MIT and unaffected.
#
# Scope is the incoming commit range, which is what the action scanned.
# Scanning all of history instead would fail on ~173 pre-existing
# findings (2025-era .env commits) that only a history rewrite can clear
# — see the security audit, not CI's job. Coverage is unchanged: every
# commit is still scanned in its PR run before it can merge.
#
# Skipped on workflow_dispatch, and only there: a dispatched run has no
# incoming range. Auto-merge dispatches CI on main after each merge (a
# GITHUB_TOKEN push triggers nothing), so without that skip every
# automated merge would leave main red and CD would never deploy.
if: github.event_name != 'workflow_dispatch'
env:
GITLEAKS_VERSION: '8.30.1'
BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }}
HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
run: |
set -euo pipefail
curl -sSfL "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \
| tar -xz gitleaks
base="$BASE_SHA"
# A branch's first push reports an all-zero "before", and a force-push
# can report a sha this clone no longer has. Both would otherwise make
# the range unresolvable.
if [ "$base" = "0000000000000000000000000000000000000000" ] \
|| ! git cat-file -e "${base}^{commit}" 2>/dev/null; then
base="${HEAD_SHA}~1"
fi
range="${base}..${HEAD_SHA}"
# gitleaks exits 0 on an empty range — it scans nothing and reports
# success, which is indistinguishable from a clean scan. Verified
# against 8.30.1 before writing this. Count the commits ourselves and
# refuse to call zero a pass.
n=$(git rev-list --count "$range")
echo "gitleaks: scanning $n commit(s) in $range"
if [ "$n" -eq 0 ]; then
echo "::error::empty scan range $range — refusing to report a pass"
exit 1
fi
./gitleaks git . --log-opts="$range" --redact --no-banner --exit-code 1
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: 24
cache: 'npm'
- name: Dependency audit
# ESCALATED 2026-08-25. The staged rollout this replaces blocked only on
# CRITICAL and merely annotated HIGH, explicitly "until the tree is
# clean". The tree is now clean — `npm audit --omit=dev` reports 0
# vulnerabilities — so the condition that comment set is met and the
# warning is gone. A warning nobody is assigned to read is not a gate; it
# sat non-blocking while 3 HIGH advisories accumulated (postcss ×2,
# js-yaml), and none of them were even version-pinned — the lockfile had
# just drifted below ranges that already allowed the patched releases.
#
# `--audit-level=high` and one command, not two: blocking at high already
# covers critical, so a separate critical line would be dead weight.
#
# `--omit=dev` is deliberate — dev-only advisories do not ship, and
# blocking merges on them would make this gate churn until someone
# weakens it. (js-yaml above was dev-only; it was patched anyway because
# the range already allowed it.)
#
# Tied to the `node-version` (npm major) above, and that coupling is
# load-bearing. On npm 11, `npm audit --audit-level=critical` was
# observed to exit 1 against a tree whose own --json output reported
# {moderate:2, high:1, critical:0} — i.e. the level stops gating the
# exit code: the line below silently becomes "block on ANY advisory,
# including low", which will wedge every merge on something unfixable
# and get this gate weakened rather than debugged.
# If you bump Node here, re-check this step's exit code first.
# Re-checked 2026-08-31 for the 20->24 bump (npm 11.x): the tree is
# clean at every level (npm audit --omit=dev --json -> total 0), so
# the command below exits 0. The npm 11 caveat still stands the day
# an advisory lands: low/moderate will block too — fix or override
# the advisory per the ladder below; do not lower the level.
#
# WHEN THIS BLOCKS YOU and no patched version exists upstream, do NOT
# lower the level — that silently re-opens the whole class. In order of
# preference: bump the dependency; add an `overrides` entry in
# package.json pinning a patched transitive; or, if genuinely unfixable,
# land the exception as its own commit naming the advisory and why, so
# the decision is reviewable instead of inherited.
run: npm audit --audit-level=high --omit=dev
# Everything GitHub would have chained off this run, had it emitted the event.
#
# CD and Main Red Alert both trigger on `workflow_run` of CI. GitHub does not
# emit that event when the CI run was itself created with the default
# GITHUB_TOKEN — which is exactly how the auto-merge sweep re-arms CI after a
# bot merge (a GITHUB_TOKEN push triggers nothing, hence the dispatch). So on
# the automated path both consumers are dead, and dead *silently*: green CI,
# no red check, nothing deployed. That stranded 14 verified commits on main
# for eight hours on 2026-08-04 while the box served the old build.
#
# A dispatched run therefore does the handoff itself. The push path is left
# alone — its `workflow_run` fires normally, and is the better path there
# (CD downloads the exact artifact this run built). One trigger per path.
post-main:
# `verify-in-worktree` is DELIBERATELY absent here — it is not an oversight,
# do not add it. It proves agents can run `verify` locally; it does not say
# anything about whether the commit is safe to ship (build-and-smoke already
# ran the same `verify` on the same code). Blocking a deploy of verified code
# on a developer-experience gate would trade a real outage for a process bug.
# The enforcement point is PR-time: auto-merge requires EVERY check green, so
# a worktree regression cannot reach main in the first place.
needs: [build-and-smoke, security]
if: always() && github.ref == 'refs/heads/main' && github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
actions: write # dispatch cd.yml
issues: write # file/close the red-main issue
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
steps:
- uses: actions/checkout@v7
- name: Resolve this run's verdict
id: verdict
run: |
if [ "${{ needs.build-and-smoke.result }}" = "success" ] \
&& [ "${{ needs.security.result }}" = "success" ]; then
echo "conclusion=success" >> "$GITHUB_OUTPUT"
elif [ "${{ needs.build-and-smoke.result }}" = "cancelled" ] \
|| [ "${{ needs.security.result }}" = "cancelled" ]; then
echo "conclusion=cancelled" >> "$GITHUB_OUTPUT"
else
echo "conclusion=failure" >> "$GITHUB_OUTPUT"
fi
- name: File or resolve the main-red issue
env:
CONCLUSION: ${{ steps.verdict.outputs.conclusion }}
RUN_SHA: ${{ github.sha }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: bash scripts/ci/main-red-alert.sh
- name: Arm CD
if: steps.verdict.outputs.conclusion == 'success'
run: bash scripts/ci/arm-cd.sh