Symptoms and causes specific to a hand-rolled, persistent, emulated runner. For how the pieces fit together see architecture.md.
The runner still appears in Settings → Actions → Runners after
docker compose down.
Deregistration did not complete. Check docker compose logs for
WARNING: config.sh remove failed. Usual causes:
- Killed before the trap ran. A job was in flight and exceeded
stop_grace_period(2m), so Docker sent SIGKILL. Raise it indocker-compose.yml. - PAT expired or lost
Administration: read & write. The removal token is minted fresh at shutdown, so an expired PAT breaks teardown even though startup worked earlier. - Container removed with
docker rm -f, which skips SIGTERM entirely.
Harmless to fix by hand: delete the entry in the GitHub UI. --replace on the
next start also reclaims a runner of the same name.
| Log line | Cause |
|---|---|
ERROR: REPO_URL must be an https:// URL |
An ssh remote in .env. Use the https://github.com/owner/repo form |
curl … 404 from token.sh |
Wrong REPO_URL, or the PAT is not scoped to that repository |
curl … 403 from token.sh |
PAT lacks Administration: Read and write |
jq: error … null |
GitHub returned a response without a token — usually the 403 above |
Http response code: NotFound from 'POST …/actions/runner-registration' |
Registration token expired before config.sh used it. Restart |
Test the PAT directly:
curl -fsS -X POST \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Accept: application/vnd.github+json" \
https://api.github.com/repos/OWNER/REPO/actions/runners/registration-tokenThe job fails with DaemonDisappearedException and a daemon log that stops
mid-build. This is almost always the kernel OOM-killer, not a crash. The tell
is that Gradle never printed java.lang.OutOfMemoryError — the JVM did not
exhaust its own heap, it was SIGKILLed from outside.
Confirm it against the Docker VM's kernel log:
docker run --rm --privileged --pid=host alpine \
nsenter -t 1 -m -u -n -i dmesg | grep -iE "killed process|out of memory"A hit naming task=java and this container's ID is proof:
Out of memory: Killed process 6187 (java) … anon-rss:3291992kB
task_memcg=/docker/f656a5eb52ba… global_oom
global_oom means the whole VM ran out, not a per-container limit —
/sys/fs/cgroup/memory.max is max here, so the container is only bounded by
Docker Desktop's allocation.
Compare what Gradle asks for against what exists:
docker compose exec runner free -m # VM total
grep jvmargs ../utilityvault/gradle.properties # what the build wantsObserved 2026-07-29: -Xmx4g requested inside a 3.83 GB VM on an 8 GB Mac.
Unwinnable. org.gradle.parallel=true and org.gradle.workers.max=4 make it
worse by forking further JVMs alongside the daemon.
Fixes, in order of leverage:
- Cap the heap for the self-hosted path only — in
ci.yml, guarded byinputs.runner, never by editing the target repo'sgradle.properties(4g is correct for local development on the same machine). - Raise Docker Desktop → Resources → swap before raising memory. Swap absorbs a short peak; on an 8 GB host there is little RAM left to give.
- Give the runner a cheaper job. See decisions.md § memory ceiling.
Expected to some degree — the image runs under x86_64 emulation on Apple Silicon (see decisions.md). Check Docker Desktop → General → "Use Rosetta for x86_64/amd64 emulation" is enabled; without it, emulation falls back to QEMU and is far slower.
Also verify the Gradle cache is actually persisting:
docker compose exec runner du -sh /cache/gradleA few hundred MB after the first build is normal. Near-zero means
GRADLE_USER_HOME is not landing on the volume.
docker compose exec runner du -sh /_work /cache/gradle /opt/hostedtoolcache
docker system dfhooks/post-job.sh prunes the workspace only above BUILD_CACHE_MAX_MB
(default 5000) — lower it in .env. Note it deliberately never touches
/cache/gradle, which is the expensive cache worth keeping.
The gh CLI is installed by the Dockerfile, so this means the image is stale
relative to it:
docker compose up -d --buildSame applies to any tool added to the Dockerfile after the image was last
built.
The baked SDK covers platforms;android-36.1 and build-tools;36.0.0. Note the
.1: compileSdk's minorApiLevel selects a different package, so a pin of
36 looks right and still makes AGP download 36.1 mid-build. If Gradle
wants something else, add it to the sdkmanager --install line in the
Dockerfile and rebuild — see
decisions.md.
runs-on: labels do not match the runner. Compare the workflow's runs-on: list
against RUNNER_LABELS in .env and the labels shown in the GitHub UI. Every
label in runs-on: must be present on the runner.
For ci.yml specifically, the more likely cause is that the run was not
dispatched with runner=self-hosted — PRs always resolve to a hosted runner by
design, and there is no push trigger at all. See
workflows.md.
./test_token.sh
bash -n entrypoint.sh token.sh hooks/post-job.sh