How a self-hosted runner actually works, and which file here owns which part of it. For the reasoning behind these choices see decisions.md.
GitHub refuses to let a runner register with a permanent credential.
config.sh only accepts a registration token that expires in about an hour.
So something must mint a fresh one every time the container starts, using a
longer-lived credential. That is the entire reason token.sh exists, and the
axis along which every runner implementation varies.
Build time (Dockerfile) — download and unpack the official
actions/runner release tarball. That
yields:
| Component | Role |
|---|---|
bin/Runner.Listener |
The long-running process that polls GitHub for jobs |
bin/Runner.Worker |
Spawned per job to execute the workflow steps |
externals/ |
Bundled Node.js and git helpers — what every uses: JavaScript action runs on |
config.sh / run.sh / svc.sh |
Shell wrappers around the binary |
Nothing contacts GitHub at this stage. This phase just puts the program on disk, alongside the Android toolchain layered on top of it.
Container start (entrypoint.sh) — four steps:
- Mint —
token.shPOSTs to/repos/{owner}/{repo}/actions/runners/registration-tokenwith the PAT and gets back a ~1-hour token. - Register —
config.sh --unattended --replace --disableupdatetells GitHub a runner with this name and these labels exists, and writes a.runnercredentials file locally. - Poll — the Dockerfile's
CMD(Runner.Listener run) opens a long-poll connection and blocks. On a job: spawnRunner.Worker, execute the steps, run the post-job hook, resume polling. - Deregister — on SIGTERM, let the current job finish, mint a fresh token
(the registration one is long spent), then
config.sh remove. Skipping this leaves a permanently "offline" runner listed in the repo settings.
| File | Owns |
|---|---|
entrypoint.sh |
Register, run, deregister. Deliberately toolchain-free — no Java, Android or Gradle references, so it is reusable as-is for a runner serving a different kind of repo |
token.sh |
PAT → registration token. Repo-scoped, github.com only |
Dockerfile |
JDK 21, Android SDK, gh, git, and all toolchain env vars |
docker-compose.yml |
Cache volumes, linux/amd64 platform, shutdown grace period |
hooks/post-job.sh |
Prunes build output once the workspace crosses a size threshold |
.env |
PAT, REPO_URL, labels — gitignored |
test_token.sh |
Asserts the REPO_URL → API path parsing |
The split matters: adding a runner for a Python or Node repo later means writing
a new Dockerfile and reusing entrypoint.sh and token.sh unchanged.
entrypoint.sh runs the listener in the background and waits on it, which
looks like a mistake and is not:
"$@" &
RUNNER_PID=$!
trap 'shutdown SIGTERM' TERM
wait "${RUNNER_PID}" || trueBash defers signal handlers while a foreground child runs. With a foreground
listener, SIGTERM from docker compose down would sit pending until the listener
exited on its own — Docker would SIGKILL at the grace deadline and the runner
would never deregister. wait is interruptible, so the handler fires
immediately. For the same reason the listener is never exec'd: exec replaces
the shell and destroys the traps.
stop_grace_period: 2m in the compose file gives an in-flight job time to
finish before that deadline.
This implementation is repo-scoped with PAT auth, on github.com, persistent (not ephemeral), running as root, with no docker-in-docker. Everything outside that was deliberately left out — see decisions.md § Deliberately not built.