feat: Forgejo-native CI — a ci-box tenant and a forgejo-runner command family #110

Merged
andres merged 9 commits from build/109-forgejo-ci-box into main 2026-07-28 19:58:37 +00:00
18 changed files with 2157 additions and 26 deletions

4
.github/labeler.yml vendored
View file

@ -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"]

2
.github/labels.conf vendored
View file

@ -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

171
README.md
View file

@ -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/<ref>.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@<pin-sha>/` 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,20 @@ 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. `FORGEJO__service__REQUIRE_SIGNIN_VIEW=false` is what
> makes an instance usable as a registry host.
>
> Verified on `forgejo.heavyduty.builders` (2026-07-27): a credential-less
> `templates_resolve` against `RIG_TEMPLATES_HOST=https://forgejo.heavyduty.builders`
> fetches and extracts a real repository archive. The mint-time path works
> there today.
**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 +1069,162 @@ 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 <url>`
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 <pin>``forgejo-runner` release (default: latest at install
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 <name>` — runner name (default: this host's hostname)
- `--labels <csv>` — 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.
**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 <name>` — 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__<section>__<KEY>` 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
```
> **`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.
> **The ruling is to keep the value above**`actions/*` are exactly what
> `DEFAULT_ACTIONS_URL` is designed to resolve, and `code.forgejo.org` mirrors
> them — and to make the eight first-party references absolute:
>
> ```yaml
> - uses: https://forgejo.heavyduty.builders/heavy-duty/ceremony/actions/docs-sync@0.3.0
> ```
>
> That change is tracked in forgejo#112, not made here. Until it lands,
> **rig's own CI is not expected to run on Forgejo**.
> `rig forgejo-runner` exists to run *your repositories'* workflows and does not
> depend on any of it — the value above is correct for those from the start.
> **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 <role>-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.
>
> On `forgejo.heavyduty.builders` this is **already set** — verified
> 2026-07-27 by resolving a registry from it with no credentials at all.
### `rig users apply --file <path>`
Converges named operator accounts from a declarative users file — on **every**

48
bin/rig
View file

@ -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 <url> [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 <name>]
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 <name>]
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 <path>
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 <url>", 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:-}"

4
changelog.d/109.md Normal file
View file

@ -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 (#109)
- The template registry can be served from any forge — `RIG_TEMPLATES_HOST` (#109)

View file

@ -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

View file

@ -0,0 +1,494 @@
#!/usr/bin/env bash
# rig forgejo-runner install — Forgejo Actions runner as a systemd service
# under an unprivileged user. Outbound-only (long-poll to the instance), no
# inbound ports. Convergent toward --instance: re-running against the instance
# the box is already on leaves it alone; a box registered to a DIFFERENT
# instance is refused, never silently restarted on the old one.
#
# The GitHub sibling (runner-install.sh) refuses Docker outright: it converges
# a fleet MACHINE, where `docker` group membership is root-equivalent and the
# blast radius is the machine. This command's home is a ci-box TENANT, where
# bootstrap-tenant.sh has already installed Docker and added the tenant user to
# the group, and where the blast radius is a disposable guest with no inbound
# path. Same trade, different machine, opposite answer — which is why this is a
# separate command and not a flag on that one.
set -euo pipefail
HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
# shellcheck source=SCRIPTDIR/lib/forgejo-runner-config.sh
. "$HERE/lib/forgejo-runner-config.sh"
log() { printf 'rig-forgejo-runner: %s\n' "$*"; }
warn() { printf 'rig-forgejo-runner: WARNING: %s\n' "$*" >&2; }
die() { printf 'rig-forgejo-runner: ERROR: %s\n' "$1" >&2; exit "${2:-1}"; }
# The default label map. `runs-on: ubuntu-latest` is what a workflow written
# for GitHub says, so it must mean something here or every workflow needs
# editing to migrate; catthehacker's image is the act/Forgejo ecosystem's
# stand-in for GitHub's runner image. `docker` is the lean second option.
#
# Both are `docker://` — jobs run in CONTAINERS on the box's own dockerd, not
# on the box itself. No docker-in-docker: the guide this came from stacks a
# privileged dind sidecar with a plaintext tcp://…:2375 daemon to isolate jobs
# from a shared CI server, and inside a box that boundary is already paid for.
DEFAULT_LABELS='ubuntu-latest:docker://ghcr.io/catthehacker/ubuntu:act-22.04,docker:docker://node:22-bookworm'
# fetch_and_verify_sha256 <asset-url> <file> <sumfile> <label>
#
# The whole checksum POLICY, in one place: fetch the published .sha256 beside
# an asset and prove the download matches it. Prints the reason on stderr and
# returns 1 on any failure; the caller supplies the refusal in its own voice.
#
# BYTE-IDENTICAL to the copy in docs/templates/ci-box/install.sh, diffed by
# test/cli.sh — the valid_version / templates_archive_urls precedent. The two
# downloaders cannot share a lib: this one sources commands/lib/, and that one
# is a REGISTRY DEFINITION that runs standalone inside a mint from a fetched
# tarball, with rig's tree nowhere in reach. So the pin is the only mechanism
# that keeps one policy from becoming two.
#
# Review !110 is the evidence for why that matters: a fail-open branch lived in
# BOTH copies while a grep for "checksum mismatch" passed against both, because
# the string it looked for sat right beside the branch it could not see. The
# next checksum-policy change must not be able to land in one file only.
#
# AN UNFETCHABLE CHECKSUM REFUSES — it is a gate, not a courtesy. The earlier
# reasoning ("do not let an upstream layout change break installs") reasons
# about the wrong failure: a layout change moves the BINARY url too, so the
# download would already have died. "Binary yes, checksum no" is not what a
# layout change looks like — it is what an interfered fetch looks like, which
# is precisely what a checksum exists to catch. Failing open would hand an
# unverified root install to anyone able to block a single URL. There is
# deliberately no bypass flag: if upstream really does move its assets, that is
# a rig PR editing the URL, not an operator improvising past a security gate.
fetch_and_verify_sha256() {
local url="$1" file="$2" sumfile="$3" label="$4" want got
if ! curl -fsSL "${url}.sha256" -o "$sumfile" 2>/dev/null; then
printf 'no published .sha256 for %s at %s.sha256 — the binary itself downloaded, so this is not an upstream layout change; check what is intercepting the fetch\n' "$label" "$url" >&2
return 1
fi
# The published .sha256 names the asset, not our temp path. Compare the
# digest itself rather than rewriting the file into sha256sum -c's format:
# one comparison, no parsing of a file we did not write.
want="$(tr -d '\r' < "$sumfile" 2>/dev/null | awk '{print $1}' | head -n1)"
got="$(sha256sum "$file" | awk '{print $1}')"
if [ -z "$want" ]; then
printf 'the published checksum for %s is unreadable — a fetch that succeeds but returns nothing usable is not a verified download\n' "$label" >&2
return 1
fi
if [ "$want" != "$got" ]; then
printf 'checksum mismatch for %s: published %s, downloaded %s\n' "$label" "$want" "$got" >&2
return 1
fi
printf 'checksum verified (%s)\n' "$got"
}
usage() {
cat <<'EOF'
usage: rig forgejo-runner install --instance <url> [options]
--instance <url> Forgejo instance the runner registers to (required),
e.g. https://forgejo.example.com
--version <pin> forgejo-runner release to install, e.g. 12.13.2
(default: the latest release, resolved at install
time). Pin it for a deterministic, auditable install.
--name <name> runner name (default: this host's hostname)
--labels <csv> runner labels; replaces the default. The default maps
ubuntu-latest and docker onto container images, so a
workflow written for GitHub runs unchanged.
--user <name> unprivileged service user (default: the tenant user
`ci` when it exists, else forgejo-runner; created if
absent; never root)
Installs forgejo-runner as a systemd service under an unprivileged user. The
runner is an agent, not a server: it long-polls the instance outbound and
receives jobs down that already-established connection, so it needs ZERO
inbound ports.
Jobs run in Docker containers on this box's own daemon. Inside a ci-box tenant
that daemon is already there — `rig bootstrap ci-box` installs it and puts the
tenant user in the `docker` group.
Provide the runner registration token via the FORGEJO_RUNNER_TOKEN env var or
the interactive prompt. Get one from the scope you want the runner to serve:
instance Site Administration > Actions > Runners > Create new Runner
org Org > Settings > Actions > Runners
repo Repo > Settings > Actions > Runners
The SCOPE IS THE TOKEN'S, not a flag here. It is consumed at registration and
never written to disk by rig.
Convergent toward --instance: re-running against the instance this box is
already on re-uses the binary, skips registration, and never asks for a token.
A box registered to a DIFFERENT instance is refused — take it off the old one
with `rig forgejo-runner remove` first.
EOF
}
# --- args (validated before the root check, so errors are testable) ---------
INSTANCE=""
VERSION=""
RUNNER_NAME="$(hostname)"
LABELS="$DEFAULT_LABELS"
# Whether --labels was ASKED FOR, distinct from what it resolved to. A rerun
# cannot apply labels (Forgejo owns them from registration time), and the
# difference between "operator requested a change" and "operator passed
# nothing" is what separates a warning worth printing from noise on every
# converge.
LABELS_EXPLICIT=0
RUNNER_USER=""
while [ $# -gt 0 ]; do
case "$1" in
--instance)
[ $# -ge 2 ] || die "--instance needs a value" 2
INSTANCE="$2"; shift 2 ;;
--version)
[ $# -ge 2 ] || die "--version needs a value" 2
VERSION="$2"; shift 2 ;;
--name)
[ $# -ge 2 ] || die "--name needs a value" 2
RUNNER_NAME="$2"; shift 2 ;;
--labels)
[ $# -ge 2 ] || die "--labels needs a value" 2
LABELS="$2"; LABELS_EXPLICIT=1; shift 2 ;;
--user)
[ $# -ge 2 ] || die "--user needs a value" 2
RUNNER_USER="$2"; shift 2 ;;
--repo)
# Named, not "unknown flag": everyone arrives here from `rig runner
# install --repo`, and the honest answer is that the argument does not
# exist on this forge rather than that it is misspelled.
[ $# -ge 2 ] && shift
die "--repo does not exist here: a Forgejo runner registers to an INSTANCE, and whether it serves that whole instance, one org, or one repo is a property of the registration TOKEN you mint in Forgejo's UI. Pass --instance <url> and mint the token at the scope you want." 2 ;;
-h|--help) usage; exit 0 ;;
*) die "unknown flag: $1" 2 ;;
esac
done
# --- validation ----------------------------------------------------------
[ -n "$INSTANCE" ] || die "--instance <url> is required" 2
case "$INSTANCE" in
https://*|http://*) ;;
*) die "--instance must be a URL with a scheme, e.g. https://forgejo.example.com (got: $INSTANCE)" 2 ;;
esac
# A path component would be a repo URL — the GitHub habit, and the one mistake
# that produces a runner registered somewhere subtly wrong rather than a clean
# failure. Refuse it by name.
case "${INSTANCE#*://}" in
*/*[!/]*) die "--instance takes the instance ROOT, not a repository URL: got ${INSTANCE}. Scope comes from the token, not the URL." 2 ;;
esac
VERSION="${VERSION#v}"
# The same sane-version charset the resolve-latest path enforces further down.
# An explicit pin skipped it entirely and went straight into the download URL,
# so a value carrying `/` or `..` was interpolated into a URL PATH rather than
# refused. Not a trust boundary — this command is already root, and the operator
# typed the flag — but a pin that cannot name a release should fail BY NAME at
# parse time, not as an opaque 404 forty lines later. install.sh's
# valid_version is the same instinct, and the asymmetry was the whole defect:
# the value rig resolves for itself was checked, the one it is handed was not.
case "$VERSION" in
"") ;; # unset — the latest release is resolved and validated below
*[!0-9.]*|.*|*.)
die "--version must be a release number like 12.13.2 (got: ${VERSION})" 2 ;;
esac
[ -n "$LABELS" ] || die "--labels must not be empty" 2
# The tenant user is the default when it is there: inside a ci-box the runner
# IS the tenant, and inventing a second service account beside it would leave
# the docker-group membership bootstrap-tenant.sh converged on the wrong user.
# Falls back to a dedicated account so this still works on a plain machine.
if [ -z "$RUNNER_USER" ]; then
if id -u ci >/dev/null 2>&1; then RUNNER_USER="ci"; else RUNNER_USER="forgejo-runner"; fi
fi
[ "$RUNNER_USER" != "root" ] || die "runner user must not be root" 2
# --- guards ----------------------------------------------------------------
[ "$(id -u)" -eq 0 ] || die "must run as root"
if [ -r /etc/os-release ]; then
# Sourced in a subshell: os-release defines VERSION (e.g. "13 (trixie)"),
# which would clobber this script's $VERSION.
# shellcheck source=/dev/null
OS_FAMILY="$(. /etc/os-release && printf '%s %s' "${ID:-}" "${ID_LIKE:-}")"
case "$OS_FAMILY" in
*debian*) ;;
*) warn "not a Debian-family system (${OS_FAMILY:-unknown}); proceeding anyway" ;;
esac
else
warn "cannot read /etc/os-release; proceeding anyway"
fi
command -v curl >/dev/null || die "curl is required (run rig bootstrap first)"
command -v systemctl >/dev/null || die "systemctl is required — this command installs the runner as a systemd service"
# --- is this box already registered somewhere else? --------------------------
# Before anything is prompted for, downloaded, or started: --instance must
# agree with what is already on the box. Everything below treats an existing
# .runner as "nothing to do" — right for the instance the box is already on,
# silently wrong for any other. See assert_runner_instance.
REG_PENDING=1
if id -u "$RUNNER_USER" >/dev/null 2>&1; then
USER_HOME="$(getent passwd "$RUNNER_USER" | cut -d: -f6)"
RUNNER_DIR="$USER_HOME/forgejo-runner"
assert_runner_instance "$RUNNER_DIR" "$INSTANCE" || exit 1
if [ -e "$RUNNER_DIR/.runner" ]; then
REG_PENDING=0
fi
fi
# --- registration token — only when registration is actually pending -------
if [ "$REG_PENDING" -eq 1 ]; then
FORGEJO_RUNNER_TOKEN="${FORGEJO_RUNNER_TOKEN:-}"
# Prompt only on a tty: headless, a bare `read` dies under set -e with no
# message at all. Refuse loudly, naming the variable.
if [ -z "$FORGEJO_RUNNER_TOKEN" ]; then
[ -t 0 ] || die "FORGEJO_RUNNER_TOKEN is unset and stdin is not a tty — set FORGEJO_RUNNER_TOKEN to run unattended"
read -rsp "forgejo runner registration token: " FORGEJO_RUNNER_TOKEN || { echo; die "no registration token read (EOF) — set FORGEJO_RUNNER_TOKEN to run unattended"; }
echo
fi
[ -n "$FORGEJO_RUNNER_TOKEN" ] || die "empty registration token"
fi
# --- user --------------------------------------------------------------------
if ! id -u "$RUNNER_USER" >/dev/null 2>&1; then
useradd --create-home --shell /bin/bash "$RUNNER_USER"
log "created user ${RUNNER_USER}"
else
log "user exists"
fi
USER_HOME="$(getent passwd "$RUNNER_USER" | cut -d: -f6)"
RUNNER_GROUP="$(id -gn "$RUNNER_USER")"
RUNNER_DIR="$USER_HOME/forgejo-runner"
BIN=/usr/local/bin/forgejo-runner
# The runner talks to dockerd over its socket, so it needs the group. In a
# ci-box bootstrap-tenant.sh already did this for the tenant user; on a plain
# machine, or for a --user that is not the tenant, it has not.
if getent group docker >/dev/null 2>&1; then
if id -nG "$RUNNER_USER" | tr ' ' '\n' | grep -qx docker; then
log "${RUNNER_USER} already in the docker group"
else
usermod -aG docker "$RUNNER_USER"
log "added ${RUNNER_USER} to the docker group"
fi
else
warn "no docker group on this box — jobs using docker:// labels will fail. Inside a ci-box, 'rig bootstrap ci-box' installs docker; elsewhere install it before running jobs."
fi
# --- download ----------------------------------------------------------------
# Forgejo publishes BARE BINARIES (not a tarball) with a .sha256 beside each
# one. Taking that checksum is nearly free and makes the install auditable —
# the same instinct as `coolify install`'s mandatory version pin.
#
# "Already present" is NOT enough to skip here, and this is where the GitHub
# sibling's shape must not be copied. Its skip is justified by "self-update
# owns upgrades" — actions/runner updates itself, and GitHub refuses jobs from
# stale runners, so freezing it would be pointless. **forgejo-runner does not
# self-update.** Nothing else ever moves the version, so a bare presence check
# would mean the binary a box first happened to get is the binary it keeps
# forever.
#
# That lands hardest on the path this command is FOR: a ci-box's template
# install.sh preinstalls /usr/local/bin/forgejo-runner at mint, so the
# executable always exists before an operator ever runs this — and --version,
# documented as the deterministic-pin lever, would silently do nothing on
# every ci-box in the fleet.
#
# So: converge toward --version when it is given, exactly as this command
# converges toward --instance. A pin is not a trust boundary the way an
# instance is (that one refuses), it is an instruction — including downward,
# which is what a pin is for. Absent a pin, an existing binary is left alone:
# chasing "latest" on every converge would make a re-run an unrequested
# upgrade, and convergence must not be a moving target.
# runner_version_of / runner_download_decision live in the lib, so the rule can
# be driven by test/cli.sh without root — see there for the full reasoning.
PRESENT_VER=""
HAVE_BIN=no
if [ -x "$BIN" ]; then
HAVE_BIN=yes
PRESENT_VER="$(runner_version_of "$BIN")"
fi
case "$(runner_download_decision "$HAVE_BIN" "$PRESENT_VER" "$VERSION")" in
skip)
NEED_DOWNLOAD=0
if [ -n "$VERSION" ]; then
log "forgejo-runner ${VERSION} already installed; skipping download"
else
log "forgejo-runner ${PRESENT_VER:-(version unreadable)} already present at ${BIN}; skipping download (pass --version <pin> to converge to a specific release)"
fi ;;
converge)
NEED_DOWNLOAD=1
log "converging ${BIN}: ${PRESENT_VER:-unreadable} -> ${VERSION} (--version)" ;;
*)
NEED_DOWNLOAD=1 ;;
esac
if [ "$NEED_DOWNLOAD" -eq 1 ]; then
case "$(uname -m)" in
x86_64) ARCH="amd64" ;;
aarch64) ARCH="arm64" ;;
*) die "unsupported arch: $(uname -m)" ;;
esac
if [ -z "$VERSION" ]; then
# No pin given: resolve the latest release by following the redirect on
# the /releases/latest page — no API call, no token, no JSON to parse on
# a dependency-free box (install.sh's resolve_latest_tag idiom).
LATEST_URL="$(curl -fsSLI -o /dev/null -w '%{url_effective}' \
https://code.forgejo.org/forgejo/runner/releases/latest)" \
|| die "could not resolve the latest forgejo-runner release"
VERSION="${LATEST_URL##*/}"
VERSION="${VERSION#v}"
case "$VERSION" in
""|*[!0-9.]*) die "could not parse a version from ${LATEST_URL}" ;;
esac
log "resolved latest forgejo-runner: ${VERSION}"
fi
ASSET="forgejo-runner-${VERSION}-linux-${ARCH}"
URL="https://code.forgejo.org/forgejo/runner/releases/download/v${VERSION}/${ASSET}"
WORKDIR="$(mktemp -d)"
cleanup() { rm -rf "$WORKDIR"; }
trap cleanup EXIT
log "downloading forgejo-runner ${VERSION} (${ARCH})"
curl -fsSL "$URL" -o "$WORKDIR/forgejo-runner" \
|| die "could not download ${URL}"
fetch_and_verify_sha256 "$URL" "$WORKDIR/forgejo-runner" "$WORKDIR/forgejo-runner.sha256" "$ASSET" \
|| die "refusing to install an unverified ${ASSET} — it lands as root and runs under a systemd unit. See the checksum failure above."
# Staged beside the target and RENAMED into place, never written over.
# Replacing a running executable in place fails with ETXTBSY, and this path
# now runs on boxes where the daemon is live (a --version converge). A
# rename is atomic and leaves the running process on the old inode until the
# restart below picks up the new one.
install -m 0755 -o root -g root "$WORKDIR/forgejo-runner" "$BIN.rig-new"
mv -f "$BIN.rig-new" "$BIN"
log "installed ${BIN}"
fi
# `|| true` so the refusal BELOW is the one that fires. Under set -euo pipefail
# a bare `VAR="$(cmd | head)"` dies at the assignment when cmd exits non-zero,
# which is precisely the case this line exists to diagnose — see the lib.
INSTALLED_VER="$("$BIN" --version 2>/dev/null | head -n1 || true)"
[ -n "$INSTALLED_VER" ] || die "${BIN} does not answer --version — the download landed but cannot run"
# The converge actually took — asserted, not assumed. A pin that silently did
# not land is exactly the failure --version exists to make impossible.
if [ -n "$VERSION" ]; then
EFFECTIVE_VER="$(runner_version_of "$BIN")"
[ "$EFFECTIVE_VER" = "$VERSION" ] \
|| die "asked for forgejo-runner ${VERSION} but ${BIN} reports ${EFFECTIVE_VER:-nothing} after install"
fi
# --- register ----------------------------------------------------------------
# UPSTREAM MARKS `register` DEPRECATED (measured on v12.13.2: both `register`
# and `create-runner-file` carry "(deprecated)" in their help). It is chosen
# here anyway, deliberately, and this is the reasoning to revisit when it
# finally goes:
#
# - It still works. `daemon` reads the `.runner` this writes, resolves the
# instance from it, and connects — verified against a live instance, where
# a planted `.runner` got as far as "Unauthenticated: unregistered runner".
# The mechanism is intact; only the credential was fake.
# - The successor needs MORE than rig can honestly ask for at this layer:
# `daemon --url --uuid --token-url` requires the runner to already exist on
# the instance, so the operator would have to create it via API/UI and
# carry back a UUID. That is a second, differently-shaped credential dance
# for no gain today.
# - `register` writes a file `status` can read back. The successor's config
# lives in flags on a unit line, where "what is this box registered to" has
# no on-disk answer that is not just rig's own copy of what it was told.
#
# When upstream removes it: the shape becomes `daemon --url/--uuid`, the unit
# gains those flags, and forgejo-runner-config.sh's readers move to whatever
# holds the UUID. assert_runner_instance's contract survives either way — it
# asks about the instance, which both spellings record.
install -d -m 0755 -o "$RUNNER_USER" -g "$RUNNER_GROUP" "$RUNNER_DIR"
if [ -e "$RUNNER_DIR/.runner" ]; then
log "already registered; skipping registration"
# Registration was skipped, so the labels on the instance are the ones it was
# registered with — NOT whatever this invocation was passed. Say so when the
# operator explicitly asked for different ones, rather than letting the
# request evaporate. Only when EXPLICIT: comparing the default against a
# runner registered with custom labels would warn on every plain converge.
if [ "$LABELS_EXPLICIT" -eq 1 ] && [ -r "$RUNNER_DIR/.rig-labels" ]; then
RECORDED="$(cat "$RUNNER_DIR/.rig-labels")"
if [ "$RECORDED" != "$LABELS" ]; then
warn "--labels was not applied: this runner is already registered, and Forgejo owns its labels from registration time. It still has: ${RECORDED}. Labels are what 'runs-on' matches, so changing them means re-registering: 'rig forgejo-runner remove' then install again with the labels you want."
fi
fi
else
log "registering runner ${RUNNER_NAME} against ${INSTANCE}"
(cd "$RUNNER_DIR" && runuser -u "$RUNNER_USER" -- env HOME="$USER_HOME" \
"$BIN" register --no-interactive \
--instance "$INSTANCE" --token "$FORGEJO_RUNNER_TOKEN" \
--name "$RUNNER_NAME" --labels "$LABELS") \
|| die "registration failed — check the token is a RUNNER registration token from ${INSTANCE} and has not been used already"
[ -e "$RUNNER_DIR/.runner" ] \
|| die "register reported success but wrote no ${RUNNER_DIR}/.runner"
# INSIDE the registration branch, where runner-install.sh keeps its copy and
# for the same reason: this file records what rig ACTUALLY registered with,
# so `status` has something to read back. Writing it unconditionally — as an
# earlier draft did — makes a plain re-run stamp this invocation's labels
# over a registration that used different ones, and `status` then reports
# confidently wrong labels while Forgejo still holds the originals. A
# metadata file that can disagree with the thing it describes is worse than
# no metadata file.
printf '%s\n' "$LABELS" > "$RUNNER_DIR/.rig-labels"
chown "$RUNNER_USER:$RUNNER_GROUP" "$RUNNER_DIR/.rig-labels"
fi
# EVERY run, registration or not: .runner holds the runner's own long-lived
# token, and a mode that drifted leaks it silently. See the lib.
forgejo_runner_secure "$RUNNER_DIR" "$RUNNER_USER" "$RUNNER_GROUP"
# --- service -------------------------------------------------------------
# Written by rig rather than shipped by upstream: forgejo-runner has no
# svc.sh, so there is no vendor unit to defer to (the GitHub sibling defers to
# actions/runner's). Converged like every file rig writes — cmp-guarded, so a
# re-run that changes nothing reloads nothing.
UNIT=/etc/systemd/system/forgejo-runner.service
UNIT_TMP="$(mktemp)"
cat > "$UNIT_TMP" <<EOF
[Unit]
Description=Forgejo Actions runner
Documentation=https://forgejo.org/docs/latest/admin/actions/
After=network-online.target docker.service
Wants=network-online.target
[Service]
Type=simple
User=${RUNNER_USER}
WorkingDirectory=${RUNNER_DIR}
ExecStart=${BIN} daemon
Restart=on-failure
RestartSec=10
# The runner supervises job containers on this box's docker socket; it is not
# a sandbox for them. These keep the DAEMON from being a soft target.
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ProtectHome=read-only
ReadWritePaths=${RUNNER_DIR}
[Install]
WantedBy=multi-user.target
EOF
if ! cmp -s "$UNIT_TMP" "$UNIT" 2>/dev/null; then
install -m 0644 "$UNIT_TMP" "$UNIT"
systemctl daemon-reload
log "systemd unit written: ${UNIT}"
else
log "systemd unit already current"
fi
rm -f "$UNIT_TMP"
systemctl enable forgejo-runner >/dev/null 2>&1 || die "could not enable forgejo-runner.service"
systemctl restart forgejo-runner || die "could not start forgejo-runner.service — see 'journalctl -u forgejo-runner'"
# Assert the EFFECTIVE state, not systemctl's exit code: a unit that starts and
# immediately dies (bad token, unreachable instance) leaves restart succeeding
# and the runner absent. Settle briefly, then ask.
active=""
for _ in 1 2 3 4 5 6; do
if systemctl is-active forgejo-runner >/dev/null 2>&1; then active=1; break; fi
sleep 2
done
[ -n "$active" ] || die "forgejo-runner.service is not active after 12s — see 'journalctl -u forgejo-runner' (a bad token or an unreachable instance both land here)"
log "runner ${RUNNER_NAME} (${INSTALLED_VER}) installed and running"
log "labels: ${LABELS}"
log "verify it shows Idle under ${INSTANCE} > Site Administration > Actions > Runners"
log "this box needs no inbound ports — the runner polls the instance outbound"

