fix: writable /usr/local/bin for Pkl self-install, per-job gitconfig reset - #4
Merged
persello merged 1 commit intoAug 15, 2026
Conversation
…istent runners Two bugs only surface on this fleet's long-lived, job-reused containers (never on ephemeral GitHub-hosted runners): 1. Consuming workflows self-install a version-pinned Pkl with `curl -o /usr/local/bin/pkl && chmod +x`. That path is root:root 0755, so the unprivileged runner user hits EACCES. The image also only baked in 0.30.1 while bender-driver/dti-fsic-driver/vehicle-message-definitions all pin 0.31.1 (verified against those repos' ci.yml). Fix: bump the baked-in version to 0.31.1 and chown /usr/local/bin to runner:runner so future version drift no longer hard-fails. A PATH-based redirect can't work here since the destination is a literal absolute path in those workflows, not PATH-resolved. 2. A shared canvas setup snippet writes a git `insteadOf` rewrite via `git config --global set` then `--add`. Values accumulate in the runner user's $HOME/.gitconfig across every job a replica has ever served, until a later plain `set` call hits an already multi-valued key and fails with "cannot overwrite multiple values with a single value". Fix: a new job-started-hook.sh, wired via ACTIONS_RUNNER_HOOK_JOB_STARTED, resets $HOME/.gitconfig before every job -- chosen over job-completed-hook.sh because a cancelled/killed job skips the completed hook and would leak pollution into the next job regardless. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K6wzprHLZeXyWAxdSM98Hy
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Two confirmed bugs in the runner fleet's image, both root-caused by the fact that these containers are long-lived and reused across many job runs — a difference from GitHub-hosted runners that neither bug's underlying pattern accounts for.
Base: this stacks on top of the still-open #3 (
feat/runner-fleet-overhaul→main), since that branch is what the live fleet actually runs today (confirmed via the local deployment checkout —mainitself is several months stale relative to it). This PR targetsfeat/runner-fleet-overhaul, notmain.Open issues:
gh issue list --repo jkuracing/github-runner --state openreturns none. No existing issue covers this work — recording that omission explicitly rather than leaving it silent.Bug 1 — Pkl self-install fails with
EACCESConsuming workflows (verified against
bender-driver,dti-fsic-driver, andvehicle-message-definitionsci.yml, all three identical) do:On a GitHub-hosted runner this works because the job owns the whole VM. On this fleet it fails:
/usr/local/binisroot:root 0755from the image's ownapt/curlinstalls, andcurl -otruncates the existing file in place (open(..., O_TRUNC)), which needs write on that file's inode — not just search/exec on the directory. Separately, the image only baked in Pkl 0.30.1, while all three migrated repos pin 0.31.1, so even a working install step would still be re-fetching a version the image doesn't already ship.Why not the
$RUNNER_TEMP/bin/$GITHUB_PATHoption: that only helps when a job resolves the tool throughPATH. These workflows write to the literal absolute path/usr/local/bin/pkl, not somethingPATH-resolved, so exporting a different writable directory can't intercept it — the write still targets/usr/local/binregardless. Editing the destination path in every consuming workflow is exactly the per-repo workaround this task explicitly ruled out. So the only central fix is making that directory (and the files in it) writable by the user that actually runs jobs.Fix (Dockerfile):
chown -R runner:runner /usr/local/bin, folded into the existinguseradd -m runnerblock (after it, so the user exists; after every tool install including sccache'sinstall -m 0755, so nothing installed later reverts to root-only). Chosechownoverchmod a+wto match this file's own idiom (chown -R runner:runneralready appears twice in that block) rather than leaving a world-writable system directory./usr/local/binholds only tools this image itself installs (just, pkl, uv/maturin, sccache, bun — no apt package puts anything there), so handing ownership torunnerdoesn't touch anything owned by another principal, and the runner user already executes arbitrary job code with far broader access than this.README.md.Bug 2 — git config accumulates across job runs
A shared setup snippet used by canvas-consuming repos (the same
Install PKL CLI/canvas-deploy-keystep family) configures a gitinsteadOfrewrite viagit config --global set… then--add. On an ephemeral GitHub-hosted runner this is harmless — the VM, and$HOME/.gitconfigwith it, is destroyed after one job. On this fleet the container (and$HOME/.gitconfigfor therunneruser) outlives any single job, so values accumulate under the same key run after run, until a later job's plainsetcall collides with an already multi-valued key and fails with:Fix: new
job-started-hook.sh, wired viaACTIONS_RUNNER_HOOK_JOB_STARTEDinentrypoint.sh, resets$HOME/.gitconfigto a clean baseline (its absence — nothing in this image's build or entrypoint ever writes to it, verified by grep) before every job.Why a job-STARTED hook, not (only)
job-completed-hook.sh:job-completed-hook.sh— the repo's existing per-job hook point, used today for the disk sweep — only runs after a job finishes normally. A cancelled, timed-out, or forcibly-killed job skips it entirely, and that job's accumulated.gitconfigwould survive into the next job regardless — exactly the collision this is meant to prevent.ACTIONS_RUNNER_HOOK_JOB_STARTEDruns before every job no matter how the previous one ended, so it's the only placement that actually closes the gap rather than narrowing it. It mirrors the existing hook's own idiom:set -uo pipefail(not-e), unconditionalexit 0, a header explaining why a cleanup step must never be able to fail the job it's protecting.What I verified vs. couldn't
Verified (no Docker needed) — reproduced the exact reported failure and confirmed the fix, in plain bash against a real, empty
HOME:NOT verified — disclosing plainly: I did not get a full
docker build .of this Dockerfile to complete. A build attempt OOM'd partway through an unrelated, pre-existing layer (libwebkit2gtk-4.1-dev, before my changes are even reached), and shortly afterward OrbStack's engine went fully "Stopped" on the shared host, taking the live 12-container fleet down with it. The coordinator restarted OrbStack and confirmed the fleet is back up and serving real CI, including a safety-relevant firmware PR in flight. Given the timing correlation, retrying the build right now was judged not worth the risk of taking the VM down a second time while real CI depends on it, so a full build-verification pass is deliberately deferred to a quieter moment rather than blocking this PR.Concretely, this means: the
chown -R runner:runner /usr/local/binstep, the Pkl 0.31.1 fetch/URL, andjob-started-hook.sh's wiring into the DockerfileCOPY/chmodhave not been exercised in an actual built image or container — only read closely and reasoned about. Thejob-started-hook.shscript logic itself (the part that matters for correctness) is verified per above; what's unverified is purely the image-build mechanics (theCOPY, thechown, the new Pkl URL actually resolving inside the build's network context, etc.).Rollout note (intentionally NOT done here)
This PR is source-only. Deploying it requires rebuilding the live fleet's images with
docker compose build(no service argument — that file's own comment warns that scoping to one service silently leaves the other eleven on the old image) and then recreating/restarting the 12 running containers. Until that rebuild happens, the currently-running containers still carry:/usr/local/binpermissions and Pkl 0.30.1 (Bug 1 still live), and$HOME/.gitconfigstate they've already accumulated (Bug 2's existing damage is not cleaned up by this PR — landing the fix only stops further accumulation from the next rebuild onward; any replica already sitting on a multi-valued key needs that file cleared directly, which is a live-infrastructure action outside this PR's scope, to be handled separately with explicit confirmation).Test plan
job-started-hook.shfixes it, standalone in bashdocker build .end-to-end (deferred — see verification section)pkl --versionresolves 0.31.1 as therunneruser withwhich -a pklshowing no earlier shadow copydocker compose build(no service arg) + recreate the 12 containers, once reviewed.gitconfigstate on any replica that already has it (not part of this PR)🤖 Generated with Claude Code