diff --git a/.github/labeler.yml b/.github/labeler.yml index 2328070..acb71bc 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -11,7 +11,9 @@ "scope:runner": - changed-files: - any-glob-to-any-file: - ["commands/runner-*.sh", "commands/lib/runner-config.sh"] + ["commands/runner-*.sh", "commands/lib/runner-config.sh", + "commands/forgejo-runner-*.sh", "commands/lib/forgejo-runner-config.sh", + "docs/templates/ci-box/**"] "scope:coolify": - changed-files: - any-glob-to-any-file: ["commands/coolify-*.sh"] diff --git a/.github/labels.conf b/.github/labels.conf index 94167b5..ef6116e 100644 --- a/.github/labels.conf +++ b/.github/labels.conf @@ -2,7 +2,7 @@ panel=claude-bot-andresmgsl codex-bot-andresmgsl grok-bot-andresmgsl kimi-bot-an triage-actors=dan-claude-bot scope:bootstrap|C5DEF5|bootstrap — hardening a pristine server into a node scope:users|C5DEF5|users-* — class model, apply/status, close-root -scope:runner|C5DEF5|runner-* — GitHub runner lifecycle +scope:runner|C5DEF5|runner-* / forgejo-runner-* — CI runner lifecycle, either forge scope:coolify|C5DEF5|coolify-* — Coolify and backup install scope:db|C5DEF5|db.sh — dump/restore scope:installer|C5DEF5|install.sh — how rig lands on a machine diff --git a/README.md b/README.md index 09e06ef..69d5bfa 100644 --- a/README.md +++ b/README.md @@ -470,6 +470,7 @@ actually contains. `staging-box` is the one in-tree tenant — mechanism-adjacen |------|---------| | `RIG_TEMPLATES_DIR` | a local folder — no fetch: the offline-test path, and "try a template before it exists anywhere" | | `RIG_TEMPLATES_REF` | any ref of `RIG_TEMPLATES_REPO` (default `heavy-duty/rig-templates`), fetched as an unauthenticated tarball at bootstrap time | +| `RIG_TEMPLATES_HOST` | which **forge** that repo lives on (default `https://github.com`) — not a mirror knob but a URL-grammar one: GitHub serves three candidate archive paths (`refs/tags`, `refs/heads`, bare) and Forgejo serves exactly one (`/archive/.tar.gz`), so the host decides what is even worth requesting. `install.sh` reads the same variable for its snapshot, so the two cannot disagree about where the registry is | | *(neither set; matching snapshot installed)* | **the installed pin snapshot** — `install.sh` best-effort fetches `RIG_TEMPLATES_PIN` once into `templates@/` inside the versioned rig tree; default converges read it with zero registry network I/O | | *(snapshot absent, empty, or stale)* | **live fetch of the in-tree pin** — the pre-snapshot fallback: `RIG_TEMPLATES_PIN` in `commands/lib/templates.sh` is fetched at converge time. A failed snapshot download only warns during install, so rig remains usable and retries here | @@ -479,6 +480,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 ` + +The other forge's runner, and a different shape of box. Where `rig runner` +converges a fleet **machine** (`runner-server`), this converges a **ci-box +tenant** — a box guest whose whole job is running CI: + +```sh +box mint ci-box # box auto-runs: rig bootstrap ci-box +box shell ci-box +sudo rig forgejo-runner install --instance https://forgejo.heavyduty.builders +``` + +Installs `forgejo-runner` as a systemd service under an unprivileged user +(default: the tenant user `ci`). Like its GitHub sibling the runner is an +agent, not a server — it long-polls the instance outbound and needs **zero +inbound ports**. + +**Jobs run in containers, and there is no docker-in-docker.** +`rig bootstrap ci-box` already installed Docker and put the tenant user in the +`docker` group, so the runner drives that daemon directly. The usual dind +sidecar — privileged, with a plaintext `tcp://…:2375` socket — exists to +isolate jobs from a *shared* CI server; inside a box that boundary is already +paid for. The box is network-isolated, has no inbound path, and is thrown away. + +That is also why this command allows what `rig runner install` refuses. Docker +group membership is root-equivalent, and on a fleet machine the blast radius is +the machine. Here it is a disposable guest. Same trade, different box, opposite +answer — which is why these are two commands and not one with a `--forge` flag. + +- `--version ` — `forgejo-runner` release (default: latest at install + time). **Convergent, and it replaces an existing binary** — including + downward, because a pin is an instruction rather than a floor. Without a + pin, a binary already on the box is left alone: chasing "latest" on every + converge would make a plain re-run an unrequested upgrade. + The published `.sha256` is **verified before the binary is installed**, and + a missing or unreadable checksum **refuses** just as a mismatch does — this + binary runs as root under a systemd unit, so the gate does not fail open. + + > Unlike `rig runner install`, presence alone is not enough to skip the + > download here. That command can skip because `actions/runner` + > **self-updates**; `forgejo-runner` does not, so nothing else would ever + > move the version — and a ci-box's template preinstalls the binary at mint, + > which would leave `--version` doing nothing on the exact path this command + > is for. +- `--name ` — runner name (default: this host's hostname) +- `--labels ` — replaces the default map: + `ubuntu-latest:docker://ghcr.io/catthehacker/ubuntu:act-22.04,docker:docker://node:22-bookworm`, + so `runs-on: ubuntu-latest` works in a workflow written for GitHub. + **Applied at registration only.** Forgejo owns a runner's labels from the + moment it registers, so passing `--labels` to a re-run cannot change them — + rig says so rather than letting the request evaporate, and changing labels + means `remove` then `install` again +- `--user ` — service user (default: `ci` when it exists, else + `forgejo-runner`) + +**There is no `--repo`, and that is the substantive difference.** A Forgejo +runner registers to an *instance*; whether it then serves that whole instance, +one organisation, or one repository is a property of the **registration +token**, which you mint in Forgejo's UI at the scope you want: + +| scope | where the token comes from | +|---|---| +| instance | Site Administration → Actions → Runners | +| organisation | Org → Settings → Actions → Runners | +| repository | Repo → Settings → Actions → Runners | + +Pass it via `FORGEJO_RUNNER_TOKEN` or the interactive prompt. It is consumed at +registration and never written to disk by rig. + +Convergent **toward `--instance`** — re-running against the instance the box is +already on re-uses the binary and skips registration. Pointed at a *different* +instance it refuses and names both, for the same reason `rig runner install` +refuses a different repo. + +> **`.runner` holds a credential here.** GitHub's names a repository; Forgejo's +> holds the runner's own long-lived token. rig installs it `0600` owned by the +> runner user and **re-asserts that mode on every converge** — a mode that +> drifted leaks the secret silently, since nothing fails and the runner keeps +> working. `status` warns when it finds one that has. + +### `rig forgejo-runner status` + +Which instance this box's runner is registered to — instance, name, labels, +directory, unit and state. Reads the box only: no token, no network call, and +it never prints the registration secret `.runner` holds. Exits 1 when no runner +is installed. + +### `rig forgejo-runner remove` + +Stops and disables the service and wipes the local registration. The binary and +the user stay, so a later `install` re-registers without downloading anything. + +**Always local-only, and there is no `--local` flag.** Forgejo has no runner +deregistration endpoint — no removal token, nothing to hand back — so the box +is cleaned and the runner stays listed as offline until you delete it under +Actions → Runners. Offering the flag would advertise a server-side alternative +that does not exist. For the same reason there is no `forgejo-runner repoint`: +a move cannot be one atomic act, so it is `remove` then `install`. + +### Enabling Actions on the Forgejo side + +Two environment variables on the Forgejo service (Coolify → Environment +Variables → redeploy). Forgejo maps `FORGEJO__
__` onto its +config, so this survives image upgrades in a way an edited `app.ini` does not: + +``` +FORGEJO__actions__ENABLED=true +FORGEJO__actions__DEFAULT_ACTIONS_URL=https://code.forgejo.org +``` + +> **`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 -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 ` Converges named operator accounts from a declarative users file — on **every** diff --git a/bin/rig b/bin/rig index 4ec2cb3..1f9b7ee 100755 --- a/bin/rig +++ b/bin/rig @@ -87,6 +87,23 @@ commands: re-register, reusing the binary already on the box. Needs a removal token for the old repo and a registration token for the new one. Run as root. + forgejo-runner install --instance [options] + Forgejo Actions runner as a systemd service under an unprivileged + user — outbound-only, jobs in containers on this box's own dockerd. + Its home is a ci-box tenant, where 'rig bootstrap ci-box' already + installed that daemon. Registers to an INSTANCE: whether the runner + serves that instance, one org or one repo is a property of the + registration token, so there is no --repo. Prompts for the token + (FORGEJO_RUNNER_TOKEN env overrides). Run as root. + forgejo-runner status [--user ] + Which instance this box's Forgejo runner is registered to: instance, + name, labels, unit. Reads the box only — no token, no network call, + and never prints the registration secret .runner holds. Run as root. + forgejo-runner remove [--user ] + Take the service down and wipe this box's registration. Always + local-only: Forgejo has no deregistration endpoint, so the runner + stays listed offline until you delete it in the instance. Needs no + token. Run as root. users apply --file Converge named operator accounts from a declarative users file, on every class: groups by role (admin/rig/box), passwords locked always, @@ -443,6 +460,37 @@ case "$cmd" in ;; esac ;; + forgejo-runner) + shift + sub="${1:-}" + case "$sub" in + install) + shift + exec "$ROOT/commands/forgejo-runner-install.sh" "$@" + ;; + status) + shift + exec "$ROOT/commands/forgejo-runner-status.sh" "$@" + ;; + remove) + shift + exec "$ROOT/commands/forgejo-runner-remove.sh" "$@" + ;; + repoint) + # The GitHub sibling HAS this verb, so an operator will try it. Say why + # it cannot exist here rather than printing usage and leaving them to + # infer it: 'repoint' is atomic because GitHub lets rig deregister from + # the old repo; Forgejo has no such endpoint, so the honest shape is + # two acts, and one of them leaves a stale entry behind. + printf 'rig: forgejo-runner has no repoint: Forgejo has no deregistration endpoint, so a move cannot be one atomic act. Use "rig forgejo-runner remove" then "rig forgejo-runner install --instance ", and delete the stale runner in the old instance under Actions > Runners.\n' >&2 + exit 2 + ;; + *) + usage >&2 + exit 2 + ;; + esac + ;; users) shift sub="${1:-}" diff --git a/changelog.d/109.md b/changelog.d/109.md new file mode 100644 index 0000000..f49bbb1 --- /dev/null +++ b/changelog.d/109.md @@ -0,0 +1,4 @@ +### Added + +- `rig forgejo-runner install|status|remove` registers a Forgejo Actions runner against an instance, jobs in containers on the box's own dockerd (#109) +- The template registry can be served from any forge — `RIG_TEMPLATES_HOST` (#109) diff --git a/commands/bootstrap-undo.sh b/commands/bootstrap-undo.sh index 71e7339..1af226d 100755 --- a/commands/bootstrap-undo.sh +++ b/commands/bootstrap-undo.sh @@ -24,6 +24,28 @@ if [ "$runner_installed" -eq 1 ]; then die "a GitHub runner is installed — run 'rig runner remove' first so undo does not leave a ghost runner in the repository" fi +# The same hazard, the other forge (#109): leaving the tailnet under a live +# Forgejo runner strands a registration this box can no longer serve, and +# Forgejo has no deregistration endpoint — so the ghost it leaves is one +# somebody has to delete BY HAND in the instance's admin UI. That makes the +# refusal more load-bearing here than for GitHub, not less. +# +# RIG_FORGEJO_RUNNER_DIR mirrors RIG_RUNNER_DIR above so tests can point this +# at a fixture. The glob covers the tenant default (`ci`) and the dedicated +# account alike, because both are reachable defaults of `install --user`. +forgejo_runner_installed=0 +if [ -n "${RIG_FORGEJO_RUNNER_DIR:-}" ]; then + [ -e "$RIG_FORGEJO_RUNNER_DIR/.runner" ] && forgejo_runner_installed=1 +else + for runner_config in /home/*/forgejo-runner/.runner /root/forgejo-runner/.runner; do + [ -e "$runner_config" ] && forgejo_runner_installed=1 + done + [ -e /etc/systemd/system/forgejo-runner.service ] && forgejo_runner_installed=1 +fi +if [ "$forgejo_runner_installed" -eq 1 ]; then + die "a Forgejo runner is installed — run 'rig forgejo-runner remove' first so undo does not leave a ghost runner in the instance" +fi + join_by="" while IFS= read -r field; do case "$field" in diff --git a/commands/forgejo-runner-install.sh b/commands/forgejo-runner-install.sh new file mode 100755 index 0000000..224e406 --- /dev/null +++ b/commands/forgejo-runner-install.sh @@ -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