rig/commands/forgejo-runner-install.sh
cluade-reviewer-andresmgsl cc5f478e77 fix(ci): the check job installs the tools its image does not ship
rig's own `ci / check` failed 9 times out of 27 ci.yml tasks on the first
Forgejo runner, every one on `shellcheck: command not found`. `ubuntu-latest`
maps to catthehacker's SLIM act image, and workflows written for GitHub
reasonably assume GitHub's tool surface.

Measured before choosing (2026-08-01, streaming ghcr layer blobs rather than
pulling): the parity image is 18.67 GB on the wire and 54.52 GB extracted,
against a box-class ci tenant with ~34-40 GB free. There is no cheap middle —
runner-22.04 is the same slim class, tool for tool, and ships no shellcheck
either. `apt-get install -y shellcheck` costs 7s and yields the same
ShellCheck 0.8.0 that full-22.04 carries.

So the slim default stays and the workflow equips itself. The `command -v`
short-circuit keeps either forge from paying for the other; the sudo is a
no-op on the act path and load-bearing on GitHub's.

`ubuntu-latest-full` ships in the default map beside it: a mapping pulls
nothing until a job matches it, and Forgejo freezes labels at registration,
so a label absent then cannot be added without re-registering.

A plain converge now warns when a runner carries a SUPERSEDED default —
matched against the exact strings rig has shipped, so a map the operator
chose stays silent. The message says plainly that nothing is broken.

Refs #144
2026-08-01 21:22:01 +00:00

572 lines
29 KiB
Bash
Executable file

