Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 15 additions & 6 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# Copy to .env and fill in. Never commit .env.
# Variables interpolated by docker-compose.yml for the 3-runner X64 layout.
# Variables interpolated by docker-compose.yml. Per-host examples with the
# target labels live in hosts/ (hetzner, beelink, rpi5).

# GitHub App that registers the runners (required)
APP_ID=
Expand All @@ -9,10 +10,18 @@ APP_LOGIN=

# Optional — values match the compose defaults
RUNNER_NAME_PREFIX=runner
LABELS=self-hosted,Linux,X64,beelink,docker,large
LABELS=self-hosted,Linux,X64,docker,x86,large,light,beelink
RUNNER_GROUP=Default
DISABLE_AUTO_UPDATE=false

# EPHEMERAL is hard-coded to "false" in docker-compose.yml so the three
# runners stay persistent and keep their per-runner cache volumes.
# Setting it here has no effect.
# Runner self-update. The upstream entrypoint tests this with `[ -n ... ]`,
# so ANY non-empty value disables auto-update - "false" included. Leave it
# unset/empty to keep auto-update on; set it to e.g. "true" to turn it off.
# DISABLE_AUTO_UPDATE=true

# EPHEMERAL is hard-coded to "true" in docker-compose.yml (each runner takes
# one job, then a fresh container registers again; the caches live on the
# named volumes). Setting it here has no effect.

# Nightly pruner (see pruner.sh)
# CACHE_CAP_BYTES=21474836480
# PRUNE_INTERVAL_SECONDS=86400
2 changes: 2 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ jobs:
VERSION=${{ steps.versions.outputs.runner_tag }}
JAVA_VERSION=${{ matrix.java_version }}
NODE_VERSION=${{ matrix.node_version }}
RUNNER_IMAGE_TAG=pr-${{ github.event.pull_request.number }}
RUNNER_IMAGE_REVISION=${{ github.event.pull_request.head.sha }}

gate:
name: All builds passed
Expand Down
13 changes: 13 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,17 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4

# Image identity baked into the image as RUNNER_IMAGE_TAG /
# RUNNER_IMAGE_REVISION (Dockerfile ARG -> ENV). The tag is the fully
# pinned one, which never changes meaning; the revision is the commit.
- name: Docker metadata (image identity)
id: meta
uses: docker/metadata-action@v6
with:
images: ghcr.io/${{ github.repository_owner }}/github-runner
tags: |
type=raw,value=${{ steps.versions.outputs.runner_version }}-jdk${{ matrix.java_version }}-sdk${{ steps.versions.outputs.compile_sdk }}-node${{ matrix.node_version }}

- name: Build and push by digest
id: build
uses: docker/build-push-action@v7
Expand All @@ -89,6 +100,8 @@ jobs:
VERSION=${{ steps.versions.outputs.runner_tag }}
JAVA_VERSION=${{ matrix.java_version }}
NODE_VERSION=${{ matrix.node_version }}
RUNNER_IMAGE_TAG=${{ steps.meta.outputs.version }}
RUNNER_IMAGE_REVISION=${{ github.sha }}
outputs: type=image,name=ghcr.io/${{ github.repository_owner }}/github-runner,push-by-digest=true,name-canonical=true,push=true

- name: Export digest
Expand Down
28 changes: 27 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -101,11 +101,28 @@ LABEL maintainer="asen.lekov@dodi.li"
# the only other option, `safe.directory=*`, turns the check off for everything.
ENV TAR_OPTIONS=--no-same-owner

# Tool caches that outlive a job. Jobs run as root (HOME=/root), so these are
# the tools' own defaults made explicit: docker-compose.yml mounts one named
# volume on each of them. Mount the specific directories, never ~/.cache as a
# whole, or it would shadow the pnpm volume at ~/.cache/pnpm.
# - mise: tool installs (data) and downloads (cache). jdx/mise-action and
# `mise install` fill these.
# - hk: HK_CACHE_DIR is its config/tool cache. HK_PKL_CACHE_DIR defaults to
# ~/.cache/pklr, a sibling of ~/.cache/hk, so it is pinned underneath
# HK_CACHE_DIR to live on the same volume.
ENV MISE_DATA_DIR=/root/.local/share/mise \
MISE_CACHE_DIR=/root/.cache/mise \
HK_CACHE_DIR=/root/.cache/hk \
HK_PKL_CACHE_DIR=/root/.cache/hk/pklr

