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
187 changes: 187 additions & 0 deletions .agents/skills/github-runner/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
---
name: github-runner
description: 'GitHub Actions self-hosted runner as a supervised container service
— rootless

(uid 1000), Arch/CachyOS packages, credential-backed registration token.

Use when working with GitHub Actions runners, CI/CD infrastructure, or runner registration.'
---

# github-runner -- GitHub Actions self-hosted runner

## Candy Properties

| 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 -R`ed to uid 1000 (the one ownership `cmd:`) |

## 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).

## Environment / token contract

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

## Lifecycle Hooks (token-guarded)

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

## Usage

```yaml
# 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
```

```bash
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

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`):

```bash
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

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.

## Used In Boxes

- `/charly-distros:githubrunner`

## Related Candies

- `/charly-infrastructure:supervisord` — process manager dependency
- `/charly-distros:container-nesting` — rootless nested podman/buildah/skopeo + subuid layout + caps

## When to Use This Skill

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

## Author + Test References

- `/charly-image:layer` — candy authoring reference (tasks, vars, secret_accept, check block syntax)
- `/charly-check:check` — declarative testing framework for the `check:` block + the `check-githubrunner-pod` bed
- `/charly-build:secrets` — the credential store backing `RUNNER_TOKEN`
Loading
Loading