#!/usr/bin/env bash
# rig forgejo-runner install — Forgejo Actions runner as a systemd service
# under an unprivileged user. Outbound-only (long-poll to the instance), no
# inbound ports. Convergent toward --instance: re-running against the instance
# the box is already on leaves it alone; a box registered to a DIFFERENT
# instance is refused, never silently restarted on the old one.
#
# The GitHub sibling (runner-install.sh) refuses Docker outright: it converges
# a fleet MACHINE, where `docker` group membership is root-equivalent and the
# blast radius is the machine. This command's home is a ci-box TENANT, where
# bootstrap-tenant.sh has already installed Docker and added the tenant user to
# the group, and where the blast radius is a disposable guest with no inbound
# path. Same trade, different machine, opposite answer — which is why this is a
# separate command and not a flag on that one.
set -euo pipefail
HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
# shellcheck source=SCRIPTDIR/lib/forgejo-runner-config.sh
. "$HERE/lib/forgejo-runner-config.sh"
log() { printf 'rig-forgejo-runner: %s\n' "$*"; }
warn() { printf 'rig-forgejo-runner: WARNING: %s\n' "$*" >&2; }
die() { printf 'rig-forgejo-runner: ERROR: %s\n' "$1" >&2; exit "${2:-1}"; }
# The default label map. `runs-on: ubuntu-latest` is what a workflow written
# for GitHub says, so it must mean something here or every workflow needs
# editing to migrate; catthehacker's image is the act/Forgejo ecosystem's
# stand-in for GitHub's runner image. `docker` is the lean second option.
#
# Every entry is `docker://` — jobs run in CONTAINERS on the box's own dockerd,
# not on the box itself. No docker-in-docker: the guide this came from stacks a
# privileged dind sidecar with a plaintext tcp://…:2375 daemon to isolate jobs
# from a shared CI server, and inside a box that boundary is already paid for.
#
# `ubuntu-latest` is the SLIM act image and does not carry GitHub's tool
# surface. That is a deliberate choice, ruled on 2026-08-01 (#144) after rig's
# own `ci / check` failed 9 times on `shellcheck: command not found`. The
# measurement, so nobody re-litigates it from the tag names:
#
# image on the wire extracted shellcheck
# act-22.04 (this one) 0.55 GB 2.2 GB no
# runner-22.04 0.55 GB 2.2 GB no <- not a middle
# full-22.04 18.67 GB 54.52 GB yes
#
# There is no cheap parity image: `runner-22.04` sounds like one and is the
# same slim class, tool for tool. Parity is 54.52 GB or nothing, and a
# box-class ci tenant does not have it — so mapping `ubuntu-latest` to
# `full-22.04` would be a default that cannot land on the host class it is
# for. `apt-get install -y shellcheck` takes 7s and yields the SAME
# ShellCheck 0.8.0 that `full-22.04` ships, so parity buys no newer tool
# either.
#
# The rule that follows, and the one to state when a workflow surprises
# someone: on this forge a workflow must not assume tools from the image.
# `ubuntu-latest` means "a Linux container that runs GitHub-shaped
# workflows", not "GitHub's runner image" — rig's own .github/workflows/ci.yml
# installs what it uses, and that is the pattern to copy.
#
# `ubuntu-latest-full` is the escape hatch for anyone who does want the whole
# GitHub tool surface. A label mapping pulls NOTHING until a job matches it,
# so shipping it by default costs a box that never says `runs-on:
# ubuntu-latest-full` exactly nothing — and it must ship by default, because
# Forgejo freezes labels at registration and a label absent at that moment
# cannot be added later without re-registering the runner. Budget ~120 GB of
# disk on any box that intends to use it.
DEFAULT_LABELS='ubuntu-latest:docker://ghcr.io/catthehacker/ubuntu:act-22.04,ubuntu-latest-full:docker://ghcr.io/catthehacker/ubuntu:full-22.04,docker:docker://node:22-bookworm'
# Every label map rig has ever shipped as its DEFAULT, oldest first. Append the
# outgoing string here whenever DEFAULT_LABELS changes; never edit or remove a
# row, because the whole point is to recognise a runner registered long ago.
#
# This exists so a plain converge can tell "registered under an older rig" from
# "the operator chose these labels" — the second must stay silent, and nothing
# on disk distinguishes them except the string itself.
SUPERSEDED_DEFAULTS=(
# pre-#144: no ubuntu-latest-full escape hatch
'ubuntu-latest:docker://ghcr.io/catthehacker/ubuntu:act-22.04,docker:docker://node:22-bookworm'
)
# labels_are_a_superseded_default <recorded> — true when the recorded map is one
# rig itself used to ship. Exact match only: a near-miss is an operator's map.
labels_are_a_superseded_default() {
local recorded="$1" past
for past in "${SUPERSEDED_DEFAULTS[@]}"; do
[ "$recorded" = "$past" ] && return 0
done
return 1
}
# fetch_and_verify_sha256 <asset-url> <file> <sumfile> <label>
#
# The whole checksum POLICY, in one place: fetch the published .sha256 beside
# an asset and prove the download matches it. Prints the reason on stderr and
# returns 1 on any failure; the caller supplies the refusal in its own voice.
#
# BYTE-IDENTICAL to the copy in docs/templates/ci-box/install.sh, diffed by
# test/cli.sh — the valid_version / templates_archive_urls precedent. The two
# downloaders cannot share a lib: this one sources commands/lib/, and that one
# is a REGISTRY DEFINITION that runs standalone inside a mint from a fetched
# tarball, with rig's tree nowhere in reach. So the pin is the only mechanism
# that keeps one policy from becoming two.
#
# Review !110 is the evidence for why that matters: a fail-open branch lived in
# BOTH copies while a grep for "checksum mismatch" passed against both, because
# the string it looked for sat right beside the branch it could not see. The
# next checksum-policy change must not be able to land in one file only.
#
# AN UNFETCHABLE CHECKSUM REFUSES — it is a gate, not a courtesy. The earlier
# reasoning ("do not let an upstream layout change break installs") reasons
# about the wrong failure: a layout change moves the BINARY url too, so the
# download would already have died. "Binary yes, checksum no" is not what a
# layout change looks like — it is what an interfered fetch looks like, which
# is precisely what a checksum exists to catch. Failing open would hand an
# unverified root install to anyone able to block a single URL. There is
# deliberately no bypass flag: if upstream really does move its assets, that is
# a rig PR editing the URL, not an operator improvising past a security gate.
fetch_and_verify_sha256() {
local url="$1" file="$2" sumfile="$3" label="$4" want got
if ! curl -fsSL "${url}.sha256" -o "$sumfile" 2>/dev/null; then
printf 'no published .sha256 for %s at %s.sha256 — the binary itself downloaded, so this is not an upstream layout change; check what is intercepting the fetch\n' "$label" "$url" >&2
return 1
fi
# The published .sha256 names the asset, not our temp path. Compare the
# digest itself rather than rewriting the file into sha256sum -c's format:
# one comparison, no parsing of a file we did not write.
want="$(tr -d '\r' < "$sumfile" 2>/dev/null | awk '{print $1}' | head -n1)"
got="$(sha256sum "$file" | awk '{print $1}')"
if [ -z "$want" ]; then
printf 'the published checksum for %s is unreadable — a fetch that succeeds but returns nothing usable is not a verified download\n' "$label" >&2
return 1
fi
if [ "$want" != "$got" ]; then
printf 'checksum mismatch for %s: published %s, downloaded %s\n' "$label" "$want" "$got" >&2
return 1
fi
printf 'checksum verified (%s)\n' "$got"
}
usage() {
cat <<'EOF'
usage: rig forgejo-runner install --instance <url> [options]
--instance <url> Forgejo instance the runner registers to (required),
e.g. https://forgejo.example.com
--version <pin> forgejo-runner release to install, e.g. 12.13.2
(default: the latest release, resolved at install
time). Pin it for a deterministic, auditable install.
--name <name> runner name (default: this host's hostname)
--labels <csv> runner labels; replaces the default. The default maps
ubuntu-latest and docker onto container images, so a
workflow written for GitHub runs unchanged.
--user <name> unprivileged service user (default: the tenant user
`ci` when it exists, else forgejo-runner; created if
absent; never root)
Installs forgejo-runner as a systemd service under an unprivileged user. The
runner is an agent, not a server: it long-polls the instance outbound and
receives jobs down that already-established connection, so it needs ZERO
inbound ports.
Jobs run in Docker containers on this box's own daemon. Inside a ci-box tenant
that daemon is already there — `rig bootstrap ci-box` installs it and puts the
tenant user in the `docker` group.
Provide the runner registration token via the FORGEJO_RUNNER_TOKEN env var or
the interactive prompt. Get one from the scope you want the runner to serve:
instance Site Administration > Actions > Runners > Create new Runner
org Org > Settings > Actions > Runners
repo Repo > Settings > Actions > Runners
The SCOPE IS THE TOKEN'S, not a flag here. It is consumed at registration and
never written to disk by rig.
Convergent toward --instance: re-running against the instance this box is
already on re-uses the binary, skips registration, and never asks for a token.
A box registered to a DIFFERENT instance is refused — take it off the old one
with `rig forgejo-runner remove` first.
EOF
}
# --- args (validated before the root check, so errors are testable) ---------
INSTANCE=""
VERSION=""
RUNNER_NAME="$(hostname)"
LABELS="$DEFAULT_LABELS"
# Whether --labels was ASKED FOR, distinct from what it resolved to. A rerun
# cannot apply labels (Forgejo owns them from registration time), and the
# difference between "operator requested a change" and "operator passed
# nothing" is what separates a warning worth printing from noise on every
# converge.
LABELS_EXPLICIT=0
RUNNER_USER=""
while [ $# -gt 0 ]; do
case "$1" in
--instance)
[ $# -ge 2 ] || die "--instance needs a value" 2
INSTANCE="$2"; shift 2 ;;
--version)
[ $# -ge 2 ] || die "--version needs a value" 2
VERSION="$2"; shift 2 ;;
--name)
[ $# -ge 2 ] || die "--name needs a value" 2
RUNNER_NAME="$2"; shift 2 ;;
--labels)
[ $# -ge 2 ] || die "--labels needs a value" 2
LABELS="$2"; LABELS_EXPLICIT=1; shift 2 ;;
--user)
[ $# -ge 2 ] || die "--user needs a value" 2
RUNNER_USER="$2"; shift 2 ;;
--repo)
# Named, not "unknown flag": everyone arrives here from `rig runner
# install --repo`, and the honest answer is that the argument does not
# exist on this forge rather than that it is misspelled.
[ $# -ge 2 ] && shift
die "--repo does not exist here: a Forgejo runner registers to an INSTANCE, and whether it serves that whole instance, one org, or one repo is a property of the registration TOKEN you mint in Forgejo's UI. Pass --instance <url> and mint the token at the scope you want." 2 ;;
-h|--help) usage; exit 0 ;;
*) die "unknown flag: $1" 2 ;;
esac
done
# --- validation ----------------------------------------------------------
[ -n "$INSTANCE" ] || die "--instance <url> is required" 2
case "$INSTANCE" in
https://*|http://*) ;;
*) die "--instance must be a URL with a scheme, e.g. https://forgejo.example.com (got: $INSTANCE)" 2 ;;
esac
# A path component would be a repo URL — the GitHub habit, and the one mistake
# that produces a runner registered somewhere subtly wrong rather than a clean
# failure. Refuse it by name.
case "${INSTANCE#*://}" in
*/*[!/]*) die "--instance takes the instance ROOT, not a repository URL: got ${INSTANCE}. Scope comes from the token, not the URL." 2 ;;
esac
VERSION="${VERSION#v}"
# The same sane-version charset the resolve-latest path enforces further down.
# An explicit pin skipped it entirely and went straight into the download URL,
# so a value carrying `/` or `..` was interpolated into a URL PATH rather than
# refused. Not a trust boundary — this command is already root, and the operator
# typed the flag — but a pin that cannot name a release should fail BY NAME at
# parse time, not as an opaque 404 forty lines later. install.sh's
# valid_version is the same instinct, and the asymmetry was the whole defect:
# the value rig resolves for itself was checked, the one it is handed was not.
case "$VERSION" in
"") ;; # unset — the latest release is resolved and validated below
*[!0-9.]*|.*|*.)
die "--version must be a release number like 12.13.2 (got: ${VERSION})" 2 ;;
esac
[ -n "$LABELS" ] || die "--labels must not be empty" 2
# The tenant user is the default when it is there: inside a ci-box the runner
# IS the tenant, and inventing a second service account beside it would leave
# the docker-group membership bootstrap-tenant.sh converged on the wrong user.
# Falls back to a dedicated account so this still works on a plain machine.
if [ -z "$RUNNER_USER" ]; then
if id -u ci >/dev/null 2>&1; then RUNNER_USER="ci"; else RUNNER_USER="forgejo-runner"; fi
fi
[ "$RUNNER_USER" != "root" ] || die "runner user must not be root" 2
# --- guards ----------------------------------------------------------------
[ "$(id -u)" -eq 0 ] || die "must run as root"
if [ -r /etc/os-release ]; then
# Sourced in a subshell: os-release defines VERSION (e.g. "13 (trixie)"),
# which would clobber this script's $VERSION.
# shellcheck source=/dev/null
OS_FAMILY="$(. /etc/os-release && printf '%s %s' "${ID:-}" "${ID_LIKE:-}")"
case "$OS_FAMILY" in
*debian*) ;;
*) warn "not a Debian-family system (${OS_FAMILY:-unknown}); proceeding anyway" ;;
esac
else
warn "cannot read /etc/os-release; proceeding anyway"
fi
command -v curl >/dev/null || die "curl is required (run rig bootstrap first)"
command -v systemctl >/dev/null || die "systemctl is required — this command installs the runner as a systemd service"
# --- is this box already registered somewhere else? --------------------------
# Before anything is prompted for, downloaded, or started: --instance must
# agree with what is already on the box. Everything below treats an existing
# .runner as "nothing to do" — right for the instance the box is already on,
# silently wrong for any other. See assert_runner_instance.
REG_PENDING=1
if id -u "$RUNNER_USER" >/dev/null 2>&1; then
USER_HOME="$(getent passwd "$RUNNER_USER" | cut -d: -f6)"
RUNNER_DIR="$USER_HOME/forgejo-runner"
assert_runner_instance "$RUNNER_DIR" "$INSTANCE" || exit 1
if [ -e "$RUNNER_DIR/.runner" ]; then
REG_PENDING=0
fi
fi
# --- registration token — only when registration is actually pending -------
if [ "$REG_PENDING" -eq 1 ]; then
FORGEJO_RUNNER_TOKEN="${FORGEJO_RUNNER_TOKEN:-}"
# Prompt only on a tty: headless, a bare `read` dies under set -e with no
# message at all. Refuse loudly, naming the variable.
if [ -z "$FORGEJO_RUNNER_TOKEN" ]; then
[ -t 0 ] || die "FORGEJO_RUNNER_TOKEN is unset and stdin is not a tty — set FORGEJO_RUNNER_TOKEN to run unattended"
read -rsp "forgejo runner registration token: " FORGEJO_RUNNER_TOKEN || { echo; die "no registration token read (EOF) — set FORGEJO_RUNNER_TOKEN to run unattended"; }
echo
fi
[ -n "$FORGEJO_RUNNER_TOKEN" ] || die "empty registration token"
fi
# --- user --------------------------------------------------------------------
if ! id -u "$RUNNER_USER" >/dev/null 2>&1; then
useradd --create-home --shell /bin/bash "$RUNNER_USER"
log "created user ${RUNNER_USER}"
else
log "user exists"
fi
USER_HOME="$(getent passwd "$RUNNER_USER" | cut -d: -f6)"
RUNNER_GROUP="$(id -gn "$RUNNER_USER")"
RUNNER_DIR="$USER_HOME/forgejo-runner"
BIN=/usr/local/bin/forgejo-runner
# The runner talks to dockerd over its socket, so it needs the group. In a
# ci-box bootstrap-tenant.sh already did this for the tenant user; on a plain
# machine, or for a --user that is not the tenant, it has not.
if getent group docker >/dev/null 2>&1; then
if id -nG "$RUNNER_USER" | tr ' ' '\n' | grep -qx docker; then
log "${RUNNER_USER} already in the docker group"
else
usermod -aG docker "$RUNNER_USER"
log "added ${RUNNER_USER} to the docker group"
fi
else
warn "no docker group on this box — jobs using docker:// labels will fail. Inside a ci-box, 'rig bootstrap ci-box' installs docker; elsewhere install it before running jobs."
fi
# --- download ----------------------------------------------------------------
# Forgejo publishes BARE BINARIES (not a tarball) with a .sha256 beside each
# one. Taking that checksum is nearly free and makes the install auditable —
# the same instinct as `coolify install`'s mandatory version pin.
#
# "Already present" is NOT enough to skip here, and this is where the GitHub
# sibling's shape must not be copied. Its skip is justified by "self-update
# owns upgrades" — actions/runner updates itself, and GitHub refuses jobs from
# stale runners, so freezing it would be pointless. **forgejo-runner does not
# self-update.** Nothing else ever moves the version, so a bare presence check
# would mean the binary a box first happened to get is the binary it keeps
# forever.
#
# That lands hardest on the path this command is FOR: a ci-box's template
# install.sh preinstalls /usr/local/bin/forgejo-runner at mint, so the
# executable always exists before an operator ever runs this — and --version,
# documented as the deterministic-pin lever, would silently do nothing on
# every ci-box in the fleet.
#
# So: converge toward --version when it is given, exactly as this command
# converges toward --instance. A pin is not a trust boundary the way an
# instance is (that one refuses), it is an instruction — including downward,
# which is what a pin is for. Absent a pin, an existing binary is left alone:
# chasing "latest" on every converge would make a re-run an unrequested
# upgrade, and convergence must not be a moving target.
# runner_version_of / runner_download_decision live in the lib, so the rule can
# be driven by test/cli.sh without root — see there for the full reasoning.
PRESENT_VER=""
HAVE_BIN=no
if [ -x "$BIN" ]; then
HAVE_BIN=yes
PRESENT_VER="$(runner_version_of "$BIN")"
fi
case "$(runner_download_decision "$HAVE_BIN" "$PRESENT_VER" "$VERSION")" in
skip)
NEED_DOWNLOAD=0
if [ -n "$VERSION" ]; then
log "forgejo-runner ${VERSION} already installed; skipping download"
else
log "forgejo-runner ${PRESENT_VER:-(version unreadable)} already present at ${BIN}; skipping download (pass --version <pin> to converge to a specific release)"
fi ;;
converge)
NEED_DOWNLOAD=1
log "converging ${BIN}: ${PRESENT_VER:-unreadable} -> ${VERSION} (--version)" ;;
*)
NEED_DOWNLOAD=1 ;;
esac
if [ "$NEED_DOWNLOAD" -eq 1 ]; then
case "$(uname -m)" in
x86_64) ARCH="amd64" ;;
aarch64) ARCH="arm64" ;;
*) die "unsupported arch: $(uname -m)" ;;
esac
if [ -z "$VERSION" ]; then
# No pin given: resolve the latest release by following the redirect on
# the /releases/latest page — no API call, no token, no JSON to parse on
# a dependency-free box (install.sh's resolve_latest_tag idiom).
LATEST_URL="$(curl -fsSLI -o /dev/null -w '%{url_effective}' \
https://code.forgejo.org/forgejo/runner/releases/latest)" \
|| die "could not resolve the latest forgejo-runner release"
VERSION="${LATEST_URL##*/}"
VERSION="${VERSION#v}"
case "$VERSION" in
""|*[!0-9.]*) die "could not parse a version from ${LATEST_URL}" ;;
esac
log "resolved latest forgejo-runner: ${VERSION}"
fi
ASSET="forgejo-runner-${VERSION}-linux-${ARCH}"
URL="https://code.forgejo.org/forgejo/runner/releases/download/v${VERSION}/${ASSET}"
WORKDIR="$(mktemp -d)"
cleanup() { rm -rf "$WORKDIR"; }
trap cleanup EXIT
log "downloading forgejo-runner ${VERSION} (${ARCH})"
curl -fsSL "$URL" -o "$WORKDIR/forgejo-runner" \
|| die "could not download ${URL}"
fetch_and_verify_sha256 "$URL" "$WORKDIR/forgejo-runner" "$WORKDIR/forgejo-runner.sha256" "$ASSET" \
|| die "refusing to install an unverified ${ASSET} — it lands as root and runs under a systemd unit. See the checksum failure above."
# Staged beside the target and RENAMED into place, never written over.
# Replacing a running executable in place fails with ETXTBSY, and this path
# now runs on boxes where the daemon is live (a --version converge). A
# rename is atomic and leaves the running process on the old inode until the
# restart below picks up the new one.
install -m 0755 -o root -g root "$WORKDIR/forgejo-runner" "$BIN.rig-new"
mv -f "$BIN.rig-new" "$BIN"
log "installed ${BIN}"
fi
# `|| true` so the refusal BELOW is the one that fires. Under set -euo pipefail
# a bare `VAR="$(cmd | head)"` dies at the assignment when cmd exits non-zero,
# which is precisely the case this line exists to diagnose — see the lib.
INSTALLED_VER="$("$BIN" --version 2>/dev/null | head -n1 || true)"
[ -n "$INSTALLED_VER" ] || die "${BIN} does not answer --version — the download landed but cannot run"
# The converge actually took — asserted, not assumed. A pin that silently did
# not land is exactly the failure --version exists to make impossible.
if [ -n "$VERSION" ]; then
EFFECTIVE_VER="$(runner_version_of "$BIN")"
[ "$EFFECTIVE_VER" = "$VERSION" ] \
|| die "asked for forgejo-runner ${VERSION} but ${BIN} reports ${EFFECTIVE_VER:-nothing} after install"
fi
# --- register ----------------------------------------------------------------
# UPSTREAM MARKS `register` DEPRECATED (measured on v12.13.2: both `register`
# and `create-runner-file` carry "(deprecated)" in their help). It is chosen
# here anyway, deliberately, and this is the reasoning to revisit when it
# finally goes:
#
# - It still works. `daemon` reads the `.runner` this writes, resolves the
# instance from it, and connects — verified against a live instance, where
# a planted `.runner` got as far as "Unauthenticated: unregistered runner".
# The mechanism is intact; only the credential was fake.
# - The successor needs MORE than rig can honestly ask for at this layer:
# `daemon --url --uuid --token-url` requires the runner to already exist on
# the instance, so the operator would have to create it via API/UI and
# carry back a UUID. That is a second, differently-shaped credential dance
# for no gain today.
# - `register` writes a file `status` can read back. The successor's config
# lives in flags on a unit line, where "what is this box registered to" has
# no on-disk answer that is not just rig's own copy of what it was told.
#
# When upstream removes it: the shape becomes `daemon --url/--uuid`, the unit
# gains those flags, and forgejo-runner-config.sh's readers move to whatever
# holds the UUID. assert_runner_instance's contract survives either way — it
# asks about the instance, which both spellings record.
install -d -m 0755 -o "$RUNNER_USER" -g "$RUNNER_GROUP" "$RUNNER_DIR"
# forgejo-runner's cache server writes to $HOME/.cache, which ProtectHome makes
# read-only below. Create it HERE, before the unit can reference it: a
# ReadWritePaths entry naming a path that does not exist makes systemd refuse
# to start the unit at all ("Failed to set up mount namespacing"), which is
# worse than the disabled cache it was meant to fix. Measured on a live runner,
# 2026-07-31 (#135). Root-owned would fail the same way under User=, so it
# carries the runner's own ownership like RUNNER_DIR above.
install -d -m 0755 -o "$RUNNER_USER" -g "$RUNNER_GROUP" "$USER_HOME/.cache"
if [ -e "$RUNNER_DIR/.runner" ]; then
log "already registered; skipping registration"
# Registration was skipped, so the labels on the instance are the ones it was
# registered with — NOT whatever this invocation was passed. Say so when the
# operator explicitly asked for different ones, rather than letting the
# request evaporate.
if [ -r "$RUNNER_DIR/.rig-labels" ]; then
RECORDED="$(cat "$RUNNER_DIR/.rig-labels")"
if [ "$LABELS_EXPLICIT" -eq 1 ]; then
if [ "$RECORDED" != "$LABELS" ]; then
warn "--labels was not applied: this runner is already registered, and Forgejo owns its labels from registration time. It still has: ${RECORDED}. Labels are what 'runs-on' matches, so changing them means re-registering: 'rig forgejo-runner remove' then install again with the labels you want."
fi
elif labels_are_a_superseded_default "$RECORDED"; then
# A plain converge on a runner registered before the defaults moved (the
# #144 mapping change is the first). Silence here is what let a runner
# keep serving a stale map while every re-run reported success.
#
# It warns on a SUPERSEDED DEFAULT rather than on "anything that is not
# the current default", which would fire on every converge of a runner
# the operator deliberately gave --labels — noise, not drift, and the
# reason the old code only spoke when EXPLICIT. Matching known past
# defaults exactly is what tells those two apart.
#
# Deliberately not an error and deliberately not alarming about CI: a
# runner on the old default still runs jobs, and under #144's ruling it
# still runs them GREEN. Re-registering buys the new LABELS, nothing else.
warn "this runner registered with an older rig default label map, and Forgejo owns labels from registration time — it still has: ${RECORDED}. Nothing is broken: jobs keep running, and 'runs-on: ubuntu-latest' still works. Re-register only if you want the labels added since: 'rig forgejo-runner remove' then install again. Current default: ${DEFAULT_LABELS}"
fi
fi
else
log "registering runner ${RUNNER_NAME} against ${INSTANCE}"
(cd "$RUNNER_DIR" && runuser -u "$RUNNER_USER" -- env HOME="$USER_HOME" \
"$BIN" register --no-interactive \
--instance "$INSTANCE" --token "$FORGEJO_RUNNER_TOKEN" \
--name "$RUNNER_NAME" --labels "$LABELS") \
|| die "registration failed — check the token is a RUNNER registration token from ${INSTANCE} and has not been used already"
[ -e "$RUNNER_DIR/.runner" ] \
|| die "register reported success but wrote no ${RUNNER_DIR}/.runner"
# INSIDE the registration branch, where runner-install.sh keeps its copy and
# for the same reason: this file records what rig ACTUALLY registered with,
# so `status` has something to read back. Writing it unconditionally — as an
# earlier draft did — makes a plain re-run stamp this invocation's labels
# over a registration that used different ones, and `status` then reports
# confidently wrong labels while Forgejo still holds the originals. A
# metadata file that can disagree with the thing it describes is worse than
# no metadata file.
printf '%s\n' "$LABELS" > "$RUNNER_DIR/.rig-labels"
chown "$RUNNER_USER:$RUNNER_GROUP" "$RUNNER_DIR/.rig-labels"
fi
# EVERY run, registration or not: .runner holds the runner's own long-lived
# token, and a mode that drifted leaks it silently. See the lib.
forgejo_runner_secure "$RUNNER_DIR" "$RUNNER_USER" "$RUNNER_GROUP"
# --- service -------------------------------------------------------------
# Written by rig rather than shipped by upstream: forgejo-runner has no
# svc.sh, so there is no vendor unit to defer to (the GitHub sibling defers to
# actions/runner's). Converged like every file rig writes — cmp-guarded, so a
# re-run that changes nothing reloads nothing.
UNIT=/etc/systemd/system/forgejo-runner.service
UNIT_TMP="$(mktemp)"
cat > "$UNIT_TMP" <<EOF
[Unit]
Description=Forgejo Actions runner
Documentation=https://forgejo.org/docs/latest/admin/actions/
After=network-online.target docker.service
Wants=network-online.target
[Service]
Type=simple
User=${RUNNER_USER}
WorkingDirectory=${RUNNER_DIR}
ExecStart=${BIN} daemon
Restart=on-failure
RestartSec=10
# The runner supervises job containers on this box's docker socket; it is not
# a sandbox for them. These keep the DAEMON from being a soft target.
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ProtectHome=read-only
ReadWritePaths=${RUNNER_DIR} ${USER_HOME}/.cache
[Install]
WantedBy=multi-user.target
EOF
if ! cmp -s "$UNIT_TMP" "$UNIT" 2>/dev/null; then
install -m 0644 "$UNIT_TMP" "$UNIT"
systemctl daemon-reload
log "systemd unit written: ${UNIT}"
else
log "systemd unit already current"
fi
rm -f "$UNIT_TMP"
systemctl enable forgejo-runner >/dev/null 2>&1 || die "could not enable forgejo-runner.service"
systemctl restart forgejo-runner || die "could not start forgejo-runner.service — see 'journalctl -u forgejo-runner'"
# Assert the EFFECTIVE state, not systemctl's exit code: a unit that starts and
# immediately dies (bad token, unreachable instance) leaves restart succeeding
# and the runner absent. Settle briefly, then ask.
active=""
for _ in 1 2 3 4 5 6; do
if systemctl is-active forgejo-runner >/dev/null 2>&1; then active=1; break; fi
sleep 2
done
[ -n "$active" ] || die "forgejo-runner.service is not active after 12s — see 'journalctl -u forgejo-runner' (a bad token or an unreachable instance both land here)"
log "runner ${RUNNER_NAME} (${INSTALLED_VER}) installed and running"
log "labels: ${LABELS}"
log "verify it shows Idle under ${INSTANCE} > Site Administration > Actions > Runners"
log "this box needs no inbound ports — the runner polls the instance outbound"