# Job hooks: JOB_STARTED is the poison sweep (covers OOM / killed runner);
# JOB_COMPLETED is the tidy-up. Neither wipes package / Gradle caches.
COPY pre-job.sh /usr/local/bin/pre-job.sh
COPY cleanup.sh /usr/local/bin/cleanup.sh
RUN chmod +x /usr/local/bin/pre-job.sh /usr/local/bin/cleanup.sh
# pruner.sh is not a hook: the `pruner` compose service runs it nightly
# (LRU cache caps + docker prune), keeping those scans off the job path.
COPY pruner.sh /usr/local/bin/pruner.sh
RUN chmod +x /usr/local/bin/pre-job.sh /usr/local/bin/cleanup.sh /usr/local/bin/pruner.sh
ENV ACTIONS_RUNNER_HOOK_JOB_STARTED=/usr/local/bin/pre-job.sh
ENV ACTIONS_RUNNER_HOOK_JOB_COMPLETED=/usr/local/bin/cleanup.sh

Expand All @@ -117,6 +134,15 @@ ENV ACTIONS_RUNNER_HOOK_JOB_COMPLETED=/usr/local/bin/cleanup.sh
# registration happens instead. See preflight-entrypoint.sh for details.
COPY preflight-entrypoint.sh /usr/local/bin/preflight-entrypoint.sh
RUN chmod +x /usr/local/bin/preflight-entrypoint.sh
# Image identity. deploy.yml passes the image tag and the commit it was built
# from; jobs read them (e.g. the runner report) to tell which image they ran
# on. They default to "dev" for local builds. Declared last: the values change
# on every build, so anything after them would never hit the layer cache.
ARG RUNNER_IMAGE_TAG=dev
ARG RUNNER_IMAGE_REVISION=dev
ENV RUNNER_IMAGE_TAG=$RUNNER_IMAGE_TAG \
RUNNER_IMAGE_REVISION=$RUNNER_IMAGE_REVISION

ENTRYPOINT ["/usr/local/bin/preflight-entrypoint.sh"]
# Declaring ENTRYPOINT resets the CMD inherited from the base image, so the
# base image's CMD is restated here. See
Expand Down
148 changes: 126 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,39 +17,142 @@ Linux hosts run natively without emulation.
To run the container, check out the environment variables from the base image:
https://github.com/myoung34/docker-github-actions-runner#environment-variables

[`docker-compose.yml`](docker-compose.yml) is the X64 beelink layout — three
persistent runners, each with its own work **and** cache volumes — not a
side file. After pulling a new image on the host:
[`docker-compose.yml`](docker-compose.yml) is the X64 layout (beelink by
default; hetzner uses the same file with its own `.env`) — three ephemeral
runners, each with its own work **and** cache volumes, plus a nightly
`pruner`. Per-host `.env` examples with the target labels are in
[`hosts/`](hosts/). After pulling a new image on a host:

```
docker compose pull && docker compose up -d
```

Existing named `runner-work*` volumes keep their data; new cache volumes
start empty and warm on first jobs. Do not share one bun/gradle volume
start empty and warm on first jobs. Do not share one bun/gradle/mise volume
across the three containers: concurrent jobs would corrupt the same tree.

## Runner pools and labels

Jobs pick runners by capability label, never by machine name. Each host's
`.env` sets `LABELS` (the runner binary adds `self-hosted`, the OS and the
architecture itself):

| Pool | `.env` example | `LABELS` | Caches |
|------|----------------|----------|--------|
| hetzner (X64) | [`hosts/hetzner.env.example`](hosts/hetzner.env.example) | `self-hosted,Linux,X64,docker,x86,light,agent,hetzner` | all volumes |
| beelink (X64) | [`hosts/beelink.env.example`](hosts/beelink.env.example) | `self-hosted,Linux,X64,docker,x86,large,light,beelink` | all volumes |
| rpi5 (ARM64) | [`hosts/rpi5.env.example`](hosts/rpi5.env.example) | `self-hosted,Linux,ARM64,docker,agent,rpi5` | work only |

| Capability label | Meaning |
|------------------|---------|
| `light` | default checks, release, deploy steps (X64 only: no rpi5) |
| `large` | heavy jobs: Gradle/Android, Next.js builds (beelink) |
| `agent` | review, triage, assist (hetzner and rpi5) |

