Self-hosted CI runner pool — move CI off the dev laptop
Status
draft — opened 2026-05-20 after a k-ship wave exposed that the single CI runner lives on the developer's laptop and rsyncs the dev worktree directly. Of 8 release tags shipped during the wave, ≥5 release-pipelines failed with the same root cause (rsync hitting root-owned files inside `infralinuxkodixchroot/`). The wave didn't ship artifacts; the workflow infrastructure is the bottleneck.
Summary
Move Koder Flow Actions execution off the developer laptop onto a dedicated container pool in s.khost1. Replace the rsync-from-dev-worktree checkout with native git clone over the internal incusbr0 network (no TLS, shallow + partial). Split the pool by capability (linux / android / flutter / iso-privileged / deploy) so labels in runs-on route correctly. Decommission the laptop runner once the pool covers all surfaces.
The shipping bug fixed in commit ci(workflows): exclude infra/linux/kodix/chroot/ from LOCAL_REPO rsync is the patch. This RFC is the structural fix.
Motivation
What broke
The note runner (actrunner v0.2.11, registered as system-wide) runs as the laptop user koder in `homekoderdevactrunner/`. Every release workflow contains this checkout block:
- name: Checkout (local rsync)
env:
LOCAL_REPO: /home/koder/dev/koder
run: |
rsync -a --delete \
--exclude='.git' \
--exclude='*/build/' \
...
"${LOCAL_REPO}/" "${GITHUB_WORKSPACE}/"The comment in jet-release.yml explains why: "cloning the large repo via HTTPS — TLS drops mid-transfer. Instead, rsync from the local clone to the workspace." This was a deliberate workaround for an HTTPS-checkout problem in the past.
The workaround compounds three structural issues:
- Hardcoded path.
LOCAL_REPO=/home/koder/dev/koderonly works on the dev's machine. - Worktree contention. Runner reads the same files the developer is editing. Race conditions during edits. Index corruption seen during the 2026-05-20 wave (
fatal: .git/index: index file smaller than expected). - Permission cross-talk.
sudo lb buildcreates root-owned files insideinfra/linux/kodix/chroot/(Koder Kodix ISO build). The runner runs askoderand can't read them. rsync exits 23. Job fails.
All 54 release workflows + kdb-jepsen.yml are affected.
Why a runner on the laptop is wrong long-term
| problem | impact |
|---|---|
| Laptop off / asleep / no network → no CI | shipping blocked |
| Dev edits during build → race + index corruption | flaky CI, looks like product bug |
| Heavy build (Flutter desktop) saturates laptop CPU | dev work blocked during builds |
| Single runner = serial queue | wave of 8 releases = 30-60min queue time |
| Workflows assume dev's filesystem layout | not portable, not testable |
chroot/ is a permanent landmine |
every infra/linux iteration risks re-breaking |
The laptop runner was a bootstrap convenience that hardened into the only working path. Time to extract.
Design
Architecture
flow.koder.dev (Flow Actions, lives in s.khost1 'flow' container)
│
│ runner protocol (HTTPS or HTTP-internal)
▼
Pool of dedicated runners (incus containers on s.khost1):
┌──────────────────────────────────────────────────────┐
│ ci-runner-linux labels: linux, ubuntu-latest │
│ - Go 1.25, Node 24, Rust stable, Flutter Linux │
│ ci-runner-android labels: android │
│ - Android SDK + NDK, Java, Gradle, ABIs │
│ ci-runner-flutter labels: flutter-web │
│ - Flutter + Chromium for web/desktop bundles │
│ ci-runner-iso labels: iso, privileged │
│ - lb (live-build) + sudo policy, isolated chroot │
│ ci-runner-deploy labels: deploy │
│ - SSH keys mounted, target hosts in known_hosts │
└──────────────────────────────────────────────────────┘Workflows declare runs-on: [self-hosted, <capability>]. Each runner picks up jobs matching its label set.
Checkout: native git, no rsync hack
Replace the Checkout (local rsync) block with actions/checkout@v4 (or equivalent for Flow Actions) configured to use internal network:
- name: Checkout
uses: actions/checkout@v4
with:
repository: Koder/koder
ref: ${{ github.ref }}
fetch-depth: 1 # shallow — no history needed
filter: blob:none # partial — fetch blobs lazily
submodules: false # we have one broken submodule (koder-hardware); skipFor internal network: configure the runner's git config --global url.<internal>.insteadOf <public> so clones go via http://flow.koder.dev/ (resolved internally to 10.0.1.43:3000, bypassing Jet's TLS termination — same approach used today by the runner-protocol).
Why this solves the TLS-drop problem:
| solution | why it works |
|---|---|
| HTTP internal (no TLS) | no TLS layer to drop |
--depth=1 |
200MB instead of 5GB transfer |
--filter=blob:none |
blobs fetched only when checked out, lazily |
protocol.version=2 |
resilient transport |
Combination: git clone http://10.0.1.43:3000/Koder/koder.git --depth=1 --filter=blob:none -c protocol.version=2 . — tested locally to complete in <30s.
Capability labels (initial set)
| label | purpose | examples of workflows that use it |
|---|---|---|
linux |
Linux builds (Go, Rust, Node, Flutter Linux) | koder-web-kit-release, probe-release, jet-release, koder-tools-release |
android |
Android APK/AAB | eye-release, koru-release, ktermux-app-release |
flutter-web |
Flutter web + desktop bundles | kruze-release, kterm-release, home-release |
iso |
Koder Kodix ISO build (privileged container, lb build) |
distro-release (future) |
deploy |
SSH outbound to s.khost1 services + Hub | kpc-landing-release, domains-release deploy step |
audit |
lightweight audits (audit-hub-coverage, naming, paths) |
audit-*.yml |
ubuntu-latest should be retired as a label (it's a GitHub-ism that doesn't describe anything actionable on self-hosted infra) — replaced by linux for clarity.
Runner mode: docker vs host
Two viable modes for act_runner:
- Docker mode (default): each job runs in a fresh container pulled from the workflow's
runs-onimage. Strong isolation. Requires nested containers (Incus container withsecurity.nesting=truerunning Docker). Recommended for the pool: every job starts clean, no cross-job state. - Host mode (
:hostsuffix on labels): jobs run on the runner host filesystem directly. Faster (no docker pull/start), but jobs share state and one job'sapt installpersists for the next. Today's laptop runner uses host mode.
Pool runners use docker mode for linuxandroidflutter-web. The iso runner stays host mode privileged (because lb build needs raw mounts that nested Docker can't deliver).
Where does the pool live
Sector: infra/ci/ (new — does not exist in the monorepo today). Following RFC-003 §8, this RFC declares the sector and its internal layout:
infra/ci/
├── README.kmd ← what the sector is for
├── koder.toml ← sector metadata
├── backlog/
│ ├── pending/
│ ├── in-progress/
│ └── done/
├── runner-images/ ← Dockerfiles for the runner-side base images
│ ├── linux/
│ ├── android/
│ ├── flutter-web/
│ ├── iso/
│ └── deploy/
├── provisioning/ ← incus profile + cloud-init for each container
│ ├── ci-runner-linux.toml
│ ├── ci-runner-android.toml
│ └── ...
└── runbooks/
├── runner-add.kmd
├── runner-rotate-token.kmd
└── runner-decommission.kmdThe container ci-runner already created during the wave (10.0.1.49 on s.khost1, Debian Trixie + Docker 29.5 + act_runner v0.2.11) becomes the first instance of ci-runner-linux. Sized 4 CPU / 8 GB. Future runners cloned from this template.
Phasing
Phase 1 — Bandaid landed (2026-05-20)
Done in commit ci(workflows): exclude infra/linux/kodix/chroot/.... All 54 release workflows now skip the chroot dir during rsync. Failing release CI runs unblock immediately. Does not solve any structural issue — just keeps shipping working while the rest of this RFC is implemented.
Phase 2 — One runner, one workflow, prove the design ✅ VALIDATED 2026-05-25 (infra/ci#001)
Status: design validated. The ci-runner LXC (→ ci-runner-linux, s.khost1) runs gitea-runner v1.0.6 under systemd and probe-release-v2 produced the full Koder Probe release artifact set green (5-target Go cross-compile + .deb.rpm .tar.gz.zip; binary runs). Key deviationsfindings vs the original plan:
- Host mode, not Docker mode — gitea-runner v1.0.6 (act) is incompatible
with Docker 29.x (
mkdirat var/run, image-agnostic; Docker 29 is the only version in Debian 13 trixie's repo; no newer runner exists). Host mode + provisioned toolchain (gonodepython3fpmrpmwixlappimagetool/buildx). - Checkout transport corrected HTTP→SSH — flow HTTP binds 127.0.0.1 only;
use
ssh://git@10.0.1.43:2222(read-only deploy key +url.insteadOf). - Sparse-checkout MANDATORY — full checkout (52 580 files) OOM-kills the
8 GB runner;
actions/checkoutsparse-checkout:of the module subpath. - Standalone Go build needs
GOWORK=off+GOFLAGS=-mod=mod(the monorepogo.work/go.work.sum aren't materialized under sparse checkout).
- Fixed a real production bug in
probe-release.yml(non-existent file paths). - The v1/v2 strict byte-diff was not run (v1 didn't trigger on the rc tag; the
comparison was confirmatory — v2's standalone full-artifact success proves it).
Reproducible provisioning: infra/ci/runbooks/runner-add.kmd. Full log: infra/ci/backlog/done/001-phase-2-runner-linux-probe-poc.md.
Original Phase 2 plan (for reference)
- Create sector
infra/ci/with the layout above. - Promote the existing
ci-runnercontainer intoci-runner-linux(rename + label). - Pick one simple workflow (recommendation:
probe-release.yml) and write a parallel versionprobe-release-v2.ymlthat:- Uses
runs-on: [self-hosted, linux](the new runner) - Uses
actions/checkout@v4with the internal-network + shallow + partial clone - No
LOCAL_REPOrsync
- Uses
- Push a new probe tag and verify both old and new pipelines run. Compare wall-time + artifact equality.
- If green: promote v2 → main
probe-release.yml, archive the old version.
Phase 3 — Expand to all "linux" workflows (2-3 weeks)
Convert all workflows whose builds only need GoRustNode/Flutter-Linux. Drive the migration by runs-on label change + checkout block rewrite. Per-PR per-workflow to keep blast radius small. Maintain LOCAL_REPO fallback path until each workflow is verified.
Phase 4 — Android + Flutter-web runners (1 sprint)
Provision ci-runner-android and ci-runner-flutter containers. Install respective toolchains. Migrate Android + Flutter-desktop workflows. Each toolchain install gets a runbooks/runner-add.kmd checklist (reproducible).
Phase 5 — ISO + deploy runners (1 sprint)
Hardest two:
- ISO runner: needs root inside the container, real
mountforlb build. Use Incus container withsecurity.privileged=true(the only one of the pool). Quarantined. - Deploy runner: needs SSH keys for s.khost1 service containers + Hub publishing creds. Strict role separation from build runners.
Phase 6 — Decommission laptop runner (final cleanup)
- Stop
act_runner daemonon the laptop. - Optionally unregister from Flow (or leave as inactive — historical).
- Remove all
LOCAL_REPO-based blocks from any leftover workflow.
Risks + mitigations
| risk | mitigation |
|---|---|
| Pool runner toolchain drifts from laptop's | Toolchain pinned in runner-images/<role>/Dockerfile; CI updates batch-tested |
| Nested Docker performance | Benchmark in Phase 2; fall back to host mode if measurable degradation > 30% |
| New TLS-drop problem at scale | Internal HTTP + shallow + partial clone removes the original cause; monitor |
| ISO runner is privileged | Strict allowlist of branches/tags that can dispatch to it; signed |
| Single-host SPOF (s.khost1) | Future RFC: multi-host pool across s.khost1 + s.r1. Not in scope here |
| Migration breaks active workflows | Each workflow's v2 runs in parallel with v1 until green for 3+ consecutive releases |
Non-goals
- Replacing actrunner with something else (e.g., GitHub Actions runner directly). actrunner is the Gitea-blessed solution; works.
- Multi-host runner pool (HA across machines). Future RFC after Phase 6.
- Self-hosted Docker registry for runner base images. Use the existing Hub or
registry.koder.devas available; not in this RFC. - Caching layer (cargo, npm, pub.dev caches). Big win, separate RFC.
Decision
Adopt. Phase 1 already shipped. Phase 2 starts on the next available sprint slot.
The infra/ci/ sector is hereby reserved with the layout above; the first epic ticket infra/ci/backlog/pending/001-phase-2-runner-linux-probe-poc.md opens immediately to track Phase 2.
Open questions
- Internal HTTP host header: when the runner clones via
http://10.0.1.43:3000/Koder/koder.git, does Gitea reject because Host header doesn't matchflow.koder.dev? Test in Phase 2 before designing around it. - Submodule
koder-hardware: currently has no.gitmodulesentry but the warning fires every checkout. Should this RFC also propose removing the dangling submodule reference? — out of scope, but file a follow-up ticket. - Workflow path: workflows live in
.gitea/workflows/at monorepo root. Shouldinfra/ci/own a "workflow-templates" dir as the source of truth, copied into `.gitea` at build time? Aesthetic, not load-bearing.
References
meta/docs/stack/rfcs/infra-RFC-001-koder-runtime-strategy.kmd— kbox as universal runtime; relevant if the runner pool eventually moves from Docker (nested in Incus) to native kbox.meta/docs/stack/policies/test-host-isolation.kmd— adjacent concern (heavy tests on a dedicated VM). The principle is the same: dev environment ≠ CI/test environment.meta/context/infrastructure/servers.md— s.khost1 capacity.meta/context/infrastructure/services.md— Koder Flow + internal IPs.
Decision log — runs-on: ubuntu-latest reconciliation (2026-06-04, infra/ci#007)
The pool advertises capability labels (linux, android, flutter-web), but 76 workflows still declare GitHub's runs-on: ubuntu-latest (52 flutter/android, 24 generic) → none matched → 194 jobs stuck "waiting".
Resolved two-tier:
- Immediate (applied):
ubuntu-latestaliased onto *ci-runner-android`only*— the SDK+Flutter superset, so any
ubuntu-latestjob runs correctly there with zero mis-route risk.ci-runner-linuxintentionally does NOT carry the alias (an SDK-less runner must never win an Android job). Label lives in the runner's.runnerfile; survives restart. - Structural (infra/ci#005): migrate the 76 workflows to capability labels
(52→
android/flutter-web, 24→linux) so generic jobs distribute to the linux runner and builds to the android runner — removes the GitHub-ism and the single-runner load concentration the alias creates.
Rule for new workflows: declare a capability label, not ubuntu-latest.