#!/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" 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"; 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. if [ -x "$BIN" ]; then log "forgejo-runner binary already present at ${BIN}; skipping download" else 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}" if curl -fsSL "${URL}.sha256" -o "$WORKDIR/forgejo-runner.sha256" 2>/dev/null; then # 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})" else # A warning, not a refusal: rig should not become unable to install because # upstream changed its asset layout. But it must be LOUD — an operator who # needs a verified install has to know this one was not. warn "no published .sha256 for ${ASSET} — installing WITHOUT checksum verification" fi install -m 0755 -o root -g root "$WORKDIR/forgejo-runner" "$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" # --- 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" 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" 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" # rig records the labels beside the registration for `status` to read back. # Box-local metadata, never a credential — the same note runner-install.sh # writes, for the same reason. printf '%s\n' "$LABELS" > "$RUNNER_DIR/.rig-labels" chown "$RUNNER_USER:$RUNNER_GROUP" "$RUNNER_DIR/.rig-labels" # --- 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"