The rpi5 pool is an agent/utility pool: it has no `light` label and must not
mount the heavy cache volumes. Its `.env` example sets
`COMPOSE_FILE=docker-compose.yml:hosts/rpi5.compose.yml`, which layers
[`hosts/rpi5.compose.yml`](hosts/rpi5.compose.yml) over the base file so each
runner keeps only `docker.sock` and its work volume (and the pruner only
prunes Docker).

### Rolling out a label change

Runners are ephemeral and re-register on their next job, so changing labels is
a `.env` edit plus a recreate:

1. Copy the host's `hosts/<host>.env.example` values into its `.env` (keep the
real `APP_*` values). On rpi5 also keep the `COMPOSE_FILE` line.
2. `docker compose pull && docker compose up -d`.
3. Check the pool in the org's runner list (Settings, Actions, Runners): each
runner should show the labels above. `docker compose ps` shows `healthy`
once `Runner.Listener` is up.

## Ephemeral runners

`EPHEMERAL` is `"true"` in the compose file. The upstream entrypoint
(`myoung34/github-runner`) only tests `[ -n "${EPHEMERAL}" ]` (and the same for
`DISABLE_AUTO_UPDATE`), so any non-empty value, `"false"` included, passes
`--ephemeral` to `config.sh`. The previous `EPHEMERAL: "false"` therefore
never kept runners persistent: they have always been ephemeral, one job per
container, restarted by `restart: unless-stopped`. The compose file now says
what actually happens. Each job gets a clean container; the caches survive on
the named volumes below.

`DISABLE_AUTO_UPDATE` is empty by default. Set it to any non-empty value in
`.env` to disable runner self-update (`false` disables it too).

## Health check

Each runner reports healthy while `Runner.Listener` is alive
(`pgrep -f Runner.Listener`, exec form: under `CMD-SHELL` the wrapping
`sh -c` would match its own command line). The container restarts after each
ephemeral job; `start_period` (120 s) keeps the re-registration from counting
as failures.

## Image identity

The image carries `RUNNER_IMAGE_TAG` (the fully pinned tag, e.g.
`2.338.0-jdk21-sdk36-node24`) and `RUNNER_IMAGE_REVISION` (the commit it was
built from) as environment variables, set from build args by `deploy.yml`
(`build.yml` sets `pr-<number>` and the head SHA). Jobs can print them to tell
which image they ran on. Local builds default to `dev`.

## X64 cache volumes

This compose is for the **X64** class (`beelink`, `docker`, `large`). System
labels (`Linux`, `X64`) are still applied by the runner binary; set
`LABELS` in `.env` to `beelink,docker,large` (do not add a `build` label).
`EPHEMERAL` stays `false` so the volumes survive between jobs.
This compose is for the **X64** class (`beelink`, `hetzner`). The `LABELS`
default is the beelink set; hosts override it in `.env` (see the table above).
The runners are ephemeral, but the volumes survive between containers and
jobs.

Each runner gets its own:

| Mount | Volume (runner / runner-2 / runner-3) | Why |
|-------|----------------------------------------|-----|
| `/tmp/runner/work` | `runner-work*` | Job workspace. Cleanup spares `$RUNNER_WORKDIR`. |
| `/root/.bun` | `runner-bun*` | Bun install cache |
| `/root/.npm` | `runner-npm*` | npm cache |
| `/root/.cache/pnpm` | `runner-pnpm*` | pnpm store |
| `/root/.gradle` | `runner-gradle*` | Gradle modules, build-cache, transforms |
| `/root/.cargo` | `runner-cargo*` | Cargo registry |

