A self-hosted GitHub Actions runner in a container, built from the official
actions/runner release tarball with an
Android/Gradle toolchain layered on top.
Standalone by design: point it at a different repo by editing REPO_URL in
.env. Registration is scoped by an API call, not by where these files live, so
target repo and location on disk are independent.
- Current target:
utilityvault(Android, Gradle 9.3.1, AGP 9.1.1, compileSdk 36.1) - Host: macOS / Apple Silicon, 8 GB, running the image under x86_64 emulation
- Scope: single repo, PAT auth, github.com, persistent (not ephemeral)
Registration, polling and clean deregistration are verified working. The known
limitation is memory, not mechanism: ci.yml's two-flavour lint asks for a 4 GB
Gradle heap and the Docker VM has 3.83 GB, so that particular job gets OOM-killed
— see docs/decisions.md.
cp .env.example .env # paste a fine-grained PAT, set REPO_URL
./test_token.sh # sanity check, no network needed
docker compose up -d --build
docker compose logs -f # expect "Listening for Jobs"Then confirm the runner appears under the target repo's Settings → Actions →
Runners, and that docker compose down makes it disappear rather than go
offline.
Full walkthrough, including how to create the PAT: docs/setup.md.
The Dockerfile downloads the runner tarball — Runner.Listener (long-polls
GitHub for jobs), Runner.Worker (spawned per job), and a bundled Node.js that
every uses: action runs on. At container start, entrypoint.sh trades the PAT
for a short-lived registration token, registers with config.sh, runs the
listener, and deregisters on shutdown.
Details: docs/architecture.md.
Dockerfile JDK 21 + Android SDK + gh + git + runner tarball (all pinned)
entrypoint.sh register / run / deregister — deliberately toolchain-free
token.sh PAT -> 1-hour registration token
docker-compose.yml cache volumes, linux/amd64, shutdown grace period
hooks/post-job.sh prunes build output past a size threshold
test_token.sh asserts REPO_URL -> API path parsing
.env PAT, REPO_URL, labels (gitignored)
docs/ reference documentation — start at docs/README.md
reference/ myoung34's image, for reading only (gitignored)
| Page | Covers |
|---|---|
| docs/architecture.md | Registration, polling and shutdown mechanics |
| docs/setup.md | PAT creation, first run, retargeting, version bumps |
| docs/workflows.md | Required .github/workflows/ edits in the target repo |
| docs/decisions.md | Why each choice, and what was deliberately cut |
| docs/reference.md | myoung34's implementation, mapped |
| docs/troubleshooting.md | Symptoms and fixes |
Built by reading myoung34/docker-github-actions-runner (MIT) rather than depending on it — the goal was to learn the actual mechanism instead of a wrapper's env-var interface over it. Its trap-and-deregister shutdown pattern and registration-token call are the parts most directly borrowed, and it remains the recommendation for anyone who needs org/enterprise scope or GitHub App auth.
A clone lives in reference/ (gitignored) while this project is being built.
See docs/reference.md for a file-by-file map of what it does
and which parts were used.
Also relevant, and not used:
actions/runner— the runner agent itself. Its prebuilt release tarball is what this image installs; compiling the C# source ourselves would add nothing.actions/runner-images— Packer definitions for GitHub's hosted runner VMs. Not Docker, not runnable here, and would bring a 20GB+ image of tools this project does not need.
On a persistent self-hosted runner, a PR from a fork would execute untrusted code
on this machine with access to the cache volumes. ci.yml is wired so that
pull_request always resolves to a hosted runner and only a manual dispatch can
select self-hosted — fork PRs cannot reach it. Keep it that way: pointing any
automatic trigger at this runner makes the risk live again. See
docs/workflows.md.