139
commands/forgejo-runner-remove.sh Executable file
View file

@ -0,0 +1,139 @@
#!/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 <name>]
--user <name> 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? -----------------------------------------------------
# The unit is checked INDEPENDENTLY of the user, and that ordering is the whole
# point. A missing user used to exit 0 here before the unit was ever looked at,
# so a deleted account with a leftover forgejo-runner.service reported "nothing
# to remove" and left the unit behind — while the absence-assert at the end,
# which never ran, implied removal had been complete. `bootstrap --undo`'s own
# unit check would still have caught it, but a verb that claims to have removed
# everything must not be the thing that lies about it.
RUNNER_DIR=""
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"
else
log "no ${RUNNER_USER} user on this box"
fi
if [ -z "$RUNNER_DIR" ] && [ ! -e "$UNIT" ]; then
log "no runner user and no unit on this box; nothing to remove"
exit 0
fi
if [ -n "$RUNNER_DIR" ] && [ ! -e "$RUNNER_DIR/.runner" ] && [ ! -e "$UNIT" ]; then
log "no runner registered in ${RUNNER_DIR}; nothing to remove"
exit 0
fi
[ -n "$RUNNER_DIR" ] || warn "the ${RUNNER_USER} user is gone but ${UNIT} is still here — removing the orphaned unit"
INSTANCE=""
RUNNER_NAME=""
if [ -n "$RUNNER_DIR" ]; then
INSTANCE="$(forgejo_runner_instance "$RUNNER_DIR")"
RUNNER_NAME="$(forgejo_runner_name "$RUNNER_DIR")"
fi
# --- 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 -----------------------------------------------------------
# Every path below is gated on RUNNER_DIR being non-empty. With the user gone
# it is "", and an unguarded "$RUNNER_DIR/.rig-labels" would expand to
# "/.rig-labels" — an rm at the filesystem root, as root. The repo already
# treats this class of expansion as a hazard worth spelling out (`rm -rf
# "${ir:?}/versions/$ver"` in bin/rig); same discipline here.
if [ -n "$RUNNER_DIR" ]; then
if [ -e "$RUNNER_DIR/.runner" ]; then
rm -f "$RUNNER_DIR/.runner"
log "wiped the local registration"
fi
rm -f "$RUNNER_DIR/.rig-labels"
fi
# END WITH THE ABSENCE ASSERT: "removed" is a claim, and claims get verified
# (the `rig uninstall` precedent).
leftover=""
[ -n "$RUNNER_DIR" ] && [ -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."

View file

@ -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 <name>]
--user <name> 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:-<url>}' to converge the mode."
fi

