ADR-0098 — Self-hosted runner opt-in pattern (zero-disruption fallback)¶
- Status: Accepted
- Date: 2026-05-25
- Deciders: André Luiz Gallon
- Driver: ADR-0097 §Carry-forward #10 — cut residual recurring GitHub Actions cost to ~zero
- Related: ADR-0097 (10 cost-cut levers, ~60-75% reduction); PR #1090 (cost-cut wave)
- Operator-facing runbook:
docs/runbooks/self-hosted-runner-setup.md
Context¶
ADR-0097 cut recurring Actions cost ~60-75% via cron/path/concurrency/draft/matrix changes. The residual cost is the 6 heavy workflows that genuinely need to run regularly:
go-security.yml— 47-module matrix (CORE 8 weekly + FULL 47 monthly)image-scan.yml— 3-image Docker build + trivy scancodeql.yml— JS/TS deep static analysis weeklyztp-prem-tier-b-obfuscation.yml— garble + obfuscation tests weeklyztp-prem-sod-audit.yml— separation-of-duties weekly auditdashboard-visual.yml— playwright + screenshot regression weekly
These can't be cut further without losing coverage. The cost lever left is hardware: move heavy crons to a runner the operator owns.
The risk is two-fold:
- Disruption — if the migration is one-shot (workflows explicitly target
self-hosted), the day the operator hasn't set up the runner yet → all heavy workflows fail. Bad for adoption. - Fork PR security — self-hosted runners can be a vector if untrusted code from a forked PR lands on the operator's machine. GitHub's docs flag this as the #1 self-hosted runner risk.
The decision is how to migrate safely.
Decision¶
Pattern: opt-in via repo variable, zero-disruption fallback, fork-PR-safe¶
Every heavy workflow's runs-on: becomes:
runs-on: ${{ github.event.pull_request.head.repo.fork && 'ubuntu-latest' || fromJSON(vars.HEAVY_RUNNER || '"ubuntu-latest"') }}
For schedule/workflow_dispatch-only workflows (no PR trigger, no fork risk), the simpler:
runs-on: ${{ fromJSON(vars.HEAVY_RUNNER || '"ubuntu-latest"') }}
Semantics:
| Condition | runs-on resolves to |
|---|---|
HEAVY_RUNNER unset (default) |
ubuntu-latest (GitHub-hosted) |
HEAVY_RUNNER set, fork PR |
ubuntu-latest (security override) |
HEAVY_RUNNER set, internal PR/push/schedule |
fromJSON(vars.HEAVY_RUNNER) — the operator's labels |
The operator opts in by:
1. Registering a self-hosted runner with label heavy (instructions in the runbook).
2. Setting repo variable HEAVY_RUNNER = ["self-hosted", "Linux", "X64", "heavy"].
That's it. No code change. No PR. Reversible via variable delete.
Scope: 6 workflows migrated, all others stay GitHub-hosted¶
Migrated:
- go-security.yml (govulncheck + gosec jobs; discover job stays on GitHub-hosted — it's trivial)
- image-scan.yml (trivy job)
- codeql.yml (analyze job)
- ztp-prem-tier-b-obfuscation.yml (obfuscate matrix; summary job stays GitHub-hosted)
- ztp-prem-sod-audit.yml (sod-audit job)
- dashboard-visual.yml (visual job)
Explicitly NOT migrated (and why):
- ci.yml — primary PR gate, needs sterile ephemeral isolation; runs constantly so steady-state cost is high → but it's the surface where contributors interact and trust matters most
- secret-scan.yml — sterile env mandatory; gitleaks must run uncontaminated
- release.yml / tag-signature-verify.yml — release signing events; sterile env mandatory
- forensic-tamper-check.yml — reads sensitive secret; sterile env
- dco-check.yml / changelog-fragment-check.yml / check-file-size.yml / doc-audit.yml / module-conformance.yml — tiny + fast on GitHub-hosted; queueing overhead on self-hosted would cost more than running on cloud
Documentation¶
Operator-facing runbook lands at docs/runbooks/self-hosted-runner-setup.md — 5 steps from apt install docker to "workflows pick up the runner". Includes:
- Host pre-requisites + smoke checks
- Runner registration with the heavy label
- The exact HEAVY_RUNNER variable JSON
- Fallback verification (stop runner, confirm workflow still runs)
- Maintenance (disk hygiene + update cadence)
- Hardening (dedicated user, LUKS, IP restriction, no multi-repo reuse)
- Workflow taxonomy (which stays on cloud, which migrates)
- Cost expectation + rollback steps
Consequences¶
Positive¶
- Zero-disruption deploy: this PR can merge today; nothing breaks. Workflows continue on
ubuntu-latestuntil operator opts in. - Reversible without code: operator deletes
HEAVY_RUNNERvariable → instantly back on cloud. No PR. - Fork PR safe: untrusted code from external contributors NEVER lands on the operator's box. Hard guarantee at the workflow expression level.
- Cost asymptote: when wired, heavy crons add ~zero monthly cost. Electricity + hardware off-books.
- Operator hardware autonomy: any Linux box (Mac/Intel/ARM) works as long as Docker + Go's
setup-go@v5cache paths work. ARM Macs need a workflow runner that supports macOS; this stays Linux-first per the runbook.
Negative¶
- Operator on-call: if the runner dies overnight, the weekly cron queues up until operator notices. No auto-fallback (deliberate — preserves the security guarantee that workflows only land on operator boxes when operator owns them).
- Slight runtime divergence: self-hosted runners don't auto-update software like GitHub-hosted runner images do. The runbook covers periodic update cadence.
- Single point of failure: 1 host = 1 SPOF. Mitigation: registering 2+ runners with the same
heavylabel spreads load + provides redundancy. - First-time setup ~30min: registration + service install + verification per the runbook. Operator one-time cost.
Alternatives considered¶
- GitHub-hosted larger runners (4-core / 8-core paid SKUs): faster per-job but more expensive than ubuntu-latest. Rejected — we're cutting cost, not adding throughput.
- Workflow
runs-on:hardcoded toself-hosted: simpler config but breaks the day the runner is offline. Rejected — fragile + bad onboarding UX. - Custom runner label only (no fork check): cheap to set up but allows fork PRs onto operator hardware. Rejected — security regression, GitHub's docs explicitly call this out as the #1 self-hosted runner risk.
- K8s-based runners (Actions Runner Controller): cleaner scaling but adds K8s ops overhead. Rejected — out-of-scope for a single-operator + small repo.
- No migration, accept ADR-0097 reduction: keeps GitHub-hosted only at 25-40% of pre-cut cost. This is the "do nothing more" option. Operator picked the radical path.
Compliance / governance¶
- CLAUDE.md invariant (Dashboard-only operator interface): unchanged. Runner config touches GitHub Settings UI, not the Dashboard MÓDULO.
- Fork-PR security: validated by the
github.event.pull_request.head.repo.forkguard at theruns-on:level — workflow expression, not runtime check. Cannot be bypassed by a forked PR's own workflow edits because the EXPRESSION is evaluated by GitHub against the EVENT, before the workflow file checkout. - Secret exposure: the workflows migrated do NOT read sensitive secrets (gosec/trivy/CodeQL output is public; ztp-prem audits read public repo state). The 4 workflows that DO read secrets (
release.yml,tag-signature-verify.yml,forensic-tamper-check.yml,secret-scan.yml) remain on GitHub-hosted by design. - Audit trail: every self-hosted runner job is still logged in GitHub Actions UI — same audit trail as GitHub-hosted. The only difference is the runner identity in the log (
<hostname>-heavyvsubuntu-24.04).
References¶
- Runbook:
docs/runbooks/self-hosted-runner-setup.md - ADR-0097 — CI cost-cut radical (
docs/ADR/0097-ci-cost-cut-radical.md) - Recovery memo:
project_self_hosted_runner_opt_in_2026_05_25 - GitHub docs — https://docs.github.com/en/actions/hosting-your-own-runners
- Security hardening — https://docs.github.com/en/actions/hosting-your-own-runners/security-hardening-for-self-hosted-runners