From d5f2b5e6f670db1670adf6b225423dfd454d49bc Mon Sep 17 00:00:00 2001 From: charly Date: Tue, 20 Oct 2026 00:06:11 +0200 Subject: [PATCH] feat(skills): move the skill bodies to .agents/skills The bodies lived only as inline content: blocks on the skill: nodes. They now live in the repo that owns them, at .agents/skills//SKILL.md, and each node keeps its routing metadata (name, family, owner, description) with no body of its own. The plugin-marketplace generator reads this shape (skillfile.go): it takes the body from the first declared tree that holds the file and hard-errors when an inline content: disagrees with the file, so no stale second copy can survive. --- .agents/skills/github-runner/SKILL.md | 187 ++++++++++++++++++++++++++ charly.yml | 178 ------------------------ 2 files changed, 187 insertions(+), 178 deletions(-) create mode 100644 .agents/skills/github-runner/SKILL.md diff --git a/.agents/skills/github-runner/SKILL.md b/.agents/skills/github-runner/SKILL.md new file mode 100644 index 0000000..15ef0f1 --- /dev/null +++ b/.agents/skills/github-runner/SKILL.md @@ -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: [""]`, `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//actions/runner-groups --jq '.runner_groups[]|{id,name,allows_public_repositories,visibility}' + echo '{"allows_public_repositories": true}' > rg.json + gh api -X PATCH /orgs//actions/runner-groups/ --input rg.json + ``` + +2. **`labels` mismatch.** The runner must carry every label `runs-on` names. + Compare `gh api /orgs//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` diff --git a/charly.yml b/charly.yml index 2bd5f95..bf3bcb7 100644 --- a/charly.yml +++ b/charly.yml @@ -475,182 +475,4 @@ github-runner-skill: 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. - content: | - # 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: [""]`, `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//actions/runner-groups --jq '.runner_groups[]|{id,name,allows_public_repositories,visibility}' - echo '{"allows_public_repositories": true}' > rg.json - gh api -X PATCH /orgs//actions/runner-groups/ --input rg.json - ``` - - 2. **`labels` mismatch.** The runner must carry every label `runs-on` names. - Compare `gh api /orgs//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` # Phase-4 standalone-repo sweep: bare-name ref converted to @github