View file

@ -0,0 +1,163 @@
#!/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 <runner_dir> — 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 <runner_dir> — the runner's name, empty when unregistered.
forgejo_runner_name() {
[ -e "$1/.runner" ] || return 0
json_field "$1/.runner" name
}
# forgejo_runner_secure <runner_dir> <user> <group> — 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"
}
# runner_version_of <bin> — 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.
#
# `|| true` for json_field's reason, which bites harder here. Callers run under
# `set -euo pipefail`, where a pipeline whose FIRST stage exits non-zero fails
# the whole pipeline — and `PRESENT_VER="$(runner_version_of "$BIN")"` is an
# assignment, so the script dies AT THAT LINE, with no message. "Empty when it
# cannot answer" is only true if this says so out loud.
#
# That is not a hypothetical shape: the binary is `[ -x ]` but unrunnable
# exactly when a ci-box's template preinstall landed a truncated or wrong-arch
# download — the one path this command family exists for. Without this, install
# exits 1 in silence and the refusal written for that case ("the download
# landed but cannot run") is unreachable code.
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' || true
}
# runner_download_decision <have-binary yes|no> <present-ver> <wanted-ver>
# -> "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 <runner_dir> <instance-url>
#
# Returns 0 when the box has no runner, or has one already registered to
# <instance-url>: 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
}

