Skip to content

github-runner

Recipe card from the charly-distros plugin (Images — the deployable catalog).

github-runner – GitHub Actions self-hosted runner

Section titled “github-runner – GitHub Actions self-hosted runner”
Property Value
Dependencies supervisord, container-nesting
Volume state -> ${HOME}/actions-runner
Service github-runner (supervisord; runs run.sh as uid 1000)
Security none of its own — rootless nested podman comes from container-nesting (no privileged)
Install files task: — declarative pinned download: of the runner tarball + write: of the ghcr mirror config; the runner tree is root-extracted (the shared download cache is root-owned) then chown -Red to uid 1000 (the one ownership cmd:)

Packages (distro.arch, all in the official core/extra repos)

Section titled “Packages (distro.arch, all in the official core/extra repos)”
  • Toolchain: jq, git, go, cloud-guest-utils, cosign, inetutils (inetutils is hostname(1)’s only Arch provider — charly’s own venue tests run hostname to prove a peer executed host-locally, and without the binary they fail with exit 127; see CI job tool surface below)

  • Cross-arch CI: qemu-user-static, qemu-user-static-binfmt (aarch64 binfmt)

  • .NET runtime deps for the runner binary (its installdependencies.sh has no Arch branch): icu, krb5, openssl, libunwind, lttng-ust (NOT zlib — CachyOS ships zlib-ng-compat, which Provides it; an explicit zlib conflicts)

  • charly-host depends= completion: slirp4netns, libisoburn, cdrtools, swtpm — the part of the charly package’s depends= set (packaging.formats.archlinux.depends in the charly candy) the charly/virtualization candies do not already install, so the charly CLI (and the CI jobs that drive it) runs fully on the runner. (The legacy charly box pkg release-packages build that ran here is removed with the nFPM cutover — the charly generate-packages plugin + the per-distro repos build the packages now.)

  • CI job tool surface — the pool is org-wide, so a job must find whatever the org’s workflows actually call. Every entry below carries a package: check in the candy’s own plan:, so a tool that is declared but not installed fails the image build rather than a job at 02:00: tree, shellcheck, shfmt, actionlint, golangci-lint (so a job can lint without its own go install; the packaged build measured 2.14.0, and it is not what CI’s gate uses — that installs its own pinned @v2.13.2 each run, so the image cannot decide a CI lint verdict), nodejs, npm, python-pip, uv, cmake, unzip, zip, 7zip, wget, rsync, yq, ripgrep, moreutils, bc, less, lsof, strace, openbsd-netcat, bind. Deliberately absent, as decisions rather than omissions: docker (16 refs exist, all docker run --rm -v … distro package builds on hosted runners — a podman shim is not docker, and quietly swapping the two in CI trades a loud failure for a silent wrong one, R4); rust/cargo (6 refs, in repos not on the pool); a JDK and the cloud CLIs (zero refs org-wide); and passwordless sudo (8 workflow files use sudo, all in distro repos building packages on hosted runners — NOPASSWD root inside a long-lived container that runs org PRs would widen an escape path for a convenience nothing on the pool asks for).

podman/buildah/skopeo/crun/fuse-overlayfs are provided by the container-nesting dependency (not redeclared here — R3).

Variable Mechanism
RUNNER_ORG env_accept (plaintext identifier; supplied at deploy)
RUNNER_TOKEN secret_accept (credential-store-backed; never in charly.yml/quadlet)
RUNNER_WORK_DIR ${HOME}/actions-runner/_work
RUNNER_LABELS env_accept — the labels a job’s runs-on: selects on; the hook defaults to opencharly
LANG C.UTF-8 (fixed) — see Locale below
TZ UTC (fixed) — deterministic log timestamps

Locale. The base image generates none: locale -a lists only C, C.utf8 and POSIX, and with LANG unset a non-login process is in the POSIX locale, so locale charmap reports ANSI_X3.4-1968 (ASCII). LANG=C.UTF-8 is set in the candy’s env:; glibc has C.UTF-8 built in, so no locale needs generating. The base image’s /etc/locale.conf also names C.UTF-8, but that reaches only login/PAM sessions, and both a charly check and a job process are neither — the env: declaration is the one that applies, which is why the plan: assertion (locale charmap under context: runtime) genuinely fails without it. It is a claim about the locale the tools see, not about Python: CPython >= 3.7 coerces the C locale itself and reports utf-8 either way. Note that the TZ check asserts the variable (printenv TZ), not date +%Z: the image’s /etc/localtime is already UTC, so date +%Z would pass either way and prove nothing.

