#!/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. # # Both are `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. DEFAULT_LABELS='ubuntu-latest:docker://ghcr.io/catthehacker/ubuntu:act-22.04,docker:docker://node:22-bookworm' usage() { cat <<'EOF' usage: rig forgejo-runner install --instance [options] --instance Forgejo instance the runner registers to (required), e.g. https://forgejo.example.com --version 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 runner name (default: this host's hostname) --labels runner labels; replaces the default. The default maps ubuntu-latest and docker onto container images, so a workflow written for GitHub runs unchanged. --user 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 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 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}" [ -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 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}" # THE CHECKSUM IS A GATE, NOT A COURTESY — an unfetchable one refuses. # # This previously warned and installed anyway, reasoning that rig should not # become unable to install if upstream changed its asset layout. That reasons # about the wrong failure. The binary lands as root and is executed by a # systemd unit, and the two ways the checksum can go missing are: # # - upstream moved the assets — in which case the BINARY url moved too, and # the download above would already have died. A layout change does not # present as "binary yes, checksum no". # - something is interfering with the fetch — which is precisely the case # the checksum exists to catch. # # So the asymmetry is itself the signal: same origin, same release tag, one # answers and one does not. Failing open there hands an unverified root # install to anyone who can block a single URL. There is deliberately no # bypass flag: if upstream really does change layout, that is a rig PR # editing the URL above, not an operator improvising past a security gate. curl -fsSL "${URL}.sha256" -o "$WORKDIR/forgejo-runner.sha256" 2>/dev/null \ || die "no published .sha256 for ${ASSET} at ${URL}.sha256 — refusing to install an unverified binary that runs as root. The binary itself downloaded, so this is not an upstream layout change; check what is intercepting the fetch." # 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' < "$WORKDIR/forgejo-runner.sha256" | awk '{print $1}' | head -n1)" GOT="$(sha256sum "$WORKDIR/forgejo-runner" | awk '{print $1}')" [ -n "$WANT" ] || die "the published checksum for ${ASSET} is unreadable — refusing to install an unverified binary" [ "$WANT" = "$GOT" ] \ || die "checksum mismatch for ${ASSET}: published ${WANT}, downloaded ${GOT} — refusing to install" log "checksum verified (${GOT})" # 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 INSTALLED_VER="$("$BIN" --version 2>/dev/null | head -n1)" [ -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" 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. Only when EXPLICIT: comparing the default against a # runner registered with custom labels would warn on every plain converge. if [ "$LABELS_EXPLICIT" -eq 1 ] && [ -r "$RUNNER_DIR/.rig-labels" ]; then RECORDED="$(cat "$RUNNER_DIR/.rig-labels")" 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 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" </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"