View file

@ -18,7 +18,7 @@
# <role>/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 <host> <repo> <ref> — the source-tarball candidates
# for <ref>, 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/<ref>.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/<ref> 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=<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 (<repo>-<ref>,
# 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 <repo>-<ref> (slashes flattened), Forgejo writes
# bare <repo>. 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

View file

@ -0,0 +1,319 @@
# 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/<ref>.tar.gz`, `archive/refs/heads/<ref>.tar.gz`, `archive/<ref>.tar.gz` | `archive/<ref>.tar.gz` — one form, which resolves tags, branches and SHAs alike |
| Archive top-level dir | `<repo>-<ref>` | `<repo>` |
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 exposed — since cleared
**Resolved 2026-07-27, after this was written.** The operator set
`REQUIRE_SIGNIN_VIEW=false`, and the mint-time path was then verified live: a
credential-less `templates_resolve` with
`RIG_TEMPLATES_HOST=https://forgejo.heavyduty.builders` fetches and extracts a
real archive. Piece 1 is proven end to end on the target instance, not merely
argued.
The requirement below stands for *any* instance hosting a registry, and the
refusal text still names it — it is a property of the mint's creds-free
contract, not of one server's configuration. What follows is the original
analysis, kept because it is why the knob exists.
### The blocker, as originally measured
`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 `<CLI> --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 <instance> --uuid <uuid> --token-url <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.
### 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`
**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/<ref>.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.