Runner version. RUNNER_VERSION seeds the initial download — it is not a pin. The actions runner self-updates on its own schedule, and because ~/actions-runner is a persistent volume that update survives an image rebuild: measured on the live fleet, bin symlinks to bin.2.337.0 while the deployed image declares 2.334.0 (and bin.2.334.0 is still on disk beside it). --disableupdate is deliberately not set, so the fleet stays inside GitHub’s supported runner range without anyone bumping the value; the cost is that the declared number describes a fresh volume, not a running one.

The runner installs under ${HOME}/actions-runner and runs as uid 1000. The ghcr.io pull-through mirror (127.0.0.1:5000) config is written to the user location ${HOME}/.config/containers/registries.conf.d/ (rootless podman).

  • post_enable — registers the runner with RUNNER_ORG + RUNNER_TOKEN. Skips (no-op) when RUNNER_TOKEN is empty — so a token-less deploy (an check bed) brings the image up without registering.
  • pre_remove — deregisters; needs a remove-token (distinct from the registration token). Also skips when the token is empty.
# charly.yml — rootless, CachyOS
githubrunner:
base: cachyos.cachyos
build: [pac]
candy: [agent-forwarding, github-runner, charly, dbus, container-nesting]
network: host # no uid/privileged override → rootless
Terminal window
TOKEN=$(gh api -X POST /orgs/myorg/actions/runners/registration-token --jq .token)
charly config githubrunner -e RUNNER_ORG=myorg -e RUNNER_TOKEN="$TOKEN"
charly remove githubrunner -e RUNNER_TOKEN=$(gh api -X POST /orgs/myorg/actions/runners/remove-token --jq .token)

Org-runner routing: a queued job is almost never an agent problem

Section titled “Org-runner routing: a queued job is almost never an agent problem”

A job that shows labels: ["<your-label>"], status: queued, runner_name: "" while the runner reads online + busy: false is a ROUTING failure, not a runner failure — the runner has nothing to execute. Work the causes in order:

  1. The runner group does not allow public repos (the silent one). An org runner in a group whose allows_public_repositories is false is INVISIBLE to every public repo in the org, so their jobs queue forever with no error anywhere. Check and fix with a REAL boolean (the -f flag sends the string "true", which the API rejects as "true" is not a boolean — use --input):

    Terminal window
    gh api /orgs/<org>/actions/runner-groups --jq '.runner_groups[]|{id,name,allows_public_repositories,visibility}'
    echo '{"allows_public_repositories": true}' > rg.json
    gh api -X PATCH /orgs/<org>/actions/runner-groups/<id> --input rg.json
  2. labels mismatch. The runner must carry every label runs-on names. Compare gh api /orgs/<org>/actions/runners --jq '.runners[0].labels' against the job’s labels (a reusable workflow may resolve a vars. label you forgot to set, e.g. vars.REVIEW_RUNNER_LABEL).

  3. A stale broker session. After a container restart the listener can log A session for this runner already exists / TaskAgentSessionConflictException and retry on a 30s loop without taking jobs, while still reporting online. A full clean cycle clears it — charly remove (deregisters) then charly config + charly start — not just a restart.

Confirm the fix by watching the job, not the runner: a picked-up job flips to in_progress with runner_name set, and the workflow’s steps then appear.

gh must be present for a review/gate runner

Section titled “gh must be present for a review/gate runner”

A runner whose job is to execute gh-driven workflows (the org pr-validator runs gh api / gh pr comment / gh pr merge) needs the gh CLI in its image — compose the layer-gh candy (it is not part of the base toolchain). A runner without it fails the workflow at its first gh call.

Use when the user asks about:

  • GitHub Actions self-hosted runners
  • Runner registration or deregistration
  • CI/CD container infrastructure
  • RUNNER_TOKEN or RUNNER_ORG configuration