rig/commands/forgejo-runner-install.sh
cluade-reviewer-andresmgsl ab29ee78fb fix: the --version read cannot die ahead of its own refusal
Self-review of the whole head after the panel converged. One real defect,
reproduced verbatim before it was touched.

runner_version_of is a pipeline, and every forgejo-runner-* command runs under
set -euo pipefail. When the binary exits non-zero the pipeline fails, and
`PRESENT_VER="$(runner_version_of "$BIN")"` is an ASSIGNMENT — so install died
at that line, exit 1, printing nothing at all. The lib documented the opposite
("empty when it cannot answer"), and the refusal written for exactly this case
twenty lines later — "the download landed but cannot run" — was unreachable
code that could never have fired.

The shape is not hypothetical. `[ -x ]` yes, runnable no is what a truncated or
wrong-arch download leaves behind, and a ci-box's template preinstalls the
binary at mint — so the silent death sat on the one path this command family
exists to serve.

json_field, two files away, already carries `|| true` with this reasoning
written above it: "callers run under set -e with pipefail, where a grep that
matches nothing would otherwise kill the script with no message". The same
guard, for the same reason, at both sites: the lib reader and install's own
read of --version.

The test could not have caught it. vparse drove the lib through `bash -c` with
NO shell options, and its "bad" stub exits 0 — it proved garbage-in-empty-out
in a permissive shell, which is not where the function is used. vparse now runs
under set -euo pipefail, and a stub that exits non-zero is a third row.
Mutation-checked: that row exits 1 against the unpatched lib and 0 against the
fixed one. Install's site is grep-pinned, since reaching it for real needs root
and a downloaded binary.

748 passed (was 746), release 31, drill 43, shellcheck clean.
2026-07-28 09:26:17 +00:00

481 lines
24 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.
#
# 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'
# 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}"
[ -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"
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" <<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}
[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"