25
docs/templates/README.md vendored Normal file
View file

@ -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.

13
docs/templates/ci-box/creds.md vendored Normal file
View file

@ -0,0 +1,13 @@
- **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 token the operator mints in Forgejo (Site
Administration, org, or repo → Actions → Runners) and hands to
`sudo rig forgejo-runner install --instance <url>`. rig never writes that
token to disk — but it does **not expire, and registering does not spend
it**. A Forgejo registration token stays valid until somebody mints a
replacement at that same scope, and it will register as many runners as it is
shown to. Treat a leaked one as live until it has been replaced. (GitHub's
equivalent expires in about an hour; do not carry that habit across.) After
registration 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.

124
docs/templates/ci-box/install.sh vendored Executable file
View file

@ -0,0 +1,124 @@
#!/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
# registration 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:
#
# Deliberately NOT described the way the GitHub sibling describes its own
# registration token, which really does expire in about an hour. That adjective
# must not cross this forge boundary — a test pins its absence from this file
# and from creds.md, so do not reintroduce it by copying from `rig runner`.
# Forgejo's ActionRunnerToken carries no expiry field at all;
# NewRunnerToken invalidates prior tokens only when a replacement is minted at
# the same scope, and Register leaves the one it was handed active. It is
# reusable until replaced, so a leak stays live. See creds.md, which is the
# copy an agent inside the box actually reads.
#
# 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
# fetch_and_verify_sha256 <asset-url> <file> <sumfile> <label>
#
# The whole checksum POLICY, in one place: fetch the published .sha256 beside
# an asset and prove the download matches it. Prints the reason on stderr and
# returns 1 on any failure; the caller supplies the refusal in its own voice.
#
# BYTE-IDENTICAL to the copy in commands/forgejo-runner-install.sh, diffed by
# test/cli.sh — the valid_version / templates_archive_urls precedent. The two
# downloaders cannot share a lib: that one sources commands/lib/, and this one
# is a REGISTRY DEFINITION that runs standalone inside a mint from a fetched
# tarball, with rig's tree nowhere in reach. So the pin is the only mechanism
# that keeps one policy from becoming two.
#
# Review !110 is the evidence for why that matters: a fail-open branch lived in
# BOTH copies while a grep for "checksum mismatch" passed against both, because
# the string it looked for sat right beside the branch it could not see. The
# next checksum-policy change must not be able to land in one file only.
#
# AN UNFETCHABLE CHECKSUM REFUSES — it is a gate, not a courtesy. The earlier
# reasoning ("do not let an upstream layout change break installs") reasons
# about the wrong failure: a layout change moves the BINARY url too, so the
# download would already have died. "Binary yes, checksum no" is not what a
# layout change looks like — it is what an interfered fetch looks like, which
# is precisely what a checksum exists to catch. Failing open would hand an
# unverified root install to anyone able to block a single URL. There is
# deliberately no bypass flag: if upstream really does move its assets, that is
# a rig PR editing the URL, not an operator improvising past a security gate.
fetch_and_verify_sha256() {
local url="$1" file="$2" sumfile="$3" label="$4" want got
if ! curl -fsSL "${url}.sha256" -o "$sumfile" 2>/dev/null; then
printf 'no published .sha256 for %s at %s.sha256 — the binary itself downloaded, so this is not an upstream layout change; check what is intercepting the fetch\n' "$label" "$url" >&2
return 1
fi
# The published .sha256 names the asset, not our temp path. Compare the
# digest itself rather than rewriting the file into sha256sum -c's format:
# one comparison, no parsing of a file we did not write.
want="$(tr -d '\r' < "$sumfile" 2>/dev/null | awk '{print $1}' | head -n1)"
got="$(sha256sum "$file" | awk '{print $1}')"
if [ -z "$want" ]; then
printf 'the published checksum for %s is unreadable — a fetch that succeeds but returns nothing usable is not a verified download\n' "$label" >&2
return 1
fi
if [ "$want" != "$got" ]; then
printf 'checksum mismatch for %s: published %s, downloaded %s\n' "$label" "$want" "$got" >&2
return 1
fi
printf 'checksum verified (%s)\n' "$got"
}
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.
fetch_and_verify_sha256 "$URL" "$WORKDIR/forgejo-runner" "$WORKDIR/forgejo-runner.sha256" "$ASSET" \
|| { echo "ci-box install: refusing to install an unverified ${ASSET} — it lands as root inside every mint. See the checksum failure above." >&2; exit 1; }
install -m 0755 -o root -g root "$WORKDIR/forgejo-runner" "$BIN"
echo "ci-box install: installed ${BIN}"

