Empirically verified (run on a fedora-43 runner): the ssh client is present
on every runner image, not just `infra`. The bare fedora/ubuntu container base
ships no ssh and weak deps are disabled, but each image installs `git` for
checkout, and `git-core` hard-requires `openssh-clients` — so ssh is pulled in
regardless. rsync + curl are likewise in every base.
Corrects the earlier claim that CI-driven deploy needs `infra`: deploy jobs
run fine on `fedora-43`. `infra`'s real payload is `stalwart-cli` / infra
service CLIs. Also notes that runner-infra's own Containerfile comment
("the ssh client is not in the base") is misleading.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fKZzDpvjiJ9eYbPGgJvUP
12 KiB
Gitea Actions runners
The catalogue of CI runner images available to workflows on the self-hosted Gitea
(git.lair.cafe / git.internal), what each provides, and — importantly — how to add or
extend one when your workflow needs a toolchain no existing runner ships. Read this
alongside deployment-gitea-actions.md §5 ("Runner images"), which states the governing
rule this doc makes concrete:
Bake build dependencies into the runner image, not into the workflow. A workflow should never
dnf install/apt-get installat run time.
If you find yourself reaching for a package-install step in a job, stop — the answer is to extend an image here, not to mutate the runner at run time.
1. Where runners come from: gongfoo
Runners are ephemeral, single-use Podman containers managed by
gongfoo (git.lair.cafe/gongfoo/gongfoo). A
controller watches the Gitea job queue and, per (image, label-set), spawns a fresh
container that registers with Gitea, claims exactly one job, runs it, and exits. There is
no shared state between jobs and no long-lived runner to mutate — which is why toolchains
must live in the image.
The image definitions (Containerfiles + build.sh) live in gongfoo/images/. Each
registered runner is declared in gongfoo/asset/manifest.yml under runner_images,
which maps an image to its labels (what runs-on: matches), CPU/memory reservation,
and any reaper overrides.
2. The runner catalogue
runs-on: in a workflow selects a runner by label. A job's runs-on label set must be
a subset of a runner image's labels; when several images qualify, the one with the
fewest extra labels wins (so runs-on: rust lands on runner-rust, not
runner-rust-gtk3 which also carries rust). See the gongfoo readme ("Queue handling →
Image selection") for the full placement semantics.
runs-on: |
Image | Base | Key toolchain beyond the base | CPU / MEM |
|---|---|---|---|---|
fedora-43 |
runner-fedora-43 |
Fedora 43 | git, curl, jq, nodejs + npm + pnpm, podman-remote, rsync, python3-pip + yq |
2 / 2 GiB |
fedora-44 |
runner-fedora-44 |
Fedora 44 | same package set as fedora-43; this image's Containerfile is the GITEA_RUNNER_VERSION source of truth |
2 / 2 GiB |
rust |
runner-rust |
runner-fedora-44 |
rustup stable (gnu + x86_64-unknown-linux-musl targets, clippy, rustfmt), sccache, gcc, musl-gcc, cmake, pkg-config, openssl-devel, perl-core |
4 / 4 GiB |
rust-gtk3 |
runner-rust-gtk3 |
runner-rust |
GTK3 stack: gtk3-devel, glib2/gdk-pixbuf2/pango-devel, webkit2gtk4.1-devel, libsoup3-devel, libappindicator-gtk3-devel (Tauri/GTK desktop builds) | 4 / 4 GiB |
rpm |
runner-rpm |
runner-rust |
rpm-build, rpmdevtools, rpmlint, createrepo_c, copr-cli, rpm-sign, sequoia-sq, gnupg2, autotools, openssh-clients | 2 / 2 GiB |
ffmpeg |
runner-ffmpeg |
runner-rust |
rpmfusion-free, ffmpeg-devel, clang (for ffmpeg-sys/ac-ffmpeg-style builds) |
4 / 4 GiB |
infra |
runner-infra |
runner-fedora-44 |
stalwart-cli (+ xz for its tarballs). The home for infra service CLIs / IaC tooling |
2 / 2 GiB |
cuda-13.0 |
runner-cuda-13.0 |
runner-fedora-43 |
rustup stable (as runner-rust, inlined) + CUDA 13.0 (nvcc, cudart/nvrtc/cublas/cusparse/curand/cusolver-devel, cudnn9, nccl, cmake, ninja, gcc-c++) |
8 / 32 GiB |
ubuntu-24.04 |
runner-ubuntu-24.04 |
Ubuntu 24.04 | git, curl, jq, nodejs + npm + pnpm, rsync, python3-pip + yq |
2 / 2 GiB |
deb |
runner-deb |
runner-rust-ubuntu |
Debian packaging: build-essential, debhelper, devscripts, dh-make, dpkg-dev, fakeroot, lintian, reprepro (+ apt rustc/cargo from the base) |
2 / 2 GiB |
Notes:
runner-rust-ubuntu(Ubuntu 24.04 + aptrustc/cargo/clippy/rustfmt + sccache) exists only as the base forrunner-deb; it is not registered as a standalone runner, so there is norust-ubuntulabel. Default torust(Fedora, rustup) for Rust work; the Ubuntu Rust base is for producing.debs.- Every Fedora/Ubuntu base ships
pnpminstalled globally vianpm install -g pnpm, not corepack — Fedora's/Ubuntu'snodejsrpm/deb does not bundle corepack. Callpnpmdirectly; do notcorepack enable(see §4 gotchas). ssh+rsyncare on every runner.rsyncis in each base's install list, and thegitevery image installs (foractions/checkout) hard-requiresgit-core, which in turn hard-requiresopenssh-clients— so the ssh client is present even though the barefedora/ubuntucontainer base doesn't ship it and weak deps are disabled. A CI-driven deploy (ssh + rsync to targets) therefore runs fine onfedora-43/fedora-44; it does not needinfra. (runner-infra's own Containerfile re-installsopenssh-clientsbelt-and-suspenders and its comment claiming "the ssh client is not in the base" is misleading — it's pulled by git-core regardless.)- The
rusttoolchain isrustupstable, not Fedora's distro rustc, specifically so thex86_64-unknown-linux-muslstd matches the compiler and static musl builds work. sccacheis installed on the Rust images but backing it with the shared S3 cache is opt-in per workflow (setRUSTC_WRAPPER=sccache+SCCACHE_*/AWS_*— see helexa'sci.yml), not baked into the image.
Lineage
Fedora 43 ── runner-fedora-43 ─┬─ runner-cuda-13.0 (CUDA needs gcc 15 → F43, not F44)
(rust toolchain inlined)
Fedora 44 ── runner-fedora-44 ─┬─ runner-infra
└─ runner-rust ─┬─ runner-rust-gtk3
├─ runner-rpm
└─ runner-ffmpeg
Ubuntu 24.04 ─ runner-ubuntu-24.04 ─ runner-rust-ubuntu ─ runner-deb
runner-cuda-13.0 deliberately branches from Fedora 43, not the runner-rust (Fedora
44) chain: CUDA 13.0's nvcc rejects host compilers newer than gcc 15, and Fedora 44 ships
gcc 16. It re-inlines the rustup recipe rather than inheriting it.
3. Choosing the right runner
| Your job does… | Use runs-on: |
Why |
|---|---|---|
cargo build/test/clippy/fmt |
rust |
the only images with a Rust toolchain (rust, rpm, ffmpeg, rust-gtk3, cuda-13.0); plain fedora-* have no cargo |
| Build a Vite/React/npm/pnpm frontend | fedora-43 / fedora-44 |
node + npm + pnpm preinstalled |
| Deploy over ssh + rsync (CI-driven deploy) | fedora-43 / fedora-44 |
ssh (via git-core's openssh-clients dep) + rsync + curl are present on every runner |
| Infra service admin (Stalwart mail, IaC CLIs) | infra |
ships stalwart-cli and is the home for infra service tooling |
| Build/sign RPMs, publish to COPR | rpm |
rpmbuild, createrepo_c, copr-cli, signing |
Build .debs |
deb |
debhelper/devscripts/reprepro |
| CUDA / GPU-accelerated Rust (candle, flash-attn) | cuda-13.0 |
nvcc + CUDA dev libs; pinned to GPU hosts |
| ffmpeg-linked builds | ffmpeg |
ffmpeg-devel + clang |
| GTK3 / Tauri desktop | rust-gtk3 |
GTK/webkit dev libs on top of Rust |
Plain shell / jq / yq / git plumbing |
fedora-43 / fedora-44 / ubuntu-24.04 |
smallest images |
Multi-step deploys commonly split across two runners — a rust job to build + upload
the artifact, and a fedora-43 job (needs: the build) to ssh/rsync it to the targets —
mostly so the deploy job isn't billed the heavier rust image (4 CPU / 4 GiB) just to run
ssh. This is a convenience, not a necessity: the rust runner also has git-core's ssh and
rsync, so a single job could build and deploy if you prefer.
4. Gotchas that cost a run
These are the traps that make a job fail seconds in with a "command not found":
runs-on: fedora-43for a Rust build →cargo: command not found. Thefedora-*images are node/shell runners with no Rust. Userust.- Not a gotcha: ssh on
fedora-*. It's tempting to think the fedora runners lack an ssh client (the bare container base doesn't ship one, andrunner-infrare-installsopenssh-clientswith a comment saying so). But every runner installsgitfor checkout, andgit-corehard-requiresopenssh-clients— sosshis present on all of them. Deploy onfedora-43; you do not needinfrafor ssh/rsync. corepack enableanywhere → fails / no-op. No image bundles corepack;pnpmis already onPATH(installed vianpm i -g pnpm). Just runpnpm install/pnpm build. ApackageManagerfield inpackage.jsonis honoured for information but standalone pnpm won't auto-switch versions, so pin your lockfile and use the image's pnpm.- Assuming
mikefarah/yqsyntax.yqhere is the PyPIkislyuk/yq(jq-syntax wrapper), not the Gomikefarah/yq. Pipe jq expressions accordingly. runs-on: rustforcargo build --target x86_64-unknown-linux-musl. Supported — the musl std + musl-gcc are present. But if the whole target fleet is the same distro/version as the runner, a native build is simpler and avoids the musl surface entirely (deployment-gitea-actions.md§6: build on a runner no newer than the oldest target).
5. Adding or extending a runner
When no runner ships a dependency your workflow needs, add it to an image — never
dnf/apt install at run time (slow, flaky, and runners may be unprivileged). Prefer, in
order:
-
Extend an existing base. If an infra/service CLI is missing, add it to
runner-infra. If a system dev lib is missing for a Rust crate's-sysbuild, add it torunner-rust(or a narrower child). Editing a shared base benefits every downstream image. This is usually a one-line addition to adnf installlist. -
Add a new image only when the toolchain is large, conflicting, or host-pinned (GPUs, a specific compiler version). Create
gongfoo/images/runner-<name>/with aContainerfilethatFROMs the closest existing image, and abuild.sh.
Then register it in gongfoo/asset/manifest.yml under runner_images:
- name: runner-<name>
image_ref: git.lair.cafe/gongfoo/runner-<name>:latest
labels: [<label>] # what `runs-on:` matches; keep the set minimal
cpu_request: 2 # placement reservation + basis for the cgroup cap
mem_request_mb: 2048
# max_runtime_secs / log_stall_secs: reaper overrides for unusually long builds
Label discipline (matters for placement):
- Keep the label set minimal and specific. A child image inherits its purpose from its
own label, not its base's — e.g.
runner-cuda-13.0is labelled[cuda-13.0]alone (not[fedora-43, cuda-13.0]) so the subset-match doesn't route plainfedora-43jobs onto scarce GPU hosts. - Hosts can restrict which labels they accept (
allowed_labelsin the manifest) to protect scarce hardware; a new specialised label must land on a host that allows it.
How images get built
The images workflow in gongfoo (.gitea/workflows/images.yml) builds and pushes on
push to main touching images/**, and on a daily cron that also refreshes the
act_runner binary and distro packages (so images don't drift). Base images build on
bare-metal runs-on: [metal, podman] runners and podman push to git.lair.cafe. Tool
versions are pinned by ARG in the Containerfile (e.g. GITEA_RUNNER_VERSION,
SCCACHE_VERSION, STALWART_CLI_VERSION) and the workflow overrides pins to the latest
upstream release at build time; the ARG default is the offline fallback.
After a new/updated image is pushed and the manifest change is deployed (gongfoo picks up
runner_images from manifest.yml), the new label is immediately available to runs-on:.
6. Checklist for a workflow author
- Match each job to a runner by what it runs, not by distro habit (§3). Rust →
rust; frontend →fedora-*; ssh/rsync deploy →fedora-43(ssh + rsync are on every runner). - Never install packages at run time. Missing tool → extend an image (§5).
- Don't
corepack enable; callpnpmdirectly. - Reserve
infrafor its actual payload (stalwart-cli/ infra CLIs), not for ssh. - If you add an image, register it in
gongfoo/asset/manifest.ymlwith a minimal label set and sensible CPU/MEM.