convoy: forbidden-pattern gate + docs (briefs 3+4)

Closes out the migrate-ci-to-self-hosted convoy with the two
defensive follow-ups Brief 1+2 (PR #132) intentionally deferred.

Brief 3 — Check 8 of `forbidden-patterns` in ci.yml. Greps
`.github/workflows/` for `runs-on: ubuntu-latest` and fails unless
the match is in the documented allowlist (currently
`agent-context-drift.yml` only, per Decision D4). Self-tested
locally against the post-migration tree: 0 violations. Renames the
job from "Forbidden patterns (7 checks)" → "(8 checks)" and
normalizes the older "Check N/6" labels to "N/8" for consistency
(the inherited mix of `/6` and `/7` was a known cosmetic from the
unify-glass-panel-surfaces convoy).

Brief 4 — AGENTS.md § 6 and § 7 updates:
- § 6 "CI behavior": Playwright smoke runtime range updated to
  cover post-migration cold vs. warm cache (was a stale 59s figure
  from pre-migration ubuntu-latest).
- § 6 new top-level bullet "Self-hosted runner pool" alongside
  "CI minute optimizations" — covers where runners live, where
  caches are bind-mounted on CT 111, the Postgres rewire on
  CT 102, and the agent-context-drift.yml exemption + how Check 8
  enforces it.
- § 7 new bullet for the operational story: PAT rotation cadence
  + the D5 one-line `sed` revert path for when axiom is offline
  mid-PR-storm. Cross-references the axiom-server CT 111 README
  and the Beszel down alert.

Convoy doc — status flipped queued → shipped, shipped_in lists
both PRs, and follow_ups makes the cleanup-stale-ci-runs-cron +
seed-visual-baselines-on-linux items machine-greppable.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Randall Stillwell 2026-06-05 23:49:07 -05:00
parent f228c096fd
commit d6a2e8aff1
3 changed files with 82 additions and 11 deletions

View file

@ -1,8 +1,15 @@
--- ---
slug: migrate-ci-to-self-hosted slug: migrate-ci-to-self-hosted
status: queued status: shipped
opened: 2026-06-05 opened: 2026-06-05
shipped: 2026-06-05
owner: rstillw owner: rstillw
shipped_in:
- PR #132 (Briefs 1+2 — workflow migration + migrate-job rewire)
- PR #133 (Briefs 3+4 — forbidden-pattern gate + AGENTS.md docs) # update if PR number differs
follow_ups:
- cleanup-stale-ci-runs-cron (weekly GC on CT 102 for ci_run_* DBs older than 7d; Risk #4 defensive)
- seed-visual-baselines-on-linux (now easier with axiom; see queued-follow-ups below)
prerequisites: prerequisites:
- CT 111 (`ci-runner`) provisioned and online on axiom (`192.168.68.111`) - CT 111 (`ci-runner`) provisioned and online on axiom (`192.168.68.111`)
- 2× `axiom-runner-*` registered + Idle in Settings → Actions → Runners - 2× `axiom-runner-*` registered + Idle in Settings → Actions → Runners

View file

@ -116,7 +116,7 @@ jobs:
# (matches the previous independent-jobs behavior; nicer than # (matches the previous independent-jobs behavior; nicer than
# stop-at-first-failure). # stop-at-first-failure).
forbidden-patterns: forbidden-patterns:
name: Forbidden patterns (7 checks) name: Forbidden patterns (8 checks)
runs-on: [self-hosted, axiom] runs-on: [self-hosted, axiom]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@ -125,7 +125,7 @@ jobs:
FAIL=0 FAIL=0
# ---------- (1) No dev endpoints in pages/api ---------- # ---------- (1) No dev endpoints in pages/api ----------
echo "::group::Check 1/6: No dev endpoints in pages/api" echo "::group::Check 1/8: No dev endpoints in pages/api"
BAD_PATHS=( BAD_PATHS=(
"pages/api/simple.js" "pages/api/simple.js"
"pages/api/test-auth.js" "pages/api/test-auth.js"
@ -153,7 +153,7 @@ jobs:
echo "::endgroup::" echo "::endgroup::"
# ---------- (2) No wildcard CORS in pages/api ---------- # ---------- (2) No wildcard CORS in pages/api ----------
echo "::group::Check 2/6: No wildcard CORS in pages/api" echo "::group::Check 2/8: No wildcard CORS in pages/api"
MATCHES=$(grep -rEn 'Access-Control-Allow-(Origin|Methods|Headers)' pages/api/ 2>/dev/null || true) MATCHES=$(grep -rEn 'Access-Control-Allow-(Origin|Methods|Headers)' pages/api/ 2>/dev/null || true)
if [ -n "$MATCHES" ]; then if [ -n "$MATCHES" ]; then
echo "::error::Forbidden CORS headers present under pages/api/. Remove them — same-origin Vercel deployment does not need CORS." echo "::error::Forbidden CORS headers present under pages/api/. Remove them — same-origin Vercel deployment does not need CORS."
@ -169,7 +169,7 @@ jobs:
echo "::endgroup::" echo "::endgroup::"
# ---------- (3) No client-side LLM key leakage ---------- # ---------- (3) No client-side LLM key leakage ----------
echo "::group::Check 3/6: No client-side LLM key leakage" echo "::group::Check 3/8: No client-side LLM key leakage"
if [ -f pages/api/config/gemini.js ]; then if [ -f pages/api/config/gemini.js ]; then
echo "::error file=pages/api/config/gemini.js::Forbidden config endpoint — do not return API keys to browsers." echo "::error file=pages/api/config/gemini.js::Forbidden config endpoint — do not return API keys to browsers."
FAIL=1 FAIL=1
@ -225,7 +225,7 @@ jobs:
echo "::endgroup::" echo "::endgroup::"
# ---------- (4) No legacy modal shells ---------- # ---------- (4) No legacy modal shells ----------
echo "::group::Check 4/6: No new modal shells without <Modal> primitive" echo "::group::Check 4/8: No new modal shells without <Modal> primitive"
MODAL_FOUND=() MODAL_FOUND=()
while IFS= read -r file; do while IFS= read -r file; do
MODAL_FOUND+=("$file") MODAL_FOUND+=("$file")
@ -244,7 +244,7 @@ jobs:
echo "::endgroup::" echo "::endgroup::"
# ---------- (5) No deprecated color aliases ---------- # ---------- (5) No deprecated color aliases ----------
echo "::group::Check 5/6: No use of deprecated color aliases" echo "::group::Check 5/8: No use of deprecated color aliases"
PATTERNS=( PATTERNS=(
'gradient-text-purple' 'gradient-text-purple'
'gradient-text-pink' 'gradient-text-pink'
@ -278,7 +278,7 @@ jobs:
echo "::endgroup::" echo "::endgroup::"
# ---------- (6) No stale ownership/collection copy ---------- # ---------- (6) No stale ownership/collection copy ----------
echo "::group::Check 6/6: No stale ownership/collection copy" echo "::group::Check 6/8: No stale ownership/collection copy"
STRING_PATTERNS=( STRING_PATTERNS=(
'Mark Owned' 'Mark Owned'
'Owned Cards' 'Owned Cards'
@ -307,7 +307,7 @@ jobs:
echo "::endgroup::" echo "::endgroup::"
# ---------- (7) No bespoke var(--glass-surface-*) inline styles ---------- # ---------- (7) No bespoke var(--glass-surface-*) inline styles ----------
echo "::group::Check 7/7: No bespoke var(--glass-surface-*) inline styles" echo "::group::Check 7/8: No bespoke var(--glass-surface-*) inline styles"
# unify-glass-panel-surfaces convoy, Brief 7 (PR sequence after #126). # unify-glass-panel-surfaces convoy, Brief 7 (PR sequence after #126).
# After Briefs 1-6 land, every panel-shaped surface in the app # After Briefs 1-6 land, every panel-shaped surface in the app
# composes via .glass-panel / .glass-panel-strong / # composes via .glass-panel / .glass-panel-strong /
@ -359,12 +359,60 @@ jobs:
fi fi
echo "::endgroup::" echo "::endgroup::"
# ---------- (8) No drift back to `runs-on: ubuntu-latest` ----------
# migrate-ci-to-self-hosted convoy, Brief 3 (2026-06-05). After
# Brief 1+2 moved 4 of 5 workflows to `[self-hosted, axiom]` on
# CT 111, this check is the regression gate: anything other than
# `agent-context-drift.yml` reintroducing `runs-on: ubuntu-latest`
# silently re-bills GitHub Actions minutes.
#
# Allowlist (single entry, explicit):
# - .github/workflows/agent-context-drift.yml
# That file's weekly cron stays on `ubuntu-latest` per Decision D4
# of the convoy: trading ~2 min/month of billed CI for resilience
# so the cron runs even when axiom is down for maintenance.
#
# Operator override: if you DO need to add a fourth always-billed
# workflow (rare — discuss it in a convoy first), append the file
# to RUNS_ON_ALLOWLIST below in the same PR.
echo "::group::Check 8/8: No drift back to runs-on: ubuntu-latest outside agent-context-drift.yml"
RUNS_ON_ALLOWLIST=(
".github/workflows/agent-context-drift.yml"
)
RUNS_ON_FOUND=()
while IFS= read -r match; do
file=$(echo "$match" | cut -d: -f1)
skip=false
for allowed in "${RUNS_ON_ALLOWLIST[@]}"; do
if [ "$file" = "$allowed" ]; then
skip=true
break
fi
done
if [ "$skip" = true ]; then
continue
fi
RUNS_ON_FOUND+=("$match")
done < <(grep -rEn '^\s*runs-on:\s*ubuntu-latest' .github/workflows/ 2>/dev/null || true)
if [ ${#RUNS_ON_FOUND[@]} -gt 0 ]; then
echo "::error::Forbidden \`runs-on: ubuntu-latest\` outside the documented allowlist (currently agent-context-drift.yml only). Use \`runs-on: [self-hosted, axiom]\` to keep CI on the homelab pool (CT 111). See .convoys/migrate-ci-to-self-hosted.md and AGENTS.md § 7 for the revert path if axiom is down."
for match in "${RUNS_ON_FOUND[@]}"; do
file=$(echo "$match" | cut -d: -f1)
lineno=$(echo "$match" | cut -d: -f2)
echo "::error file=${file},line=${lineno}::Replace with [self-hosted, axiom] or add this file to the RUNS_ON_ALLOWLIST in ci.yml."
done
FAIL=1
else
echo "OK: no drift to ubuntu-latest outside the allowlist."
fi
echo "::endgroup::"
# ---------- Final exit ---------- # ---------- Final exit ----------
if [ "$FAIL" -ne 0 ]; then if [ "$FAIL" -ne 0 ]; then
echo "::error::One or more forbidden-pattern checks failed. See annotations above." echo "::error::One or more forbidden-pattern checks failed. See annotations above."
exit 1 exit 1
fi fi
echo "All 7 forbidden-pattern checks passed." echo "All 8 forbidden-pattern checks passed."
migrate: migrate:
name: Migrations apply (node-pg-migrate) name: Migrations apply (node-pg-migrate)

View file

@ -163,13 +163,19 @@ Code graph is indexed by `user-code-review-graph` MCP (122 files, 628 nodes, 560
Mac-generated baselines will NOT match Linux CI — `playwright.config.js`'s custom `snapshotPathTemplate` has no `{platform}` token, so a Mac update silently overwrites the canonical Linux baseline. Tracked as the queued `seed-visual-baselines-on-linux` convoy (see `.convoys/ship-readiness.md` § Queued convoys). Mac-generated baselines will NOT match Linux CI — `playwright.config.js`'s custom `snapshotPathTemplate` has no `{platform}` token, so a Mac update silently overwrites the canonical Linux baseline. Tracked as the queued `seed-visual-baselines-on-linux` convoy (see `.convoys/ship-readiness.md` § Queued convoys).
- **CI behavior:** - **CI behavior:**
- **Vitest:** the `test:` job in `.github/workflows/ci.yml` runs `npm run test:run` on every PR and push to `main` and is **blocking** (no `|| true`, no `continue-on-error`). A red test job blocks merge. - **Vitest:** the `test:` job in `.github/workflows/ci.yml` runs `npm run test:run` on every PR and push to `main` and is **blocking** (no `|| true`, no `continue-on-error`). A red test job blocks merge.
- **Playwright smoke:** runs on every PR via `preview-smoke.yml`. Gate skip via `pipeline: skip smoke` in the PR body (handled in the `gate:` job's Decide step via env-var routing — see § 7's shell-injection note). Last measured runtime: 59s end-to-end, 3/3 tests pass in 2.9s (PR #18 post-merge run). - **Playwright smoke:** runs on every PR via `preview-smoke.yml`. Gate skip via `pipeline: skip smoke` in the PR body (handled in the `gate:` job's Decide step via env-var routing — see § 7's shell-injection note). Pre-migration runtime: 59s end-to-end on `ubuntu-latest` (PR #18 post-merge run). Post-migration on the axiom pool: cold-cache first run ~6 min (Chromium download); warm cache thereafter ~12 min.
- **Screenshot diff:** runs only on PRs touching `pages/**` / `components/**` / `styles/**` / `tailwind.config.js` / `postcss.config.js` via `visual-diff.yml`. **First `Screenshot diff` run after `adopt-playwright-smoke` will fail at the test step** because no baseline exists yet; `continue-on-error: true` swallows the failure and the comment-on-PR step posts "Visual Diff — view run" with empty artifacts. That is the documented Decision-4 end state of `adopt-playwright-smoke`, not a regression — it stays that way until `seed-visual-baselines-on-linux` lands. - **Screenshot diff:** runs only on PRs touching `pages/**` / `components/**` / `styles/**` / `tailwind.config.js` / `postcss.config.js` via `visual-diff.yml`. **First `Screenshot diff` run after `adopt-playwright-smoke` will fail at the test step** because no baseline exists yet; `continue-on-error: true` swallows the failure and the comment-on-PR step posts "Visual Diff — view run" with empty artifacts. That is the documented Decision-4 end state of `adopt-playwright-smoke`, not a regression — it stays that way until `seed-visual-baselines-on-linux` lands.
- **CI minute optimizations (slash-ci-minutes convoy, 2026-06-04):** - **CI minute optimizations (slash-ci-minutes convoy, 2026-06-04):**
- **Doc-only PRs skip ALL of ci.yml + preview-smoke.yml.** Both workflows carry `paths-ignore` for `.convoys/**`, `**/*.md`, `docs/**`, `AGENTS.md`, `.cursor/**`, and `README.md`. A pure-docs PR triggers zero GitHub Actions jobs (Vercel still builds — it's not on the Actions billing). `visual-diff.yml` was already cost-conscious via a positive `paths:` allowlist and is unchanged. - **Doc-only PRs skip ALL of ci.yml + preview-smoke.yml.** Both workflows carry `paths-ignore` for `.convoys/**`, `**/*.md`, `docs/**`, `AGENTS.md`, `.cursor/**`, and `README.md`. A pure-docs PR triggers zero GitHub Actions jobs (Vercel still builds — it's not on the Actions billing). `visual-diff.yml` was already cost-conscious via a positive `paths:` allowlist and is unchanged.
- **The 6 grep-only forbidden-* jobs collapsed into one.** They previously ran as 6 independent jobs (each with its own `actions/checkout`); the consolidated `forbidden-patterns` job runs all 6 checks as labeled `::group::` sections in a single bash step, with a FAIL flag at the bottom so every violation across all 6 checks still surfaces in one run (same diagnostic behavior, ~5/6 of the per-PR checkout overhead removed). The 6 original job names (`forbidden-endpoints`, `forbidden-cors-headers`, `forbidden-client-side-llm-keys`, `forbidden-modal-shell-without-primitive`, `forbidden-deprecated-color-aliases`, `forbidden-stale-strings`) no longer appear in the checks list — references in this file (e.g. CI job `forbidden-stale-strings` blocks ...) are now informational, not check-name lookups. `pr-health-rollup.yml` was unaffected because it only looks up `Lint` and `Schema map up to date` by name. - **The 6 grep-only forbidden-* jobs collapsed into one.** They previously ran as 6 independent jobs (each with its own `actions/checkout`); the consolidated `forbidden-patterns` job runs all 6 checks as labeled `::group::` sections in a single bash step, with a FAIL flag at the bottom so every violation across all 6 checks still surfaces in one run (same diagnostic behavior, ~5/6 of the per-PR checkout overhead removed). The 6 original job names (`forbidden-endpoints`, `forbidden-cors-headers`, `forbidden-client-side-llm-keys`, `forbidden-modal-shell-without-primitive`, `forbidden-deprecated-color-aliases`, `forbidden-stale-strings`) no longer appear in the checks list — references in this file (e.g. CI job `forbidden-stale-strings` blocks ...) are now informational, not check-name lookups. `pr-health-rollup.yml` was unaffected because it only looks up `Lint` and `Schema map up to date` by name.
- **`node_modules` cached between runs** in lint / test / migrate / preview-smoke / visual-diff. Keyed on `package-lock.json` hash so any dep change invalidates correctly. Cuts `npm ci` from ~30-45s to ~3-5s on cache hit. `actions/setup-node@v4`'s built-in `cache: npm` is layered above this (caches `~/.npm`) — both stay because the `setup-node` cache helps on cache-miss days too. - **`node_modules` cached between runs** in lint / test / migrate / preview-smoke / visual-diff. Keyed on `package-lock.json` hash so any dep change invalidates correctly. Cuts `npm ci` from ~30-45s to ~3-5s on cache hit. `actions/setup-node@v4`'s built-in `cache: npm` is layered above this (caches `~/.npm`) — both stay because the `setup-node` cache helps on cache-miss days too.
- **Playwright browsers cached** in `preview-smoke.yml` + `visual-diff.yml`. Keyed on the resolved `@playwright/test` version from `package-lock.json`. Cache invalidates automatically on any Playwright version bump. On cache hit, only the system deps install (`npx playwright install-deps chromium`) runs — saves ~15-25s/run. - **Playwright browsers cached** in `preview-smoke.yml` + `visual-diff.yml`. Keyed on the resolved `@playwright/test` version from `package-lock.json`. Cache invalidates automatically on any Playwright version bump. On cache hit, only the system deps install (`npx playwright install-deps chromium`) runs — saves ~15-25s/run.
- **Self-hosted runner pool (migrate-ci-to-self-hosted convoy, 2026-06-05):**
- **4 of 5 workflows run on the axiom homelab.** `ci.yml`, `preview-smoke.yml`, `visual-diff.yml`, `pr-health-rollup.yml` use `runs-on: [self-hosted, axiom]` and execute on CT 111 in the `axiom-server` Proxmox homelab (`axiom-runner-1..4`, registered org-scoped to `stwl-labs`, ephemeral one-job-per-container via `myoung34/github-runner`). Net effect: tcg-vault CI no longer consumes GitHub Actions minutes for those four workflows.
- **`agent-context-drift.yml` deliberately stays on `ubuntu-latest`** per Decision D4 of the convoy — it's a weekly cron, costs ~2 min/month, and must run even when axiom is down for maintenance. **Check 8 of `forbidden-patterns` enforces this** as a strict allowlist (anything else reintroducing `runs-on: ubuntu-latest` fails CI).
- **Cache mounts live on the CT 111 host** and are bind-mounted into every runner container, so they survive across the ephemeral-runner lifecycle and are shared across `axiom-runner-1..4`. Paths (on CT 111): `/opt/appdata/gha-runner/shared-cache/{npm,pnpm,yarn,pip,playwright,buildx}` and the per-runner workdirs under `/opt/appdata/gha-runner/runner-N/`. The `actions/cache@v4` keys above still apply on top — the bind mounts just keep the underlying tooling caches (`~/.npm`, `~/.cache/ms-playwright`) primed across jobs.
- **Migrate job uses CT 102 shared Postgres** instead of an in-runner `services.postgres` container. `HOMELAB_CI_POSTGRES_PASSWORD` repo secret (password only — `PGHOST`/`PGUSER`/`PGPORT` are hardcoded in `ci.yml`). Each run creates a per-run database named `ci_run_${run_id}_${run_attempt}` and drops it in an `if: always()` cleanup step so failed migrations don't leak DBs. The `deckhearth_ci` role has `CREATEDB` but no superuser; a compromised runner can't reach other apps' databases on CT 102.
- **Cross-references:** convoy decisions + risks in [`.convoys/migrate-ci-to-self-hosted.md`](.convoys/migrate-ci-to-self-hosted.md); homelab-side infra in `axiom-server/proxmox/ct111/README.md`; revert path in § 7 below.
- **Manual QA:** `TESTING_GUIDE.md` still applies for flows not yet covered by automated tests (scanner camera path, card-import jobs, multi-step UI wizards). The automated smoke + visual suite is steadily eclipsing it; `TESTING_GUIDE.md` will be renamed to `docs/MANUAL_QA.md` and trimmed to truly-manual-only flows in a future cleanup convoy (see `.convoys/ship-readiness.md` § Role-doc-writer findings). - **Manual QA:** `TESTING_GUIDE.md` still applies for flows not yet covered by automated tests (scanner camera path, card-import jobs, multi-step UI wizards). The automated smoke + visual suite is steadily eclipsing it; `TESTING_GUIDE.md` will be renamed to `docs/MANUAL_QA.md` and trimmed to truly-manual-only flows in a future cleanup convoy (see `.convoys/ship-readiness.md` § Role-doc-writer findings).
## 7. Deployment ## 7. Deployment
@ -183,6 +189,16 @@ Code graph is indexed by `user-code-review-graph` MCP (122 files, 628 nodes, 560
Do not log or echo the value. If the operator rotates the token in the Vercel dashboard, re-seed the GitHub secret via `gh secret set VERCEL_AUTOMATION_BYPASS_SECRET --body "<new value>"`. See `.convoys/fix-vercel-deployment-protection-in-ci.md` and `.convoys/adopt-playwright-smoke.md`. Do not log or echo the value. If the operator rotates the token in the Vercel dashboard, re-seed the GitHub secret via `gh secret set VERCEL_AUTOMATION_BYPASS_SECRET --body "<new value>"`. See `.convoys/fix-vercel-deployment-protection-in-ci.md` and `.convoys/adopt-playwright-smoke.md`.
- **Shell-injection hardening in workflow YAML.** Never inline `${{ github.event.* }}` directly into a `run:` block — route the value through the step's `env:` block and quote it (`"$VAR_NAME"`) in shell. PR #17's CI validation caught a real syntax error from a PR body containing `(` because the gate-job's Decide step inlined `${{ github.event.pull_request.body }}` straight into bash; commit `b6f8688` swept both `preview-smoke.yml` and `visual-diff.yml` to the `env:` + quoted-shell pattern. This is GitHub's official Security Hardening guidance ("Security hardening for GitHub Actions" → "Using a third-party action"). Apply to any new workflow that reads PR body / title / branch name / commit messages in shell. - **Shell-injection hardening in workflow YAML.** Never inline `${{ github.event.* }}` directly into a `run:` block — route the value through the step's `env:` block and quote it (`"$VAR_NAME"`) in shell. PR #17's CI validation caught a real syntax error from a PR body containing `(` because the gate-job's Decide step inlined `${{ github.event.pull_request.body }}` straight into bash; commit `b6f8688` swept both `preview-smoke.yml` and `visual-diff.yml` to the `env:` + quoted-shell pattern. This is GitHub's official Security Hardening guidance ("Security hardening for GitHub Actions" → "Using a third-party action"). Apply to any new workflow that reads PR body / title / branch name / commit messages in shell.
- **CI runs on the axiom homelab (CT 111).** Four of the five workflows execute on `[self-hosted, axiom]` runners managed in the `axiom-server` repo (`proxmox/ct111/`). Day-to-day this is invisible — pushes still trigger jobs and Vercel still builds previews — but two operational notes matter:
1. **PAT rotation.** The runners authenticate to GitHub via an org-scoped PAT stored on CT 111 at `/opt/appdata/gha-runner/.env` (key `GH_PAT`, scopes `admin:org`, `repo`, `workflow`). Rotate every 90 days. After updating the value on CT 111, run `./proxmox/scripts/sync.sh restart 111` to re-register all 4 runners. If the PAT lapses silently, new jobs fail registration immediately; check `./proxmox/scripts/sync.sh logs 111 gha-runner-1` for `Http response code: NotFound` to confirm.
2. **1-line revert path (D5) — when axiom is offline mid-PR-storm.** If CT 111 is down for maintenance, hardware swap, or any reason, and a hot fix needs CI to land, swap every `[self-hosted, axiom]` back to `ubuntu-latest`:
```
sed -i '' 's/\[self-hosted, axiom\]/ubuntu-latest/g' .github/workflows/*.yml
# macOS sed needs the empty -i '' argument; on Linux it's `sed -i 's/...//g' ...`.
```
This re-bills GitHub Actions minutes for the duration of the outage. Commit the change directly to `main` (or to the affected PR's branch), let CI run, and revert the sed result once axiom is back. The `forbidden-patterns` Check 8 will block the next normal PR until the revert lands — that's intentional: the gate exists exactly to surface this drift, not to silently re-bill minutes for weeks. Beszel alerts on CT 111 down (axiom-server CT 101) so you usually know before a PR notices.
## 8. Code graph ## 8. Code graph