Skip to content

Latest commit

 

History

History
87 lines (68 loc) · 3.86 KB

File metadata and controls

87 lines (68 loc) · 3.86 KB

Architecture

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.

The one problem being solved

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.

Two phases

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:

  1. Minttoken.sh POSTs to /repos/{owner}/{repo}/actions/runners/registration-token with the PAT and gets back a ~1-hour token.
  2. Registerconfig.sh --unattended --replace --disableupdate tells GitHub a runner with this name and these labels exists, and writes a .runner credentials file locally.
  3. Poll — the Dockerfile's CMD (Runner.Listener run) opens a long-poll connection and blocks. On a job: spawn Runner.Worker, execute the steps, run the post-job hook, resume polling.
  4. 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 responsibilities

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.

Signal handling

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}" || true

Bash 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.

Scope boundaries

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.