Budget ~20 GB per runner (enforced by the post-job hook with LRU, not a
wipe), ~60 GB per beelink. ARM64 / Pi hosts should **not** mount these
cache volumes — those runners isolate to `RUNNER_TEMP`.
| Mount | Volume (runner / runner-2 / runner-3) | Why | Capped by pruner |
|-------|----------------------------------------|-----|------------------|
| `/tmp/runner/work` | `runner-work*` | Job workspace. Cleanup spares `$RUNNER_WORKDIR`. | no |
| `/root/.bun` | `runner-bun*` | Bun install cache | yes |
| `/root/.npm` | `runner-npm*` | npm cache | no |
| `/root/.cache/pnpm` | `runner-pnpm*` | pnpm store | no |
| `/root/.gradle` | `runner-gradle*` | Gradle modules, build-cache, transforms | yes |
| `/root/.cargo` | `runner-cargo*` | Cargo registry | no |
| `/root/.rustup` | `runner-rustup*` | Rust toolchains (issue #8) | no |
| `/root/.konan` | `runner-konan*` | Kotlin/Native toolchain and dependencies | no |
| `/root/.local/share/mise` | `runner-mise-data*` | mise tool installs (`MISE_DATA_DIR`) | yes, whole tool versions |
| `/root/.cache/mise` | `runner-mise-cache*` | mise downloads (`MISE_CACHE_DIR`) | yes |
| `/root/.cache/hk` | `runner-hk*` | hk cache, incl. pkl (`HK_CACHE_DIR`, `HK_PKL_CACHE_DIR`) | yes |

The Dockerfile sets `MISE_DATA_DIR`, `MISE_CACHE_DIR`, `HK_CACHE_DIR` and
`HK_PKL_CACHE_DIR` so the tools write where the volumes are mounted. The
`~/.cache` directory is never mounted as a whole: that would shadow the pnpm
volume, so each subdirectory has its own volume. `jdx/mise-action` should use
`cache: false` on these runners; the volumes replace its GitHub-cache step.

Budget ~20 GB per capped cache directory (enforced by the nightly pruner with
LRU, not a wipe). The remaining volumes (npm, pnpm, cargo, rustup, konan) are
not capped yet (issue #7 tracks the bun/gradle-only caps; hard-linked stores
need care). ARM64 / Pi hosts should **not** mount these cache volumes — those
runners isolate to `RUNNER_TEMP`, see `hosts/rpi5.compose.yml`.

## Maintenance: per-job cleanup and the nightly pruner

`cleanup.sh` (the `JOB_COMPLETED` hook) only does the cheap tidy-up after each
job: `/tmp` outside the workdir, Gradle daemon/journals/locks, runner `_diag`
logs. The expensive scans used to run there on every job and cost 21–39 s each
(issue #16). They now run once a day in the `pruner` compose service, which
uses the same image with [`pruner.sh`](pruner.sh) as its entrypoint and
mounts only the capped cache volumes plus `docker.sock`:

- LRU size caps (`CACHE_CAP_BYTES`, default 20 GiB) on gradle, bun, the mise
cache and the hk cache, evicting by last access time; mise installs are
evicted as whole `tool/version` directories so a tool is never left half
deleted (`MISE_DATA_CAP_BYTES`).
- `docker system prune -af --filter until=24h`
- `docker builder prune -af --filter until=72h`

Named volumes are never pruned. The first run is 5 minutes after start
(`PRUNE_INITIAL_DELAY`), then every `PRUNE_INTERVAL_SECONDS` (86400). Run it
once by hand with
`docker compose run --rm -e PRUNE_RUN_ONCE=true pruner`.

Do **not** tmpfs `/tmp`. `RUNNER_WORKDIR` is `/tmp/runner/work`; a tmpfs
or a blanket `find /tmp -delete` hides or wipes the work volume.
Expand All @@ -58,7 +161,8 @@ or a blanket `find /tmp -delete` hides or wipes the work volume.

Named volumes are unused if the job relocates the caches. On these X64
self-hosted runners, workflows must **not** isolate `GRADLE_USER_HOME` or
`BUN_INSTALL_CACHE_DIR` (or `npm_config_cache` / `CARGO_HOME`) to
`BUN_INSTALL_CACHE_DIR` (or `npm_config_cache` / `CARGO_HOME` /
`MISE_DATA_DIR` / `MISE_CACHE_DIR` / `HK_CACHE_DIR`) to
`$RUNNER_TEMP`. That is the `setup-stack` local-home mode in
`dodi-smart/.github` — `deps-verify` stays isolated; `pr-checks` uses the
default homes so the volumes are actually read.
Expand Down Expand Up @@ -180,7 +284,7 @@ docker build -f Dockerfile .
Build args can be used to customize the image. See the top of the
[`Dockerfile`](Dockerfile) for the current defaults and available args
(`VERSION`, `JAVA_VERSION`, `NODE_VERSION`, `COMPILE_SDK`, `BUILD_TOOLS`,
`NDK_VERSION`, `SDK_TOOLS`).
`NDK_VERSION`, `SDK_TOOLS`, `RUNNER_IMAGE_TAG`, `RUNNER_IMAGE_REVISION`).

Example:
```
Expand Down
Loading
Loading