rig's CI story was GitHub-shaped end to end. This makes it work against a self-hosted Forgejo, in three pieces. The registry fetch becomes forge-aware. templates_resolve hardcoded three github.com archive URLs; RIG_TEMPLATES_HOST now selects the grammar, because the forges genuinely differ — GitHub serves refs/tags, refs/heads and bare paths, Forgejo serves exactly one, and emitting the other two there would mean two guaranteed 404s per fetch and a failure message listing URLs that never could have worked. Measured against forgejo.heavyduty.builders, not inferred. The default stays GitHub, so every existing caller is unchanged. install.sh's snapshot reads the same variable through a byte-identical copy of the builder, diffed by the tests: a snapshot cached from a forge converge would never fetch from is worse than no snapshot, and the pin-in-the-name staleness guard cannot catch a wrong-ORIGIN snapshot, only an old one. ci-box is a tenant, not a machine role. The topology is a fleet machine hosting boxes, one of which runs CI — a '-box' guest by rig's own family rule. That also deletes the docker-in-docker layer the usual setup needs: bootstrap-tenant.sh already installs Docker and adds the tenant user to the group, and the isolation a privileged dind sidecar buys is already paid for by a box that is network-isolated, inbound-less and disposable. rig runner install refuses Docker for good reason — it converges a MACHINE, where the blast radius is the machine. Here it is a guest that gets thrown away. rig forgejo-runner is a new family beside rig runner, which is untouched. Forgejo registers against an INSTANCE and the token carries the scope, so there is no --repo to converge toward and nothing to compare; folding that into one command would make every guard bimodal to share a flag name while the contract underneath differs. assert_runner_instance asks the same trust-boundary question about the axis Forgejo actually has. There is no repoint and no --local, and both absences are explained where an operator arriving from the GitHub sibling will hit them. Forgejo's .runner holds the runner's own long-lived token, unlike GitHub's, so it is installed 0600 and the mode is re-asserted on every converge — a mode that drifted leaks the secret silently, since nothing fails and the runner keeps working. status reports it and never prints the token. Both downloads verify the published .sha256 before installing: this binary lands as root and is executed by a systemd unit. bootstrap --undo learns the guard for the same hazard on the other forge, and it matters more here — Forgejo has no deregistration endpoint, so the ghost it would strand has to be deleted by hand. Known prerequisite, documented rather than assumed: the fetch is unauthenticated by contract, and a Forgejo with REQUIRE_SIGNIN_VIEW=true answers 404 for repos it reports as public. Hosting a registry there needs FORGEJO__service__REQUIRE_SIGNIN_VIEW=false. The refusal names that case, because it is indistinguishable from a wrong ref. forgejo#109 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
365 lines
17 KiB
Bash
Executable file
365 lines
17 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'
|
|
|
|
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"
|
|
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 <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.
|
|
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" <<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"
|