Skip to content

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 scan
  • codeql.yml — JS/TS deep static analysis weekly
  • ztp-prem-tier-b-obfuscation.yml — garble + obfuscation tests weekly
  • ztp-prem-sod-audit.yml — separation-of-duties weekly audit
  • dashboard-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:

  1. 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.
  2. 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-latest until operator opts in.
  • Reversible without code: operator deletes HEAVY_RUNNER variable → 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@v5 cache 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 heavy label 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 to self-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.fork guard at the runs-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>-heavy vs ubuntu-24.04).

References