rig/docs/plans/2026-07-27-forgejo-ci-box.md
cluade-reviewer-andresmgsl 1933b07fd4
Some checks failed
ci / check (pull_request) Has been cancelled
ci / install (pull_request) Has been cancelled
ci / db-integration (pull_request) Has been cancelled
labels / labels (pull_request) Has been cancelled
fix: honour --version, scope .rig-labels, close the checksum gate
Three defects from review !110, all the same family — a stated contract the
code did not keep.

--version was swallowed on the path this command exists for. The download
block skipped on mere presence, copying runner-install.sh's shape without its
justification: actions/runner SELF-UPDATES, so freezing it would only make
GitHub refuse its jobs. forgejo-runner does not self-update, so nothing else
ever moves the version — and a ci-box's template preinstalls the binary at
mint, which meant the documented deterministic-pin lever could never fire on a
ci-box. It now converges toward the pin, downward included, because a pin is
an instruction and not a floor; absent a pin an existing binary is left alone,
since chasing latest would make a re-run an unrequested upgrade. The decision
moved to runner_download_decision in the lib as a pure function: the first
attempt at a test here grepped for a log string and survived the logic being
disabled, which is exactly the weak test the review warned about. The binary
is now renamed into place rather than written over — the converge path runs
while the daemon is live, and in-place is ETXTBSY.

.rig-labels outlived the registration it described. The write had escaped the
registration branch, where runner-install.sh correctly keeps its copy, so a
plain re-run stamped this invocation's labels over a registration made with
different ones and status then reported confidently wrong labels while Forgejo
still held the originals. Scoped again, and an EXPLICIT --labels on a re-run
now warns that Forgejo owns labels from registration time rather than letting
the request evaporate silently.

The checksum gate failed open. A missing .sha256 warned and installed anyway,
contradicting both the README and the template's own comment about unverified
root downloads. The original reasoning — do not let an upstream layout change
break installs — reasons about the wrong failure: a layout change breaks the
BINARY url too, so "binary yes, checksum no" is the shape of an interfered
fetch, which is precisely what the checksum exists to catch. Both paths refuse
now, with no bypass flag; if upstream really moves its assets that is a rig PR
editing the URL, not an operator improvising past a security gate.

Tests: the checksum paths are now DRIVEN against a stub curl through the real
template install.sh — matching, missing, mismatched and empty — instead of
grepped, and all three fixes were mutation-checked by reverting each and
confirming the suite goes red.

739/31/43 pass, shellcheck clean.

forgejo#109

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 21:08:15 +00:00

14 KiB

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:

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 exposes

templates_resolve documents a hard contract:

the fetch is unauthenticated by contract (box auto-runs bootstrap at mint, holding nothing)

Measured: on forgejo.heavyduty.builders, anonymous requests for heavy-duty/rig — reported by the API as private: false — return 404 for the API, the web page, the git remote and the archive endpoint. Only an authenticated request succeeds. The instance requires sign-in to view.

A mint holds no credentials, so a Forgejo-hosted registry is unreachable at mint time until the instance serves public repos anonymously:

FORGEJO__service__REQUIRE_SIGNIN_VIEW=false

This is a Coolify env-var change on the Forgejo service, next to the FORGEJO__actions__ENABLED=true that Actions already needs. It is recorded here and in the README as a prerequisite. Nothing in this change silently assumes it: with the gate up, the fetch fails the way any unreachable ref fails, listing every URL tried.

2. The ci-box tenant definition

Data, not mechanism — it belongs in the registry repo. It is staged in this PR under docs/templates/ci-box/ so it can be reviewed and linted here, and moves to the registry verbatim once that repo exists on Forgejo.

USER="ci"
CONTEXT_PATH=".ci/CONTEXT.md"
CLI_NAME="forgejo-runner"
CLI_SRC="/usr/local/bin/forgejo-runner"
PATH_LINE="export PATH="$HOME/.local/bin:$PATH""
NEEDS_NODE="no"

CLI_SRC is absolute rather than ~/-relative, which is the one place this definition departs from the agent tenants. Their CLI lives in the tenant's home; this binary is executed by a systemd unit, and a tenant-writable binary that a root-installed unit runs is a trivial path to root inside the box. So it lands root-owned under /usr/local/bin. test/cli.sh pins CLI_SRC against the path install.sh actually writes — a drifted pair converges to a CLI that exists and cannot run, which is the scar grok-box left.

NEEDS_NODE="no" because the runner is a static Go binary. Jobs get their Node from the container image, which is the whole point of the label mapping below — installing a second Node on the host would be a toolchain nobody reads.

install.sh fetches the release binary for the architecture and verifies the published .sha256 before installing it. Forgejo ships bare binaries with .sha256 and .asc beside them rather than a tarball, so a checksum is available for free; taking it makes the install auditable in the way coolify install's version pin is.

This satisfies the tenant schema honestly rather than by paperwork: bootstrap-tenant.sh asserts <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.

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.