Moves 4 of 5 GitHub Actions workflows from `ubuntu-latest` to the new `stwl-labs` org-level self-hosted pool (CT 111 axiom-runner-1..4) and rewires the `migrate` job to use CT 102's shared Postgres via per-run databases. Changes: - ci.yml: lint, schema-map-fresh, forbidden-patterns, migrate, test -> `[self-hosted, axiom]`. migrate job drops `services.postgres` (saved ~30s/run of image pull) and switches to `HOMELAB_CI_POSTGRES_BASE_URL` secret + per-run DB (`ci_run_<run_id>_<run_attempt>`) with `always()` cleanup so failed migrations don't leak DBs. - preview-smoke.yml: gate + smoke -> self-hosted. Playwright browser cache lives under /opt/appdata/gha-runner/shared-cache/playwright on the host bind mount; first PR primes it, subsequent runs reuse. - visual-diff.yml: gate + visual -> self-hosted (same Playwright cache). - pr-health-rollup.yml: rollup -> self-hosted. - agent-context-drift.yml: deliberately LEFT on ubuntu-latest (D4 in convoy doc). Weekly cron stays GitHub-hosted so it runs even when axiom is down. Why on this side and not the runner side: - migrate adds an explicit `sudo apt-get install -y postgresql-client` step (~10s, amortized via apt-cache survival). The runner image doesn't ship psql; baking it in would require a custom image and doesn't earn its keep for one job. Repo prereqs (set before this PR opens): - `HOMELAB_CI_POSTGRES_BASE_URL` repo secret set (value pattern: `postgres://deckhearth_ci:<pw>@192.168.68.102:5432`) - `deckhearth_ci` Postgres user created on CT 102 with CREATEDB, no superuser - stwl-labs org Actions settings: "Require approval for all outside collaborators" + runner group rejects public repos - 4 runners online: `axiom-runner-1..4`, status Idle Follow-ups (per convoy): - Brief 3: forbidden-pattern gate to catch `runs-on: ubuntu-latest` re-introduction outside the agent-context-drift allowlist - Brief 4: AGENTS.md updates + 1-line revert path (D5) - Weekly cron on CT 102 to GC any `ci_run_*` DBs older than 7d (Risk #4 mitigation) Co-authored-by: Cursor <cursoragent@cursor.com>
9.6 KiB
slug: migrate-ci-to-self-hosted status: queued opened: 2026-06-05 owner: rstillw prerequisites:
- CT 111 (
ci-runner) provisioned and online on axiom (192.168.68.111) - 2×
axiom-runner-*registered + Idle in Settings → Actions → Runners deckhearth_ciPostgres user + tracking script wired on CT 102HOMELAB_CI_POSTGRES_BASE_URLset as a GitHub Actions repo secret (value:postgres://deckhearth_ci:<pw>@192.168.68.102:5432)- Repo Settings → Actions → General → "Require approval for all outside collaborators" = enabled related_axiom_artifacts:
- axiom-server/proxmox/ct111/docker-compose.yml
- axiom-server/proxmox/ct111/.env.example
- axiom-server/proxmox/ct111/README.md
- axiom-server/.cursor/rules/ct111-ci-runner.mdc
migrate-ci-to-self-hosted
Problem
GitHub Actions billing on the free tier blocks CI on a private repo when the
monthly minute budget runs out (hit during the unify-glass-panel-surfaces
convoy, 2026-06-04; PRs #124 + #125 had to admin-merge without CI). The
slash-ci-minutes convoy (PR #126) reduced consumption by ~60% via
paths-ignore, grep consolidation, and caching, but a busy week of
implementation work still trips the limit.
This convoy migrates all 4 GitHub Actions workflows off ubuntu-latest
(GitHub-hosted, billed) onto the axiom homelab runner (CT 111,
self-hosted, free). It also rewires the migrate job to use CT 102's
shared Postgres instead of spinning up an ephemeral container per run —
saving ~30s/PR and eliminating the Docker-in-runner pull cost.
Non-goals
- Migrating to a different CI provider (CircleCI, Buildkite, etc.) — overkill.
- Hosting the production app on axiom — Vercel keeps the deploy story simple and the homelab is already at ~95% RAM allocation. Out of scope.
- Replacing the Vercel Preview deployments — Vercel still builds previews; Playwright smoke + visual-diff still run against those previews from the self-hosted runner.
Workflows to migrate
| File | Jobs | Notes |
|---|---|---|
.github/workflows/ci.yml |
lint, schema-map-fresh, forbidden-patterns, migrate, test | migrate needs the Postgres rewire (see below) |
.github/workflows/preview-smoke.yml |
gate, smoke | smoke job needs Chromium — first run will npx playwright install and cache it |
.github/workflows/visual-diff.yml |
(visual) | same Chromium cache benefit; baselines still TBD |
.github/workflows/pr-health-rollup.yml |
rollup | trivial — single gh pr comment job |
.github/workflows/agent-context-drift.yml |
(weekly cron) | Optional: leave on ubuntu-latest so the cron runs even when axiom is down. Decision deferred — see Risk #3 |
Change shape per job:
# Before
jobs:
lint:
runs-on: ubuntu-latest
# After
jobs:
lint:
runs-on: [self-hosted, axiom]
Migrate-job Postgres rewire
Today (ci.yml § migrate):
services:
postgres:
image: postgres:16
env: { POSTGRES_USER: postgres, POSTGRES_PASSWORD: postgres, POSTGRES_DB: deckhearth_test }
ports: ["5432:5432"]
env:
POSTGRES_URL: postgres://postgres:postgres@localhost:5432/deckhearth_test
Proposed:
env:
# HOMELAB_CI_POSTGRES_BASE_URL = postgres://deckhearth_ci:<pw>@192.168.68.102:5432
# (no DB name — we create a per-run DB to keep parallel runs isolated)
PGBASE: ${{ secrets.HOMELAB_CI_POSTGRES_BASE_URL }}
DBNAME: ci_run_${{ github.run_id }}_${{ github.run_attempt }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20', cache: npm }
- name: Cache node_modules
uses: actions/cache@v4
with:
path: node_modules
key: node-modules-${{ runner.os }}-node20-${{ hashFiles('package-lock.json') }}
- run: npm ci
- name: Create per-run database
run: |
psql "$PGBASE/postgres" -c "CREATE DATABASE \"$DBNAME\";"
echo "POSTGRES_URL=$PGBASE/$DBNAME" >> .env.local
- run: npm run migrate up
- name: Drop per-run database (always)
if: always()
run: psql "$PGBASE/postgres" -c "DROP DATABASE IF EXISTS \"$DBNAME\";"
Why per-run DB:
- Two PRs migrating in parallel don't collide.
- A failed migration leaves a dirty DB behind —
if: always()ensures cleanup. - Naming with
run_id+run_attemptis collision-free even with re-runs. psqlis available on Debian 13 — addpostgresql-clientto CT 111's install if not already present (see axiom-server CT 111 README).
Architecture decisions to ratify
- D1. Use
runs-on: [self-hosted, axiom](not[self-hosted]alone) so that if another runner is ever added with a differentaxiom-*label, these workflows still match correctly. - D2. Keep
ubuntu-latestas the literal string in ZERO workflow files after migration — make CT 111 a hard dependency rather than a soft one. Rationale: dual-mode workflows hide drift (e.g. cache-key OS mismatch when swapping between the two). Single mode is simpler to reason about; if axiom is down, the operator runs the 1-line revert (D5). - D3. Rewire
migrateto per-run DB on CT 102 (see above), NOT keep the ephemeralservices.postgresblock. The shared Postgres is already there and underutilized; theservices:block on a self-hosted runner requires Docker-in-runner which adds 30s of pull/start time per run. - D4. Leave
agent-context-drift.ymlonubuntu-latest. It's a weekly cron, costs ~2 min/month, and runs independently of axiom uptime. Trading $0 for resilience is a good trade here. - D5. Document a 1-line revert path in
AGENTS.md§ 7 Deployment:sed -i 's/\[self-hosted, axiom\]/ubuntu-latest/g' .github/workflows/*.ymlfor the case where axiom is offline mid-PR-storm and we need GitHub-hosted fallback fast. Operator manually re-enables billing or accepts the consumption for that day.
Risks
| # | Risk | Likelihood | Mitigation |
|---|---|---|---|
| 1 | CT 111 down → PRs queue indefinitely | medium | Beszel alerts on CT 111 down; D5 revert path documented |
| 2 | PAT expires silently → new jobs fail registration | medium | Calendar reminder at PAT mint time (90 days); runner logs surface failure on next restart |
| 3 | Malicious PR exfiltrates from runner | low (repo-scoped, "require approval" enabled) | Repo Settings gate; runner has no creds beyond secrets.HOMELAB_CI_POSTGRES_BASE_URL (scoped to deckhearth_ci schema, no other DB access) |
| 4 | Per-run DB litter on CT 102 if if: always() cleanup itself fails |
low | Add a weekly cron on CT 102: psql ... -c "DROP DATABASE IF EXISTS …" FOREACH ci_run_* older than 7d |
| 5 | Cache poisoning across runs (shared ~/.npm between runner-1 and runner-2) |
low | npm ci validates against package-lock.json checksum; corrupt cache is self-healing |
| 6 | Two ephemeral runners insufficient for peak load (5+ jobs per PR) | medium | Add runner-3: block in CT 111 compose; ~200 MB RAM per slot |
| 7 | Workflow file regressions (drift back to ubuntu-latest) |
low | Add a forbidden-patterns check (8th gate): grep -rE "^\s*runs-on:\s*ubuntu-latest" .github/workflows/ should match only the agent-context-drift cron |
Decomposition (proposed briefs)
- Brief 1 — Workflow migration. Single PR. Find/replace
runs-on: ubuntu-latest→runs-on: [self-hosted, axiom]inci.yml,preview-smoke.yml,visual-diff.yml,pr-health-rollup.yml. Leaveagent-context-drift.ymluntouched (D4). Add a HOMELAB_CI_POSTGRES_BASE_URL secret to the repo before the PR opens (otherwisemigratejob will fail on first run). - Brief 2 — Migrate-job rewire. Same PR or split? Recommend SAME PR —
the migrate job is part of
ci.yml, splitting introduces a window where migrate runs on the self-hosted runner with no Postgres. Keep coupled. - Brief 3 — Forbidden-pattern gate (Risk #7). Add 8th check to the
forbidden-patternsjob:runs-on: ubuntu-latestis only allowed inagent-context-drift.yml. Cheap insurance against drift. - Brief 4 — Documentation. Update
AGENTS.md§ 6 Testing + § 7 Deployment with the self-hosted runner story + the D5 revert path. Addaxiom-server/proxmox/ct111/README.mdas a cross-reference.
Briefs 1 + 2 are tightly coupled — recommend single combined PR. Briefs 3 + 4 are independent and can dispatch in parallel after Brief 1+2 lands.
Validation
After Brief 1+2 merges:
- Open a no-op PR (e.g. add a trailing newline to
README.md— wait, that hitspaths-ignore. Use a 1-line code comment inpages/index.jsinstead). - Confirm in the PR's Checks tab: all jobs report
In progress on axiom-runner-1oraxiom-runner-2within 10s of dispatch. - Confirm GitHub Actions billing page shows zero minute consumption for that PR.
- SSH
axiomand verify the per-run DB was created + dropped:./proxmox/scripts/sync.sh exec 102 "docker exec postgres psql -U postgres -c '\l'"— noci_run_*databases should linger.
Acceptance
- 4/4 workflows green on self-hosted runner.
- GitHub Actions minute consumption drops to ~0 (only the weekly
agent-context-driftcron remains onubuntu-latest). - Forbidden-pattern gate (8th check) catches accidental
runs-on: ubuntu-latestreintroduction. - AGENTS.md § 6/7 updated; CT 111 README cross-referenced.
Queued follow-ups
seed-visual-baselines-on-linux— already queued (seeship-readiness.md). With axiom now in the loop, this gets easier: baselines can generate on CT 111 directly vianpm run test:visual:updateagainst a preview deploy, producing Linux-compatible PNGs the CI runner will match exactly.cleanup-stale-ci-runs-cron— weekly cron on CT 102 to dropci_run_*DBs older than 7d (Risk #4 mitigation, defensive).