From 903d8371b3ac08199c641fd0502b9e29027b931c Mon Sep 17 00:00:00 2001 From: cluade-reviewer-andresmgsl Date: Mon, 27 Jul 2026 20:40:54 +0000 Subject: [PATCH 1/9] =?UTF-8?q?feat:=20Forgejo-native=20CI=20=E2=80=94=20a?= =?UTF-8?q?=20ci-box=20tenant=20and=20a=20forgejo-runner=20family?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- README.md | 119 ++++++++ bin/rig | 48 ++++ changelog.d/109.md | 4 + commands/bootstrap-undo.sh | 22 ++ commands/forgejo-runner-install.sh | 365 ++++++++++++++++++++++++ commands/forgejo-runner-remove.sh | 115 ++++++++ commands/forgejo-runner-status.sh | 91 ++++++ commands/lib/forgejo-runner-config.sh | 106 +++++++ commands/lib/templates.sh | 86 ++++-- docs/plans/2026-07-27-forgejo-ci-box.md | 274 ++++++++++++++++++ docs/templates/README.md | 25 ++ docs/templates/ci-box/creds.md | 9 + docs/templates/ci-box/install.sh | 78 +++++ docs/templates/ci-box/template.env | 16 ++ install.sh | 40 ++- test/cli.sh | 199 +++++++++++++ 16 files changed, 1573 insertions(+), 24 deletions(-) create mode 100644 changelog.d/109.md create mode 100755 commands/forgejo-runner-install.sh create mode 100755 commands/forgejo-runner-remove.sh create mode 100755 commands/forgejo-runner-status.sh create mode 100644 commands/lib/forgejo-runner-config.sh create mode 100644 docs/plans/2026-07-27-forgejo-ci-box.md create mode 100644 docs/templates/README.md create mode 100644 docs/templates/ci-box/creds.md create mode 100755 docs/templates/ci-box/install.sh create mode 100644 docs/templates/ci-box/template.env diff --git a/README.md b/README.md index 09e06ef..b298cf7 100644 --- a/README.md +++ b/README.md @@ -470,6 +470,7 @@ actually contains. `staging-box` is the one in-tree tenant — mechanism-adjacen |------|---------| | `RIG_TEMPLATES_DIR` | a local folder — no fetch: the offline-test path, and "try a template before it exists anywhere" | | `RIG_TEMPLATES_REF` | any ref of `RIG_TEMPLATES_REPO` (default `heavy-duty/rig-templates`), fetched as an unauthenticated tarball at bootstrap time | +| `RIG_TEMPLATES_HOST` | which **forge** that repo lives on (default `https://github.com`) — not a mirror knob but a URL-grammar one: GitHub serves three candidate archive paths (`refs/tags`, `refs/heads`, bare) and Forgejo serves exactly one (`/archive/.tar.gz`), so the host decides what is even worth requesting. `install.sh` reads the same variable for its snapshot, so the two cannot disagree about where the registry is | | *(neither set; matching snapshot installed)* | **the installed pin snapshot** — `install.sh` best-effort fetches `RIG_TEMPLATES_PIN` once into `templates@/` inside the versioned rig tree; default converges read it with zero registry network I/O | | *(snapshot absent, empty, or stale)* | **live fetch of the in-tree pin** — the pre-snapshot fallback: `RIG_TEMPLATES_PIN` in `commands/lib/templates.sh` is fetched at converge time. A failed snapshot download only warns during install, so rig remains usable and retries here | @@ -479,6 +480,15 @@ directory cannot answer after a pin bump, and an explicit snapshot. Logs mark the installed path as `(snapshot)` so drill evidence records which source actually served the converge. +> **A self-hosted forge must serve public repos anonymously.** The fetch is +> unauthenticated *by contract* — box auto-runs a tenant bootstrap at mint, +> holding nothing — so `RIG_TEMPLATES_HOST` can only point somewhere a +> credential-less `curl` succeeds. Forgejo with `REQUIRE_SIGNIN_VIEW=true` +> answers **404 for a repo it reports as public**, which is exactly what a +> wrong ref looks like; the refusal names this case rather than leaving you +> hunting for a typo. Set `FORGEJO__service__REQUIRE_SIGNIN_VIEW=false` on the +> instance before hosting a registry there. + **The security trade — in bold, not a footnote.** **A main-tracked rig-templates repo means every merged PR there executes as root inside every future mint.** This is acceptable — and an improvement — only because of @@ -1054,6 +1064,115 @@ prints the exact `runner install` line that finishes the job. Convergent — repointing to the repo it is already on changes nothing, exits 0, and never asks for a token. +### `rig forgejo-runner install --instance ` + +The other forge's runner, and a different shape of box. Where `rig runner` +converges a fleet **machine** (`runner-server`), this converges a **ci-box +tenant** — a box guest whose whole job is running CI: + +```sh +box mint ci-box # box auto-runs: rig bootstrap ci-box +box shell ci-box +sudo rig forgejo-runner install --instance https://forgejo.heavyduty.builders +``` + +Installs `forgejo-runner` as a systemd service under an unprivileged user +(default: the tenant user `ci`). Like its GitHub sibling the runner is an +agent, not a server — it long-polls the instance outbound and needs **zero +inbound ports**. + +**Jobs run in containers, and there is no docker-in-docker.** +`rig bootstrap ci-box` already installed Docker and put the tenant user in the +`docker` group, so the runner drives that daemon directly. The usual dind +sidecar — privileged, with a plaintext `tcp://…:2375` socket — exists to +isolate jobs from a *shared* CI server; inside a box that boundary is already +paid for. The box is network-isolated, has no inbound path, and is thrown away. + +That is also why this command allows what `rig runner install` refuses. Docker +group membership is root-equivalent, and on a fleet machine the blast radius is +the machine. Here it is a disposable guest. Same trade, different box, opposite +answer — which is why these are two commands and not one with a `--forge` flag. + +- `--version ` — `forgejo-runner` release (default: latest at install + time). The published `.sha256` is **verified before the binary is + installed**; a mismatch refuses. +- `--name ` — runner name (default: this host's hostname) +- `--labels ` — replaces the default map: + `ubuntu-latest:docker://ghcr.io/catthehacker/ubuntu:act-22.04,docker:docker://node:22-bookworm`, + so `runs-on: ubuntu-latest` works in a workflow written for GitHub +- `--user ` — service user (default: `ci` when it exists, else + `forgejo-runner`) + +**There is no `--repo`, and that is the substantive difference.** A Forgejo +runner registers to an *instance*; whether it then serves that whole instance, +one organisation, or one repository is a property of the **registration +token**, which you mint in Forgejo's UI at the scope you want: + +| scope | where the token comes from | +|---|---| +| instance | Site Administration → Actions → Runners | +| organisation | Org → Settings → Actions → Runners | +| repository | Repo → Settings → Actions → Runners | + +Pass it via `FORGEJO_RUNNER_TOKEN` or the interactive prompt. It is consumed at +registration and never written to disk by rig. + +Convergent **toward `--instance`** — re-running against the instance the box is +already on re-uses the binary and skips registration. Pointed at a *different* +instance it refuses and names both, for the same reason `rig runner install` +refuses a different repo. + +> **`.runner` holds a credential here.** GitHub's names a repository; Forgejo's +> holds the runner's own long-lived token. rig installs it `0600` owned by the +> runner user and **re-asserts that mode on every converge** — a mode that +> drifted leaks the secret silently, since nothing fails and the runner keeps +> working. `status` warns when it finds one that has. + +### `rig forgejo-runner status` + +Which instance this box's runner is registered to — instance, name, labels, +directory, unit and state. Reads the box only: no token, no network call, and +it never prints the registration secret `.runner` holds. Exits 1 when no runner +is installed. + +### `rig forgejo-runner remove` + +Stops and disables the service and wipes the local registration. The binary and +the user stay, so a later `install` re-registers without downloading anything. + +**Always local-only, and there is no `--local` flag.** Forgejo has no runner +deregistration endpoint — no removal token, nothing to hand back — so the box +is cleaned and the runner stays listed as offline until you delete it under +Actions → Runners. Offering the flag would advertise a server-side alternative +that does not exist. For the same reason there is no `forgejo-runner repoint`: +a move cannot be one atomic act, so it is `remove` then `install`. + +### Enabling Actions on the Forgejo side + +Two environment variables on the Forgejo service (Coolify → Environment +Variables → redeploy). Forgejo maps `FORGEJO__
__` onto its +config, so this survives image upgrades in a way an edited `app.ini` does not: + +``` +FORGEJO__actions__ENABLED=true +FORGEJO__actions__DEFAULT_ACTIONS_URL=https://code.forgejo.org +``` + +> **A registry served from Forgejo needs one more.** `RIG_TEMPLATES_HOST` +> (below) lets the template registry live on any forge, but the mint-time fetch +> is **unauthenticated by contract** — box auto-runs `rig bootstrap -box` +> at mint, holding no credentials. A Forgejo instance with +> `REQUIRE_SIGNIN_VIEW=true` answers **404 for public repos** to anonymous +> callers, which is indistinguishable from a wrong ref. So hosting the registry +> there also needs: +> +> ``` +> FORGEJO__service__REQUIRE_SIGNIN_VIEW=false +> ``` +> +> This affects only the *registry* fetch. `rig forgejo-runner` itself +> authenticates with a token and works either way. + ### `rig users apply --file ` Converges named operator accounts from a declarative users file — on **every** diff --git a/bin/rig b/bin/rig index 4ec2cb3..1f9b7ee 100755 --- a/bin/rig +++ b/bin/rig @@ -87,6 +87,23 @@ commands: re-register, reusing the binary already on the box. Needs a removal token for the old repo and a registration token for the new one. Run as root. + forgejo-runner install --instance [options] + Forgejo Actions runner as a systemd service under an unprivileged + user — outbound-only, jobs in containers on this box's own dockerd. + Its home is a ci-box tenant, where 'rig bootstrap ci-box' already + installed that daemon. Registers to an INSTANCE: whether the runner + serves that instance, one org or one repo is a property of the + registration token, so there is no --repo. Prompts for the token + (FORGEJO_RUNNER_TOKEN env overrides). Run as root. + forgejo-runner status [--user ] + Which instance this box's Forgejo runner is registered to: instance, + name, labels, unit. Reads the box only — no token, no network call, + and never prints the registration secret .runner holds. Run as root. + forgejo-runner remove [--user ] + Take the service down and wipe this box's registration. Always + local-only: Forgejo has no deregistration endpoint, so the runner + stays listed offline until you delete it in the instance. Needs no + token. Run as root. users apply --file Converge named operator accounts from a declarative users file, on every class: groups by role (admin/rig/box), passwords locked always, @@ -443,6 +460,37 @@ case "$cmd" in ;; esac ;; + forgejo-runner) + shift + sub="${1:-}" + case "$sub" in + install) + shift + exec "$ROOT/commands/forgejo-runner-install.sh" "$@" + ;; + status) + shift + exec "$ROOT/commands/forgejo-runner-status.sh" "$@" + ;; + remove) + shift + exec "$ROOT/commands/forgejo-runner-remove.sh" "$@" + ;; + repoint) + # The GitHub sibling HAS this verb, so an operator will try it. Say why + # it cannot exist here rather than printing usage and leaving them to + # infer it: 'repoint' is atomic because GitHub lets rig deregister from + # the old repo; Forgejo has no such endpoint, so the honest shape is + # two acts, and one of them leaves a stale entry behind. + printf 'rig: forgejo-runner has no repoint: Forgejo has no deregistration endpoint, so a move cannot be one atomic act. Use "rig forgejo-runner remove" then "rig forgejo-runner install --instance ", and delete the stale runner in the old instance under Actions > Runners.\n' >&2 + exit 2 + ;; + *) + usage >&2 + exit 2 + ;; + esac + ;; users) shift sub="${1:-}" diff --git a/changelog.d/109.md b/changelog.d/109.md new file mode 100644 index 0000000..fdfbcfc --- /dev/null +++ b/changelog.d/109.md @@ -0,0 +1,4 @@ +### Added + +- `rig forgejo-runner install|status|remove` registers a Forgejo Actions runner against an instance, jobs in containers on the box's own dockerd (forgejo#109) +- The template registry can be served from any forge — `RIG_TEMPLATES_HOST` (forgejo#109) diff --git a/commands/bootstrap-undo.sh b/commands/bootstrap-undo.sh index 71e7339..1af226d 100755 --- a/commands/bootstrap-undo.sh +++ b/commands/bootstrap-undo.sh @@ -24,6 +24,28 @@ if [ "$runner_installed" -eq 1 ]; then die "a GitHub runner is installed — run 'rig runner remove' first so undo does not leave a ghost runner in the repository" fi +# The same hazard, the other forge (#109): leaving the tailnet under a live +# Forgejo runner strands a registration this box can no longer serve, and +# Forgejo has no deregistration endpoint — so the ghost it leaves is one +# somebody has to delete BY HAND in the instance's admin UI. That makes the +# refusal more load-bearing here than for GitHub, not less. +# +# RIG_FORGEJO_RUNNER_DIR mirrors RIG_RUNNER_DIR above so tests can point this +# at a fixture. The glob covers the tenant default (`ci`) and the dedicated +# account alike, because both are reachable defaults of `install --user`. +forgejo_runner_installed=0 +if [ -n "${RIG_FORGEJO_RUNNER_DIR:-}" ]; then + [ -e "$RIG_FORGEJO_RUNNER_DIR/.runner" ] && forgejo_runner_installed=1 +else + for runner_config in /home/*/forgejo-runner/.runner /root/forgejo-runner/.runner; do + [ -e "$runner_config" ] && forgejo_runner_installed=1 + done + [ -e /etc/systemd/system/forgejo-runner.service ] && forgejo_runner_installed=1 +fi +if [ "$forgejo_runner_installed" -eq 1 ]; then + die "a Forgejo runner is installed — run 'rig forgejo-runner remove' first so undo does not leave a ghost runner in the instance" +fi + join_by="" while IFS= read -r field; do case "$field" in diff --git a/commands/forgejo-runner-install.sh b/commands/forgejo-runner-install.sh new file mode 100755 index 0000000..723baa9 --- /dev/null +++ b/commands/forgejo-runner-install.sh @@ -0,0 +1,365 @@ +#!/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" diff --git a/commands/forgejo-runner-remove.sh b/commands/forgejo-runner-remove.sh new file mode 100755 index 0000000..289f9ba --- /dev/null +++ b/commands/forgejo-runner-remove.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash +# rig forgejo-runner remove — take the service down and wipe this box's +# registration. Convergent: a box with nothing installed exits 0. +# +# There is no --local flag here, and its absence is the design. The GitHub +# sibling offers --local as an ESCAPE HATCH from a real deregistration +# handshake (config.sh remove --token, against an endpoint that mints removal +# tokens). Forgejo has no such handshake and no such endpoint: local is the +# only thing removal can ever be. Shipping the flag would advertise a +# server-side alternative that does not exist, and an operator would spend the +# afternoon hunting for the token that turns it off. +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}"; } + +usage() { + cat <<'EOF' +usage: rig forgejo-runner remove [--user ] + + --user unprivileged service user (default: the tenant user `ci` + when it exists, else forgejo-runner) + +Stops and disables the systemd service, then wipes this box's registration. +The binary and the user stay put, so a later `rig forgejo-runner install` +re-registers without downloading anything. + +Forgejo has no runner deregistration endpoint, so this is always local-only: +the box is cleaned, and the runner stays listed as offline in the instance +until you delete it under Actions > Runners. No token is needed or asked for. + +Convergent: safe to re-run; a box with no runner installed exits 0. +EOF +} + +# --- args (validated before the root check, so errors are testable) --------- +RUNNER_USER="" +while [ $# -gt 0 ]; do + case "$1" in + --user) + [ $# -ge 2 ] || die "--user needs a value" 2 + RUNNER_USER="$2"; shift 2 ;; + --local) + # Named rather than "unknown flag": it is the GitHub sibling's spelling, + # and the answer is that removal here is ALWAYS what --local means. + die "--local is not a flag here: Forgejo has no deregistration endpoint, so 'rig forgejo-runner remove' is always local-only. Re-run it without the flag, then delete the offline runner in the instance's Actions > Runners." 2 ;; + -h|--help) usage; exit 0 ;; + *) die "unknown flag: $1" 2 ;; + esac +done + +if [ -z "$RUNNER_USER" ]; then + if id -u ci >/dev/null 2>&1; then RUNNER_USER="ci"; else RUNNER_USER="forgejo-runner"; fi +fi + +# --- validation ------------------------------------------------------------ +[ "$RUNNER_USER" != "root" ] || die "runner user must not be root" 2 + +# --- guards ---------------------------------------------------------------- +[ "$(id -u)" -eq 0 ] || die "must run as root" + +UNIT=/etc/systemd/system/forgejo-runner.service + +# --- nothing to remove? ----------------------------------------------------- +if ! id -u "$RUNNER_USER" >/dev/null 2>&1; then + log "no ${RUNNER_USER} user on this box; nothing to remove" + exit 0 +fi +USER_HOME="$(getent passwd "$RUNNER_USER" | cut -d: -f6)" +RUNNER_DIR="$USER_HOME/forgejo-runner" +if [ ! -e "$RUNNER_DIR/.runner" ] && [ ! -e "$UNIT" ]; then + log "no runner registered in ${RUNNER_DIR}; nothing to remove" + exit 0 +fi + +INSTANCE="$(forgejo_runner_instance "$RUNNER_DIR")" +RUNNER_NAME="$(forgejo_runner_name "$RUNNER_DIR")" + +# --- service --------------------------------------------------------------- +# First, in both paths: stopping after the registration is wiped would strand a +# running daemon polling with credentials that no longer exist on disk. +if [ -e "$UNIT" ]; then + log "stopping and disabling forgejo-runner.service" + systemctl stop forgejo-runner >/dev/null 2>&1 || true + systemctl disable forgejo-runner >/dev/null 2>&1 || true + rm -f "$UNIT" + systemctl daemon-reload +else + log "no service installed; skipping" +fi + +# --- registration ----------------------------------------------------------- +if [ -e "$RUNNER_DIR/.runner" ]; then + rm -f "$RUNNER_DIR/.runner" + log "wiped the local registration" +fi +rm -f "$RUNNER_DIR/.rig-labels" + +# END WITH THE ABSENCE ASSERT: "removed" is a claim, and claims get verified +# (the `rig uninstall` precedent). +leftover="" +[ -e "$RUNNER_DIR/.runner" ] && leftover="$leftover $RUNNER_DIR/.runner" +[ -e "$UNIT" ] && leftover="$leftover $UNIT" +if [ -n "$leftover" ]; then + printf 'rig-forgejo-runner: remove INCOMPLETE — still present:%s\n' "$leftover" >&2 + exit 1 +fi + +log "runner removed; the binary stays for a future rig forgejo-runner install" +warn "the runner${RUNNER_NAME:+ ${RUNNER_NAME}} is still listed as offline in ${INSTANCE:-the instance} — delete it under Actions > Runners. Forgejo has no deregistration endpoint, so rig cannot do this for you." diff --git a/commands/forgejo-runner-status.sh b/commands/forgejo-runner-status.sh new file mode 100755 index 0000000..f201e9f --- /dev/null +++ b/commands/forgejo-runner-status.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +# rig forgejo-runner status — what is this box's Forgejo runner registered to? +# Read-only: reports what is already on the box. No credential, no network call. +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}"; } + +usage() { + cat <<'EOF' +usage: rig forgejo-runner status [--user ] + + --user unprivileged service user (default: the tenant user `ci` + when it exists, else forgejo-runner) + +Prints the Forgejo instance this box's runner is registered to, its runner +name, the labels rig recorded when it registered, the install directory, and +the systemd unit and its state. + +Reads only the runner's own on-disk config — no token, no network call. The +registration secret that config holds is never printed. Exits 1 when no runner +is installed. +EOF +} + +# --- args (validated before the root check, so errors are testable) --------- +RUNNER_USER="" +while [ $# -gt 0 ]; do + case "$1" in + --user) + [ $# -ge 2 ] || die "--user needs a value" 2 + RUNNER_USER="$2"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) die "unknown flag: $1" 2 ;; + esac +done + +if [ -z "$RUNNER_USER" ]; then + if id -u ci >/dev/null 2>&1; then RUNNER_USER="ci"; else RUNNER_USER="forgejo-runner"; fi +fi + +# --- validation ------------------------------------------------------------ +[ "$RUNNER_USER" != "root" ] || die "runner user must not be root" 2 + +# --- guards ---------------------------------------------------------------- +[ "$(id -u)" -eq 0 ] || die "must run as root" + +id -u "$RUNNER_USER" >/dev/null 2>&1 \ + || die "no runner installed (no ${RUNNER_USER} user on this box)" +USER_HOME="$(getent passwd "$RUNNER_USER" | cut -d: -f6)" +RUNNER_DIR="$USER_HOME/forgejo-runner" +[ -e "$RUNNER_DIR/.runner" ] \ + || die "no runner registered in ${RUNNER_DIR}" + +# --- read the runner's own config ------------------------------------------- +INSTANCE="$(forgejo_runner_instance "$RUNNER_DIR")" +RUNNER_NAME="$(forgejo_runner_name "$RUNNER_DIR")" + +if [ -r "$RUNNER_DIR/.rig-labels" ]; then + LABELS="$(cat "$RUNNER_DIR/.rig-labels")" +else + LABELS="(not recorded on this box — see the instance's Actions > Runners)" +fi + +UNIT=/etc/systemd/system/forgejo-runner.service +if [ -e "$UNIT" ]; then + STATE="$(systemctl is-active forgejo-runner 2>/dev/null || true)" + SERVICE="forgejo-runner.service (${STATE:-unknown})" +else + SERVICE="(not installed as a service)" +fi + +log "instance: ${INSTANCE:-unknown}" +log "name: ${RUNNER_NAME:-unknown}" +log "labels: ${LABELS}" +log "dir: ${RUNNER_DIR}" +log "service: ${SERVICE}" + +# status is the only command an operator runs when nothing is obviously wrong, +# which makes it the right place to notice a mode that drifted. It reports and +# does not fix: converging state is `install`'s job, and a read-only verb that +# quietly writes is a worse surprise than a loud warning. +MODE="$(stat -c '%a' "$RUNNER_DIR/.runner" 2>/dev/null || true)" +if [ -n "$MODE" ] && [ "$MODE" != "$FORGEJO_RUNNER_FILE_MODE" ]; then + warn ".runner is mode ${MODE}, not ${FORGEJO_RUNNER_FILE_MODE} — it holds this runner's registration secret, and every account on this box can read it. Re-run 'rig forgejo-runner install --instance ${INSTANCE:-}' to converge the mode." +fi diff --git a/commands/lib/forgejo-runner-config.sh b/commands/lib/forgejo-runner-config.sh new file mode 100644 index 0000000..6d8b95f --- /dev/null +++ b/commands/lib/forgejo-runner-config.sh @@ -0,0 +1,106 @@ +#!/usr/bin/env bash +# Shared reader for the Forgejo runner's own on-disk config ($RUNNER_DIR/.runner). +# Sourced by the forgejo-runner-* commands; never executed on its own. +# +# WHY A SECOND LIB, not an arm inside lib/runner-config.sh: the two files are +# different documents making different claims, and the sibling's helpers answer +# questions this one cannot ask. GitHub's .runner names a REPOSITORY +# (gitHubUrl), so `runner install` converges toward --repo. Forgejo's names an +# INSTANCE (address) and nothing else about scope — whether a registration is +# instance-wide, org, or single-repo is a property of the TOKEN, decided in +# Forgejo's UI before rig ever sees it. There is no repo here to converge +# toward, and no way to read one back. Sharing a reader would mean a +# gitHubUrl accessor that returns empty forever on one of the two forges. +# +# json_field is deliberately re-used FROM the sibling rather than copied: a +# rig-bootstrapped box has no jq, both files are flat JSON, and one grep/sed +# reader for both is the same trade lib/runner-config.sh already argued. + +HERE_FJ="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=SCRIPTDIR/runner-config.sh +. "$HERE_FJ/runner-config.sh" # json_field + +# THE CREDENTIAL FACT that shapes this whole family: Forgejo's .runner holds +# the runner's own long-lived token — the secret it authenticates every poll +# with — alongside address/name/labels. GitHub's holds no such thing. +# +# So the mode is part of the contract, not hygiene: a registration secret +# readable by every account on the box is a quiet, permanent credential leak, +# and it leaks silently — nothing fails, the runner keeps working. Converge is +# the only moment rig can notice a mode that drifted (an operator's editor, a +# restore from a tarball that lost modes, a hand-edit to add a label). +FORGEJO_RUNNER_FILE_MODE=600 + +# forgejo_runner_instance — the Forgejo instance this box's runner +# is registered to, empty when nothing is registered there. +forgejo_runner_instance() { + [ -e "$1/.runner" ] || return 0 + json_field "$1/.runner" address +} + +# forgejo_runner_name — the runner's name, empty when unregistered. +forgejo_runner_name() { + [ -e "$1/.runner" ] || return 0 + json_field "$1/.runner" name +} + +# forgejo_runner_secure — converge .runner to 0600 +# owned by the runner user. Called on every install, not only at registration. +# Silent on success: this is a mode that should always already be right, and a +# line saying so on every converge would train the reader to skip it. +forgejo_runner_secure() { + local dir="$1" user="$2" group="$3" + [ -e "$dir/.runner" ] || return 0 + chmod "$FORGEJO_RUNNER_FILE_MODE" "$dir/.runner" + chown "$user:$group" "$dir/.runner" +} + +# assert_runner_instance +# +# Returns 0 when the box has no runner, or has one already registered to +# : re-running `install` against the instance the box is already +# on is real convergence — it re-uses the binary, skips registration, exits 0. +# +# Returns 1, explaining itself on stderr, when the runner is registered to a +# DIFFERENT instance. Skipping *that* is not convergence, it is ignoring the +# argument: `install` would skip its registration step, restart the service +# against the OLD instance, and report success — leaving the instance you asked +# for with no runner and its jobs queued against one that will never come. +# +# This is assert_runner_repo's reasoning, asked about the axis Forgejo actually +# has. There is deliberately no `repoint` sibling: Forgejo has no +# deregistration handshake to perform against the old instance, so moving a +# runner is `remove` then `install` — two acts that are already honest about +# leaving a stale entry behind, rather than one verb pretending to be atomic. +assert_runner_instance() { + local dir="$1" wanted="$2" current + [ -e "$dir/.runner" ] || return 0 + + current="$(forgejo_runner_instance "$dir")" + + if [ -z "$current" ]; then + printf 'rig-forgejo-runner: ERROR: %s\n' \ +"${dir}/.runner exists but names no instance — this box's registration cannot +be read, so rig cannot tell whether it is already on ${wanted}. +Wipe the local registration and install again: + rig forgejo-runner remove" >&2 + return 1 + fi + + # Trailing slashes are a spelling difference, not a different instance: + # forgejo-runner records the URL as given, so `--instance https://f.example/` + # and `--instance https://f.example` would otherwise read as a move. + if [ "${current%/}" = "${wanted%/}" ]; then + return 0 + fi + + printf 'rig-forgejo-runner: ERROR: %s\n' \ +"this box's runner is already registered to ${current}, not ${wanted}. +install will not move a runner between instances: it would leave the service +running against the OLD instance and report success. To move it, take it off +the old instance first: + rig forgejo-runner remove +then install against the new one. Forgejo has no deregistration handshake, so +the old entry stays listed until you delete it in that instance's admin UI." >&2 + return 1 +} diff --git a/commands/lib/templates.sh b/commands/lib/templates.sh index 15c26f9..5185aaf 100644 --- a/commands/lib/templates.sh +++ b/commands/lib/templates.sh @@ -18,7 +18,7 @@ # /creds.md the per-vendor creds-free paragraph the context # renderer splices in # -# THE SOURCE IS THREE KNOBS plus the installed pin snapshot, precedence +# THE SOURCE IS FOUR KNOBS plus the installed pin snapshot, precedence # _DIR > _REF > snapshot > pin fetch: # RIG_TEMPLATES_DIR a local folder — bypasses the fetch entirely (the # offline-test path, and "try a template before it @@ -27,6 +27,8 @@ # bootstrap time (the same shape as the rig preinstall) # RIG_TEMPLATES_REPO which repo that ref lives in (default # heavy-duty/rig-templates) +# RIG_TEMPLATES_HOST which FORGE that repo lives on (default +# https://github.com) — see templates_archive_urls # and, absent both overrides, the snapshot installed beside this file when it # matches the PIN below, then a live fetch of that pin as the fallback. @@ -41,6 +43,40 @@ # agent tenants ported byte-equivalent from the case arms this PR cut. RIG_TEMPLATES_PIN=be749f7fd1ff8dd7c2359bbce7fd6abd3f403eb0 +# The forge the registry lives on. GitHub by default, so every existing caller +# is byte-unchanged; overridable because a self-hosted Forgejo is a different +# origin AND a different URL grammar (#109). +RIG_TEMPLATES_HOST_DEFAULT="https://github.com" + +# templates_archive_urls — the source-tarball candidates +# for , in order, one per line. A PURE function (no network, no globals): +# test/cli.sh lifts it and drives it against both forges, the resolve_latest_tag +# precedent in install.sh. +# +# The two forges are not URL-compatible, and the difference is not cosmetic: +# +# GitHub three forms, refs/tags FIRST so a tag always outranks a branch +# sharing its name (a pin must win), refs/heads as the fallback +# that keeps a branch ref working, then the bare form a commit SHA +# downloads through. +# Forgejo ONE form. /archive/.tar.gz resolves tags, branches and SHAs +# alike, and the refs/{tags,heads}/ paths are not served at all — +# emitting them would mean two guaranteed 404s ahead of every fetch +# and a failure message listing URLs that never could have worked. +# +# Measured against forgejo.heavyduty.builders, not inferred from the docs. +templates_archive_urls() { + local host="${1%/}" repo="$2" ref="$3" + case "$host" in + https://github.com|http://github.com|*//github.com) + printf '%s/%s/archive/refs/tags/%s.tar.gz\n' "$host" "$repo" "$ref" + printf '%s/%s/archive/refs/heads/%s.tar.gz\n' "$host" "$repo" "$ref" + printf '%s/%s/archive/%s.tar.gz\n' "$host" "$repo" "$ref" ;; + *) + printf '%s/%s/archive/%s.tar.gz\n' "$host" "$repo" "$ref" ;; + esac +} + # The template.env schema. Grammar: blank lines, '#' comments, and # KEY="value" — nothing else. Parsed by regex, never sourced. TEMPLATE_KEYS_REQUIRED=(USER CONTEXT_PATH CLI_NAME PATH_LINE) @@ -50,7 +86,11 @@ MACHINE_KEYS_REQUIRED=(ROOT_DOOR HOST JOIN) # templates_source_desc — where the resolved registry came from, for error # messages and logs: a misconfigured RIG_TEMPLATES_REPO must be visible in # the unknown-role refusal rather than looking like a typo. +# The host rides every non-snapshot description: a registry served from the +# wrong FORGE fails exactly like a misspelled repo, and naming only the repo +# would send the reader hunting for a typo that is not there (#109). templates_source_desc() { + local host="${RIG_TEMPLATES_HOST:-$RIG_TEMPLATES_HOST_DEFAULT}" if [ -n "${RIG_TEMPLATES_DIR:-}" ]; then printf 'local dir %s (RIG_TEMPLATES_DIR)' "$RIG_TEMPLATES_DIR" elif [ -z "${RIG_TEMPLATES_REF:-}" ] && templates_snapshot_usable; then @@ -58,7 +98,8 @@ templates_source_desc() { "${RIG_TEMPLATES_REPO:-heavy-duty/rig-templates}" \ "$RIG_TEMPLATES_PIN" else - printf '%s@%s%s' \ + printf '%s/%s@%s%s' \ + "${host%/}" \ "${RIG_TEMPLATES_REPO:-heavy-duty/rig-templates}" \ "${RIG_TEMPLATES_REF:-$RIG_TEMPLATES_PIN}" \ "$([ -n "${RIG_TEMPLATES_REF:-}" ] && printf ' (RIG_TEMPLATES_REF)' || printf ' (the in-tree pin)')" @@ -89,17 +130,25 @@ templates_snapshot_usable() { # $(…) call site would run the fetch in a subshell and lose TEMPLATES_TMP, # the path the caller's cleanup trap must rm). RIG_TEMPLATES_DIR wins and is # used as-is; otherwise the repo@ref tarball is fetched and extracted under -# a temp dir, recorded in TEMPLATES_TMP. Candidate URLs follow install.sh's -# precedence — a tag outranks a branch that shares its name — plus the bare -# archive/ form, which is how a commit-SHA pin (the default) downloads. +# a temp dir, recorded in TEMPLATES_TMP. Candidate URLs come from +# templates_archive_urls, which is forge-aware — see there for why the two +# forges cannot share one list. +# # Failure lists every URL tried: the fetch is unauthenticated by contract # (box auto-runs bootstrap at mint, holding nothing), so "is the repo public # and the ref real" is the whole diagnosis. +# +# On a SELF-HOSTED forge there is a third way to fail that reads exactly like +# the other two, so the refusal names it: an instance with +# REQUIRE_SIGNIN_VIEW=true serves 404 for public repos to anonymous callers — +# the same status a wrong ref gets. A mint holds no credentials and never +# will, so such an instance cannot host a registry until it serves public +# repos anonymously (#109). TEMPLATES_TMP="" # shellcheck disable=SC2034 # REGISTRY_DIR is this function's OUTPUT, read by the sourcing script REGISTRY_DIR="" templates_resolve() { - local repo ref url got="" + local repo ref host url got="" if [ -n "${RIG_TEMPLATES_DIR:-}" ]; then [ -d "$RIG_TEMPLATES_DIR" ] || { printf 'RIG_TEMPLATES_DIR is not a directory: %s\n' "$RIG_TEMPLATES_DIR" >&2 @@ -114,29 +163,32 @@ templates_resolve() { fi repo="${RIG_TEMPLATES_REPO:-heavy-duty/rig-templates}" ref="${RIG_TEMPLATES_REF:-$RIG_TEMPLATES_PIN}" + host="${RIG_TEMPLATES_HOST:-$RIG_TEMPLATES_HOST_DEFAULT}" command -v curl >/dev/null 2>&1 || { printf 'curl is required to fetch the template registry\n' >&2; return 1; } command -v tar >/dev/null 2>&1 || { printf 'tar is required to extract the template registry\n' >&2; return 1; } TEMPLATES_TMP="$(mktemp -d)" - for url in \ - "https://github.com/$repo/archive/refs/tags/$ref.tar.gz" \ - "https://github.com/$repo/archive/refs/heads/$ref.tar.gz" \ - "https://github.com/$repo/archive/$ref.tar.gz"; do + while IFS= read -r url; do if curl -fsSL "$url" -o "$TEMPLATES_TMP/templates.tar.gz" 2>/dev/null; then got="$url"; break; fi - done + done < <(templates_archive_urls "$host" "$repo" "$ref") if [ -z "$got" ]; then - printf 'cannot fetch the template registry %s@%s — tried:\n' "$repo" "$ref" >&2 - printf ' https://github.com/%s/archive/refs/tags/%s.tar.gz\n' "$repo" "$ref" >&2 - printf ' https://github.com/%s/archive/refs/heads/%s.tar.gz\n' "$repo" "$ref" >&2 - printf ' https://github.com/%s/archive/%s.tar.gz\n' "$repo" "$ref" >&2 + printf 'cannot fetch the template registry %s/%s@%s — tried:\n' "${host%/}" "$repo" "$ref" >&2 + templates_archive_urls "$host" "$repo" "$ref" | sed 's/^/ /' >&2 printf 'the fetch is unauthenticated by contract (a mint holds no credentials): the repo must be public and the ref must exist. RIG_TEMPLATES_DIR= bypasses the fetch.\n' >&2 + case "${host%/}" in + https://github.com|http://github.com) ;; + *) printf 'on a self-hosted forge, check the instance serves PUBLIC repos to anonymous callers too: Forgejo with REQUIRE_SIGNIN_VIEW=true answers 404 for a public repo, which is indistinguishable from a wrong ref above (set FORGEJO__service__REQUIRE_SIGNIN_VIEW=false).\n' >&2 ;; + esac return 1 fi tar -xzf "$TEMPLATES_TMP/templates.tar.gz" -C "$TEMPLATES_TMP" || { printf 'cannot extract the registry tarball from %s\n' "$got" >&2 return 1 } - # A GitHub archive holds exactly one top-level directory (-, - # slashes flattened) — assert that shape instead of assuming the name. + # A source archive holds exactly one top-level directory — assert that + # SHAPE, never the name, because the name is the forge's choice and the two + # disagree: GitHub writes - (slashes flattened), Forgejo writes + # bare . Globbing for the shape is what makes this line survive a + # forge swap untouched. set -- "$TEMPLATES_TMP"/*/ { [ $# -eq 1 ] && [ -d "$1" ]; } || { printf 'the registry tarball from %s does not hold exactly one top-level directory\n' "$got" >&2 diff --git a/docs/plans/2026-07-27-forgejo-ci-box.md b/docs/plans/2026-07-27-forgejo-ci-box.md new file mode 100644 index 0000000..ba13092 --- /dev/null +++ b/docs/plans/2026-07-27-forgejo-ci-box.md @@ -0,0 +1,274 @@ +# Forgejo-native CI: a `ci-box` tenant and a `forgejo-runner` command family + +Status: proposed (#109) +Date: 2026-07-27 + +## The shape of the thing + +A fleet machine hosts boxes. One of those boxes is the CI box. The Forgejo +runner lives inside it, and CI jobs run in containers on that box's own +dockerd. + +``` ++-----------------------------+ outbound HTTPS (long-poll) +| Forgejo (Coolify) | <---------------------------------+ +| forgejo.heavyduty.builders | | ++-----------------------------+ | + +------------------------------+---+ + No inbound path to the box. | staging-server (host=yes) | + The runner polls out. | +---------------------------+ | + | | ci-box (tenant) | | + | | forgejo-runner (systemd)| | + | | dockerd -> job containers| | + | +---------------------------+ | + +----------------------------------+ +``` + +Three pieces, in two repos. + +## 1. A forge-agnostic registry fetch (`commands/lib/templates.sh`) + +`templates_resolve` builds three candidate URLs, all on `github.com`. A +registry hosted anywhere else cannot be fetched. + +The fix is a fourth knob beside `_DIR` / `_REF` / `_REPO`: + +```sh +RIG_TEMPLATES_HOST the forge origin (default https://github.com) +``` + +The candidate list becomes the host's fact rather than a constant, because the +two forges genuinely differ: + +| | GitHub | Forgejo | +|---|---|---| +| Candidates | `archive/refs/tags/.tar.gz`, `archive/refs/heads/.tar.gz`, `archive/.tar.gz` | `archive/.tar.gz` — one form, which resolves tags, branches and SHAs alike | +| Archive top-level dir | `-` | `` | + +Both were measured against `forgejo.heavyduty.builders`, not assumed. + +The existing "exactly one top-level directory" assert survives untouched — it +globs `*/` rather than reconstructing the name, so the differing directory name +costs nothing. Its **comment** was wrong for Forgejo and is corrected. + +`templates_source_desc` grows the host, so a misconfigured origin shows up in +the unknown-role refusal instead of reading like a typo. The GitHub default +means every existing caller behaves exactly as before. + +`install.sh`'s `snapshot_templates` duplicates the same candidate list for its +install-time cache. It gets the same knob, from the same environment variable, +so the snapshot and the live fetch cannot disagree about where the registry is. + +### The blocker this exposes + +`templates_resolve` documents a hard contract: + +> the fetch is unauthenticated by contract (box auto-runs bootstrap at mint, +> holding nothing) + +Measured: on `forgejo.heavyduty.builders`, anonymous requests for +`heavy-duty/rig` — reported by the API as `private: false` — return **404** for +the API, the web page, the git remote and the archive endpoint. Only an +authenticated request succeeds. The instance requires sign-in to view. + +A mint holds no credentials, so **a Forgejo-hosted registry is unreachable at +mint time** until the instance serves public repos anonymously: + +``` +FORGEJO__service__REQUIRE_SIGNIN_VIEW=false +``` + +This is a Coolify env-var change on the Forgejo service, next to the +`FORGEJO__actions__ENABLED=true` that Actions already needs. It is recorded +here and in the README as a prerequisite. Nothing in this change silently +assumes it: with the gate up, the fetch fails the way any unreachable ref +fails, listing every URL tried. + +## 2. The `ci-box` tenant definition + +Data, not mechanism — it belongs in the registry repo. It is staged in this PR +under `docs/templates/ci-box/` so it can be reviewed and linted here, and moves +to the registry verbatim once that repo exists on Forgejo. + +``` +USER="ci" +CONTEXT_PATH=".ci/CONTEXT.md" +CLI_NAME="forgejo-runner" +CLI_SRC="/usr/local/bin/forgejo-runner" +PATH_LINE="export PATH="$HOME/.local/bin:$PATH"" +NEEDS_NODE="no" +``` + +`CLI_SRC` is absolute rather than `~/`-relative, which is the one place this +definition departs from the agent tenants. Their CLI lives in the tenant's +home; this binary is executed by a systemd unit, and a tenant-writable binary +that a root-installed unit runs is a trivial path to root inside the box. So it +lands root-owned under `/usr/local/bin`. `test/cli.sh` pins `CLI_SRC` against +the path `install.sh` actually writes — a drifted pair converges to a CLI that +exists and cannot run, which is the scar `grok-box` left. + +`NEEDS_NODE="no"` because the runner is a static Go binary. Jobs get their Node +from the container image, which is the whole point of the label mapping below — +installing a second Node on the host would be a toolchain nobody reads. + +`install.sh` fetches the release binary for the architecture and **verifies the +published `.sha256` before installing it**. Forgejo ships bare binaries with +`.sha256` and `.asc` beside them rather than a tarball, so a checksum is +available for free; taking it makes the install auditable in the way +`coolify install`'s version pin is. + +This satisfies the tenant schema honestly rather than by paperwork: +`bootstrap-tenant.sh` asserts ` --version` answers **as the tenant user**, +and `forgejo-runner --version` does. + +`creds.md` states the box holds no Forgejo credential and that registration is +a separate, operator-run act — which is true, and is what the rendered context +file needs to say. + +### Why the box replaces docker-in-docker + +The setup guide this design came from builds a `docker:dind` sidecar with +`privileged: true` and a plaintext `tcp://…:2375` daemon socket. Inside a +tenant that is redundant: `bootstrap-tenant.sh` already installs Docker on +every tenant and adds the tenant user to the `docker` group. The runner talks +to that daemon over its own socket. + +The isolation argument that justifies dind on a shared CI server is already +paid for here by the box: it is network-isolated, disposable, and has no +inbound path. Stacking dind inside it would add a privileged container to buy +a boundary that already exists. + +Note the trade this makes explicit: `docker` group membership is +root-equivalent *within the box*. `rig runner install` refuses Docker for +exactly that reason — but it converges a fleet **machine**, where the blast +radius is the machine. Here the blast radius is a guest that is thrown away. +That is the difference that makes the same trade correct in one place and wrong +in the other. + +## 3. `rig forgejo-runner install|status|remove` + +A new family beside `rig runner`, which is left untouched. + +```sh +box shell ci-box +sudo rig forgejo-runner install \ + --instance https://forgejo.heavyduty.builders \ + --name ci-runner-1 +``` + +Default labels: + +``` +ubuntu-latest:docker://ghcr.io/catthehacker/ubuntu:act-22.04,docker:docker://node:22-bookworm +``` + +so `runs-on: ubuntu-latest` works in a workflow written for GitHub. + +### Why not `rig runner --forge forgejo` + +GitHub's runner registers against a repository URL, and +`assert_runner_repo` converges toward `--repo`: re-running against the same +repo is a no-op, a different repo is refused because silently restarting on the +old one would leave the requested repo with jobs queued forever. + +Forgejo has no such argument. The runner registers against an **instance**, and +whether the registration is instance-wide, org, or single-repo is a property of +the *token*, decided in Forgejo's UI before rig ever sees it. 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. Two families is the smaller lie. + +### `register` is deprecated upstream, and is still the right call + +Measured on v12.13.2: both `forgejo-runner register` and `create-runner-file` +carry `(deprecated)` in their help. The successor is +`daemon --url --uuid --token-url `, which requires the +runner to **already exist on the instance** — the operator creates it via the +API or UI and carries a UUID back. + +`register` is chosen anyway: + +- It still works, and this was verified rather than assumed. A planted + `.runner` made `daemon` resolve the instance from the file, connect to a live + Forgejo, and fail with `Unauthenticated: unregistered runner` — the transport + and the file format are intact; only the credential was fake. +- The successor asks the operator for a second, differently-shaped credential + dance for no gain today. +- `register` writes a file `status` can read back. Under the successor, "what + is this box registered to" has no on-disk answer that is not merely rig's own + copy of what it was told. + +When upstream removes it: the unit line gains `--url`/`--uuid`, and the readers +in `forgejo-runner-config.sh` move to whatever holds the UUID. +`assert_runner_instance`'s contract survives either spelling, because it asks +about the instance — which both record. + +Not verified here, and left to the drill: that `register` writes `.runner` on a +live instance. That needs a real registration token, which this design cannot +mint. + +### The convergence guard that does apply + +`assert_runner_instance`: re-running against the instance this box is already +registered to re-uses the binary and skips registration; a **different** +instance refuses and names both. Same trust-boundary reasoning as +`assert_runner_repo`, asked about the axis Forgejo actually has. + +### `.runner` holds a credential here + +GitHub's `.runner` names a repo. Forgejo's holds `address`, `name`, `labels` +**and the runner's own long-lived token** — the credential it authenticates +with on every poll. + +So this family does something its GitHub sibling never had to: it installs +`.runner` as `0600` owned by the runner user, and **re-asserts that mode on +every converge**. A registration secret readable by every account on the box +would be a quiet, permanent credential leak, and convergence is the only moment +rig can notice a mode that drifted. + +`status` therefore reads `address`, `name` and `labels` and never prints the +token. + +### Removal + +Forgejo's runner has no deregistration handshake — no removal-token endpoint, +no `config.sh remove`. `remove` stops and disables the unit and wipes the local +registration, then says plainly that the entry must be deleted in Forgejo's +admin UI. `rig runner remove`'s `--local` escape hatch is the *only* mode here, +so it is not offered as a flag that suggests a server-side alternative exists. + +## Guard: `bootstrap --undo` + +`bootstrap-undo.sh` refuses to leave the tailnet while a GitHub runner is +installed, so undo cannot strand a ghost runner in a repository. The same +hazard exists for a Forgejo runner on a machine, so the guard learns the +`forgejo-runner.service` unit and the `.runner` under the runner user's home. + +## Testing + +`test/cli.sh` is dependency-free, non-root, offline. What it can prove: + +- Every new command's arg validation, `--help`, and unknown-flag refusals. +- `bin/rig` dispatch, including bare `rig forgejo-runner` showing usage. +- The forge-aware URL builder as a **pure function**, lifted and driven + directly: GitHub host yields the three-candidate list in tag-first order, + a Forgejo host yields the single `/archive/.tar.gz`. +- `templates_source_desc` names a non-default host. +- The staged `ci-box` definition passes `rig template-lint` — the same parser + a mint runs, so the definition cannot ship malformed. +- Grep-pins that the `.runner` 0600 assert and the `bootstrap --undo` + forgejo-runner guard are present, so a deleted guard cannot ship green + (the repo's existing precedent for guards that need a real machine). + +What it cannot prove, and is left to the drill: a real registration against a +live Forgejo, and a job actually executing in a container. + +## Out of scope + +`install.sh` hardcodes `github.com` in `resolve_latest_tag` and +`ref_candidate_urls` (rig's own source), and `commands/bootstrap.sh` fetches +box from `raw.githubusercontent.com`. Hosting rig itself on Forgejo needs those +too. They are follow-ups, not this change — `snapshot_templates` is included +here only because it fetches *the registry*, and leaving it behind would let +the snapshot and the live fetch disagree about where the registry lives. diff --git a/docs/templates/README.md b/docs/templates/README.md new file mode 100644 index 0000000..2c3dece --- /dev/null +++ b/docs/templates/README.md @@ -0,0 +1,25 @@ +# Staged template definitions + +Definitions here are **not** the registry. They are role definitions destined +for `heavy-duty/rig-templates`, staged in rig's tree only while the registry +repo does not yet exist on the forge that will serve them (#109). + +This is deliberately a waiting room, not a second registry: + +- `bootstrap-tenant.sh` does not look here. Nothing in this directory is + reachable by a mint, and adding a lookup would recreate exactly the coupling + the registry split was written to remove — where adding a tenant meant + editing rig. +- `test/cli.sh` lints each one with `rig template-lint`, the same parser a mint + runs. A definition that cannot pass the schema never reaches the registry. +- When the registry repo exists, a definition moves there **verbatim** and is + deleted from here in the same PR. + +To try one before it is anywhere, point a mint at it directly: + +```sh +RIG_TEMPLATES_DIR=docs/templates rig bootstrap ci-box +``` + +That is `RIG_TEMPLATES_DIR`'s stated purpose — "try a template before it exists +anywhere" — and it is the supported path, not a workaround. diff --git a/docs/templates/ci-box/creds.md b/docs/templates/ci-box/creds.md new file mode 100644 index 0000000..0b93495 --- /dev/null +++ b/docs/templates/ci-box/creds.md @@ -0,0 +1,9 @@ +- **Creds-free by default.** The box starts with no Forgejo credentials and no + git credentials. The runner binary is installed but **not registered**: + registration needs a short-lived token the operator mints in Forgejo + (Site Administration, org, or repo → Actions → Runners) and hands to + `sudo rig forgejo-runner install --instance `. The token is consumed at + registration and never written to disk by rig. After that, the runner's own + credential lives in `~/forgejo-runner/.runner`, mode 0600 — never copy it, + print it, or commit it. Secrets that CI jobs need belong in Forgejo's repo or + org secrets, injected per job, not on this box. diff --git a/docs/templates/ci-box/install.sh b/docs/templates/ci-box/install.sh new file mode 100755 index 0000000..d6351a7 --- /dev/null +++ b/docs/templates/ci-box/install.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# ci-box — the forgejo-runner binary. Run BY THE MECHANISM as root, with +# TENANT_USER/TENANT_HOME/TENANT_GROUP/ROLE exported. +# +# This lands the BINARY ONLY. Registration is deliberately not here: it needs a +# short-lived token from the Forgejo instance, and a tenant install is +# creds-free by contract — box auto-runs it at mint, holding nothing. The +# operator registers afterwards, out loud: +# +# box shell ci-box +# sudo rig forgejo-runner install --instance https://forgejo.example.com +# +# Same split as staging-box's tailnet join, for the same reason. +# +# Root-owned under /usr/local/bin rather than the tenant's home: unlike an +# agent CLI, this binary is run by a systemd unit as the tenant user, and a +# tenant-writable binary that root's unit executes is a trivial path to root +# inside the box. +set -euo pipefail + +BIN=/usr/local/bin/forgejo-runner + +if [ -x "$BIN" ]; then + exit 0 +fi + +case "$(uname -m)" in + x86_64) ARCH="amd64" ;; + aarch64) ARCH="arm64" ;; + *) echo "ci-box install: unsupported arch: $(uname -m)" >&2; exit 1 ;; +esac + +# The latest release, resolved by following the releases/latest redirect — no +# API call, no token, no JSON to parse on a dependency-free guest. A pinned +# version belongs to `rig forgejo-runner install --version`, which is where an +# operator who needs a deterministic install already is; a pin baked into the +# registry would go stale in a repo nobody watches. +LATEST_URL="$(curl -fsSLI -o /dev/null -w '%{url_effective}' \ + https://code.forgejo.org/forgejo/runner/releases/latest)" \ + || { echo "ci-box install: could not resolve the latest forgejo-runner release" >&2; exit 1; } +VERSION="${LATEST_URL##*/}" +VERSION="${VERSION#v}" +case "$VERSION" in + ""|*[!0-9.]*) echo "ci-box install: could not parse a version from ${LATEST_URL}" >&2; exit 1 ;; +esac + +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 + +echo "ci-box install: downloading forgejo-runner ${VERSION} (${ARCH})" +curl -fsSL "$URL" -o "$WORKDIR/forgejo-runner" \ + || { echo "ci-box install: could not download ${URL}" >&2; exit 1; } + +# Forgejo publishes a .sha256 beside each binary. Verifying it costs one +# request and makes the install auditable; this file executes as root inside +# every future mint, so an unverified download is the last thing it should do. +if curl -fsSL "${URL}.sha256" -o "$WORKDIR/forgejo-runner.sha256" 2>/dev/null; then + WANT="$(tr -d '\r' < "$WORKDIR/forgejo-runner.sha256" | awk '{print $1}' | head -n1)" + GOT="$(sha256sum "$WORKDIR/forgejo-runner" | awk '{print $1}')" + if [ -z "$WANT" ]; then + echo "ci-box install: the published checksum for ${ASSET} is unreadable — refusing to install an unverified binary" >&2 + exit 1 + fi + if [ "$WANT" != "$GOT" ]; then + echo "ci-box install: checksum mismatch for ${ASSET}: published ${WANT}, downloaded ${GOT} — refusing to install" >&2 + exit 1 + fi + echo "ci-box install: checksum verified (${GOT})" +else + echo "ci-box install: WARNING: no published .sha256 for ${ASSET} — installing WITHOUT checksum verification" >&2 +fi + +install -m 0755 -o root -g root "$WORKDIR/forgejo-runner" "$BIN" +echo "ci-box install: installed ${BIN}" diff --git a/docs/templates/ci-box/template.env b/docs/templates/ci-box/template.env new file mode 100644 index 0000000..e39bedd --- /dev/null +++ b/docs/templates/ci-box/template.env @@ -0,0 +1,16 @@ +# ci-box — the Forgejo CI tenant (#109). The box guest a fleet machine mints to +# run CI jobs: forgejo-runner polls the instance outbound, jobs run in +# containers on the dockerd bootstrap-tenant.sh already installed. +# +# The schema is rig's commands/lib/templates.sh; this is data, parsed and never +# sourced. +USER="ci" +CONTEXT_PATH=".ci/CONTEXT.md" +CLI_NAME="forgejo-runner" +CLI_SRC="/usr/local/bin/forgejo-runner" +PATH_LINE="export PATH="$HOME/.local/bin:$PATH"" +# NEEDS_NODE is a question about the CLI, not about the jobs. forgejo-runner is +# a static Go binary, and every job gets its own toolchain from its container +# image — a Node on the host would be a second, invisible toolchain that no +# workflow reads. +NEEDS_NODE="no" diff --git a/install.sh b/install.sh index 1423211..f3c901e 100644 --- a/install.sh +++ b/install.sh @@ -121,6 +121,29 @@ ref_candidate_urls() { printf 'https://github.com/%s/archive/refs/heads/%s.tar.gz\n' "$1" "$2" } +# The registry's candidate URLs, forge-aware — a byte-identical copy of +# commands/lib/templates.sh's, diffed by test/cli.sh so the two cannot drift +# (#109; the valid_version / warn_bootstrapped precedent). One decision about +# where a registry lives, made in one grammar: a snapshot fetched from a forge +# converge would never fetch from is worse than no snapshot at all. +# +# Copied rather than sourced on purpose. snapshot_templates runs against an +# extracted tree, so sourcing WOULD work here — but it would make the +# installer's behaviour depend on executing code from the tarball it just +# downloaded, ahead of any of it being installed. The installer reads that +# tree (sed for the pin); it does not run it. +templates_archive_urls() { + local host="${1%/}" repo="$2" ref="$3" + case "$host" in + https://github.com|http://github.com|*//github.com) + printf '%s/%s/archive/refs/tags/%s.tar.gz\n' "$host" "$repo" "$ref" + printf '%s/%s/archive/refs/heads/%s.tar.gz\n' "$host" "$repo" "$ref" + printf '%s/%s/archive/%s.tar.gz\n' "$host" "$repo" "$ref" ;; + *) + printf '%s/%s/archive/%s.tar.gz\n' "$host" "$repo" "$ref" ;; + esac +} + # --- prerequisites ----------------------------------------------------------- # curl only when something must be downloaded — a local RIG_INSTALL_SOURCE # needs none, which is what lets test/cli.sh drive REAL installs offline. @@ -255,13 +278,19 @@ set_exec() { # $1 = a rig tree: the executable bits install.sh owns # Failure is deliberately a warning: rig itself is still a complete install, # and templates_resolve preserves the live-fetch fallback. snapshot_templates() { - local tree="$1" pin repo url got="" unpack top snapshot + local tree="$1" pin repo host url got="" unpack top snapshot pin="$(sed -n 's/^RIG_TEMPLATES_PIN=//p' "$tree/commands/lib/templates.sh" 2>/dev/null | head -n1 || true)" if [ -z "$pin" ]; then warn "installed tree carries no RIG_TEMPLATES_PIN; template registry snapshot skipped." return 0 fi repo="${RIG_TEMPLATES_REPO:-heavy-duty/rig-templates}" + # The same forge knob templates_resolve reads, from the same variable (#109). + # Snapshot and live fetch MUST agree about where the registry lives: an + # install that cached from GitHub while converge fetches from Forgejo would + # serve a snapshot the pin never named, and the pin-in-the-directory-name + # staleness guard cannot catch a WRONG-ORIGIN snapshot, only an old one. + host="${RIG_TEMPLATES_HOST:-https://github.com}" snapshot="$tree/templates@$pin" if ! command -v curl >/dev/null 2>&1; then warn "curl is unavailable; template registry snapshot $repo@$pin was not installed (converge will retry the live fetch)." @@ -270,13 +299,10 @@ snapshot_templates() { unpack="$TMPDIR/templates-unpack" rm -rf "$unpack" mkdir -p "$unpack" - log "downloading template registry snapshot $repo@$pin" - for url in \ - "https://github.com/$repo/archive/refs/tags/$pin.tar.gz" \ - "https://github.com/$repo/archive/refs/heads/$pin.tar.gz" \ - "https://github.com/$repo/archive/$pin.tar.gz"; do + log "downloading template registry snapshot ${host%/}/$repo@$pin" + while IFS= read -r url; do if curl -fsSL "$url" -o "$TMPDIR/templates.tar.gz" 2>/dev/null; then got="$url"; break; fi - done + done < <(templates_archive_urls "$host" "$repo" "$pin") if [ -z "$got" ]; then warn "could not fetch template registry snapshot $repo@$pin; rig installed without it (converge will retry the live fetch)." return 0 diff --git a/test/cli.sh b/test/cli.sh index d5b59ab..1072647 100644 --- a/test/cli.sh +++ b/test/cli.sh @@ -3094,6 +3094,205 @@ rm -f "$WBBIN" "$WBINST" rm -rf "$WORK" +# --- the forge knob: where a template registry lives (#109) ----------------- +# templates_archive_urls is a PURE function, so the fetch's URL grammar is +# testable with no network and no root — which matters because the two forges +# disagree in a way that is invisible until a fetch 404s. +# tau — the builder under test, sourced fresh each call so +# no test can leave state behind for the next one. +tau() { bash -c ". '$ROOT/commands/lib/templates.sh'; templates_archive_urls \"\$1\" \"\$2\" \"\$3\"" _ "$@"; } +taun() { tau "$@" | wc -l | tr -d ' '; } + +# GitHub keeps all three candidates, tag-first: a pin must outrank a branch +# that happens to share its name. This is the pre-#109 behaviour, pinned so the +# forge split cannot quietly change the default forge's precedence. +check "templates: github yields three candidates" 0 "3" \ + taun https://github.com o/r ref +check "templates: github puts refs/tags first (a pin outranks a branch)" 0 "refs/tags/ref.tar.gz" \ + bash -c ". '$ROOT/commands/lib/templates.sh'; templates_archive_urls https://github.com o/r ref | head -n1" +check "templates: github keeps the bare form for a SHA pin" 0 "https://github.com/o/r/archive/ref.tar.gz" \ + bash -c ". '$ROOT/commands/lib/templates.sh'; templates_archive_urls https://github.com o/r ref | tail -n1" + +# Forgejo serves ONE form. Emitting the refs/{tags,heads}/ paths there would be +# two guaranteed 404s per fetch and a failure message listing URLs that never +# could have worked — so the count is the assertion, not just the content. +check "templates: a forgejo host yields exactly one candidate" 0 "1" \ + taun https://forgejo.example.com o/r ref +check "templates: the forgejo candidate is /archive/.tar.gz" 0 "https://forgejo.example.com/o/r/archive/ref.tar.gz" \ + tau https://forgejo.example.com o/r ref +tau_greps() { tau "$2" o/r ref | grep -q "$1"; } +check "templates: a forgejo host emits no refs/ paths at all" 1 "" \ + tau_greps 'refs/' https://forgejo.example.com +# A trailing slash is a spelling of the same host, not a different one — it +# would otherwise produce a '//'-doubled URL that some proxies 404. +check "templates: a trailing slash on the host does not double the separator" 1 "" \ + tau_greps 'com//' https://forgejo.example.com/ + +# The host must ride the source description: a registry served from the wrong +# FORGE fails exactly like a misspelled repo, and naming only the repo sends +# the reader hunting for a typo that is not there. +tsd() { bash -c "RIG_TEMPLATES_HOST='$1'; . '$ROOT/commands/lib/templates.sh'; templates_source_desc"; } +check "templates: source_desc names the default forge" 0 "https://github.com/heavy-duty/rig-templates@" \ + tsd "" +check "templates: source_desc names a non-default forge" 0 "https://forgejo.example.com/heavy-duty/rig-templates@" \ + tsd "https://forgejo.example.com" + +# One decision about where a registry lives, two copies of the grammar: a +# snapshot fetched from a forge converge would never fetch from is worse than +# no snapshot at all, and the pin-in-the-name staleness guard cannot catch a +# WRONG-ORIGIN snapshot — only an old one. (valid_version's precedent.) +TAULIB="$(mktemp)"; TAUINST="$(mktemp)" +awk '/^templates_archive_urls\(\) \{/,/^\}/' "$ROOT/commands/lib/templates.sh" > "$TAULIB" +awk '/^templates_archive_urls\(\) \{/,/^\}/' "$ROOT/install.sh" > "$TAUINST" +check "templates_archive_urls: extracted from the lib (guards the awk)" 0 "archive" cat "$TAULIB" +check "templates_archive_urls: lib and install.sh copies are byte-identical" 0 "" diff "$TAULIB" "$TAUINST" +rm -f "$TAULIB" "$TAUINST" + +# --- the ci-box definition (#109) ------------------------------------------- +# Staged in rig's tree only until the registry exists on the forge that will +# serve it. Lint it with the SAME parser a mint runs, so a definition that +# cannot converge never reaches the registry. +check "ci-box: the staged definition passes template-lint" 0 "OK" \ + "$ROOT/bin/rig" template-lint "$ROOT/docs/templates/ci-box" +tfam() { bash -c ". '$ROOT/commands/lib/templates.sh'; template_family '$1'"; } +check "ci-box: it is a TENANT by the family rule" 0 "tenant" tfam ci-box + +# The mechanism asserts ' --version' answers as the tenant user, so +# CLI_SRC must be the absolute path install.sh actually writes. A drifted pair +# converges to a CLI that exists and cannot run — the grok-box scar. +cibox_src_matches_install() { + local dir="$ROOT/docs/templates/ci-box" + # shellcheck source=/dev/null + . "$ROOT/commands/lib/templates.sh" + template_parse_env "$dir/template.env" >/dev/null || return 2 + grep -qF "BIN=$TPL_CLI_SRC" "$dir/install.sh" +} +check "ci-box: CLI_SRC is the path its install.sh installs" 0 "" cibox_src_matches_install + +# Registration holds a credential, so it must NOT be in the definition: a +# tenant install is creds-free by contract — box auto-runs it at mint, holding +# nothing. Registration is the operator's separate, out-loud act. +check "ci-box: its install.sh takes no token" 1 "" \ + grep -q -- '--token' "$ROOT/docs/templates/ci-box/install.sh" +check "ci-box: its install.sh does not register" 1 "" \ + grep -qE '^[^#]*forgejo-runner +register' "$ROOT/docs/templates/ci-box/install.sh" +# The staging area must stay a waiting room: a lookup from bootstrap-tenant +# would recreate the coupling the registry split removed. +check "ci-box: bootstrap-tenant does not read the staging dir" 1 "" \ + grep -q 'docs/templates' "$ROOT/commands/bootstrap-tenant.sh" + +# --- rig forgejo-runner (#109) ---------------------------------------------- +FR="$ROOT/commands/forgejo-runner-install.sh" +check "forgejo-runner: bare subcommand shows usage, exit 2" 2 "usage:" "$ROOT/bin/rig" forgejo-runner +check "forgejo-runner: install --help exits 0" 0 "usage:" "$FR" --help +check "forgejo-runner: --instance is required" 2 "--instance is required" "$FR" +check "forgejo-runner: --instance needs a value" 2 "needs a value" "$FR" --instance +check "forgejo-runner: unknown flag exits 2" 2 "unknown flag" "$FR" --instance https://f.example.com --nope +check "forgejo-runner: empty --labels refused" 2 "must not be empty" "$FR" --instance https://f.example.com --labels '' +check "forgejo-runner: the runner user is never root" 2 "must not be root" "$FR" --instance https://f.example.com --user root +# A schemeless host and a repo URL are the two ways an operator mis-states the +# instance, and only one of them would fail loudly on its own — a repo URL +# registers somewhere subtly wrong instead. Both refuse by name. +check "forgejo-runner: a schemeless instance refuses" 2 "must be a URL with a scheme" \ + "$FR" --instance forgejo.example.com +check "forgejo-runner: a repository URL is not an instance" 2 "not a repository URL" \ + "$FR" --instance https://f.example.com/acme/widgets +# --repo is the GitHub habit. It must explain that scope lives in the TOKEN, +# not read as a typo — that is the single biggest conceptual difference +# between the two forges' runners. +check "forgejo-runner: --repo explains itself rather than 'unknown flag'" 2 "property of the registration TOKEN" \ + "$FR" --instance https://f.example.com --repo acme/widgets +# Likewise the two verbs the GitHub sibling has and this one cannot. +check "forgejo-runner: remove --local explains why it is not a flag" 2 "always local-only" \ + "$ROOT/commands/forgejo-runner-remove.sh" --local +check "forgejo-runner: repoint explains why it cannot exist" 2 "no deregistration endpoint" \ + "$ROOT/bin/rig" forgejo-runner repoint --instance https://f.example.com +check "forgejo-runner: status --help exits 0" 0 "usage:" "$ROOT/commands/forgejo-runner-status.sh" --help +check "forgejo-runner: remove --help exits 0" 0 "usage:" "$ROOT/commands/forgejo-runner-remove.sh" --help + +# assert_runner_instance, against fixtures — the convergence contract, which +# needs no root and no network to prove. +FRW="$(mktemp -d)" +frmk() { mkdir -p "$FRW/$1"; printf '%s\n' "$2" > "$FRW/$1/.runner"; } +frmk same '{"id":3,"uuid":"u","name":"ci-1","token":"SECRET","address":"https://f.example.com"}' +frmk slash '{"id":3,"uuid":"u","name":"ci-1","token":"SECRET","address":"https://f.example.com/"}' +frmk other '{"id":3,"uuid":"u","name":"ci-1","token":"SECRET","address":"https://other.example.com"}' +frmk noaddr '{"id":3,"uuid":"u","name":"ci-1","token":"SECRET"}' +mkdir -p "$FRW/none" +fri() { bash -c ". '$ROOT/commands/lib/forgejo-runner-config.sh'; assert_runner_instance \"\$1\" \"\$2\"" _ "$@"; } +check "forgejo-runner: no registration is nothing to compare (exit 0)" 0 "" \ + fri "$FRW/none" https://f.example.com +check "forgejo-runner: the same instance converges (exit 0)" 0 "" \ + fri "$FRW/same" https://f.example.com +check "forgejo-runner: a trailing slash is the same instance" 0 "" \ + fri "$FRW/slash" https://f.example.com +check "forgejo-runner: a DIFFERENT instance refuses, naming both" 1 "https://other.example.com" \ + fri "$FRW/other" https://f.example.com +check "forgejo-runner: an unreadable address refuses rather than guessing" 1 "names no instance" \ + fri "$FRW/noaddr" https://f.example.com +# The readers must return the fields status prints — and NOT the token beside +# them. A status that leaked the registration secret into a terminal, a log or +# a screenshot would be the worst bug this family could have. +frread() { bash -c ". '$ROOT/commands/lib/forgejo-runner-config.sh'; $1 '$2'"; } +check "forgejo-runner: the instance reader reads address" 0 "https://f.example.com" \ + frread forgejo_runner_instance "$FRW/same" +check "forgejo-runner: the name reader reads name" 0 "ci-1" \ + frread forgejo_runner_name "$FRW/same" +# A status that leaked the registration secret into a terminal, a log, or a +# screenshot would be the worst bug this family could have — so prove the +# readers cannot carry it, rather than trusting that nobody prints it. +fr_leaks() { + . "$ROOT/commands/lib/forgejo-runner-config.sh" + { forgejo_runner_instance "$FRW/same"; forgejo_runner_name "$FRW/same"; } | grep -q SECRET +} +check "forgejo-runner: no reader ever returns the token" 1 "" fr_leaks +check "forgejo-runner: status reads no token field at all" 1 "" \ + grep -q 'json_field .* token' "$ROOT/commands/forgejo-runner-status.sh" + +# The mode is the contract, not hygiene: .runner holds a long-lived credential +# here (GitHub's does not), and a drifted mode leaks it SILENTLY — nothing +# fails, the runner keeps working. Converge must fix it; status must notice it. +fr_secure_mode() { + chmod 644 "$FRW/same/.runner" + . "$ROOT/commands/lib/forgejo-runner-config.sh" + forgejo_runner_secure "$FRW/same" "$(id -un)" "$(id -gn)" + stat -c%a "$FRW/same/.runner" +} +check "forgejo-runner: secure converges a world-readable .runner to 0600" 0 "600" fr_secure_mode +fr_secure_every_run() { awk '/^# EVERY run/,/^forgejo_runner_secure/' "$FR" | grep -q forgejo_runner_secure; } +check "forgejo-runner: install converges the mode on EVERY run, not only at registration" 0 "" \ + fr_secure_every_run +check "forgejo-runner: status warns on a drifted mode" 0 "FORGEJO_RUNNER_FILE_MODE" \ + grep -o "FORGEJO_RUNNER_FILE_MODE" "$ROOT/commands/forgejo-runner-status.sh" +rm -rf "$FRW" + +# The download must be verified. This binary is installed as root and executed +# by a systemd unit; an unverified fetch is the one step here that silently +# turns a network compromise into root on the box. +check "forgejo-runner: install verifies the published checksum" 0 "checksum mismatch" \ + grep -o "checksum mismatch" "$FR" +check "ci-box: its install.sh verifies the published checksum too" 0 "checksum mismatch" \ + grep -o "checksum mismatch" "$ROOT/docs/templates/ci-box/install.sh" + +# undo must refuse under a live Forgejo runner for a STRONGER reason than the +# GitHub one: Forgejo has no deregistration endpoint, so the ghost it strands +# has to be deleted by hand in the instance. Grep-pinned — proving it needs a +# tailnet, so a deleted guard would otherwise ship green (the repo's precedent +# for machine-only guards). +check "undo: refuses while a Forgejo runner is installed" 0 "rig forgejo-runner remove" \ + grep -o "rig forgejo-runner remove" "$ROOT/commands/bootstrap-undo.sh" +check "undo: the forgejo guard has a test hook like RIG_RUNNER_DIR's" 0 "RIG_FORGEJO_RUNNER_DIR" \ + grep -o "RIG_FORGEJO_RUNNER_DIR" "$ROOT/commands/bootstrap-undo.sh" +check "undo: the forgejo guard watches the unit as well as the file" 0 "forgejo-runner.service" \ + grep -o "forgejo-runner.service" "$ROOT/commands/bootstrap-undo.sh" + +# rig runner (GitHub) must be untouched by all of this — the whole reason this +# is a second family rather than a --forge flag on the first. +check "rig runner: still requires --repo, unchanged" 2 "--repo is required" \ + "$ROOT/commands/runner-install.sh" +check "rig runner: still speaks github.com" 0 "https://github.com/" \ + grep -o "https://github.com/\${repo}" "$ROOT/commands/lib/runner-config.sh" + echo "---" echo "$PASS passed, $FAIL failed" [ "$FAIL" -eq 0 ] From 1933b07fd4ae95d466ad02b19d79d5ad30b28efa Mon Sep 17 00:00:00 2001 From: cluade-reviewer-andresmgsl Date: Mon, 27 Jul 2026 21:08:15 +0000 Subject: [PATCH 2/9] fix: honour --version, scope .rig-labels, close the checksum gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three defects from review !110, all the same family — a stated contract the code did not keep. --version was swallowed on the path this command exists for. The download block skipped on mere presence, copying runner-install.sh's shape without its justification: actions/runner SELF-UPDATES, so freezing it would only make GitHub refuse its jobs. forgejo-runner does not self-update, so nothing else ever moves the version — and a ci-box's template preinstalls the binary at mint, which meant the documented deterministic-pin lever could never fire on a ci-box. It now converges toward the pin, downward included, because a pin is an instruction and not a floor; absent a pin an existing binary is left alone, since chasing latest would make a re-run an unrequested upgrade. The decision moved to runner_download_decision in the lib as a pure function: the first attempt at a test here grepped for a log string and survived the logic being disabled, which is exactly the weak test the review warned about. The binary is now renamed into place rather than written over — the converge path runs while the daemon is live, and in-place is ETXTBSY. .rig-labels outlived the registration it described. The write had escaped the registration branch, where runner-install.sh correctly keeps its copy, so a plain re-run stamped this invocation's labels over a registration made with different ones and status then reported confidently wrong labels while Forgejo still held the originals. Scoped again, and an EXPLICIT --labels on a re-run now warns that Forgejo owns labels from registration time rather than letting the request evaporate silently. The checksum gate failed open. A missing .sha256 warned and installed anyway, contradicting both the README and the template's own comment about unverified root downloads. The original reasoning — do not let an upstream layout change break installs — reasons about the wrong failure: a layout change breaks the BINARY url too, so "binary yes, checksum no" is the shape of an interfered fetch, which is precisely what the checksum exists to catch. Both paths refuse now, with no bypass flag; if upstream really moves its assets that is a rig PR editing the URL, not an operator improvising past a security gate. Tests: the checksum paths are now DRIVEN against a stub curl through the real template install.sh — matching, missing, mismatched and empty — instead of grepped, and all three fixes were mutation-checked by reverting each and confirming the suite goes red. 739/31/43 pass, shellcheck clean. forgejo#109 Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 22 +++- commands/forgejo-runner-install.sh | 143 ++++++++++++++++++----- commands/lib/forgejo-runner-config.sh | 45 ++++++++ docs/plans/2026-07-27-forgejo-ci-box.md | 31 +++++ docs/templates/ci-box/install.sh | 36 +++--- test/cli.sh | 147 ++++++++++++++++++++++-- 6 files changed, 374 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index b298cf7..38ed5bb 100644 --- a/README.md +++ b/README.md @@ -1094,12 +1094,28 @@ the machine. Here it is a disposable guest. Same trade, different box, opposite answer — which is why these are two commands and not one with a `--forge` flag. - `--version ` — `forgejo-runner` release (default: latest at install - time). The published `.sha256` is **verified before the binary is - installed**; a mismatch refuses. + time). **Convergent, and it replaces an existing binary** — including + downward, because a pin is an instruction rather than a floor. Without a + pin, a binary already on the box is left alone: chasing "latest" on every + converge would make a plain re-run an unrequested upgrade. + The published `.sha256` is **verified before the binary is installed**, and + a missing or unreadable checksum **refuses** just as a mismatch does — this + binary runs as root under a systemd unit, so the gate does not fail open. + + > Unlike `rig runner install`, presence alone is not enough to skip the + > download here. That command can skip because `actions/runner` + > **self-updates**; `forgejo-runner` does not, so nothing else would ever + > move the version — and a ci-box's template preinstalls the binary at mint, + > which would leave `--version` doing nothing on the exact path this command + > is for. - `--name ` — runner name (default: this host's hostname) - `--labels ` — replaces the default map: `ubuntu-latest:docker://ghcr.io/catthehacker/ubuntu:act-22.04,docker:docker://node:22-bookworm`, - so `runs-on: ubuntu-latest` works in a workflow written for GitHub + so `runs-on: ubuntu-latest` works in a workflow written for GitHub. + **Applied at registration only.** Forgejo owns a runner's labels from the + moment it registers, so passing `--labels` to a re-run cannot change them — + rig says so rather than letting the request evaporate, and changing labels + means `remove` then `install` again - `--user ` — service user (default: `ci` when it exists, else `forgejo-runner`) diff --git a/commands/forgejo-runner-install.sh b/commands/forgejo-runner-install.sh index 723baa9..f6ce3d6 100755 --- a/commands/forgejo-runner-install.sh +++ b/commands/forgejo-runner-install.sh @@ -79,6 +79,12 @@ 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 @@ -93,7 +99,7 @@ while [ $# -gt 0 ]; do RUNNER_NAME="$2"; shift 2 ;; --labels) [ $# -ge 2 ] || die "--labels needs a value" 2 - LABELS="$2"; shift 2 ;; + LABELS="$2"; LABELS_EXPLICIT=1; shift 2 ;; --user) [ $# -ge 2 ] || die "--user needs a value" 2 RUNNER_USER="$2"; shift 2 ;; @@ -207,9 +213,52 @@ fi # 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 - log "forgejo-runner binary already present at ${BIN}; skipping download" -else + 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" ;; @@ -237,27 +286,54 @@ else 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" + # 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` @@ -285,6 +361,17 @@ INSTALLED_VER="$("$BIN" --version 2>/dev/null | head -n1)" 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" \ @@ -294,17 +381,21 @@ else || 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" -# 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 diff --git a/commands/lib/forgejo-runner-config.sh b/commands/lib/forgejo-runner-config.sh index 6d8b95f..b2f7ea7 100644 --- a/commands/lib/forgejo-runner-config.sh +++ b/commands/lib/forgejo-runner-config.sh @@ -55,6 +55,51 @@ forgejo_runner_secure() { chown "$user:$group" "$dir/.runner" } +# runner_version_of — the bare version number ("12.13.2") the binary +# reports, empty when it cannot answer. `forgejo-runner --version` prints +# "forgejo-runner version v12.13.2"; the leading v is stripped so this compares +# against a --version argument, which has its own v stripped at parse. +runner_version_of() { + "$1" --version 2>/dev/null | head -n1 \ + | sed -nE 's/.*[Vv]ersion[[:space:]]+v?([0-9][0-9A-Za-z.+-]*).*/\1/p' +} + +# runner_download_decision +# -> "install" | "skip" | "converge" +# +# A PURE function, and pure on purpose: this is the decision review !110 caught +# being wrong, and it was wrong in a way no grep could see. Lifting it out of +# the root-only install path is what makes "a pre-existing binary plus +# --version" a real test rather than a string match. +# +# The rule, and why it is not the GitHub sibling's: +# +# no binary -> install. Nothing to reason about. +# binary, no --version -> skip. Chasing "latest" on every converge would make +# a plain re-run an unrequested upgrade, and a +# convergent verb must not be a moving target. +# binary, pin matches -> skip. +# binary, pin differs -> CONVERGE, including downward. A pin is an +# instruction, not a floor. +# +# runner-install.sh skips on mere presence because actions/runner SELF-UPDATES, +# so its version moves regardless and freezing it would only make GitHub refuse +# the runner's jobs. forgejo-runner does not self-update: nothing else ever +# moves this version, and a ci-box's template preinstalls the binary at mint — +# so mere-presence here would leave --version dead on the one path this whole +# command exists to serve. +# +# An unreadable present version (empty) with a pin asked for falls to +# "converge", which is the right direction: a binary that cannot say what it is +# should be replaced by one that can. +runner_download_decision() { + local have="$1" present="$2" want="$3" + [ "$have" = yes ] || { printf 'install\n'; return 0; } + [ -n "$want" ] || { printf 'skip\n'; return 0; } + [ "$present" = "$want" ] && { printf 'skip\n'; return 0; } + printf 'converge\n' +} + # assert_runner_instance # # Returns 0 when the box has no runner, or has one already registered to diff --git a/docs/plans/2026-07-27-forgejo-ci-box.md b/docs/plans/2026-07-27-forgejo-ci-box.md index ba13092..e28e770 100644 --- a/docs/plans/2026-07-27-forgejo-ci-box.md +++ b/docs/plans/2026-07-27-forgejo-ci-box.md @@ -215,6 +215,37 @@ registered to re-uses the binary and skips registration; a **different** instance refuses and names both. Same trust-boundary reasoning as `assert_runner_repo`, asked about the axis Forgejo actually has. +### Three corrections from review !110 + +The first draft carried three defects that review caught, all of the same +family — a stated contract that the code did not actually keep: + +1. **`--version` was swallowed on the primary path.** The download block + skipped on mere presence, copying `rig runner install`'s shape without its + justification: `actions/runner` self-updates, `forgejo-runner` does not. And + a ci-box's template *preinstalls* the binary at mint, so the flag documented + as the deterministic-pin lever could never fire on a ci-box. The decision is + now `runner_download_decision` in the lib — a pure function, so the rule is + driven by tests instead of asserted by grep — and the binary is renamed into + place rather than written over, since the converge path now runs while the + daemon is live (in-place would be `ETXTBSY`). + +2. **`.rig-labels` outlived the registration it described.** The write had + escaped the registration branch, where `runner-install.sh` correctly keeps + its copy. A plain re-run stamped that invocation's labels over a + registration made with different ones, and `status` then reported + confidently wrong labels while Forgejo held the originals. It is scoped + again, and an *explicit* `--labels` on a re-run now warns that Forgejo owns + labels from registration time. + +3. **The checksum gate failed open.** A missing `.sha256` warned and installed + anyway — contradicting both the README and this file's own comment about + unverified root downloads. The original reasoning (do not let an upstream + layout change break installs) reasons about the wrong failure: a layout + change breaks the *binary* URL too, so "binary yes, checksum no" is the + shape of an interfered fetch, which is what the checksum is for. Both paths + refuse now, with no bypass flag. + ### `.runner` holds a credential here GitHub's `.runner` names a repo. Forgejo's holds `address`, `name`, `labels` diff --git a/docs/templates/ci-box/install.sh b/docs/templates/ci-box/install.sh index d6351a7..1efa397 100755 --- a/docs/templates/ci-box/install.sh +++ b/docs/templates/ci-box/install.sh @@ -58,21 +58,29 @@ curl -fsSL "$URL" -o "$WORKDIR/forgejo-runner" \ # Forgejo publishes a .sha256 beside each binary. Verifying it costs one # request and makes the install auditable; this file executes as root inside # every future mint, so an unverified download is the last thing it should do. -if curl -fsSL "${URL}.sha256" -o "$WORKDIR/forgejo-runner.sha256" 2>/dev/null; then - WANT="$(tr -d '\r' < "$WORKDIR/forgejo-runner.sha256" | awk '{print $1}' | head -n1)" - GOT="$(sha256sum "$WORKDIR/forgejo-runner" | awk '{print $1}')" - if [ -z "$WANT" ]; then - echo "ci-box install: the published checksum for ${ASSET} is unreadable — refusing to install an unverified binary" >&2 - exit 1 - fi - if [ "$WANT" != "$GOT" ]; then - echo "ci-box install: checksum mismatch for ${ASSET}: published ${WANT}, downloaded ${GOT} — refusing to install" >&2 - exit 1 - fi - echo "ci-box install: checksum verified (${GOT})" -else - echo "ci-box install: WARNING: no published .sha256 for ${ASSET} — installing WITHOUT checksum verification" >&2 +# An unfetchable checksum REFUSES — it does not warn and continue. The comment +# above says an unverified root download is the last thing this file should do, +# and an earlier draft then did exactly that whenever the .sha256 404'd. The +# binary downloading while its checksum does not is not an upstream layout +# change (that would break both URLs); it is the shape of an interfered fetch, +# which is the case the checksum exists to catch. Failing open here would hand +# an unverified root install, inside every future mint, to anyone who can block +# one URL. +if ! curl -fsSL "${URL}.sha256" -o "$WORKDIR/forgejo-runner.sha256" 2>/dev/null; then + echo "ci-box install: no published .sha256 for ${ASSET} — 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." >&2 + exit 1 fi +WANT="$(tr -d '\r' < "$WORKDIR/forgejo-runner.sha256" | awk '{print $1}' | head -n1)" +GOT="$(sha256sum "$WORKDIR/forgejo-runner" | awk '{print $1}')" +if [ -z "$WANT" ]; then + echo "ci-box install: the published checksum for ${ASSET} is unreadable — refusing to install an unverified binary" >&2 + exit 1 +fi +if [ "$WANT" != "$GOT" ]; then + echo "ci-box install: checksum mismatch for ${ASSET}: published ${WANT}, downloaded ${GOT} — refusing to install" >&2 + exit 1 +fi +echo "ci-box install: checksum verified (${GOT})" install -m 0755 -o root -g root "$WORKDIR/forgejo-runner" "$BIN" echo "ci-box install: installed ${BIN}" diff --git a/test/cli.sh b/test/cli.sh index 1072647..3a87488 100644 --- a/test/cli.sh +++ b/test/cli.sh @@ -3266,13 +3266,146 @@ check "forgejo-runner: status warns on a drifted mode" 0 "FORGEJO_RUNNER_FILE_MO grep -o "FORGEJO_RUNNER_FILE_MODE" "$ROOT/commands/forgejo-runner-status.sh" rm -rf "$FRW" -# The download must be verified. This binary is installed as root and executed -# by a systemd unit; an unverified fetch is the one step here that silently -# turns a network compromise into root on the box. -check "forgejo-runner: install verifies the published checksum" 0 "checksum mismatch" \ - grep -o "checksum mismatch" "$FR" -check "ci-box: its install.sh verifies the published checksum too" 0 "checksum mismatch" \ - grep -o "checksum mismatch" "$ROOT/docs/templates/ci-box/install.sh" +# --- the checksum gate, DRIVEN not grepped (review !110) -------------------- +# This binary is installed as root and executed by a systemd unit, so the +# verification is the one step here that silently turns a network compromise +# into root on the box. A grep for the mismatch string proved only that a +# string existed — it could not have caught the fail-open branch that used to +# sit beside it. So drive the real script against a stub curl, the +# test/release.sh idiom, and assert on what it DOES. +CIBOX="$ROOT/docs/templates/ci-box/install.sh" +CBW="$(mktemp -d)"; CBSTUB="$CBW/stub"; mkdir -p "$CBSTUB" +cat > "$CBSTUB/curl" <<'CBCURL' +#!/usr/bin/env bash +# Scripted curl — never the network. +# CB_REDIRECT what -w %{url_effective} answers (the latest-release probe) +# CB_PAYLOAD file copied to -o for the BINARY url +# CB_SUM text written to -o for the .sha256 url; unset => that url 404s +set -u +out="" url="" probe=0 +while [ $# -gt 0 ]; do + case "$1" in + -o) out="$2"; shift 2 ;; + -w) probe=1; shift 2 ;; + -*) shift ;; + *) url="$1"; shift ;; + esac +done +if [ "$probe" -eq 1 ]; then printf '%s' "${CB_REDIRECT:-}"; exit 0; fi +case "$url" in + *.sha256) + [ -n "${CB_SUM:-}" ] || exit 22 + # A fetch that SUCCEEDS but yields nothing usable is its own case, distinct + # from a 404 — a truncated proxy response looks exactly like this. + if [ "$CB_SUM" = EMPTY ]; then : > "${out:?}"; exit 0; fi + printf '%s asset\n' "$CB_SUM" > "${out:?}"; exit 0 ;; + *) + cp "${CB_PAYLOAD:?}" "${out:?}"; exit 0 ;; +esac +CBCURL +chmod +x "$CBSTUB/curl" +printf 'not-really-a-binary\n' > "$CBW/payload" +CB_GOOD="$(sha256sum "$CBW/payload" | awk '{print $1}')" +# install(1) must not touch the real /usr/local/bin, and the script runs as a +# non-root user here — stub it to a writable target so the test reaches the +# checksum logic rather than dying on permissions. +cat > "$CBSTUB/install" < "$VSTUB/fr"; chmod +x "$VSTUB/fr" +printf '#!/bin/sh\necho "garbage"\n' > "$VSTUB/bad"; chmod +x "$VSTUB/bad" +check "forgejo-runner: the version reader strips the leading v" 0 "12.13.2" vparse "$VSTUB/fr" +check "forgejo-runner: an unreadable version yields empty, not garbage" 0 "" vparse "$VSTUB/bad" +rm -rf "$VSTUB" + +# The decision itself, driven — this is the case review !110 caught, and a +# grep could not have caught it. Each row is a real lifecycle situation. +dec() { bash -c ". '$FRLIB'; runner_download_decision \"\$1\" \"\$2\" \"\$3\"" _ "$@"; } +check "version: nothing installed -> install" 0 "install" dec no "" "" +check "version: nothing installed, pin asked -> install" 0 "install" dec no "" 12.13.2 +check "version: binary present, no pin -> skip (no surprise upgrade)" 0 "skip" dec yes 12.13.2 "" +check "version: binary present, pin MATCHES -> skip" 0 "skip" dec yes 12.13.2 12.13.2 +# THE ci-box CASE: the template preinstalled a binary at mint, and the operator +# then pins. Mere presence used to swallow this entirely. +check "version: binary present, pin DIFFERS -> converge" 0 "converge" dec yes 12.13.2 12.13.0 +check "version: a pin may converge DOWNWARD (a pin is not a floor)" 0 "converge" dec yes 12.13.2 9.0.0 +check "version: an unreadable present version + pin -> converge" 0 "converge" dec yes "" 12.13.2 +# ...and the command must actually consult it rather than re-deciding inline. +check "forgejo-runner: install routes the decision through the shared rule" 0 "runner_download_decision" \ + grep -o "runner_download_decision" "$FR" +# shellcheck disable=SC2016 # the '${BIN}' is the LITERAL text being grepped for +check "forgejo-runner: the pin is asserted to have LANDED, not assumed" 0 "reports" \ + grep -o 'but ${BIN} reports' "$FR" +# Replacing a live executable in place is ETXTBSY; the converge path now runs +# on boxes where the daemon is up, so it must rename into place. +# shellcheck disable=SC2016 # the '$BIN' is the LITERAL text being grepped for +check "forgejo-runner: the binary is renamed into place, never written over" 0 "mv -f" \ + grep -o 'mv -f "\$BIN.rig-new" "\$BIN"' "$FR" + +# --- .rig-labels must not outlive the registration it describes (review !110) +# A plain re-run used to stamp this invocation's labels over a registration +# made with different ones — status then reported confidently wrong labels +# while Forgejo still held the originals. +labels_write_is_scoped() { + # The write must sit INSIDE the else-branch that actually registers, the way + # runner-install.sh keeps its copy. + awk '/^else$/,/^fi$/' "$FR" | grep -q 'rig-labels' +} +check "forgejo-runner: .rig-labels is written only where registration happens" 0 "" \ + labels_write_is_scoped +check "forgejo-runner: an explicit --labels on a rerun warns it was not applied" 0 "was not applied" \ + grep -o -- "--labels was not applied" "$FR" +check "forgejo-runner: that warning is gated on --labels being EXPLICIT" 0 "LABELS_EXPLICIT" \ + grep -o "LABELS_EXPLICIT" "$FR" +# The GitHub sibling is the precedent this restores — pin that it still scopes +# its own write, so the two cannot drift apart again. +gh_labels_write_is_scoped() { + awk '/^else$/,/^fi$/' "$ROOT/commands/runner-install.sh" | grep -q 'rig-labels' +} +check "rig runner: the sibling still scopes ITS .rig-labels write too" 0 "" \ + gh_labels_write_is_scoped # undo must refuse under a live Forgejo runner for a STRONGER reason than the # GitHub one: Forgejo has no deregistration endpoint, so the ghost it strands From cf5858bb606e472f2b459ae0f3f5327a49c67950 Mon Sep 17 00:00:00 2001 From: cluade-reviewer-andresmgsl Date: Mon, 27 Jul 2026 21:19:33 +0000 Subject: [PATCH 3/9] fix: one checksum policy, labeler coverage, orphaned-unit removal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Net-new review findings from grok and kimi on !110. Their items 1-3 were codex's, already fixed in 1933b07; these are the ones only they raised. grok #4 — the two downloaders would drift. docs/templates/ci-box/install.sh and the download block in forgejo-runner-install.sh were near-copies, and grok named the exact consequence with the exact evidence: fail-open survived in BOTH 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 whole policy — fetch, unreadable, mismatch — is now fetch_and_verify_sha256, byte-identical in both files and diffed by test/cli.sh. They cannot share a lib: the command sources commands/lib/, and the template is a registry definition that runs standalone inside a mint with rig's tree nowhere in reach, which is the same situation valid_version faces between bin/rig and install.sh. Mutation-checked by drifting one copy's message and confirming the diff goes red. kimi #2 — the labeler could not see this family. scope:runner matched commands/runner-*.sh only, so forgejo-runner-*.sh and the staged ci-box definition scored no scope at all. Globs extended and the label's description now says either forge rather than GitHub. kimi #4 — remove stranded a unit whose user was gone. The missing-user check exited 0 before the unit was ever looked at, so a deleted account with a leftover forgejo-runner.service reported "nothing to remove" while the absence-assert that never ran implied the opposite. The unit is now checked independently. Auditing that fix surfaced a hazard kimi did not mention: with the user gone RUNNER_DIR is "", and the later unguarded "$RUNNER_DIR/.rig-labels" would have expanded to "/.rig-labels" — an rm at the filesystem root, as root. Every RUNNER_DIR path is now gated, and a test pins that none is unguarded. kimi #1 — the README handed out a config that breaks rig's own gates. DEFAULT_ACTIONS_URL is a single fallback and rig's workflows need two origins; measured: code.forgejo.org serves actions/checkout (200) but not heavy-duty/ceremony (404), which lives on the Forgejo instance. With the value the README recommended, all eight ceremony references fail to resolve. The section now states the conflict with the counts, says which references would break, and explicitly does NOT pick a side — that is an infra decision, and rig's CI running on Forgejo is not something rig forgejo-runner depends on. Asked the maintainer for direction. 746/31/43 pass, shellcheck clean, labeler.yml parses. forgejo#109 Co-Authored-By: Claude Opus 5 (1M context) --- .github/labeler.yml | 4 +- .github/labels.conf | 2 +- README.md | 22 ++++++++ commands/forgejo-runner-install.sh | 80 +++++++++++++++++++----------- commands/forgejo-runner-remove.sh | 48 +++++++++++++----- docs/templates/ci-box/install.sh | 74 ++++++++++++++++++--------- test/cli.sh | 49 +++++++++++++++--- 7 files changed, 207 insertions(+), 72 deletions(-) diff --git a/.github/labeler.yml b/.github/labeler.yml index 2328070..acb71bc 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -11,7 +11,9 @@ "scope:runner": - changed-files: - any-glob-to-any-file: - ["commands/runner-*.sh", "commands/lib/runner-config.sh"] + ["commands/runner-*.sh", "commands/lib/runner-config.sh", + "commands/forgejo-runner-*.sh", "commands/lib/forgejo-runner-config.sh", + "docs/templates/ci-box/**"] "scope:coolify": - changed-files: - any-glob-to-any-file: ["commands/coolify-*.sh"] diff --git a/.github/labels.conf b/.github/labels.conf index 94167b5..ef6116e 100644 --- a/.github/labels.conf +++ b/.github/labels.conf @@ -2,7 +2,7 @@ panel=claude-bot-andresmgsl codex-bot-andresmgsl grok-bot-andresmgsl kimi-bot-an triage-actors=dan-claude-bot scope:bootstrap|C5DEF5|bootstrap — hardening a pristine server into a node scope:users|C5DEF5|users-* — class model, apply/status, close-root -scope:runner|C5DEF5|runner-* — GitHub runner lifecycle +scope:runner|C5DEF5|runner-* / forgejo-runner-* — CI runner lifecycle, either forge scope:coolify|C5DEF5|coolify-* — Coolify and backup install scope:db|C5DEF5|db.sh — dump/restore scope:installer|C5DEF5|install.sh — how rig lands on a machine diff --git a/README.md b/README.md index 38ed5bb..ffd76ef 100644 --- a/README.md +++ b/README.md @@ -1174,6 +1174,28 @@ FORGEJO__actions__ENABLED=true FORGEJO__actions__DEFAULT_ACTIONS_URL=https://code.forgejo.org ``` +> **`DEFAULT_ACTIONS_URL` is a single fallback, and rig's own workflows need +> two origins.** It decides where a *bare* `uses: owner/repo@ref` resolves. +> Measured: +> +> | reference | count in `.github/workflows/` | `code.forgejo.org` | +> |---|---|---| +> | `actions/checkout@v4` | 3 | **200** — mirrored | +> | `heavy-duty/ceremony/...@0.3.0` | 8 | **404** — lives on this Forgejo instead | +> +> So with the value above, every ceremony guard (`changelog-armed`, +> `changelog-monotonic`, `changelog-assembled`, `drill-recorded`, +> `runner-isolated`, `docs-sync`, and the release/labels callers) fails to +> resolve the moment rig's CI actually runs on the forge. +> +> Forgejo accepts an absolute URL per step, so one side has to be explicit — +> which one is a **decision this PR does not make**. Either set the default to +> this instance and make the three `actions/*` references absolute +> (`uses: https://code.forgejo.org/actions/checkout@v4`), or keep the value +> above and make the eight ceremony references absolute. Until that lands, +> **rig's CI is not expected to run on Forgejo** — `rig forgejo-runner` is for +> running *your* repositories' workflows, and does not depend on this. + > **A registry served from Forgejo needs one more.** `RIG_TEMPLATES_HOST` > (below) lets the template registry live on any forge, but the mint-time fetch > is **unauthenticated by contract** — box auto-runs `rig bootstrap -box` diff --git a/commands/forgejo-runner-install.sh b/commands/forgejo-runner-install.sh index f6ce3d6..e3a6fb5 100755 --- a/commands/forgejo-runner-install.sh +++ b/commands/forgejo-runner-install.sh @@ -33,6 +33,55 @@ die() { printf 'rig-forgejo-runner: ERROR: %s\n' "$1" >&2; exit "${2:-1}"; } # 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