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
572 lines
29 KiB
Bash
Executable file
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"
|