From d6a2e8aff147183f41a7b1f35fe45a70778f7e39 Mon Sep 17 00:00:00 2001 From: Randall Stillwell Date: Fri, 5 Jun 2026 23:49:07 -0500 Subject: [PATCH 1/2] convoy: forbidden-pattern gate + docs (briefs 3+4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .convoys/migrate-ci-to-self-hosted.md | 9 +++- .github/workflows/ci.yml | 66 +++++++++++++++++++++++---- AGENTS.md | 18 +++++++- 3 files changed, 82 insertions(+), 11 deletions(-) diff --git a/.convoys/migrate-ci-to-self-hosted.md b/.convoys/migrate-ci-to-self-hosted.md index d6c3d2b..cabb722 100644 --- a/.convoys/migrate-ci-to-self-hosted.md +++ b/.convoys/migrate-ci-to-self-hosted.md @@ -1,8 +1,15 @@ --- slug: migrate-ci-to-self-hosted -status: queued +status: shipped opened: 2026-06-05 +shipped: 2026-06-05 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: - CT 111 (`ci-runner`) provisioned and online on axiom (`192.168.68.111`) - 2× `axiom-runner-*` registered + Idle in Settings → Actions → Runners diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 119fbeb..3ac5391 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -116,7 +116,7 @@ jobs: # (matches the previous independent-jobs behavior; nicer than # stop-at-first-failure). forbidden-patterns: - name: Forbidden patterns (7 checks) + name: Forbidden patterns (8 checks) runs-on: [self-hosted, axiom] steps: - uses: actions/checkout@v4 @@ -125,7 +125,7 @@ jobs: FAIL=0 # ---------- (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=( "pages/api/simple.js" "pages/api/test-auth.js" @@ -153,7 +153,7 @@ jobs: echo "::endgroup::" # ---------- (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) if [ -n "$MATCHES" ]; then 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::" # ---------- (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 echo "::error file=pages/api/config/gemini.js::Forbidden config endpoint — do not return API keys to browsers." FAIL=1 @@ -225,7 +225,7 @@ jobs: echo "::endgroup::" # ---------- (4) No legacy modal shells ---------- - echo "::group::Check 4/6: No new modal shells without primitive" + echo "::group::Check 4/8: No new modal shells without primitive" MODAL_FOUND=() while IFS= read -r file; do MODAL_FOUND+=("$file") @@ -244,7 +244,7 @@ jobs: echo "::endgroup::" # ---------- (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=( 'gradient-text-purple' 'gradient-text-pink' @@ -278,7 +278,7 @@ jobs: echo "::endgroup::" # ---------- (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=( 'Mark Owned' 'Owned Cards' @@ -307,7 +307,7 @@ jobs: echo "::endgroup::" # ---------- (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). # After Briefs 1-6 land, every panel-shaped surface in the app # composes via .glass-panel / .glass-panel-strong / @@ -359,12 +359,60 @@ jobs: fi 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 ---------- if [ "$FAIL" -ne 0 ]; then echo "::error::One or more forbidden-pattern checks failed. See annotations above." exit 1 fi - echo "All 7 forbidden-pattern checks passed." + echo "All 8 forbidden-pattern checks passed." migrate: name: Migrations apply (node-pg-migrate) diff --git a/AGENTS.md b/AGENTS.md index 4c598e4..cc14087 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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). - **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. - - **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 ~1–2 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. - **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. - **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. - **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). ## 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 ""`. 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. +- **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 -- 2.45.2 From 9a9614983e67818503414f0e7a883146079d8baa Mon Sep 17 00:00:00 2001 From: Randall Stillwell Date: Fri, 5 Jun 2026 23:49:39 -0500 Subject: [PATCH 2/2] chore: drop placeholder comment now PR #133 number is known Co-authored-by: Cursor --- .convoys/migrate-ci-to-self-hosted.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.convoys/migrate-ci-to-self-hosted.md b/.convoys/migrate-ci-to-self-hosted.md index cabb722..670d99c 100644 --- a/.convoys/migrate-ci-to-self-hosted.md +++ b/.convoys/migrate-ci-to-self-hosted.md @@ -6,7 +6,7 @@ shipped: 2026-06-05 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 + - PR #133 (Briefs 3+4 — forbidden-pattern gate + AGENTS.md docs) 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) -- 2.45.2