16
docs/templates/ci-box/template.env vendored Normal file
View file

@ -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"

View file

@ -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

View file

@ -3094,6 +3094,428 @@ 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 <host> <repo> <ref> — 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/<ref>.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 '<CLI> --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 <url> 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
# The pin rig RESOLVES was charset-checked; the pin it is HANDED was not, and
# that one is the one that reaches a URL path. Refused by name at parse time.
check "forgejo-runner: --version refuses a path, not a release number" 2 "release number like" \
"$FR" --instance https://f.example.com --version ../../etc/passwd
check "forgejo-runner: --version refuses a non-numeric pin" 2 "release number like" \
"$FR" --instance https://f.example.com --version latest
# Reaching the root check is the proof a good pin got THROUGH validation: this
# runs as a normal user in CI, so "must run as root" is the next gate down.
check "forgejo-runner: a plain release number passes validation" 1 "must run as root" \
"$FR" --instance https://f.example.com --version 12.13.2
check "forgejo-runner: a leading v is stripped before that check" 1 "must run as root" \
"$FR" --instance https://f.example.com --version v12.13.2
# 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
# remove used to exit 0 on a missing user BEFORE looking at the unit, so a
# deleted account with a leftover forgejo-runner.service reported "nothing to
# remove" and stranded it — while the absence-assert that never ran implied the
# opposite (review !110). The unit check must not sit behind the user check.
remove_checks_unit_independently() {
# The early-exit must require BOTH the user to be absent AND the unit to be
# missing; a user-only guard is the regression.
# shellcheck disable=SC2016 # the '$RUNNER_DIR'/'$UNIT' are LITERAL text being grepped for
grep -q 'if \[ -z "\$RUNNER_DIR" \] && \[ ! -e "\$UNIT" \]' "$ROOT/commands/forgejo-runner-remove.sh"
}
check "forgejo-runner: remove checks the unit even when the user is gone" 0 "" \
remove_checks_unit_independently
check "forgejo-runner: remove warns about an orphaned unit" 0 "orphaned unit" \
grep -o "orphaned unit" "$ROOT/commands/forgejo-runner-remove.sh"
# With the user gone RUNNER_DIR is "", and an unguarded "$RUNNER_DIR/.rig-labels"
# would expand to "/.rig-labels" — an rm at the filesystem root, as root.
# shellcheck disable=SC2016 # the '$RUNNER_DIR' is LITERAL text being grepped for
check "forgejo-runner: remove never rm's an unguarded \$RUNNER_DIR path" 1 "" \
grep -qE '^rm -f "\$RUNNER_DIR' "$ROOT/commands/forgejo-runner-remove.sh"
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 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" <<CBINST
#!/usr/bin/env bash
exec /usr/bin/install -m 0755 "\${@: -2:1}" "$CBW/installed"
CBINST
chmod +x "$CBSTUB/install"
cibox_run() { # cibox_run [VAR=val ...] — the REAL template install.sh, stubbed
rm -f "$CBW/installed"
env PATH="$CBSTUB:$PATH" \
CB_REDIRECT=https://code.forgejo.org/forgejo/runner/releases/tag/v9.9.9 \
CB_PAYLOAD="$CBW/payload" "$@" bash "$CIBOX"
}
cibox_installed() { [ -e "$CBW/installed" ]; }
check "ci-box: a matching checksum installs" 0 "checksum verified" \
cibox_run CB_SUM="$CB_GOOD"
check "ci-box: ...and the binary really landed" 0 "" cibox_installed
# The regression this replaces: a 404 on .sha256 used to warn and install.
check "ci-box: a MISSING checksum refuses (exit 1)" 1 "refusing to install an unverified" \
cibox_run
check "ci-box: ...naming the fetch, not blaming an upstream layout change" 1 "check what is intercepting the fetch" \
cibox_run
check "ci-box: ...and installed NOTHING" 1 "" cibox_installed
check "ci-box: a MISMATCHED checksum refuses" 1 "checksum mismatch" \
cibox_run CB_SUM=0000000000000000000000000000000000000000000000000000000000000000
check "ci-box: ...and installed nothing either" 1 "" cibox_installed
check "ci-box: an EMPTY checksum file refuses" 1 "unreadable" cibox_run CB_SUM=EMPTY
check "ci-box: ...and installed nothing there either" 1 "" cibox_installed
rm -rf "$CBW"
# ONE checksum policy, two files that cannot share a lib — the rig command
# sources commands/lib/, the template is a registry definition that runs
# standalone inside a mint with rig's tree nowhere in reach. So the copies are
# byte-identical and diffed, the valid_version precedent (review !110).
#
# This pin is what the driven tests above cannot give on their own: they
# exercise the TEMPLATE's copy, and without the diff a fix could land there
# while the rig command kept a stale policy. That is exactly how fail-open
# survived in both while a grep for "checksum mismatch" passed against both.
FVLIB="$(mktemp)"; FVTPL="$(mktemp)"
awk '/^fetch_and_verify_sha256\(\) \{/,/^\}/' "$FR" > "$FVLIB"
awk '/^fetch_and_verify_sha256\(\) \{/,/^\}/' "$CIBOX" > "$FVTPL"
check "checksum policy: extracted from the command (guards the awk)" 0 "sha256sum" cat "$FVLIB"
check "checksum policy: extracted from the template (guards the awk)" 0 "sha256sum" cat "$FVTPL"
check "checksum policy: the two copies are byte-identical" 0 "" diff "$FVLIB" "$FVTPL"
rm -f "$FVLIB" "$FVTPL"
# ...and neither may drift back to warn-and-continue.
check "forgejo-runner: install has no warn-and-continue checksum branch" 1 "" \
grep -q "WITHOUT checksum verification" "$FR"
check "ci-box: no warn-and-continue checksum branch either" 1 "" \
grep -q "WITHOUT checksum verification" "$CIBOX"
check "forgejo-runner: install routes through the shared checksum policy" 0 "fetch_and_verify_sha256" \
grep -o "fetch_and_verify_sha256 \"\$URL\"" "$FR"
# --- GitHub's token adjective must not cross the forge boundary -------------
# Measured in Forgejo's own source, not inferred: ActionRunnerToken carries NO
# expiry field; NewRunnerToken invalidates prior tokens only when a replacement
# is minted at the same scope; Register leaves the token it was handed active.
# Reusable until replaced — where GitHub's expires in about an hour.
#
# This is pinned rather than merely fixed because creds.md is spliced into the
# ci-box's own CONTEXT.md: it is the text an AGENT INSIDE THE BOX reads about
# its own credentials. "short-lived" there makes a leaked token look
# self-expiring while it is still registering runners. The wording arrived by
# being copied from the GitHub sibling, so the same copy can bring it back.
CICREDS="$ROOT/docs/templates/ci-box/creds.md"
check "ci-box: creds.md never calls the registration token short-lived" 1 "" \
grep -qi "short-lived" "$CICREDS"
# Plain absence, not a phrase match: the wording this replaced wrapped across
# two comment lines, so a phrase pin would have passed against the very text it
# was written to catch. The file explains the ban without spelling the word.
check "ci-box: the install header does not call it short-lived either" 1 "" \
grep -qi "short-lived" "$CIBOX"
# ...and says the true thing, so the pin cannot be satisfied by deleting the
# claim rather than correcting it.
check "ci-box: creds.md states the token does not expire" 0 "not expire" \
grep -o "not expire" "$CICREDS"
check "ci-box: creds.md states a leaked token stays live" 0 "leaked" \
grep -o "leaked" "$CICREDS"
# --- --version must converge, not be swallowed (review !110) ----------------
# forgejo-runner does NOT self-update, and a ci-box's template preinstalls the
# binary at mint — so a bare presence check would make --version dead on the
# exact path this command exists for.
FRLIB="$ROOT/commands/lib/forgejo-runner-config.sh"
# Driven under the CALLER'S shell options, and that is half the test. Every
# forgejo-runner-* command is `set -euo pipefail`; a reader that only returns
# empty in a permissive shell has not kept its contract where it is used.
vparse() { bash -c "set -euo pipefail; . '$FRLIB'; runner_version_of \"\$1\"" _ "$1"; }
VSTUB="$(mktemp -d)"
printf '#!/bin/sh\necho "forgejo-runner version v12.13.2"\n' > "$VSTUB/fr"; chmod +x "$VSTUB/fr"
printf '#!/bin/sh\necho "garbage"\n' > "$VSTUB/bad"; chmod +x "$VSTUB/bad"
# `[ -x ]` yes, runnable no: a truncated or wrong-arch download, which is what a
# ci-box template preinstall leaves behind when it half-lands. Distinct from
# `bad` — that one EXITS 0 and merely says nothing parseable, so it never
# exercised the pipeline-failure path at all.
printf '#!/bin/sh\nexit 1\n' > "$VSTUB/dead"; chmod +x "$VSTUB/dead"
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"
check "forgejo-runner: a binary that cannot RUN yields empty, not a silent set -e death" 0 "" \
vparse "$VSTUB/dead"
rm -rf "$VSTUB"
# The install site's own read of --version needs the same guard, or its refusal
# is unreachable: the assignment dies before the test below it can fire. Pinned
# by grep because reaching that line for real needs root and a downloaded binary.
check "forgejo-runner: the --version read cannot die ahead of its own refusal" 0 "|| true" \
grep -o 'head -n1 || true' "$FR"
# 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
# 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 <owner/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 ]