# Challenges building the Hetzner CI runner setupA retrospective on what actually went wrong while moving chromium-fork'sbuild off a resource-starved local runner onto ephemeral Hetzner Cloudboxes. Written for whoever touches this next — most of these took realdebugging time to nail down, and the fixes are non-obvious enough toregress if the reasoning behind them gets lost.## Provisioning**Dedicated vCPU blocked by an account limit.** The original plan was a`ccx43` (dedicated vCPU, 64GB RAM). Hetzner rejected it with`resource_limit_exceeded` — new/unverified projects start with adedicated-vCPU limit of 0 as an anti-fraud measure (dedicated vCPU andGPU servers are common targets for crypto-mining fraud via stolenpayment methods), lifted only via a support ticket. Fell back to `cpx51`(shared vCPU, 32GB RAM, 360GB disk) instead, which was availableimmediately.**SSH key mismatch on the very first manual test.** Initially attached apre-registered Hetzner SSH key ("Philip Macbook Pro M2") to ahand-created server — `Permission denied (publickey,password)`. Theprivate half of that key lived on a *different* machine than the onerunning the commands. Fixed at the design level, not just for this onekey: every server now gets a fresh ephemeral ed25519 keypair generatedper run and uploaded to Hetzner as part of `up`, with no dependency onany pre-existing registered key.## depot_tools as root**`python3_bin_reldir.txt` missing, breaking `install-build-deps.py`.**depot_tools' `python3` wrapper hard-requires a bootstrap file that'snormally created by `update_depot_tools` — which has an explicit guard:```if [ "$USER" == "root" ]; thenecho Running depot tools as root is sad.exitfi```Silently no-ops as root, which this box always is. `gclient sync` stillworked because it goes through `vpython3` (bootstrapped separately), butany later direct `python3` invocation — like `install-build-deps.py`'sshebang — failed with `python3_bin_reldir.txt not found`.Fix: call the two functions `update_depot_tools` invokes *after* itsroot check (`cipd_bin_setup`, `bootstrap_python3`) directly, sidesteppingthe check entirely. Needed `set +u` around that block too — those arethird-party scripts not written to be safe under `nounset`, and `cipd`itself references an unset variable with no default.**Missing `file` package.** `install-build-deps.py`'s `dev_list()` shellsout to `file -L <path>` to detect ELF architecture. Not installed bydefault on the minimal Ubuntu cloud image. Small fix, but it took a fullrun reaching that exact line to surface.## The SSH/PTY rabbit holeThis was the single biggest time sink, in three layers, each one lookinglike the full fix until the next real run disproved it.**Layer 1 — PTY stdin doesn't deliver EOF reliably.** The original`sshExecScript` piped a script to `bash -s` via `session.Run()` with`Stdin` set to a `strings.Reader`, over a PTY (needed so the remote sideline-buffers instead of block-buffering — see "buffering" below). Ithung indefinitely — but only after the script's real work had alreadyfinished. The script would run to completion, print its own finaloutput, and then the local process would just sit there for 10+ minutesuntil manually killed.Root cause: SSH channel EOF just means "not sending more data on thischannel right now" — for a plain exec channel that maps onto closing thechild's stdin pipe (a pipe cleanly returns EOF from `read()` once closed,unconditionally). For a PTY-backed session, the server is managing an*interactive terminal*, where a pause in input isn't supposed to mean"hang up" — so it doesn't reliably translate into an EOF condition theremote shell's `read()` ever sees. Confirmed as a known class of issue,not specific to this codebase: [golang/go#6088](https://github.com/golang/go/issues/6088)(identical `x/crypto/ssh` symptom) and[net-ssh/net-ssh#460](https://github.com/net-ssh/net-ssh/issues/460)(different language entirely, same symptom, and explicitly confirmsremoving `request_pty` fixes it).First fix attempt: switch to `Start()` + `StdinPipe()` + explicit`Close()`, and append `exit $?` to the script so bash terminates itselfinstead of depending on EOF at all. This fixed short scripts in testing.**Layer 2 — a real script still hung, differently.** Against a real`sync.sh` (7.4KB), the process hung again — but this time a liveinspection (once the runner moved to mainframe, making its ephemeral SSHkey locally reachable) showed `bash` blocked in `do_select()` on itsstdin fd, with `/root/depot_tools` not even cloned yet — despite laterscript output (an `apt-get install`) having already reached the log.The script was being *truncated* mid-delivery, not just missing itstermination signal. Consistent with a PTY's small, fixed-sizecanonical-mode input queue (historically 4KB on Linux) silently droppingthe tail of a multi-KB single `Write()` call when it arrives faster thanbash drains it. `exit $?` didn't help because it was itself part of whatgot dropped.Real fix: stop sending script content through a PTY's stdin at all.Upload the script as a file first (over a plain, non-PTY session — apipe has no such queue-size limit), then execute it as a file with nostdin of its own. No script content ever touches a PTY; the PTY is stillused for the *execution* (live output streaming), but that session hasnothing written to its stdin, so there's no EOF ambiguity left to have.**Layer 3 — the fix for layer 2 had the same bug as layer 1.** Buildingthe upload mechanism (`uploadFile`, piping a file's bytes to `cat >path`), the very first real test of it hung — because `sshSession` wasrequesting a PTY *unconditionally*, and `uploadFile` inherited it for aplain `cat`. Fixed by making PTY allocation opt-in per session: on foranything running a real command whose output should stream live, off forraw file transfer. (A PTY's canonical-mode line discipline is activelywrong for binary-safe transfer anyway, independent of the hang — it canreinterpret control bytes like Ctrl-D/Ctrl-C that happen to appear inotherwise-arbitrary content.)**Buffering, separately.** Even once EOF/truncation were fixed, buildoutput over a plain (non-PTY) pipe arrives block-buffered on the remoteside instead of line-buffered — a multi-hour build's output would showup in laggy multi-KB chunks instead of streaming as it happens. That'sthe reason a PTY is used for command *execution* at all, despite all ofthe above: it makes `ninja`/`gclient`/`python` think they're talking toa real terminal, so they line-buffer.## Cleanup correctness**`down()` deleted the local key even when the remote delete failed.**If the Hetzner API call to delete the server errored for any reason, thecode still unconditionally removed the local ephemeral private key —meaning a failed teardown could both leave the server running *and*destroy the one thing that could get back into it to inspect or manuallyclean up. Found by hitting exactly this: a server still alive, its keyalready gone, no way back in except the Hetzner web console (whichturned out to be VNC-over-websocket, not worth building a client for onthe spot) or an account-level password reset (which doesn't apply to thealready-running OS instance without a reboot, which would have destroyedthe exact state being investigated). Fixed: only remove the local keyonce the server delete is confirmed to have succeeded.**Ordinary step failure vs. Go-level error, cleanup-wise.** `exec` (usedfor each CI phase against an already-provisioned box) deliberatelydoesn't tear down on an ordinary nonzero remote exit code — that's leftfor the workflow's own separate, later `down` step, so one failing buildphase doesn't nuke a box other phases might still want to inspect.But a genuine Go-level error partway through (upload failure, SSHconnection failure, a panic) previously fell through to nothing untilthat separate step ran. Added a `defer`-based cleanup specifically forthat case, careful to distinguish it from a normal nonzero exit code —verified both paths against a real box (`exit 3` from the remote commandleaves the server alive and propagates the code; an upload failureagainst a nonexistent local file tears the server down immediately).## The runner host itself**Migrated off a non-cloud machine.** The `self-hosted-ubuntu` label(used by chromium-fork's CI *and* all six `mirror-sync/sync-*.yml` jobs)was originally served by some separate, apparently non-cloud machine —inferred from a `/Volumes/ExtremeSSD/...` workdir path in chromium-fork'sCI history, a macOS volume mount. It restarted mid-job at least once,silently killing an in-flight run and orphaning a Hetzner box with nograceful cleanup at all (the runner process just vanished). Fixed byadding a new, dedicated `services.gitea-actions-runner` instance on`mainframe` itself (the same box already running Forgejo, already usingPodman for other services) — reliable, cloud-hosted, and kept as its owninstance rather than merged into the existing `ubuntu` one so it nevercontends with unrelated repos' CI for runner capacity.**Registration token scope.** Needed an org-scoped (Cold-Air-Networks)runner registration token, not an instance-wide admin one — Forgejo'sAPI exposes this via `GET` (not `POST`, despite it being atoken-*creation* action) on`/orgs/{org}/actions/runners/registration-token`.**agenix wiring has two separate places to update, not one.**`secrets/default.nix` only configures *encryption recipients* for the`agenix` CLI tool — it does **not** by itself expose a decrypted secret'spath to the rest of the NixOS config. That requires a second, separate`age.secrets.<name> = { file = ...; owner = ...; }` declarationelsewhere (this repo's convention: in `linux.nix`, in the same block asthe other secrets). Missing this second declaration means`config.age.secrets.<name>.path` simply doesn't exist as an option —easy to miss since the `.age` file itself decrypts and evaluates fineeither way.**New files need `git add` before Nix will see them, even with a dirtytree.** `nix eval`/`nix build` against a local flake includes*modifications* to already-tracked files in a dirty working tree, butsilently ignores brand-new, not-yet-staged files entirely — surfaced asa confusing "Path ... is not tracked by Git" error deep in an unrelatedevaluation trace (agenix trying to read a secret file that, from Nix'sperspective, didn't exist yet).**Stale macOS Keychain git credentials.** `git push` to`forge.quinefoundation.com` intermittently failed with a plain `403`using the credential the `osxkeychain` helper had cached, for reasonsnever fully root-caused (likely a rotated/stale stored credential).Explicit `https://user:pass@host/...` credentials in the push URL workedevery time; the keychain-stored path was eventually abandoned entirelyfor this remote.## The three-hour mysteryTwo CI runs (#62, #64) died with *zero* trace: no error in the job log,no OOM in `dmesg`/kernel journal, nothing in the runner service's ownsystemd journal (which, at `info` level, only ever logs one line per*task* — dispatch — never per-step detail). Run #62 left Gitea/Forgejoshowing "running" forever after the process and its box had alreadyvanished; run #64 got marked "failure" within seconds of the log's lastline, mid-compile, with healthy throughput right up to the cutoff.First hypothesis, based on the symptom shape alone: a known upstreamact_runner/Gitea desync bug([go-gitea/gitea#35645](https://github.com/go-gitea/gitea/issues/35645),[#35956](https://github.com/go-gitea/gitea/issues/35956)) — the runnerand server losing sync on task state. Plausible, but unverifiable fromoutside without better visibility, so two diagnostic tools went in*before* trying to fix anything further:- **`act_runner` debug logging** (`settings.log.level = "debug"` in theNixOS module) — `info` level was structurally incapable of everexplaining this, since it doesn't log per-step detail at all.- **An independent local log for `hetzner-runner` itself**(`~/hetzner-runner-debug.log` on the runner host, written on everylifecycle event) plus a 30-second heartbeat during any in-flight remotecommand — decoupled entirely from Gitea's own log capture, which hadby then been directly observed lagging real execution state on twoseparate occasions.That paid off immediately on the very next real run (#65). The localheartbeat log showed the process alive and healthy right up to a precisetimestamp, then nothing — no error, no signal-caught line, just stops.Debug-level journal for that exact window had the real answer directlyfrom act_runner itself:```❌ Failure - Main Build chrome/browser (~5-20min)this step has been cancelled: signal: killed```Not a desync bug at all. Run #65's total elapsed time from `up` to kill:**2h59m58s** — matching `act_runner`'s default `runner.timeout: 3h` tothe second. A cold build (fresh box every run, no cache) genuinely takeslonger than the default job timeout; nothing was actually wrong.Fixed by doubling *both* independently-configured 3h defaults to 6h —`act_runner`'s own `runner.timeout`, and Forgejo's `ENDLESS_TASK_TIMEOUT`(`app.ini`, `[actions]` section — not managed by the NixOS flake, livesdirectly on the data volume). The runner's own generated configexplicitly warns that either one firing independently has the sameeffect, so both had to move together.**A second-order finding from the same investigation:** atimeout-triggered cancellation aborts the whole job immediately ratherthan continuing on to later steps — meaning the dedicated `if: always()`teardown step never got a chance to run, and the orphaned box from run#65 sat alive for 6 hours before being noticed and manually deleted. Thecloud-init self-destruct failsafe (`max-lifetime`) would have caught iteventually, but at its then-default 8h it left only 2 hours of marginover the newly-doubled 6h job timeout — uncomfortably tight givenprovisioning/retry overhead on top. Widened to 10h.## Running themeAlmost every one of these was invisible until proven otherwise byrunning the *actual* thing against a *real* box, for a *realistic*duration, with *real* output volume — short synthetic tests("does `run -- echo hi` work?") passed cleanly at every layer of thePTY bug and would never have caught it. Cost real time and real Hetznerspend to work through, and every orphaned server or key found mid-waygot cleaned up as its own explicit step, not deferred to "later" — worthpreserving as a habit for whatever gets built on top of this next.