Compare commits

..

68 commits
0.3.1 ... main

Author SHA1 Message Date
cb4de44bf4 Merge pull request 'fix: refuse a PATH without /usr/sbin, before the token prompt' (#140) from build/139-sbin-path-preflight into main
All checks were successful
ci / check (push) Successful in 56s
ci / install (push) Successful in 3s
ci / db-integration (push) Successful in 3s
release / release (push) Successful in 6s
Reviewed-on: #140
Reviewed-by: grok-reviewer-andresmgsl <andres+3@heavyduty.builders>
Reviewed-by: kimi-reviewer-andresmgsl <andres+4@heavyduty.builders>
Reviewed-by: codex-reviewer-andresmgsl <andres+2@heavyduty.builders>
2026-08-02 07:57:52 +00:00
7aed6ea098 fix: preflight every admin binary a command uses, not only useradd
Some checks failed
ci / check (pull_request) Successful in 55s
ci / install (pull_request) Successful in 3s
ci / db-integration (pull_request) Successful in 3s
labels / labels (pull_request) Failing after 7s
Addresses codex (1538) and kimi (1539): the sweep caught the reported
incident and not the class. Both are right, and there were four sites, not
three.

  forgejo-runner-install  useradd -> useradd usermod   (usermod -aG docker,
                          reached only after the token has been spent)
  users-apply             useradd usermod -> + groupadd (called two lines
                          into convergence), and visudo when a role needs it
  bootstrap-tenant        NEW site (kimi) — usermod -aG docker runs AFTER
                          docker and node are installed, so an unguarded
                          PATH fails it mid-convergence on a changed machine
  runner-install          unchanged: useradd is the only admin binary it
                          calls, and declaring more would refuse boxes that
                          are fine

visudo is checked after the sudo-install block rather than beside the root
check, because until sudo is installed its absence has an innocent cause.
Below that block it does not: sudo is present, so a missing visudo means
/usr/sbin is off PATH. That case is the quiet one — the sudoers block reads
`command -v visudo` as "no sudo on the box means no role needed it", so
apply reported success having granted roles without the escalation those
roles exist for. The other three sites at least crash.

Measured which binaries this covers (Debian 13): useradd, usermod, groupadd,
userdel, groupdel and visudo are /usr/sbin; gpasswd is /usr/bin and so is
NOT affected and deliberately not preflighted. visudo shares the directory
but ships in `sudo`, not `passwd` — which is why it needs its own treatment.

Tests: the sbin-less fixtures could only ever prove the FIRST binary is
named, since useradd wins every race. Six new checks use partial PATHs that
resolve the earlier binaries and withhold exactly one, plus the ordering
assertions (no token prompt, no group created) and the negative case — a
users file needing no sudo must NOT be refused for a missing visudo.

Refs #139
2026-08-02 00:05:02 +00:00
5187b74fa0 Merge remote-tracking branch 'origin/main' into pr140 2026-08-01 23:58:21 +00:00
0d36b4dc95 Merge pull request 'fix: slim ubuntu-latest default; install shellcheck in ci.yml (#144)' (#146) from build/144-default-labels-option-b into main
All checks were successful
ci / check (push) Successful in 55s
ci / install (push) Successful in 3s
ci / db-integration (push) Successful in 3s
release / release (push) Successful in 6s
Reviewed-on: #146
Reviewed-by: codex-reviewer-andresmgsl <andres+2@heavyduty.builders>
Reviewed-by: cluade-reviewer-andresmgsl <andres+1@heavyduty.builders>
Reviewed-by: kimi-reviewer-andresmgsl <andres+4@heavyduty.builders>
2026-08-01 23:23:41 +00:00
6f92b9eaa6 test: drive the retired-default label recogniser (#144)
Some checks failed
ci / check (pull_request) Successful in 55s
ci / install (pull_request) Successful in 3s
ci / db-integration (pull_request) Successful in 3s
labels / labels (pull_request) Failing after 7s
Extract labels_are_a_retired_default and assert the four cases a grep pin
cannot: pre-#144 matches, current default does not, custom --labels does
not, near-miss does not. Addresses the remaining REQUEST_CHANGES on !146.

Refs #144
2026-08-01 21:38:26 +00:00
f0f17ad2ff fix: warn only on retired default labels, not custom maps (#144)
Some checks failed
ci / check (pull_request) Successful in 54s
ci / install (pull_request) Successful in 3s
ci / db-integration (pull_request) Successful in 3s
labels / labels (pull_request) Failing after 6s
Plain converge comparing RECORDED to the current default nags every runner
registered with intentional --labels (including drill Leg 3). Match known
past DEFAULT_LABELS strings instead — same intent, no noise. Changelog
split per review.

Refs #144
2026-08-01 21:33:11 +00:00
23965bebca test: forgejo-runner version pins work when CI runs as root (#144)
Some checks failed
ci / check (pull_request) Successful in 55s
ci / install (pull_request) Successful in 3s
ci / db-integration (pull_request) Successful in 3s
labels / labels (pull_request) Failing after 6s
act-22.04 jobs are uid 0, so "must run as root" is never the next gate after
--version validation. Accept the unattended-token refuse when already root;
keep the non-root arm for GitHub-hosted runners.

Refs #144
2026-08-01 21:18:33 +00:00
ad3133d1c0 fix: slim ubuntu-latest default; install shellcheck in ci.yml (#144)
Some checks failed
ci / check (pull_request) Failing after 52s
ci / install (pull_request) Successful in 3s
ci / db-integration (pull_request) Successful in 3s
labels / labels (pull_request) Failing after 6s
Option B (andres ruling): keep act-22.04 for ubuntu-latest so box-class
ci tenants can hold the image; workflows supply tools the slim image
lacks. Opt-in ubuntu-latest-full for operators who need GH parity.
Plain converge warns when recorded labels lag the current default map.

Refs #144
2026-08-01 21:13:46 +00:00
7f2501d0fe fix: refuse a PATH without /usr/sbin, before the token prompt
Some checks failed
ci / check (pull_request) Failing after 7s
ci / install (pull_request) Successful in 4s
ci / db-integration (pull_request) Successful in 4s
labels / labels (pull_request) Failing after 7s
Reported from a real ci-box: `rig forgejo-runner install` read a registration
token off the operator's terminal and then died with

  …/forgejo-runner-install.sh: line 250: useradd: command not found

rig checked `id -u` and concluded it could administer the machine. Being root
and being able to FIND the admin binaries are different facts, and only the
first was asserted. `su` without `-`, sudo with a sanitised secure_path, and
several container images all produce a root shell with no /usr/sbin on PATH,
which is where useradd lives.

Three call sites had it: both runner installers and users apply. The last is
the worst — it runs mid-convergence, so a PATH-shorn root could fail partway
through a user sweep rather than before it starts.

require_admin_bins refuses rather than repairing PATH itself: a command that
quietly prepends /usr/sbin teaches the operator nothing and leaves a
misconfigured host misconfigured. The message names the remedy and,
deliberately, not this script — echoing an internal path back at someone who
typed `rig forgejo-runner install` is the unhelpful half of the original error.

It sits beside each root check, so identity and capability are asserted
together and before anything is spent. A secret typed for a run that could
never succeed is the avoidable half of this bug, and there is a test for
exactly that ordering.

Closes #139

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 18:39:03 +00:00
9cb81c9f6b Merge pull request 'test: cli.sh reads fixtures, not the host it happens to run on' (#137) from build/136-cli-hermetic into main
Some checks failed
ci / check (push) Failing after 7s
ci / install (push) Successful in 4s
ci / db-integration (push) Successful in 4s
release / release (push) Successful in 7s
Reviewed-on: #137
Reviewed-by: grok-reviewer-andresmgsl <andres+3@heavyduty.builders>
Reviewed-by: kimi-reviewer-andresmgsl <andres+4@heavyduty.builders>
Reviewed-by: codex-reviewer-andresmgsl <andres+2@heavyduty.builders>
2026-07-31 21:24:04 +00:00
0524f288c3 Merge pull request 'docs: README install quick start names the Forgejo channel (RIG_HOST)' (#132) from build/131-readme-forgejo-install into main
Some checks are pending
ci / check (push) Waiting to run
ci / install (push) Waiting to run
ci / db-integration (push) Waiting to run
release / release (push) Waiting to run
Reviewed-on: #132
Reviewed-by: grok-reviewer-andresmgsl <andres+3@heavyduty.builders>
Reviewed-by: codex-reviewer-andresmgsl <andres+2@heavyduty.builders>
Reviewed-by: cluade-reviewer-andresmgsl <andres+1@heavyduty.builders>
2026-07-31 21:17:58 +00:00
86b749876a Merge pull request 'feat(drill): a forgejo-runner lifecycle leg beside the GitHub one' (#130) from build/129-forgejo-runner-drill-leg into main
Some checks are pending
ci / check (push) Waiting to run
ci / install (push) Waiting to run
ci / db-integration (push) Waiting to run
release / release (push) Waiting to run
Reviewed-on: #130
2026-07-31 20:56:18 +00:00
306844daa8 Merge pull request 'fix(forgejo-runner): 'active' is not proof the runner is fetching' (#134) from build/133-status-active-is-not-health into main
Some checks are pending
ci / check (push) Waiting to run
ci / install (push) Waiting to run
ci / db-integration (push) Waiting to run
release / release (push) Waiting to run
Reviewed-on: #134
Reviewed-by: kimi-reviewer-andresmgsl <andres+4@heavyduty.builders>
Reviewed-by: grok-reviewer-andresmgsl <andres+3@heavyduty.builders>
2026-07-31 20:55:18 +00:00
ce43c021a5 Merge pull request 'fix(forgejo-runner): the cache server can start' (#138) from build/135-runner-cache-dir into main
Some checks are pending
ci / check (push) Waiting to run
ci / install (push) Waiting to run
ci / db-integration (push) Waiting to run
release / release (push) Waiting to run
Reviewed-on: #138
Reviewed-by: kimi-reviewer-andresmgsl <andres+4@heavyduty.builders>
Reviewed-by: grok-reviewer-andresmgsl <andres+3@heavyduty.builders>
Reviewed-by: codex-reviewer-andresmgsl <andres+2@heavyduty.builders>
2026-07-31 20:35:33 +00:00
7385707770 test: the third hermetic pin can actually fail
Some checks failed
ci / check (pull_request) Failing after 7s
ci / install (pull_request) Successful in 4s
ci / db-integration (pull_request) Successful in 4s
labels / labels (pull_request) Failing after 7s
kimi was right that inline_undo_is_sealed matched itself: it grepped the whole
file for a string that appears inside its own body, so it succeeded on every
tree forever. Their suggested anchoring had the identical flaw for the identical
reason — any literal this function searches for necessarily appears in this
function — which I only found by running their probe and watching the pin stay
green while the behavioural test went red.

So it locates the real check by line number and reads only that block. head -1
takes the site at :202, never this body at :3311.

Driven, since a pin nobody has watched fail is not a pin: deleting the :205
wiring now fails exactly this check, and deleting all three seals fails all
three. That is the third vacuous guard I have written on this branch family;
the comment says why the obvious shapes do not work, so the next person does
not rediscover it a fourth time.

Refs #136

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 20:32:08 +00:00
d46697034d test: pin the hermeticity, not just achieve it
Some checks failed
ci / check (pull_request) Failing after 7s
ci / install (pull_request) Successful in 4s
ci / db-integration (pull_request) Successful in 3s
labels / labels (pull_request) Failing after 8s
codex and kimi both drove the same experiment: delete the two wiring lines this
branch adds and the suite stays 786/786 on any host without a real Forgejo
runner. Hermetic today, unpinned tomorrow — and #136's task list names the
guard verbatim: "a check that fails if either group can see host state".

Three checks assert the suite's own helpers keep their seals: undo() passes
RIG_FORGEJO_RUNNER_DIR, cibox_run() passes CIBOX_BIN, and the hand-rolled undo
invocation behind "failed logout is loud" passes it too — that third one being
the site I missed first time round, which is exactly why it earns a check
rather than a comment.

They assert on the test side deliberately: the production knobs are already
covered, and the regression worth catching is a deletion in the suite.

Verified by re-running codex's experiment: with both wiring lines gone, the two
seal checks fail where previously nothing did.

Refs #136

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 20:15:35 +00:00
848e3f42ef test(forgejo-runner): drive the liveness note's state boundary
Some checks failed
ci / check (pull_request) Failing after 7s
ci / install (pull_request) Successful in 4s
ci / db-integration (pull_request) Successful in 4s
labels / labels (pull_request) Failing after 7s
codex and kimi are right: the four checks proved the LINES EXIST and nothing
proved they fire only when the unit is active. kimi deleted the state guard and
the suite stayed 790/790 green, so the boundary #133 cares about most — no
misleading liveness note on an absent or inactive unit — was unprotected. #133's
own test plan says driven, not grepped where behaviour can be executed, and this
was the line it crossed.

The note is now a function, which is what makes the boundary executable, and
the suite drives it on active (note present, remedy named), inactive (empty)
and unset (empty). Removing the guard now fails two checks instead of none.

The no-warn and no-network guards stay greps: those properties are source-level
by nature, as kimi noted.

Two things I got wrong on the way, both caught by running it rather than
reading it: the function was defined below its call site (shellcheck SC2218),
and the block reused $WORK, which is rm -rf'd at :3206 long before it — so it
now takes its own scratch dir like the file's other fixtures.

Refs #133

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 20:13:23 +00:00
9afcdf858f fix(drill): the leg row is the whole lifecycle, not just the job
Some checks failed
ci / check (pull_request) Failing after 7s
ci / install (pull_request) Successful in 4s
ci / db-integration (pull_request) Successful in 4s
labels / labels (pull_request) Failing after 7s
codex, grok and kimi all landed on the same hole at e42ba31, and they are
right. The record row keyed only on fj_took, so install/status/remove failures
called `no` without gating it. A dispatched job only needs SOMETHING answering
runs-on: drill — and this leg removes locally, telling the operator to delete
the stale runner by hand, so a leftover drill-labeled runner from the previous
drill is the designed-for aftermath, not a contrived case. It answers the
fixture exactly, and the row then read "PASS — registered, took a job,
removed" for a drill whose install never happened.

The exit code was never wrong: every one of those failures also called `no`.
What was wrong is the row, and the row is what outlives the run in
drills/<v>.md. A row claiming a lifecycle that did not happen is exactly what
the gate exists to refuse — kimi's framing, and it is the right one.

forgejo_leg_row folds all five assertions, and the leg no longer dispatches
after install or status has failed: a foreign runner's success could only ever
be evidence about someone else's runner. The regression composes the real
functions over a payload carrying a newer successful run, the way the leg
composes them.

The GitHub leg has the identical shape and is byte-frozen by #129's contract;
left alone deliberately, and worth a sibling issue for triage.

Refs #129

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 20:04:16 +00:00
a45d84892f fix(forgejo-runner): the cache server can start
Some checks failed
ci / check (pull_request) Failing after 7s
ci / install (pull_request) Successful in 4s
ci / db-integration (pull_request) Successful in 4s
labels / labels (pull_request) Failing after 7s
ProtectHome=read-only made the whole home read-only and only RUNNER_DIR was
punched back through, so forgejo-runner could not create $HOME/.cache and
disabled its cache server on every install. actions/cache was silently off on
every rig-installed Forgejo runner, evidenced by one error line in the journal
while `status` reported a healthy runner.

Both halves are required, and the obvious one-line version is WORSE than the
bug: a ReadWritePaths entry naming a path that does not exist makes systemd
refuse to start the unit at all — "Failed to set up mount namespacing", three
restart attempts, service down. Measured on a live runner before writing this.

So the directory is created at install, owned by the runner user like
RUNNER_DIR beside it, and the unit lists it. ProtectHome stays read-only: the
runner supervises job containers on this box's docker socket, and the cache is
not a reason to widen that.

Verified live from scratch: directory removed, unit removed, converge, then
zero cache-server errors and the two cache listeners bound.

Closes #135

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 16:59:03 +00:00
fd5c04efc9 test: cli.sh reads fixtures, not the host it happens to run on
Some checks failed
ci / check (pull_request) Failing after 7s
ci / install (pull_request) Successful in 4s
ci / db-integration (pull_request) Successful in 4s
labels / labels (pull_request) Failing after 7s
13 checks failed on any box with a Forgejo runner installed — which is
exactly a box that has been drilled or used as a ci-box. Unmodified main,
773/13 on this machine.

Seven bootstrap --undo checks fell through to the real host scan. The
production code already ships the escape hatch and documents it —
bootstrap-undo.sh:33, "RIG_FORGEJO_RUNNER_DIR mirrors RIG_RUNNER_DIR above so
tests can point this at a fixture" — and the suite simply never set it. One
check rebuilt its env by hand instead of using undo(), so it needed the same
variable a second time.

Six ci-box checks drove the real template installer, which correctly exits 0
when /usr/local/bin/forgejo-runner already exists. CIBOX_BIN is a test-only
override in the same spirit; the production default is untouched and remains
the only path the mechanism uses.

Neither guard is disarmed: driven for real, undo still refuses while a runner
exists, and the template installer still does nothing when the binary is
present. Removing either override brings its failures straight back (7 and 6).

Closes #136

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 16:51:45 +00:00
e42ba31a09 fix(drill): the pre-dispatch baseline folds max too, and the suite is hermetic
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
Round 2 of !130. Both blockers were mine, and one was the same mistake I had
just fixed one layer up.

1. fj_pre still took `head -n1`. The verdict folds max over every id, but the
   PRE-DISPATCH snapshot did not, so an oldest-first payload named an old run
   as the baseline — and a later poll finding the same body then read the
   PREVIOUS drill's run as this dispatch's result. That is a false PASS on the
   take-a-job assertion, strictly worse than the false FAIL entry[0] caused
   inside the verdict. Both sides now share forgejo_max_task_id, and a test
   composes them the way the leg does so the pair cannot drift apart again.

2. test/drill.sh copied its pretty-printed fixture from /tmp/fjfix — a scratch
   path that existed only on the box the fix was built on. Everywhere else the
   cp failed, the guard returned pending, and the suite was 65/66. The claimed
   66/66 was true on one machine. The fixture is written inline like every
   other one; verified by deleting the scratch dir and running the suite from
   a clean tree under env -i.

Refs #129

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 00:29:06 +00:00
1daf7004d7 fix(forgejo-runner): 'active' is not proof the runner is fetching
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
status reports `systemctl is-active` as the service line, and that is the
strongest health signal it has — the command reads only on-disk config, by
contract. But a poller can go quiet while the process stays up: measured
2026-07-30 while probing for #129, a daemon logged "[poller] launched" and
never fetched a job dispatched four minutes later, while a daemon started
fresh claimed that same queued task in one second. Both times it read as a
label-mapping bug on the forge, which is the wrong place to look.

Says so where an operator already looks when nothing is obviously wrong, and
names the remedy. log, not warn: an idle-but-healthy runner is silent in
exactly the same way a stalled one is, so there is no signal separating them
and a warning on every run would be crying wolf.

No network call and no token read — the header contract, and test/cli.sh's
existing guard, both stand.

Refs #133

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 00:15:02 +00:00
9c0e508b76 fix(drill): read every task, and fail a configured leg that cannot mint
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
Addresses grok's and kimi's REQUEST_CHANGES on !130.

1. forgejo_run_verdict read only the FIRST entry of actions/tasks. That
   payload accumulates, so the moment a repo is drilled twice our run shares
   it with older ones — and nothing documents the sort order. A green job
   then reports as a timeout: a false FAILURE on the gate this leg exists to
   provide. It now inspects every entry and lets the newest id above pre_id
   decide. Newlines are stripped first, so a pretty-printed payload parses
   like a compact one.

2. A mint that yielded nothing degraded to SKIPPED "no registration token
   source" — violating #129's own acceptance ("token source present but the
   instance is unreachable -> the leg FAILS; it must not skip and must not
   pass") and sending the operator to check an env var they had already set.
   forgejo_token_verdict separates absent inputs from a configured leg that
   could not mint; only the former skips.

3. The pre---yes confirm block still announced a GitHub runner alone.

Refs #129

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 00:13:59 +00:00
4b9de4818f docs: Forgejo dev-tree line beside the release one — the family lives on main until 0.3.2 tags
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
2026-07-30 23:57:06 +00:00
47aaeda319 docs: README install quick start names the Forgejo channel (RIG_HOST)
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
2026-07-30 23:52:44 +00:00
1179d3142f feat(drill): a forgejo-runner lifecycle leg beside the GitHub one
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
#109 shipped `rig forgejo-runner` and the drill never touched it, so the
release gate could pass with the whole family broken. Its only coverage was
15 argument-handling checks in test/cli.sh.

The Forgejo half cannot be a copy of the GitHub one. Measured against
forgejo.heavyduty.builders (8.0.3+gitea-1.22.0), not read from the docs:
GET actions/tasks carries NO `conclusion` field — `status` holds the terminal
outcome — `id` is a global task id while the URL ends in run_number, and the
payload lists ASSIGNED tasks only, so a queued run is invisible (measured:
200s of total_count 0 while the web UI showed the run). That makes "no new
id" the only signal the runner never took the job, which is the verdict this
leg exists to produce.

forgejo_run_verdict() is the testable core of that, grep-and-sed rather than
jq for the same reason json_field() is. Tests were written first and cover
the cases that must NOT pass: a stale run with the pre-dispatch id, an
assigned-but-still-running task, and a cancelled run.

Refs #129

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:27:36 +00:00
4a6e9761e5 Merge pull request 'fix: align Ceremony actors with Forgejo' (#128) from codex-reviewer-andresmgsl/rig:build/116-forgejo-actors into main
Some checks are pending
ci / check (push) Waiting to run
ci / install (push) Waiting to run
ci / db-integration (push) Waiting to run
release / release (push) Waiting to run
Reviewed-on: #128
Reviewed-by: grok-reviewer-andresmgsl <andres+3@heavyduty.builders>
Reviewed-by: kimi-reviewer-andresmgsl <andres+4@heavyduty.builders>
Reviewed-by: cluade-reviewer-andresmgsl <andres+1@heavyduty.builders>
2026-07-30 22:57:19 +00:00
7aca642ba5 fix: preserve triage across both forges
Some checks failed
labels / labels (pull_request) Has been cancelled
ci / check (pull_request) Has been cancelled
ci / install (pull_request) Has been cancelled
ci / db-integration (pull_request) Has been cancelled
2026-07-30 18:31:16 +00:00
0362e5cf43 fix: align Ceremony actors with Forgejo
Some checks failed
labels / labels (pull_request) Has been cancelled
ci / check (pull_request) Has been cancelled
ci / install (pull_request) Has been cancelled
ci / db-integration (pull_request) Has been cancelled
2026-07-30 17:49:39 +00:00
12803b2b97 Merge pull request 'feat: install channel is forge-agnostic (RIG_HOST / BOX_HOST)' (#114) from build/111-install-channel-forge-agnostic into main
Some checks are pending
ci / check (push) Waiting to run
ci / install (push) Waiting to run
ci / db-integration (push) Waiting to run
release / release (push) Waiting to run
Reviewed-on: #114
Reviewed-by: kimi-reviewer-andresmgsl <andres+4@heavyduty.builders>
Reviewed-by: cluade-reviewer-andresmgsl <andres+1@heavyduty.builders>
Reviewed-by: codex-reviewer-andresmgsl <andres+2@heavyduty.builders>
2026-07-30 17:00:21 +00:00
a3ec47113a fix: emit BOX_MANUAL line-by-line; bare command for single candidate
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
claude REQUEST_CHANGES on 1c9a245: the all-candidates-fail path still
interpolated multi-line BOX_MANUAL into one warn, orphaning the or: line
and leaving try: non-pasteable on the default host.

- box_manual_emit log|warn prints one recovery line at a time
- single-candidate hosts (GitHub) get a bare pasteable command (no try:)
- multi-candidate keeps try:/or: prefixes
- never ${BOX_MANUAL} inside a log/warn string
- cli.sh asserts the emission invariant
2026-07-29 22:38:32 +00:00
1c9a245595 fix: BOX_MANUAL is pasteable try:/or: lines, not prose shell
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
codex REQUEST_CHANGES on !114 / #125: multi-candidate recovery was
rendered as `curl A | bash; if that 404s: curl B | bash`, which
`bash -n` rejects (exit 2). Operators on RIG_SKIP_BOX_INSTALL / no-curl
/ all-candidates-failed paths were handed non-executable recovery.

- box_manual_cmd / box_manual_text: one pasteable command per candidate
- display as separate `try:` / `or:` lines (newlines, no prose join)
- post-probe rewrite still collapses to the URL that worked
- execution tests: both Forgejo URLs visible; each command passes bash -n
2026-07-29 15:22:49 +00:00
ec73c86de7 fix: shellcheck SC2129, BOX_MANUAL lists candidates, drive box_install_urls
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
Claude re-review on c7d56d3:
- Group the installer-fn extracts in test/release.sh (SC2129).
- BOX_MANUAL on multi-candidate hosts lists every URL so BOX_REF=main
  never hands the operator only a 404ing /raw/tag/ form.
- Execute box_install_urls under the harness (tag-before-branch order).
- Cover Forgejo's no-release 404 path in resolve_latest_tag.
2026-07-29 14:53:07 +00:00
c7d56d3b80 docs: shorten changelog.d/111.md under the 300-char entry guard
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
2026-07-29 14:45:57 +00:00
1070673562 docs: keep changelog.d/111.md aligned with narrowed claim
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
2026-07-29 14:45:47 +00:00
1343cb9871 fix: release channel is tag-only; revert coolify Documentation=
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
Claude REQUEST_CHANGES on !114: the latest-release path must never fall
through to refs/heads/<tag> (would install a branch while INSTALLED_FROM
still names the release). release_tag_url is the single refs/tags URL on
every forge; regression test drives the heads-only stub.

Also restore coolify Documentation= to github.com — forge flip needs an
@andres ruling, not a shipped default in this PR.
2026-07-29 14:45:40 +00:00
df075b9ecf fix: review round — pin-wins candidates, no BOX_RAW_KIND guess
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
Address codex REQUEST_CHANGES + claude's live Forgejo measurements:

- ref_candidate_urls is host-only: same refs/tags then refs/heads on every
  forge (Forgejo serves both paths; bare /archive/<ref> no longer special).
- bootstrap box raw fetch tries /raw/tag/ then /raw/branch/ and never
  guesses kind from spelling; download is separate from execute.
- BOX_HOST defaults through RIG_HOST; comments narrow the zero-GitHub claim
  (box@0.9.0 installer still hardcodes GitHub for its archive).
- SC2016 suppressions on the new grep assertions; tests updated.
2026-07-29 14:42:59 +00:00
24abfbdb79 feat: install channel is forge-agnostic (RIG_HOST / BOX_HOST)
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
install.sh resolves releases and archive URLs through RIG_HOST (default
GitHub), using Forgejo's single /archive/<ref>.tar.gz form and the same
/releases/latest redirect grammar. Bootstrap's box fetch gets BOX_HOST
with forge-aware raw-file URLs. Refusal hints and coolify Documentation=
stop pointing only at GitHub.

Closes #111
2026-07-29 14:33:38 +00:00
90cbfe0057 Merge pull request 'fix: rig's ceremony CI guards resolve on Forgejo' (#113) from build/112-ceremony-uses-absolute into main
Some checks are pending
ci / check (push) Waiting to run
ci / install (push) Waiting to run
ci / db-integration (push) Waiting to run
release / release (push) Waiting to run
Reviewed-on: #113
Reviewed-by: kimi-reviewer-andresmgsl <andres+4@heavyduty.builders>
Reviewed-by: grok-reviewer-andresmgsl <andres+3@heavyduty.builders>
Reviewed-by: codex-reviewer-andresmgsl <andres+2@heavyduty.builders>
2026-07-29 13:37:33 +00:00
cluade-reviewer-andresmgsl
54efc17246 docs: the changelog fragment says what changed and stops
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
CONTRIBUTING's changelog rule is explicit — the fragment answers one question,
what is different in this version; why it was wrong and what it implies belong
in the PR body and the commit message, which is where anyone chasing the
reasoning already goes. The entry carried both.

Review: codex-reviewer-andresmgsl on !113.

Refs #112

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:22:08 +00:00
cluade-reviewer-andresmgsl
25f337494c fix: rig's ceremony CI guards resolve on Forgejo
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
The six ceremony actions in ci.yml are step `uses:` references, and a bare
step reference resolves through FORGEJO__actions__DEFAULT_ACTIONS_URL — which
this forge points at code.forgejo.org, where heavy-duty/ceremony does not
exist. Measured with a registered runner on the instance: `git clone
https://code.forgejo.org/heavy-duty/ceremony` -> 404, so every guard fails the
moment rig's own CI runs on the forge. Those six become absolute.

The two reusable-workflow callers do NOT move, and this is the correction to
the original "make all eight absolute" ruling. A reusable `uses:` never
consults DEFAULT_ACTIONS_URL; it resolves against the runner's own instance,
so both callers already fetch ceremony from this forge and expand. Absolutising
release.yml's would actively break CI: ceremony's docs-sync reads rig's pin out
of that one line with a grep anchored to the bare spelling, so the prefix makes
the pin invisible and docs-sync exits 1 with "no pin line ... found none".

actions/checkout@v4 stays bare in all three places — the mirror serves it, and
that is exactly what DEFAULT_ACTIONS_URL is for. The pin stays @0.3.0
throughout; no .ceremony/ edit, no pin bump.

test/cli.sh pins all three shapes, including the docs-sync grep verbatim, so
ceremony's parser and rig's caller cannot drift apart silently.

Refs #112

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:10:38 +00:00
e7471edc41 Merge pull request 'feat: Forgejo-native CI — a ci-box tenant and a forgejo-runner command family' (#110) from build/109-forgejo-ci-box into main
Some checks are pending
ci / check (push) Waiting to run
ci / install (push) Waiting to run
ci / db-integration (push) Waiting to run
release / release (push) Waiting to run
Reviewed-on: #110
Reviewed-by: grok-reviewer-andresmgsl <andres+3@heavyduty.builders>
Reviewed-by: kimi-reviewer-andresmgsl <andres+4@heavyduty.builders>
Reviewed-by: codex-reviewer-andresmgsl <andres+2@heavyduty.builders>
2026-07-28 19:58:36 +00:00
0370cc9818 fix: --version is refused at parse time, not at the 404
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
codex's non-blocking nit, folded in by the maintainer's call now that the round
is open anyway.

The resolve-latest path validated what rig worked out for itself
(""|*[!0-9.]*), and an explicit --version went straight into the download URL
unchecked — so the value rig is HANDED, which is the one that reaches a URL
path, was the only one nobody looked at. `--version ../../etc/passwd` was
interpolated rather than refused.

Not a trust boundary: this command is already root and the operator typed the
flag. It is a diagnosis defect. A pin that cannot name a release should fail by
name, next to the flag, rather than as an opaque 404 forty lines later.
install.sh's valid_version is the same instinct.

Four rows: two refusals, and two that reach "must run as root" — which is how a
non-root suite proves a good pin got THROUGH validation rather than merely
failing later. Mutation-checked: the refusals exit 1 instead of 2 without the
guard.

756 passed (was 752), release 31, drill 43, shellcheck clean.
2026-07-28 16:18:33 +00:00
fbdce5284e fix: the ci-box token guidance says what a Forgejo token actually is
Round finding from @codex-reviewer-andresmgsl, elevated to blocking by
@grok-reviewer-andresmgsl and @kimi-reviewer-andresmgsl. Unanimous, and right.

creds.md called the registration token "short-lived" and said it was "consumed
at registration". Both are GitHub's facts, copied across the forge boundary
with the rest of the sibling's shape. Forgejo's primary source, read rather
than inferred:

  models/actions/runner_token.go — ActionRunnerToken has NO expiry field. Only
  IsActive, Created, Updated. NewRunnerToken flips IsActive false on prior
  tokens at the same scope and only there, so a token dies when somebody mints
  its replacement, never on a clock.

  routers/api/actions/runner/runner.go — Register reads the token, refuses it
  when !IsActive ("please use the latest one"), and returns WITHOUT setting
  IsActive = false. Registration does not spend it. One token registers as many
  runners as it is shown to.

So it is long-lived and reusable — the precise opposite of the adjective, and
GitHub's really does expire in about an hour, which is why runner-install.sh is
correct to use it.

This is not a wording nit because of where the wording lives. creds.md is
spliced into the ci-box's own CONTEXT.md: it is the paragraph an agent INSIDE
the box reads about its own credentials. Telling that reader the token
self-expires is telling it a leaked one stops mattering on its own, while it is
still registering runners.

Pinned, not merely fixed, per codex's ask — the phrase arrived by copying from
the GitHub sibling, so the same copy can bring it back. Four rows: absence from
both files, and presence of the true claim, so the pin cannot be satisfied by
deleting the sentence instead of correcting it. The first draft of the CIBOX
pin was a phrase match and passed against the exact text it was written to
catch — the old wording wrapped across two comment lines. It is a plain absence
check now, and the file explains the ban without spelling the word.

Mutation-checked: all four go red against the old wording, green after.
2026-07-28 16:18:33 +00:00
25d10b084d docs: the changelog fragment cites bare #109
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
Maintainer ruling on the issue-number collision raised in the PR body.

CONTRIBUTING's rule is "cite the issue or PR — (#96)", and fragments are
assembled into CHANGELOG.md verbatim, so a fragment is the one place the
convention is actually load-bearing. `forgejo#109` links on neither forge and
would seed a second citation spelling in every future fragment.

The prose distinction stays where it earns its keep: PR bodies and review
comments still write forgejo#N, because that is where a reader has both
numbering schemes in front of them at once.
2026-07-28 09:26:17 +00:00
ab29ee78fb fix: the --version read cannot die ahead of its own refusal
Self-review of the whole head after the panel converged. One real defect,
reproduced verbatim before it was touched.

runner_version_of is a pipeline, and every forgejo-runner-* command runs under
set -euo pipefail. When the binary exits non-zero the pipeline fails, and
`PRESENT_VER="$(runner_version_of "$BIN")"` is an ASSIGNMENT — so install died
at that line, exit 1, printing nothing at all. The lib documented the opposite
("empty when it cannot answer"), and the refusal written for exactly this case
twenty lines later — "the download landed but cannot run" — was unreachable
code that could never have fired.

The shape is not hypothetical. `[ -x ]` yes, runnable no is what a truncated or
wrong-arch download leaves behind, and a ci-box's template preinstalls the
binary at mint — so the silent death sat on the one path this command family
exists to serve.

json_field, two files away, already carries `|| true` with this reasoning
written above it: "callers run under set -e with pipefail, where a grep that
matches nothing would otherwise kill the script with no message". The same
guard, for the same reason, at both sites: the lib reader and install's own
read of --version.

The test could not have caught it. vparse drove the lib through `bash -c` with
NO shell options, and its "bad" stub exits 0 — it proved garbage-in-empty-out
in a permissive shell, which is not where the function is used. vparse now runs
under set -euo pipefail, and a stub that exits non-zero is a third row.
Mutation-checked: that row exits 1 against the unpatched lib and 0 against the
fixed one. Install's site is grep-pinned, since reaching it for real needs root
and a downloaded binary.

748 passed (was 746), release 31, drill 43, shellcheck clean.
2026-07-28 09:26:17 +00:00
6c3b08117f docs: B1 is cleared — the mint-time Forgejo fetch is proven live
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
The operator turned off REQUIRE_SIGNIN_VIEW on forgejo.heavyduty.builders, so
the blocker this PR documented no longer applies to that instance. Re-measured
anonymously: heavy-duty/rig, ceremony, box, cast and stoke all answer 200 on
the API, the web page and git ls-remote, where rig 404'd on every one of those
this morning.

More to the point, the mechanism is now proven rather than argued: a
credential-less templates_resolve with
RIG_TEMPLATES_HOST=https://forgejo.heavyduty.builders fetches and extracts a
real archive end to end. That was the one part of piece 1 that could not be
demonstrated while the gate was up.

The requirement itself is unchanged and the refusal text still names it — it
follows from the mint's creds-free contract, not from one server's config, and
any other instance hosting a registry needs the same setting. What changed is
that this instance now satisfies it, so the docs say "verified satisfied"
instead of "blocked".

The plan doc keeps the original analysis under a heading that marks it
historical: it is why the knob exists, and deleting it would lose the
reasoning.

forgejo#109

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 21:30:18 +00:00
72ae875b90 docs: record the DEFAULT_ACTIONS_URL ruling
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
The maintainer ruled on the conflict kimi found reviewing !110: keep
DEFAULT_ACTIONS_URL=https://code.forgejo.org — actions/* are exactly what that
setting is designed to resolve, and the mirror serves them — and make the eight
first-party ceremony references absolute instead.

The README stated the conflict but deliberately left the direction open. It now
names the ruling and shows the absolute form, and points at forgejo#112 where
the workflow edits are tracked. Those edits are not made here: they touch
release ceremony, and folding them into a PR three reviewers have already read
would invalidate those reviews for no benefit.

forgejo#109

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 21:26:18 +00:00
cf5858bb60 fix: one checksum policy, labeler coverage, orphaned-unit removal
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
Net-new review findings from grok and kimi on !110. Their items 1-3 were
codex's, already fixed in 1933b07; these are the ones only they raised.

grok #4 — the two downloaders would drift. docs/templates/ci-box/install.sh
and the download block in forgejo-runner-install.sh were near-copies, and grok
named the exact consequence with the exact evidence: fail-open survived in BOTH
while a grep for "checksum mismatch" passed against both, because the string it
looked for sat right beside the branch it could not see. The whole policy —
fetch, unreadable, mismatch — is now fetch_and_verify_sha256, byte-identical in
both files and diffed by test/cli.sh. They cannot share a lib: the command
sources commands/lib/, and the template is a registry definition that runs
standalone inside a mint with rig's tree nowhere in reach, which is the same
situation valid_version faces between bin/rig and install.sh. Mutation-checked
by drifting one copy's message and confirming the diff goes red.

kimi #2 — the labeler could not see this family. scope:runner matched
commands/runner-*.sh only, so forgejo-runner-*.sh and the staged ci-box
definition scored no scope at all. Globs extended and the label's description
now says either forge rather than GitHub.

kimi #4 — remove stranded a unit whose user was gone. The missing-user check
exited 0 before the unit was ever looked at, so a deleted account with a
leftover forgejo-runner.service reported "nothing to remove" while the
absence-assert that never ran implied the opposite. The unit is now checked
independently. Auditing that fix surfaced a hazard kimi did not mention: with
the user gone RUNNER_DIR is "", and the later unguarded "$RUNNER_DIR/.rig-labels"
would have expanded to "/.rig-labels" — an rm at the filesystem root, as root.
Every RUNNER_DIR path is now gated, and a test pins that none is unguarded.

kimi #1 — the README handed out a config that breaks rig's own gates.
DEFAULT_ACTIONS_URL is a single fallback and rig's workflows need two origins;
measured: code.forgejo.org serves actions/checkout (200) but not
heavy-duty/ceremony (404), which lives on the Forgejo instance. With the value
the README recommended, all eight ceremony references fail to resolve. The
section now states the conflict with the counts, says which references would
break, and explicitly does NOT pick a side — that is an infra decision, and
rig's CI running on Forgejo is not something rig forgejo-runner depends on.
Asked the maintainer for direction.

746/31/43 pass, shellcheck clean, labeler.yml parses.

forgejo#109

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 21:19:33 +00:00
1933b07fd4 fix: honour --version, scope .rig-labels, close the checksum gate
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
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
903d8371b3 feat: Forgejo-native CI — a ci-box tenant and a forgejo-runner family
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
rig's CI story was GitHub-shaped end to end. This makes it work against a
self-hosted Forgejo, in three pieces.

The registry fetch becomes forge-aware. templates_resolve hardcoded three
github.com archive URLs; RIG_TEMPLATES_HOST now selects the grammar, because
the forges genuinely differ — GitHub serves refs/tags, refs/heads and bare
paths, Forgejo serves exactly one, and emitting the other two there would mean
two guaranteed 404s per fetch and a failure message listing URLs that never
could have worked. Measured against forgejo.heavyduty.builders, not inferred.
The default stays GitHub, so every existing caller is unchanged. install.sh's
snapshot reads the same variable through a byte-identical copy of the builder,
diffed by the tests: a snapshot cached from a forge converge would never fetch
from is worse than no snapshot, and the pin-in-the-name staleness guard cannot
catch a wrong-ORIGIN snapshot, only an old one.

ci-box is a tenant, not a machine role. The topology is a fleet machine
hosting boxes, one of which runs CI — a '-box' guest by rig's own family rule.
That also deletes the docker-in-docker layer the usual setup needs:
bootstrap-tenant.sh already installs Docker and adds the tenant user to the
group, and the isolation a privileged dind sidecar buys is already paid for by
a box that is network-isolated, inbound-less and disposable. rig runner
install refuses Docker for good reason — it converges a MACHINE, where the
blast radius is the machine. Here it is a guest that gets thrown away.

rig forgejo-runner is a new family beside rig runner, which is untouched.
Forgejo registers against an INSTANCE and the token carries the scope, so
there is no --repo to converge toward and nothing to compare; folding that
into one command would make every guard bimodal to share a flag name while the
contract underneath differs. assert_runner_instance asks the same
trust-boundary question about the axis Forgejo actually has. There is no
repoint and no --local, and both absences are explained where an operator
arriving from the GitHub sibling will hit them.

Forgejo's .runner holds the runner's own long-lived token, unlike GitHub's, so
it is installed 0600 and the mode is re-asserted on every converge — a mode
that drifted leaks the secret silently, since nothing fails and the runner
keeps working. status reports it and never prints the token.

Both downloads verify the published .sha256 before installing: this binary
lands as root and is executed by a systemd unit.

bootstrap --undo learns the guard for the same hazard on the other forge, and
it matters more here — Forgejo has no deregistration endpoint, so the ghost it
would strand has to be deleted by hand.

Known prerequisite, documented rather than assumed: the fetch is
unauthenticated by contract, and a Forgejo with REQUIRE_SIGNIN_VIEW=true
answers 404 for repos it reports as public. Hosting a registry there needs
FORGEJO__service__REQUIRE_SIGNIN_VIEW=false. The refusal names that case,
because it is indistinguishable from a wrong ref.

forgejo#109

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 20:40:54 +00:00
Daniel Marin
93608e8d78
Merge pull request #163 from claude-bot-andresmgsl/build/162-tenant-cron
Some checks failed
ci / check (push) Has been cancelled
ci / install (push) Has been cancelled
ci / db-integration (push) Has been cancelled
release / release (push) Has been cancelled
fix: arm cron on agent tenant boxes
2026-07-25 20:30:17 +01:00
claude-bot-andresmgsl
559a5b23ba test: drive cron converge against stubbed systemctl
The service converge+assert block becomes converge_cron(), behavior-
identical, so test/cli.sh can lift it verbatim (the drop_incus precedent)
and drive it: the already-enabled/active re-run is a no-op, a disabled
stopped unit converges, and an unrecoverably masked or start-dead unit
dies nonzero naming cron and #162 — proven by execution, not only by
structural pins. The converge logs now state the probe fact instead of
claiming an unverified success (panel nit).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 19:11:13 +00:00
claude-bot-andresmgsl
29c4a0840b fix: arm cron on agent tenant boxes
Agent tenants exist to run the cron-driven duty engine, and the engine's
installer runs unprivileged — it can detect a missing cron but never
apt-get it. Install cron with the shared toolbelt, then assert the
effective state, not the package: crontab on PATH AND cron.service
enabled and active, converging (unmask/enable/start) best-effort first.
A masked daemon with the binary present is exactly the silent-inert box
the issue reports, so the service asserts are the authority and either
failing dies naming cron.

staging-box stays exempt with the rest of the agent-assert block: no
agent, no duty engine. Machine roles (bootstrap.sh) are deliberately
not widened — the issue names tenants as the demonstrated gap.

Closes #162

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 18:54:27 +00:00
Daniel Marin
34ff1c8917
Merge pull request #161 from claude-bot-andresmgsl/build/160-self-scoped-tags
fix: scope the netmap tag read to Self, not the whole document
2026-07-25 17:16:07 +01:00
Daniel Marin
4703c2b570
Merge pull request #159 from codex-bot-andresmgsl/build/153-registry-snapshot
feat: install the pinned template registry snapshot
2026-07-25 17:15:43 +01:00
claude-bot-andresmgsl
56478d7a7d fix: scope the netmap tag read to Self
json_string_array took the first "Tags" array anywhere in the document,
justified by Self-before-Peer field order. An untagged Self omits the key
entirely (Go omitempty), so the match fell through into Peer and returned
a peer's tag — false-refusing every login join and false-verifying
untagged authkey joins on any tailnet with a tagged node. Extract Self by
brace counting (Location nests an object, so slicing to the next key
would truncate) and read the array inside it.

Refs #160

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 16:02:54 +00:00
codex-bot-andresmgsl
dfd2debff1 fix: group template snapshot changelog entry 2026-07-25 16:00:29 +00:00
codex-bot-andresmgsl
b849eb57d3 test: prove drill records registry source 2026-07-25 13:41:01 +00:00
codex-bot-andresmgsl
247d9b9ec5 feat: install and record registry snapshot 2026-07-25 13:38:54 +00:00
codex-bot-andresmgsl
5794a80d0e feat: prefer installed template snapshot 2026-07-25 13:34:58 +00:00
Daniel Marin
2af3b24fd8
Merge pull request #156 from codex-bot-andresmgsl/build/152-machine-role-template
feat: add registry-backed machine roles
2026-07-25 14:31:40 +01:00
codex-bot-andresmgsl
c08bcc6622 fix: align machine role boundaries 2026-07-25 13:07:53 +00:00
codex-bot-andresmgsl
86bf52d4a5 fix: group machine role changelog entry 2026-07-25 13:07:01 +00:00
codex-bot-andresmgsl
597e1105f4 fix: format changelog fragment as entry 2026-07-25 11:02:27 +00:00
codex-bot-andresmgsl
88e59b6ec8 test: cover machine template registry 2026-07-25 11:00:21 +00:00
codex-bot-andresmgsl
587a44617b feat: add registry-backed machine roles 2026-07-25 10:57:52 +00:00
github-actions[bot]
535caea94b chore: bump main to 0.3.2-dev — a dev install must not impersonate 0.3.1 2026-07-25 10:32:29 +00:00
47 changed files with 4292 additions and 127 deletions

4
.github/labeler.yml vendored
View file

@ -11,7 +11,9 @@
"scope:runner": "scope:runner":
- changed-files: - changed-files:
- any-glob-to-any-file: - 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": "scope:coolify":
- changed-files: - changed-files:
- any-glob-to-any-file: ["commands/coolify-*.sh"] - any-glob-to-any-file: ["commands/coolify-*.sh"]

6
.github/labels.conf vendored
View file

@ -1,8 +1,8 @@
panel=claude-bot-andresmgsl codex-bot-andresmgsl grok-bot-andresmgsl kimi-bot-andresmgsl panel=cluade-reviewer-andresmgsl codex-reviewer-andresmgsl grok-reviewer-andresmgsl kimi-reviewer-andresmgsl
triage-actors=dan-claude-bot triage-actors=dan-claude-bot cluade-reviewer-andresmgsl
scope:bootstrap|C5DEF5|bootstrap — hardening a pristine server into a node scope:bootstrap|C5DEF5|bootstrap — hardening a pristine server into a node
scope:users|C5DEF5|users-* — class model, apply/status, close-root 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:coolify|C5DEF5|coolify-* — Coolify and backup install
scope:db|C5DEF5|db.sh — dump/restore scope:db|C5DEF5|db.sh — dump/restore
scope:installer|C5DEF5|install.sh — how rig lands on a machine scope:installer|C5DEF5|install.sh — how rig lands on a machine

View file

@ -26,7 +26,21 @@ jobs:
# The file list is printed so under-coverage shows up in the log, and # The file list is printed so under-coverage shows up in the log, and
# the comm below turns under-coverage into a failure rather than a # the comm below turns under-coverage into a failure rather than a
# thing someone has to notice: every tracked `.sh` must be in the set. # thing someone has to notice: every tracked `.sh` must be in the set.
#
# Install shellcheck when missing (#144). rig's default Forgejo label
# maps ubuntu-latest to catthehacker's act-22.04 (slim), which does not
# ship shellcheck; GitHub-hosted ubuntu-latest does. The conditional
# keeps each forge from paying for the other.
#
# sudo: load-bearing on GitHub (job runs as `runner` with passwordless
# sudo) and a no-op on act-22.04 (jobs run as uid 0; the image has no
# `runner` account). Do not delete it as "dead weight" — that breaks
# the GitHub half the day that image stops preinstalling shellcheck.
run: | run: |
if ! command -v shellcheck >/dev/null 2>&1; then
sudo apt-get update
sudo apt-get install -y shellcheck
fi
shopt -s globstar dotglob shopt -s globstar dotglob
files=(bin/* **/*.sh) files=(bin/* **/*.sh)
printf 'shellcheck: %s\n' "${files[@]}" printf 'shellcheck: %s\n' "${files[@]}"
@ -50,26 +64,38 @@ jobs:
# lives with its implementation upstream; the six pins below and the # lives with its implementation upstream; the six pins below and the
# two workflow callers must always name the same ceremony tag. # two workflow callers must always name the same ceremony tag.
# #
# These six are ABSOLUTE and the two callers in release.yml/labels.yml
# are BARE. That asymmetry is measured, not stylistic (#112) — do not
# "tidy" either half into the other:
# a step `uses:` resolves a bare name through DEFAULT_ACTIONS_URL,
# which this forge points at code.forgejo.org, where ceremony does not
# exist — bare 404s and every guard below fails;
# a reusable-workflow `uses:` never consults DEFAULT_ACTIONS_URL at
# all — it resolves against the runner's own instance, so bare already
# works there. And release.yml's caller is the line ceremony's own
# docs-sync greps for its pin, anchored to the bare spelling: make it
# absolute and the pin goes invisible and docs-sync exits 1.
#
# changelog-armed: the version-keyed arming rule (rig#66; the # changelog-armed: the version-keyed arming rule (rig#66; the
# unconditional form rig#44 reverted — this is its correct return). # unconditional form rig#44 reverted — this is its correct return).
- uses: heavy-duty/ceremony/actions/changelog-armed@0.3.0 - uses: https://forgejo.heavyduty.builders/heavy-duty/ceremony/actions/changelog-armed@0.3.0
# changelog-monotonic: no shipped heading deleted or duplicated # changelog-monotonic: no shipped heading deleted or duplicated
# (#98, box#122). Strict by default: an unresolvable base ref is red, # (#98, box#122). Strict by default: an unresolvable base ref is red,
# never a quiet skip — hence the fetch-depth: 0 above. # never a quiet skip — hence the fetch-depth: 0 above.
- uses: heavy-duty/ceremony/actions/changelog-monotonic@0.3.0 - uses: https://forgejo.heavyduty.builders/heavy-duty/ceremony/actions/changelog-monotonic@0.3.0
# changelog-assembled: a release's stamped section must exactly match # changelog-assembled: a release's stamped section must exactly match
# the fragments it consumed. Vacuous on non-release PRs. # the fragments it consumed. Vacuous on non-release PRs.
- uses: heavy-duty/ceremony/actions/changelog-assembled@0.3.0 - uses: https://forgejo.heavyduty.builders/heavy-duty/ceremony/actions/changelog-assembled@0.3.0
# drill-recorded: a release version carries drills/<version>.md # drill-recorded: a release version carries drills/<version>.md
# (rig's drill meaning: drills/README.md). Vacuous on -dev trees. # (rig's drill meaning: drills/README.md). Vacuous on -dev trees.
- uses: heavy-duty/ceremony/actions/drill-recorded@0.3.0 - uses: https://forgejo.heavyduty.builders/heavy-duty/ceremony/actions/drill-recorded@0.3.0
# runner-isolated: PR-triggered workflows never execute unreviewed # runner-isolated: PR-triggered workflows never execute unreviewed
# branch code on a self-hosted runner. # branch code on a self-hosted runner.
- uses: heavy-duty/ceremony/actions/runner-isolated@0.3.0 - uses: https://forgejo.heavyduty.builders/heavy-duty/ceremony/actions/runner-isolated@0.3.0
# docs-sync: the .ceremony/ doctrine mirror is byte-identical to the # docs-sync: the .ceremony/ doctrine mirror is byte-identical to the
# pin read from release.yml (ceremony#19) — a hand edit or a # pin read from release.yml (ceremony#19) — a hand edit or a
# half-done pin bump goes red here. # half-done pin bump goes red here.
- uses: heavy-duty/ceremony/actions/docs-sync@0.3.0 - uses: https://forgejo.heavyduty.builders/heavy-duty/ceremony/actions/docs-sync@0.3.0
# The install LIFECYCLE against a tree install.sh itself produced — the four # The install LIFECYCLE against a tree install.sh itself produced — the four
# beats box and cast already run in CI (#106): install from this checkout, # beats box and cast already run in CI (#106): install from this checkout,

View file

@ -16,8 +16,8 @@ genuinely rig's.
1. **Fork and branch.** Contributors work from forks; upstream branches are 1. **Fork and branch.** Contributors work from forks; upstream branches are
for maintainers. Title the PR conventionally (`feat:`, `fix:`, `docs:`). for maintainers. Title the PR conventionally (`feat:`, `fix:`, `docs:`).
2. **The review panel** (`.github/labels.conf`'s `panel=` line): 2. **The review panel** (`.github/labels.conf`'s `panel=` line):
`claude-bot-andresmgsl`, `codex-bot-andresmgsl`, `grok-bot-andresmgsl`, `cluade-reviewer-andresmgsl`, `codex-reviewer-andresmgsl`,
`kimi-bot-andresmgsl` — `grok-reviewer-andresmgsl`, `kimi-reviewer-andresmgsl` —
the required verdicts for a PR are the panel minus its author. The the required verdicts for a PR are the panel minus its author. The
maintainer (`danmt`) takes the last word and merges. maintainer (`danmt`) takes the last word and merges.
3. **Checks must be green**: `shellcheck`, `bash test/cli.sh` and 3. **Checks must be green**: `shellcheck`, `bash test/cli.sh` and

221
README.md
View file

@ -19,6 +19,11 @@ takes arguments, does its work, and stores no credential, ever.
curl -fsSL https://raw.githubusercontent.com/heavy-duty/rig/main/install.sh | RIG_REF=main bash curl -fsSL https://raw.githubusercontent.com/heavy-duty/rig/main/install.sh | RIG_REF=main bash
# the latest release: # the latest release:
curl -fsSL https://raw.githubusercontent.com/heavy-duty/rig/main/install.sh | bash curl -fsSL https://raw.githubusercontent.com/heavy-duty/rig/main/install.sh | bash
# the same two channels from the Forgejo mirror (RIG_HOST picks the forge; #111):
curl -fsSL https://forgejo.heavyduty.builders/heavy-duty/rig/raw/branch/main/install.sh \
| RIG_HOST=https://forgejo.heavyduty.builders RIG_REF=main bash
curl -fsSL https://forgejo.heavyduty.builders/heavy-duty/rig/raw/branch/main/install.sh \
| RIG_HOST=https://forgejo.heavyduty.builders bash
``` ```
This README tracks `main`, so the quick start installs that same development This README tracks `main`, so the quick start installs that same development
@ -37,6 +42,11 @@ curl -fsSL .../install.sh | RIG_REF=main bash # the development tree
A tag outranks a branch of the same name (the pin must win); anything that A tag outranks a branch of the same name (the pin must win); anything that
is not a tag falls back to `refs/heads/<ref>`. is not a tag falls back to `refs/heads/<ref>`.
`RIG_HOST` picks the forge the channel reads — `https://github.com` by
default (#111) — and it is chosen independently of the URL the script itself
came from, so installing *from* the Forgejo mirror means naming the mirror
twice, as the quick start's third line does.
The layout, under the install root (`~/.local/share/rig`): The layout, under the install root (`~/.local/share/rig`):
``` ```
@ -401,6 +411,34 @@ unattended VM-host appliance) — and `workstation` is the machine at the keyboa
end of all the SSH connections: `root-door=closed`, `join=login`, entering the end of all the SSH connections: `root-door=closed`, `join=login`, entering the
tailnet as *your* device rather than the fleet's. tailnet as *your* device rather than the fleet's.
### Machine-role templates
Machine presets can also live in the
[heavy-duty/rig-templates](https://github.com/heavy-duty/rig-templates)
registry. A `*-server` directory is a fleet-machine definition; the exact
name `workstation` is the deliberate suffix-less exception. Its
`template.env` contains exactly the three traits bootstrap's built-in table
uses:
```dotenv
ROOT_DOOR="open" # open|closed
HOST="no" # yes|no
JOIN="authkey" # authkey|login
```
An `install.sh` is optional. When present, bootstrap runs it as root,
non-interactively, from the definition directory with `RIG_ROLE` set, after
the tailnet join, host setup, role marker prerequisites, and operator
convergence. A nonzero exit fails bootstrap and names the role and registry
source. The definition owns idempotence, just as bootstrap does.
Built-in roles and `custom` take precedence over registry names. Any other
non-tenant role is looked up in the resolved registry; the same three source
knobs below apply, including `RIG_TEMPLATES_DIR` for an offline local
definition. Pin reviewed registry content into rig's tree before using it on
fleet machines: an optional machine `install.sh` executes as root on metal,
and an override is the operator explicitly choosing a different trust root.
### `rig bootstrap <role>-box` — the box tenants ### `rig bootstrap <role>-box` — the box tenants
Run as root, **inside** a [box](https://github.com/heavy-duty/box)-minted Run as root, **inside** a [box](https://github.com/heavy-duty/box)-minted
@ -436,13 +474,35 @@ actually contains. `staging-box` is the one in-tree tenant — mechanism-adjacen
(sshd hardening through the shared `lib/sshd.sh`, docker, no agent), user (sshd hardening through the shared `lib/sshd.sh`, docker, no agent), user
`ops`, box#69's server posture with `root-door=open` acceptance. `ops`, box#69's server posture with `root-door=open` acceptance.
**Where the registry comes from — three knobs, precedence high to low:** **Where the registry comes from — precedence high to low:**
| knob | meaning | | knob | meaning |
|------|---------| |------|---------|
| `RIG_TEMPLATES_DIR` | a local folder — no fetch: the offline-test path, and "try a template before it exists anywhere" | | `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_REF` | any ref of `RIG_TEMPLATES_REPO` (default `heavy-duty/rig-templates`), fetched as an unauthenticated tarball at bootstrap time |
| *(neither set)* | **the in-tree pin**`RIG_TEMPLATES_PIN` in `commands/lib/templates.sh`, the `BOX_RELEASE` discipline: bumped by ordinary reviewed rig PR, so a rig release freezes the mechanism+registry pair, and a newer rig matches newer templates by default (the #110 ruling) | | `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 |
The pin remains the only source of truth. An older `templates@<sha>/`
directory cannot answer after a pin bump, and an explicit
`RIG_TEMPLATES_REF` always fetches that ref rather than consulting the
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 **The security trade — in bold, not a footnote.** **A main-tracked
rig-templates repo means every merged PR there executes as root inside every rig-templates repo means every merged PR there executes as root inside every
@ -1019,6 +1079,163 @@ prints the exact `runner install` line that finishes the job.
Convergent — repointing to the repo it is already on changes nothing, exits 0, Convergent — repointing to the repo it is already on changes nothing, exits 0,
and never asks for a token. 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 it only governs *steps*.**
> It decides where a bare `uses: owner/repo@ref` on a **step** resolves — and
> nothing else. Measured on this instance, with a registered runner (#112):
>
> | reference | count | bare resolves to | verdict |
> |---|---|---|---|
> | `actions/checkout@v4` (step) | 3 | `code.forgejo.org` | **200** — mirrored, keep bare |
> | `heavy-duty/ceremony/actions/…` (step) | 6 | `code.forgejo.org` | **404** — must be absolute |
> | `heavy-duty/ceremony/.github/workflows/…` (reusable) | 2 | **this instance** | **200** — keep bare |
>
> The last row is the one that surprises: a **reusable-workflow** `uses:` never
> consults `DEFAULT_ACTIONS_URL` at all. It resolves against the runner's own
> instance, so the `release.yml` and `labels.yml` callers already work bare.
>
> **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 only the **six** first-party *step* references absolute:
>
> ```yaml
> - uses: https://forgejo.heavyduty.builders/heavy-duty/ceremony/actions/docs-sync@0.3.0
> ```
>
> The two reusable callers **stay bare**, and `release.yml`'s must: ceremony's
> `docs-sync` reads rig's pin out of that line with a grep anchored to the bare
> spelling, so absolutising it hides the pin and the guard exits 1. `test/cli.sh`
> pins all three shapes.
> `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>` ### `rig users apply --file <path>`
Converges named operator accounts from a declarative users file — on **every** Converges named operator accounts from a declarative users file — on **every**

View file

@ -1 +1 @@
0.3.1 0.3.2-dev

58
bin/rig
View file

@ -87,6 +87,23 @@ commands:
re-register, reusing the binary already on the box. Needs a removal 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. token for the old repo and a registration token for the new one.
Run as root. 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> users apply --file <path>
Converge named operator accounts from a declarative users file, on Converge named operator accounts from a declarative users file, on
every class: groups by role (admin/rig/box), passwords locked always, every class: groups by role (admin/rig/box), passwords locked always,
@ -135,10 +152,12 @@ commands:
install/upgrade: install/upgrade:
curl -fsSL https://raw.githubusercontent.com/heavy-duty/rig/main/install.sh | bash curl -fsSL https://raw.githubusercontent.com/heavy-duty/rig/main/install.sh | bash
Installs the latest RELEASE (RIG_REF=<tag> pins one, RIG_REF=main Installs the latest RELEASE from GitHub (the default RIG_HOST).
tracks the development tree). Re-run any time: an installed version RIG_REF=<tag> pins one, RIG_REF=main tracks the development tree.
converges (no-op), a new one installs side by side at From a Forgejo host: set RIG_HOST to that origin and fetch install.sh
<root>/versions/<v> and becomes the default. from <host>/heavy-duty/rig/raw/branch/main/install.sh (#111).
Re-run any time: an installed version converges (no-op), a new one
installs side by side at <root>/versions/<v> and becomes the default.
EOF EOF
} }
@ -443,6 +462,37 @@ case "$cmd" in
;; ;;
esac 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) users)
shift shift
sub="${1:-}" 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)

3
changelog.d/111.md Normal file
View file

@ -0,0 +1,3 @@
### Changed
- `install.sh` and bootstrap's box fetch take `RIG_HOST` / `BOX_HOST` so a Forgejo origin can serve the install channel (#111)

3
changelog.d/112.md Normal file
View file

@ -0,0 +1,3 @@
### Fixed
- rig's six ceremony CI guards resolve on Forgejo (#112)

3
changelog.d/116.md Normal file
View file

@ -0,0 +1,3 @@
### Fixed
- Ceremony recognizes the Forgejo review panel and both forges' triage actors (#116)

3
changelog.d/129.md Normal file
View file

@ -0,0 +1,3 @@
### Added
- The release drill exercises `rig forgejo-runner` beside `rig runner`, so both shipped runner families carry evidence (#129)

3
changelog.d/131.md Normal file
View file

@ -0,0 +1,3 @@
### Fixed
- The README's install quick start documents the Forgejo channel — `RIG_HOST` shipped in #111 but was documented nowhere (#131)

3
changelog.d/133.md Normal file
View file

@ -0,0 +1,3 @@
### Fixed
- `rig forgejo-runner status` no longer lets a service's `(active)` stand as proof the runner is fetching jobs (#133)

3
changelog.d/135.md Normal file
View file

@ -0,0 +1,3 @@
### Fixed
- Forgejo runners installed by rig can start their cache server — `$HOME/.cache` is created and punched through `ProtectHome` (#135)

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

@ -0,0 +1,4 @@
### Fixed
- `rig forgejo-runner install`, `rig runner install`, `rig users apply` and `rig bootstrap <tenant>` refuse with a named remedy when root's `PATH` carries no `/usr/sbin`, instead of dying on `useradd: command not found` after prompting for a token (#139)
- `rig users apply` no longer reports success having silently skipped the sudoers drop-in when `visudo` is off `PATH` (#139)

8
changelog.d/144.md Normal file
View file

@ -0,0 +1,8 @@
### Added
- Default Forgejo runner labels include opt-in `ubuntu-latest-full` for the GitHub-parity image (#144)
### Fixed
- `ci.yml` installs `shellcheck` when the runner image lacks it, so Forgejo's slim `ubuntu-latest` can run `check` (#144)
- Plain `rig forgejo-runner install` warns when recorded labels are a retired rig default, without nagging custom `--labels` (#144)

3
changelog.d/152.md Normal file
View file

@ -0,0 +1,3 @@
### Added
- Machine-role templates can declare bootstrap traits and an optional final root install hook (#152)

3
changelog.d/153.md Normal file
View file

@ -0,0 +1,3 @@
### Added
- Pinned template registries install with rig and serve default converges offline (#153)

3
changelog.d/160.md Normal file
View file

@ -0,0 +1,3 @@
### Fixed
- The netmap tag read is scoped to `Self`: an untagged node next to tagged peers no longer reads a peer's tag, false-refusing `--join login` and false-verifying untagged authkey joins (#160)

3
changelog.d/162.md Normal file
View file

@ -0,0 +1,3 @@
### Fixed
- Agent tenant boxes ship cron — binary asserted, service enabled and active — so the duty engine can arm its timer (#162)

View file

@ -31,6 +31,8 @@ HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
. "$HERE/lib/templates.sh" # templates_resolve / template_parse_env / render_tenant_context . "$HERE/lib/templates.sh" # templates_resolve / template_parse_env / render_tenant_context
# shellcheck source=SCRIPTDIR/lib/users-config.sh # shellcheck source=SCRIPTDIR/lib/users-config.sh
. "$HERE/lib/users-config.sh" # read_role_marker / root_door_of . "$HERE/lib/users-config.sh" # read_role_marker / root_door_of
# shellcheck source=SCRIPTDIR/lib/admin-path.sh
. "$HERE/lib/admin-path.sh" # require_admin_bins
# shellcheck source=SCRIPTDIR/lib/sshd.sh # shellcheck source=SCRIPTDIR/lib/sshd.sh
. "$HERE/lib/sshd.sh" # harden_sshd (the staging-box tenant) . "$HERE/lib/sshd.sh" # harden_sshd (the staging-box tenant)
# shellcheck source=SCRIPTDIR/lib/manifest.sh # shellcheck source=SCRIPTDIR/lib/manifest.sh
@ -200,6 +202,15 @@ else
fi fi
[ "$(id -u)" -eq 0 ] || die "must run as root" [ "$(id -u)" -eq 0 ] || die "must run as root"
# Root is not enough here either (#139). This mint adds the tenant user to the
# docker group with `usermod` far below — AFTER installing docker and node,
# which is what makes it the worst-placed of the four call sites: on a
# PATH-shorn root it dies mid-convergence with a bare `usermod: command not
# found`, having already changed the machine, rather than before touching it.
#
# Unconditional because the docker block below is unconditional — "every tenant
# gets docker" is the stated rule there, so every tenant reaches the usermod.
require_admin_bins usermod
if [ -r /etc/os-release ]; then if [ -r /etc/os-release ]; then
# Sourced in a subshell: os-release defines VERSION, NAME, ID, etc. — # Sourced in a subshell: os-release defines VERSION, NAME, ID, etc. —
# sourcing it in the main shell silently clobbers same-named script vars. # sourcing it in the main shell silently clobbers same-named script vars.
@ -233,6 +244,27 @@ append_line_once() {
chown "$TENANT_USER:$TENANT_GROUP" "$file" chown "$TENANT_USER:$TENANT_GROUP" "$file"
} }
# The binary on PATH is not the effective state — an image can ship crontab
# with cron.service masked or stopped, and an unarmed timer is exactly the
# silent-inert box #162 is about. Converge best-effort, then assert what
# systemd actually reports; the assert is the authority. Enabling an
# already-enabled unit is a no-op and no path here touches any crontab.
# A function so test/cli.sh can lift it verbatim and drive it against a
# stubbed systemctl, the drop_incus precedent.
converge_cron() {
if ! systemctl is-enabled cron >/dev/null 2>&1; then
log "cron.service not enabled — converging"
systemctl unmask cron >/dev/null 2>&1 || true
systemctl enable cron >/dev/null 2>&1 || true
fi
if ! systemctl is-active cron >/dev/null 2>&1; then
log "cron.service not active — starting"
systemctl start cron >/dev/null 2>&1 || true
fi
systemctl is-enabled cron >/dev/null 2>&1 || die "cron.service is not enabled after converge — the duty engine's timer never fires without it (#162)"
systemctl is-active cron >/dev/null 2>&1 || die "cron.service is not active after converge — the duty engine's timer never fires without it (#162)"
}
# --- packages ---------------------------------------------------------------- # --- packages ----------------------------------------------------------------
export DEBIAN_FRONTEND=noninteractive export DEBIAN_FRONTEND=noninteractive
log "installing base packages (tenant ${ROLE})" log "installing base packages (tenant ${ROLE})"
@ -246,8 +278,11 @@ else
# The shared agent toolbelt the templates carried, plus the definition's # The shared agent toolbelt the templates carried, plus the definition's
# APT_EXTRAS (claude-box's zsh rides there). Unquoted on purpose — it is a # APT_EXTRAS (claude-box's zsh rides there). Unquoted on purpose — it is a
# word list, every word already vetted by the parser's package-name gate. # word list, every word already vetted by the parser's package-name gate.
# cron is toolbelt, not a template flavour: every agent tenant exists to
# run the cron-driven duty engine, whose unprivileged installer can detect
# a missing cron but never apt-get it (#162).
# shellcheck disable=SC2086 # shellcheck disable=SC2086
apt-get install -y -qq git gh curl ca-certificates gnupg ripgrep jq tmux age unzip build-essential $TPL_APT_EXTRAS apt-get install -y -qq git gh curl ca-certificates gnupg ripgrep jq tmux age unzip build-essential cron $TPL_APT_EXTRAS
fi fi
# Assert the effective toolbelt, not apt's exit code — tmux is the box#65 # Assert the effective toolbelt, not apt's exit code — tmux is the box#65
# contract ('box tmux' runs tmux new-session inside every box) and gh is how # contract ('box tmux' runs tmux new-session inside every box) and gh is how
@ -256,6 +291,9 @@ command -v tmux >/dev/null 2>&1 || die "tmux missing after package install — '
if [ "$ROLE" != "staging-box" ]; then if [ "$ROLE" != "staging-box" ]; then
command -v gh >/dev/null 2>&1 || die "gh missing after package install" command -v gh >/dev/null 2>&1 || die "gh missing after package install"
command -v git >/dev/null 2>&1 || die "git missing after package install" command -v git >/dev/null 2>&1 || die "git missing after package install"
command -v crontab >/dev/null 2>&1 || die "crontab missing after package install — the duty engine arms itself with cron (#162)"
# staging-box is exempt with the rest of this block: no agent, no engine.
converge_cron
fi fi
# --- docker ------------------------------------------------------------------ # --- docker ------------------------------------------------------------------

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" die "a GitHub runner is installed — run 'rig runner remove' first so undo does not leave a ghost runner in the repository"
fi 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="" join_by=""
while IFS= read -r field; do while IFS= read -r field; do
case "$field" in case "$field" in

View file

@ -12,6 +12,8 @@ HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
. "$HERE/lib/users-config.sh" # parse_users_file — the --users PRE-FLIGHT only . "$HERE/lib/users-config.sh" # parse_users_file — the --users PRE-FLIGHT only
# shellcheck source=SCRIPTDIR/lib/manifest.sh # shellcheck source=SCRIPTDIR/lib/manifest.sh
. "$HERE/lib/manifest.sh" # manifest_stamp — provenance, written beside the marker . "$HERE/lib/manifest.sh" # manifest_stamp — provenance, written beside the marker
# shellcheck source=SCRIPTDIR/lib/templates.sh
. "$HERE/lib/templates.sh" # registry-backed machine-role definitions
# The users lib is sourced for validation, never for convergence: `users apply` # The users lib is sourced for validation, never for convergence: `users apply`
# stays the single owner of what a users file DOES to a box (#51). Bootstrap # stays the single owner of what a users file DOES to a box (#51). Bootstrap
# borrows the parser so a typo'd users file is caught in the same breath as a # borrows the parser so a typo'd users file is caught in the same breath as a
@ -119,6 +121,7 @@ EOF
# --- args (validated before the root check, so errors are testable) --------- # --- args (validated before the root check, so errors are testable) ---------
ROLE="${1:-}" ROLE="${1:-}"
MACHINE_TEMPLATE_DIR=""
case "$ROLE" in case "$ROLE" in
--undo) --undo)
shift shift
@ -136,7 +139,20 @@ case "$ROLE" in
exec "$HERE/bootstrap-tenant.sh" "$@" ;; exec "$HERE/bootstrap-tenant.sh" "$@" ;;
-h|--help) usage; exit 0 ;; -h|--help) usage; exit 0 ;;
"") usage >&2; die "role required (control-plane-server|workload-server|runner-server|staging-server|dev-server|workstation|custom — or a '-box' tenant role from the template registry, e.g. claude-box)" 2 ;; "") usage >&2; die "role required (control-plane-server|workload-server|runner-server|staging-server|dev-server|workstation|custom — or a '-box' tenant role from the template registry, e.g. claude-box)" 2 ;;
*) die "unknown role: $ROLE (want control-plane-server|workload-server|runner-server|staging-server|dev-server|workstation|custom — or a '-box' tenant role from the template registry, e.g. claude-box)" 2 ;; *)
shift
templates_resolve || exit 2
trap '[ -n "$TEMPLATES_TMP" ] && rm -rf "$TEMPLATES_TMP"' EXIT
MACHINE_TEMPLATE_DIR="$REGISTRY_DIR/$ROLE"
if [[ ! "$ROLE" =~ ^[a-z][a-z0-9-]*-server$ ]] \
|| [ "$(template_family "$ROLE" 2>/dev/null || true)" != "machine" ] \
|| [ ! -f "$MACHINE_TEMPLATE_DIR/template.env" ]; then
MACHINE_ROLES="$(templates_machine_roles "$REGISTRY_DIR" | paste -sd'|' -)"
[ -n "$MACHINE_ROLES" ] || MACHINE_ROLES="none"
die "unknown role: $ROLE (want control-plane-server|workload-server|runner-server|staging-server|dev-server|workstation|custom; machine roles from $(templates_source_desc): $MACHINE_ROLES; or a '-box' tenant role)" 2
fi
machine_template_parse_env "$MACHINE_TEMPLATE_DIR/template.env" \
|| die "invalid machine role $ROLE from $(templates_source_desc)" 2 ;;
esac esac
# Role→traits map — the single place a role's shape is declared (issue #26). # Role→traits map — the single place a role's shape is declared (issue #26).
@ -156,6 +172,7 @@ case "$ROLE" in
dev-server) ROOT_DOOR=closed HOST=yes JOIN=authkey ;; dev-server) ROOT_DOOR=closed HOST=yes JOIN=authkey ;;
workstation) ROOT_DOOR=closed HOST=yes JOIN=login ;; workstation) ROOT_DOOR=closed HOST=yes JOIN=login ;;
custom) ;; custom) ;;
*) ROOT_DOOR="$TPL_ROOT_DOOR" HOST="$TPL_HOST" JOIN="$TPL_JOIN" ;;
esac esac
# custom has no hostname default: a made-up name on a made-up shape helps nobody. # custom has no hostname default: a made-up name on a made-up shape helps nobody.
@ -706,22 +723,111 @@ if [ "$HOST" = "yes" ]; then
BOX_RELEASE=0.9.0 BOX_RELEASE=0.9.0
BOX_REPO="${BOX_REPO:-heavy-duty/box}" BOX_REPO="${BOX_REPO:-heavy-duty/box}"
BOX_REF="${BOX_REF:-$BOX_RELEASE}" BOX_REF="${BOX_REF:-$BOX_RELEASE}"
BOX_INSTALL_URL="https://raw.githubusercontent.com/${BOX_REPO}/${BOX_REF}/install.sh" # BOX_HOST: which forge serves box's *installer script* (#111). Parallel to
BOX_MANUAL="curl -fsSL ${BOX_INSTALL_URL} | BOX_YES=1 BOX_REF=${BOX_REF} bash" # RIG_HOST / RIG_TEMPLATES_HOST. Defaults to RIG_HOST when set, else GitHub,
# so a Forgejo-sourced rig stays Forgejo-native for this fetch without a
# second knob — override with BOX_HOST when the two must diverge.
# Raw-file grammar:
# GitHub raw.githubusercontent.com/<repo>/<ref>/install.sh
# Forgejo <host>/<repo>/raw/{tag|branch}/<ref>/install.sh
# Forgejo's bare /raw/<ref>/ is branch-first (opposite of /archive/<ref>),
# so we never guess kind from spelling: try /raw/tag/ then /raw/branch/
# and let the fetch decide (same pin-wins rule as ref_candidate_urls).
# SCOPE: this only moves the script fetch. box@0.9.0's installer still
# hardcodes GitHub for its own archive — zero-GitHub bootstrap needs a
# BOX_HOST knob in heavy-duty/box (tracked separately).
BOX_HOST="${BOX_HOST:-${RIG_HOST:-https://github.com}}"
BOX_HOST="${BOX_HOST%/}"
box_install_urls() {
case "$BOX_HOST" in
https://github.com|http://github.com|*//github.com)
printf 'https://raw.githubusercontent.com/%s/%s/install.sh\n' "$BOX_REPO" "$BOX_REF" ;;
*)
printf '%s/%s/raw/tag/%s/install.sh\n' "$BOX_HOST" "$BOX_REPO" "$BOX_REF"
printf '%s/%s/raw/branch/%s/install.sh\n' "$BOX_HOST" "$BOX_REPO" "$BOX_REF" ;;
esac
}
# One pasteable recovery command per candidate URL. Never join candidates
# with English prose or shell metacharacters — "curl A | bash; if that
# 404s: curl B | bash" is not valid shell (`bash -n` exits 2) and is the
# same class of operator-facing failure #111 exists to remove (#125 /
# codex REQUEST_CHANGES on !114). Multi-candidate display uses separate
# prefixed lines (try: / or:); a single candidate (GitHub default) is a
# bare pasteable command — a try: prefix turns paste into a silent no-op
# (`try:` is not a command; the pipe's bash still exits 0). After a live
# probe succeeds the install loop rewrites BOX_MANUAL to the single URL
# that worked. Consumers MUST emit via box_manual_emit — never interpolate
# ${BOX_MANUAL} into a single log/warn string (multi-line orphans the or:
# line; claude REQUEST_CHANGES on 1c9a245).
box_manual_cmd() { # box_manual_cmd <url> — one pasteable install line
printf 'curl -fsSL %s | BOX_YES=1 BOX_REF=%s bash\n' "$1" "$BOX_REF"
}
box_manual_text() {
local _n=0 _url _cmd _urls=()
while IFS= read -r _url; do
[ -n "$_url" ] && _urls+=("$_url")
done < <(box_install_urls)
# Single candidate: bare command (no try:). Multi: try:/or: lines.
if [ "${#_urls[@]}" -le 1 ]; then
if [ "${#_urls[@]}" -eq 1 ]; then
_cmd="$(box_manual_cmd "${_urls[0]}")"
printf '%s' "$_cmd"
fi
return 0
fi
for _url in "${_urls[@]}"; do
_cmd="$(box_manual_cmd "$_url")"
_cmd="${_cmd%$'\n'}"
_n=$((_n + 1))
if [ "$_n" -eq 1 ]; then
printf 'try: %s\n' "$_cmd"
else
printf 'or: %s\n' "$_cmd"
fi
done
}
# Emit BOX_MANUAL one line at a time through log or warn. Never splice the
# multi-line value into a prose sentence.
box_manual_emit() { # box_manual_emit log|warn
local _fn="$1" _line
while IFS= read -r _line; do
[ -n "$_line" ] && "$_fn" " ${_line}"
done <<EOF
$BOX_MANUAL
EOF
}
BOX_INSTALL_URL="$(box_install_urls | head -n1)"
# Newline-separated recovery lines — each command (after optional try:/or:
# prefix) is independently pasteable.
BOX_MANUAL="$(box_manual_text)"
if [ "${RIG_SKIP_BOX_INSTALL:-}" = "1" ]; then if [ "${RIG_SKIP_BOX_INSTALL:-}" = "1" ]; then
log "RIG_SKIP_BOX_INSTALL=1 — skipping box install; to prepare Incus by hand later: ${BOX_MANUAL}" log "RIG_SKIP_BOX_INSTALL=1 — skipping box install; to prepare Incus by hand later:"
box_manual_emit log
elif ! command -v curl >/dev/null 2>&1; then elif ! command -v curl >/dev/null 2>&1; then
warn "curl not found — skipping box install; once curl is present, prepare Incus with: ${BOX_MANUAL}" warn "curl not found — skipping box install; once curl is present, prepare Incus with:"
box_manual_emit warn
else else
log "installing box (${BOX_REPO}@${BOX_REF}) and running its host setup — box owns Incus, not rig" log "installing box (${BOX_REPO}@${BOX_REF}) and running its host setup — box owns Incus, not rig"
# BOX_YES=1 in the environment: non-interactive AND keeps setup-host, so box # BOX_YES=1 in the environment: non-interactive AND keeps setup-host, so box
# builds the Incus stack rather than only dropping the CLI on PATH. Running as # builds the Incus stack rather than only dropping the CLI on PATH. Running as
# root, box installs globally (/opt/box + /usr/local/bin). No-op if box is # root, box installs globally (/opt/box + /usr/local/bin). No-op if box is
# already installed, so re-running bootstrap converges instead of reinstalling. # already installed, so re-running bootstrap converges instead of reinstalling.
# A curl failure (no network) fails the pipe under pipefail and lands in the # Download and execute are separate so a 404 on /raw/tag/ can fall through
# else — a warning, never an abort: box is the host extra, the OS+tailnet core # to /raw/branch/ without running a half-fetched body, and so an installer
# is already done. # that runs and fails is NOT retried against the next candidate.
if curl -fsSL "$BOX_INSTALL_URL" | BOX_YES=1 BOX_REF="$BOX_REF" bash; then # A curl failure (no network) lands in the else — a warning, never an
# abort: box is the host extra, the OS+tailnet core is already done.
BOX_SCRIPT="$(mktemp)"
BOX_GOT=""
while IFS= read -r _box_url; do
if curl -fsSL "$_box_url" -o "$BOX_SCRIPT"; then
BOX_GOT="$_box_url"
BOX_INSTALL_URL="$_box_url"
BOX_MANUAL="$(box_manual_cmd "$BOX_INSTALL_URL" | tr -d '\n')"
break
fi
done < <(box_install_urls)
if [ -n "$BOX_GOT" ] && BOX_YES=1 BOX_REF="$BOX_REF" bash "$BOX_SCRIPT"; then
# Don't trust the exit code — prove the effective state (issue #12). An # Don't trust the exit code — prove the effective state (issue #12). An
# installer can exit 0 having done less than it claims: box's setup-host # installer can exit 0 having done less than it claims: box's setup-host
# is written for a sudo-capable user, and one of its paths exits 0 after # is written for a sudo-capable user, and one of its paths exits 0 after
@ -741,14 +847,18 @@ if [ "$HOST" = "yes" ]; then
if box doctor >/dev/null 2>&1; then if box doctor >/dev/null 2>&1; then
log "box installed and host set up — 'box doctor' passed; mint guest boxes with 'box new'" log "box installed and host set up — 'box doctor' passed; mint guest boxes with 'box new'"
else else
warn "box is on PATH but 'box doctor' does not pass — the CLI landed, the host stack is unproven. Run 'box doctor' for the verdict, then 'box setup-host' (or finish by hand: ${BOX_MANUAL})" warn "box is on PATH but 'box doctor' does not pass — the CLI landed, the host stack is unproven. Run 'box doctor' for the verdict, then 'box setup-host' (or finish by hand:)"
box_manual_emit warn
fi fi
else else
warn "box's installer reported success but no 'box' is on PATH — the install did not take effect. Finish the host by hand: ${BOX_MANUAL}" warn "box's installer reported success but no 'box' is on PATH — the install did not take effect. Finish the host by hand:"
box_manual_emit warn
fi fi
else else
warn "box install did not complete (no network, or box's installer failed); bootstrap's core work is done. Finish the host by hand: ${BOX_MANUAL}" warn "box install did not complete (no network, or box's installer failed); bootstrap's core work is done. Finish the host by hand:"
box_manual_emit warn
fi fi
rm -f "$BOX_SCRIPT"
fi fi
fi fi
@ -784,6 +894,17 @@ if [ -n "$USERS_FILE" ]; then
"$HERE/users-apply.sh" --file "$USERS_FILE" "$HERE/users-apply.sh" --file "$USERS_FILE"
fi fi
# A registry machine's optional install is the final convergence phase: after
# join, host setup, the marker prerequisites, and operators. It inherits the
# caller environment, adds only the selected role, and runs from its definition
# directory. Definitions own idempotence, like bootstrap itself.
if [ -n "$MACHINE_TEMPLATE_DIR" ] && [ -e "$MACHINE_TEMPLATE_DIR/install.sh" ]; then
log "running install hook for ${ROLE} from $(templates_source_desc)"
if ! (cd "$MACHINE_TEMPLATE_DIR" && RIG_ROLE="$ROLE" bash ./install.sh); then
die "install hook failed for role $ROLE from $(templates_source_desc)"
fi
fi
log "done — role ${ROLE}, hostname ${TS_HOSTNAME}" log "done — role ${ROLE}, hostname ${TS_HOSTNAME}"
if [ "$ROLE" = "control-plane-server" ]; then if [ "$ROLE" = "control-plane-server" ]; then
log "next: rig coolify install --version <pin>" log "next: rig coolify install --version <pin>"

View file

@ -0,0 +1,557 @@
#!/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"
# shellcheck source=SCRIPTDIR/lib/admin-path.sh
. "$HERE/lib/admin-path.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.
#
# WHY act-22.04 (slim) for ubuntu-latest, not full-22.04 — measured 2026-08-01
# against ghcr manifests (#144):
# act-22.04: ~0.55 GB compressed / ~2.2 GB on disk — no shellcheck
# full-22.04: ~18.67 GB compressed / ~54.5 GB on disk — has shellcheck 0.8.0
# A normal box-class ci tenant cannot hold 54.5 GB (typical free space ~34 GB).
# So ubuntu-latest stays slim, and workflows must not assume GitHub-image tools
# (rig's own ci.yml installs shellcheck when missing). Operators who need the
# full tool surface opt in with runs-on: ubuntu-latest-full — that label is
# inert until matched, so boxes that never ask pay nothing.
DEFAULT_LABELS='ubuntu-latest:docker://ghcr.io/catthehacker/ubuntu:act-22.04,ubuntu-latest-full:docker://ghcr.io/catthehacker/ubuntu:full-22.04,docker:docker://node:22-bookworm'
# labels_are_a_retired_default <recorded>
#
# True when <recorded> is a past DEFAULT_LABELS value rig has shipped — the
# only plain-converge case that should warn about re-registration (#144).
# Custom operator maps (drill's Leg 3, any --labels) must return false so a
# bare re-run stays quiet. One pattern per past default; add when the string
# changes. Extracted and driven by test/cli.sh — a grep pin alone cannot prove
# the match is exact.
labels_are_a_retired_default() {
case "${1:-}" in
'ubuntu-latest:docker://ghcr.io/catthehacker/ubuntu:act-22.04,docker:docker://node:22-bookworm') return 0 ;;
*) return 1 ;;
esac
}
# 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 (slim act image), ubuntu-latest-full
(opt-in parity image), and docker onto containers so
a workflow written for GitHub runs; full tools need
runs-on: ubuntu-latest-full or an install step.
--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"
# Root is not enough: the admin binaries must also be reachable (#139). This
# sits beside the root check so identity and capability are asserted together,
# and BEFORE any prompt or download — a token typed for a doomed run is waste.
#
# BOTH binaries this command goes on to call: useradd at the user-create below,
# usermod at the docker-group add further down. Naming only the first would
# still consume the token on a PATH that happened to resolve useradd but not
# usermod — the same failure one step later, which is the shape #75 exists to
# refuse (a sweep that covers most of its call sites is the hole the next bug
# arrives through).
require_admin_bins useradd usermod
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"
# forgejo-runner's cache server writes to $HOME/.cache, which ProtectHome makes
# read-only below. Create it HERE, before the unit can reference it: a
# ReadWritePaths entry naming a path that does not exist makes systemd refuse
# to start the unit at all ("Failed to set up mount namespacing"), which is
# worse than the disabled cache it was meant to fix. Measured on a live runner,
# 2026-07-31 (#135). Root-owned would fail the same way under User=, so it
# carries the runner's own ownership like RUNNER_DIR above.
install -d -m 0755 -o "$RUNNER_USER" -g "$RUNNER_GROUP" "$USER_HOME/.cache"
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. Forgejo owns
# labels from registration time; a re-run never rewrites them.
#
# Two warn paths (#144):
# EXPLICIT --labels that differs → operator asked and it was not applied.
# Plain converge whose recorded labels match a known *retired* default →
# rig's default map moved (e.g. added ubuntu-latest-full). Without this
# the operator re-runs install, sees "already registered", and believes
# they have the new default while Forgejo still holds the old set.
#
# Do NOT warn on every RECORDED != current default: that fires forever for
# any runner the operator deliberately gave custom --labels (drill's Leg 3
# registers drill:docker://node:22-bookworm). The old LABELS_EXPLICIT-only
# gate existed to avoid that noise; retired-default matching keeps the
# silence for intentional maps and still catches silent drift off a past
# rig default. Re-register only to pick up labels the old set never had —
# nothing matching the recorded set is broken by the map change alone.
if [ -r "$RUNNER_DIR/.rig-labels" ]; then
RECORDED="$(cat "$RUNNER_DIR/.rig-labels")"
if [ "$LABELS_EXPLICIT" -eq 1 ] && [ "$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."
elif [ "$LABELS_EXPLICIT" -eq 0 ] && labels_are_a_retired_default "$RECORDED"; then
warn "this runner was registered with an older rig default label set. The current default adds ubuntu-latest-full (the GitHub-parity image). Labels are fixed at registration, so picking it up means re-registering: 'rig forgejo-runner remove' then install again. Nothing you run today is affected — re-register only if you want the new label."
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} ${USER_HOME}/.cache
[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."

118
commands/forgejo-runner-status.sh Executable file
View file

@ -0,0 +1,118 @@
#!/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}"; }
# forgejo_runner_liveness_note <systemctl-state> — what `active` does not cover.
#
# `active` is the strongest health signal this command has, and it proves only
# that a process exists — not that the runner is still asking Forgejo for work.
# A poller can go quiet while the daemon stays up: measured 2026-07-30 (#129,
# #133), a daemon logged "[poller] launched" and never fetched a job dispatched
# four minutes later, while a daemon started fresh claimed that same queued task
# in one second. Both times it read as a label-mapping bug on the forge, which
# is the wrong place to look.
#
# A FUNCTION rather than an inline `if`, because the state boundary is the part
# worth pinning: an absent or inactive unit must say nothing, and a grep over
# the source cannot tell the difference (codex/kimi, !134).
#
# log, not warn: nothing has been DETECTED here. An idle runner with no queued
# jobs is silent in exactly the same way a stalled one is, so there is no signal
# separating them — a warning on every status run would be crying wolf, and warn
# in this file means a drift actually measured (the .runner mode below).
forgejo_runner_liveness_note() {
[ "${1:-}" = active ] || return 0
log " note: 'active' is not proof the runner is fetching jobs — only that the process is up."
log " If a job stays queued and its run page says it never started, run"
log " 'systemctl restart forgejo-runner' and re-read before suspecting the labels."
}
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}"
forgejo_runner_liveness_note "${STATE:-}"
# 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,47 @@
#!/usr/bin/env bash
# admin-path.sh — assert the admin binaries are REACHABLE, not merely that we
# are root.
#
# Being uid 0 and being able to find useradd are different facts, and rig
# asserted only the first. `su` without `-`, sudo with a sanitised secure_path,
# and several container images all hand you a root shell whose PATH carries no
# /usr/sbin — which is where useradd, usermod and groupadd live on Debian. The
# result was a bare `useradd: command not found` naming a line number inside a
# versioned install root, emitted AFTER a registration token had been read off
# the operator's terminal (#139).
#
# Which binaries this covers is measured, not assumed (Debian 13, 2026-08-01):
#
# useradd usermod groupadd userdel groupdel /usr/sbin package: passwd
# visudo /usr/sbin package: sudo
# gpasswd /usr/bin package: passwd
#
# Two consequences worth keeping written down. `gpasswd` is in the same PACKAGE
# as useradd but a different DIRECTORY, so it is reachable on a PATH-shorn root
# and does not belong in any of these preflights — do not add it for symmetry.
# And `visudo` shares the directory but not the package, so its absence has a
# second, innocent cause (sudo simply not installed) that the others do not, so
# `rig users apply` checks it separately, after the point where that cause is
# ruled out. See the comment there.
#
# (Spelled without the `.sh` on purpose: test/cli.sh pins that exactly one file
# under commands/ names that script, to catch a second caller appearing. A
# comment is not a caller, but the pin is deliberately blunt and cheap.)
#
# It REFUSES rather than repairing PATH itself. A command that quietly prepends
# /usr/sbin teaches the operator nothing and leaves a misconfigured host
# misconfigured; the same reason bootstrap refuses rather than guessing. The
# message carries the fix so the refusal costs one paste, not an investigation.
# require_admin_bins <bin>... — die unless every one resolves on PATH.
require_admin_bins() {
local missing=() b
for b in "$@"; do
command -v "$b" >/dev/null 2>&1 || missing+=("$b")
done
[ "${#missing[@]}" -eq 0 ] && return 0
# Names the REMEDY, not this script: the operator typed a `rig ...` command,
# and echoing the internal path back at them is the unhelpful half of the
# original `useradd: command not found`.
die "cannot find ${missing[*]} on PATH — it lives in /usr/sbin, which this root shell does not carry (a 'su' without '-' does this, and so do some container images). Re-run the same rig command with: PATH=/usr/sbin:/sbin:\$PATH"
}

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

@ -14,8 +14,8 @@ json_field() {
| head -n1 | sed 's/.*:[[:space:]]*"//; s/"$//' || true | head -n1 | sed 's/.*:[[:space:]]*"//; s/"$//' || true
} }
# json_string_array <file> <key> — the elements of the FIRST array named <key>, # json_string_array <file> <key> — the elements of the array named <key> inside
# one per line, empty when the key is absent or the array is empty. # the netmap's `Self` object, one per line; empty when Self or the key is absent.
# #
# json_field's sibling for the one shape it cannot read: `.Self.Tags` from # json_field's sibling for the one shape it cannot read: `.Self.Tags` from
# `tailscale status --json` is a JSON array, and bootstrap must assert on it to # `tailscale status --json` is a JSON array, and bootstrap must assert on it to
@ -23,21 +23,44 @@ json_field() {
# not the tag rig requested. Same grep/sed spirit, same jq-free reason: a # not the tag rig requested. Same grep/sed spirit, same jq-free reason: a
# rig-bootstrapped box has no jq and we will not install one to read one field. # rig-bootstrapped box has no jq and we will not install one to read one field.
# #
# `tr -d '\n'` first, because tailscale pretty-prints its JSON and an array # Scoped to Self, NOT document-global. The previous body took the first "Tags"
# spans lines — grep is line-oriented and would never see `[ ... ]` whole # array anywhere in the file and justified it with Self-before-Peer field order.
# otherwise. `\[[^]]*\]` then captures the first flat array body for <key> # That holds only when Self HAS tags: an untagged Self omits the key entirely
# (tag strings never contain `]`, so this is safe); the inner `grep -o` pulls # (Go omitempty), so the match fell through into Peer and returned a PEER's tag
# every quoted token out of it, and `sed 1d` drops the key's own name — which # — silently inverting both callers on any tailnet with a tagged node (#160).
# `"key":[...]` leads with — leaving just the elements.
# #
# FIRST array wins by design, and the caller leans on it: `tailscale status # Self is brace-counted rather than sliced to the next key: PeerStatus carries a
# --json` emits Self before Peer (Go struct field order, stable), so the first # nested object (Location, a pointer with omitempty), which would end a naive
# "Tags" is the node's OWN, never a peer's. An absent key omits itself entirely # slice early whenever it is present. Known limit of staying jq-free: a `{` or
# (Go's omitempty) rather than emitting `[]` — which is exactly the untagged, # `}` inside a STRING value within Self would miscount — no PeerStatus string
# user-owned node bootstrap must catch. Never fails under `set -e`+pipefail: a # field (hostnames, DNS names, OS, key strings) can contain one, so this is
# non-match is a fact to test for, like json_field, not a reason to die. # sound in practice, but it is a real assumption, written down on purpose.
#
# `tr -d '\n'` first, because tailscale pretty-prints its JSON and the object
# spans lines — awk and grep are line-oriented and would never see it whole
# otherwise. `\[[^]]*\]` then captures the flat array body for <key> (tag
# strings never contain `]`, so this is safe); the inner `grep -o` pulls every
# quoted token out of it, and `sed 1d` drops the key's own name — which
# `"key":[...]` leads with — leaving just the elements. Never fails under
# `set -e`+pipefail: a non-match is a fact to test for, not a reason to die.
json_string_array() { json_string_array() {
tr -d '\n' < "$1" 2>/dev/null \ local self
self="$(tr -d '\n' < "$1" 2>/dev/null | awk '
{
i = index($0, "\"Self\"")
if (i == 0) exit
s = substr($0, i)
j = index(s, "{")
if (j == 0) exit
depth = 0
for (k = j; k <= length(s); k++) {
c = substr(s, k, 1)
if (c == "{") depth++
else if (c == "}") { depth--; if (depth == 0) { print substr(s, j, k - j + 1); exit } }
}
}')" || true
[ -n "$self" ] || return 0
printf '%s' "$self" \
| grep -o "\"$2\"[[:space:]]*:[[:space:]]*\[[^]]*\]" \ | grep -o "\"$2\"[[:space:]]*:[[:space:]]*\[[^]]*\]" \
| head -n1 | grep -o '"[^"]*"' | sed '1d; s/^"//; s/"$//' || true | head -n1 | grep -o '"[^"]*"' | sed '1d; s/^"//; s/"$//' || true
} }

View file

@ -18,7 +18,8 @@
# <role>/creds.md the per-vendor creds-free paragraph the context # <role>/creds.md the per-vendor creds-free paragraph the context
# renderer splices in # renderer splices in
# #
# THE SOURCE IS THREE KNOBS, precedence _DIR > _REF > pin: # 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 # RIG_TEMPLATES_DIR a local folder — bypasses the fetch entirely (the
# offline-test path, and "try a template before it # offline-test path, and "try a template before it
# exists anywhere") # exists anywhere")
@ -26,7 +27,10 @@
# bootstrap time (the same shape as the rig preinstall) # bootstrap time (the same shape as the rig preinstall)
# RIG_TEMPLATES_REPO which repo that ref lives in (default # RIG_TEMPLATES_REPO which repo that ref lives in (default
# heavy-duty/rig-templates) # heavy-duty/rig-templates)
# and, absent both overrides, the PIN below. # 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.
# The default registry ref a mint converges — the BOX_RELEASE discipline # The default registry ref a mint converges — the BOX_RELEASE discipline
# (#103): one line, bumped deliberately by ordinary rig PR after review, so a # (#103): one line, bumped deliberately by ordinary rig PR after review, so a
@ -39,41 +43,112 @@
# agent tenants ported byte-equivalent from the case arms this PR cut. # agent tenants ported byte-equivalent from the case arms this PR cut.
RIG_TEMPLATES_PIN=be749f7fd1ff8dd7c2359bbce7fd6abd3f403eb0 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 # The template.env schema. Grammar: blank lines, '#' comments, and
# KEY="value" — nothing else. Parsed by regex, never sourced. # KEY="value" — nothing else. Parsed by regex, never sourced.
TEMPLATE_KEYS_REQUIRED=(USER CONTEXT_PATH CLI_NAME PATH_LINE) TEMPLATE_KEYS_REQUIRED=(USER CONTEXT_PATH CLI_NAME PATH_LINE)
TEMPLATE_KEYS_OPTIONAL=(CLI_SRC NEEDS_NODE APT_EXTRAS) TEMPLATE_KEYS_OPTIONAL=(CLI_SRC NEEDS_NODE APT_EXTRAS)
MACHINE_KEYS_REQUIRED=(ROOT_DOOR HOST JOIN)
# templates_source_desc — where the resolved registry came from, for error # templates_source_desc — where the resolved registry came from, for error
# messages and logs: a misconfigured RIG_TEMPLATES_REPO must be visible in # messages and logs: a misconfigured RIG_TEMPLATES_REPO must be visible in
# the unknown-role refusal rather than looking like a typo. # 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() { templates_source_desc() {
local host="${RIG_TEMPLATES_HOST:-$RIG_TEMPLATES_HOST_DEFAULT}"
if [ -n "${RIG_TEMPLATES_DIR:-}" ]; then if [ -n "${RIG_TEMPLATES_DIR:-}" ]; then
printf 'local dir %s (RIG_TEMPLATES_DIR)' "$RIG_TEMPLATES_DIR" printf 'local dir %s (RIG_TEMPLATES_DIR)' "$RIG_TEMPLATES_DIR"
elif [ -z "${RIG_TEMPLATES_REF:-}" ] && templates_snapshot_usable; then
printf '%s@%s (snapshot)' \
"${RIG_TEMPLATES_REPO:-heavy-duty/rig-templates}" \
"$RIG_TEMPLATES_PIN"
else else
printf '%s@%s%s' \ printf '%s/%s@%s%s' \
"${host%/}" \
"${RIG_TEMPLATES_REPO:-heavy-duty/rig-templates}" \ "${RIG_TEMPLATES_REPO:-heavy-duty/rig-templates}" \
"${RIG_TEMPLATES_REF:-$RIG_TEMPLATES_PIN}" \ "${RIG_TEMPLATES_REF:-$RIG_TEMPLATES_PIN}" \
"$([ -n "${RIG_TEMPLATES_REF:-}" ] && printf ' (RIG_TEMPLATES_REF)' || printf ' (the in-tree pin)')" "$([ -n "${RIG_TEMPLATES_REF:-}" ] && printf ' (RIG_TEMPLATES_REF)' || printf ' (the in-tree pin)')"
fi fi
} }
# templates_resolve — resolve the three knobs to a LOCAL directory holding # The snapshot path is derived from this library's installed tree. Its
# pin-bearing directory name is the staleness guard: an older snapshot is
# invisible after a pin bump. A usable registry has at least one definition;
# an empty directory means an interrupted extraction and falls through to the
# same live fetch as an absent snapshot.
templates_snapshot_dir() {
local lib_dir
lib_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
printf '%s/templates@%s' "$(cd "$lib_dir/../.." && pwd)" "$RIG_TEMPLATES_PIN"
}
templates_snapshot_usable() {
local snapshot role_env
snapshot="$(templates_snapshot_dir)"
[ -d "$snapshot" ] || return 1
role_env="$(find "$snapshot" -mindepth 2 -maxdepth 2 -type f -name template.env -print -quit 2>/dev/null)"
[ -n "$role_env" ]
}
# templates_resolve — resolve the knobs to a LOCAL directory holding
# the registry, left in the REGISTRY_DIR global (a global, not stdout: a # the registry, left in the REGISTRY_DIR global (a global, not stdout: a
# $(…) call site would run the fetch in a subshell and lose TEMPLATES_TMP, # $(…) 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 # 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 # 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 # a temp dir, recorded in TEMPLATES_TMP. Candidate URLs come from
# precedence — a tag outranks a branch that shares its name — plus the bare # templates_archive_urls, which is forge-aware — see there for why the two
# archive/<ref> form, which is how a commit-SHA pin (the default) downloads. # forges cannot share one list.
#
# Failure lists every URL tried: the fetch is unauthenticated by contract # Failure lists every URL tried: the fetch is unauthenticated by contract
# (box auto-runs bootstrap at mint, holding nothing), so "is the repo public # (box auto-runs bootstrap at mint, holding nothing), so "is the repo public
# and the ref real" is the whole diagnosis. # 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="" TEMPLATES_TMP=""
# shellcheck disable=SC2034 # REGISTRY_DIR is this function's OUTPUT, read by the sourcing script # shellcheck disable=SC2034 # REGISTRY_DIR is this function's OUTPUT, read by the sourcing script
REGISTRY_DIR="" REGISTRY_DIR=""
templates_resolve() { templates_resolve() {
local repo ref url got="" local repo ref host url got=""
if [ -n "${RIG_TEMPLATES_DIR:-}" ]; then if [ -n "${RIG_TEMPLATES_DIR:-}" ]; then
[ -d "$RIG_TEMPLATES_DIR" ] || { [ -d "$RIG_TEMPLATES_DIR" ] || {
printf 'RIG_TEMPLATES_DIR is not a directory: %s\n' "$RIG_TEMPLATES_DIR" >&2 printf 'RIG_TEMPLATES_DIR is not a directory: %s\n' "$RIG_TEMPLATES_DIR" >&2
@ -82,31 +157,38 @@ templates_resolve() {
REGISTRY_DIR="$RIG_TEMPLATES_DIR" REGISTRY_DIR="$RIG_TEMPLATES_DIR"
return 0 return 0
fi fi
if [ -z "${RIG_TEMPLATES_REF:-}" ] && templates_snapshot_usable; then
REGISTRY_DIR="$(templates_snapshot_dir)"
return 0
fi
repo="${RIG_TEMPLATES_REPO:-heavy-duty/rig-templates}" repo="${RIG_TEMPLATES_REPO:-heavy-duty/rig-templates}"
ref="${RIG_TEMPLATES_REF:-$RIG_TEMPLATES_PIN}" 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 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; } command -v tar >/dev/null 2>&1 || { printf 'tar is required to extract the template registry\n' >&2; return 1; }
TEMPLATES_TMP="$(mktemp -d)" TEMPLATES_TMP="$(mktemp -d)"
for url in \ while IFS= read -r url; do
"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
if curl -fsSL "$url" -o "$TEMPLATES_TMP/templates.tar.gz" 2>/dev/null; then got="$url"; break; fi 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 if [ -z "$got" ]; then
printf 'cannot fetch the template registry %s@%s — tried:\n' "$repo" "$ref" >&2 printf 'cannot fetch the template registry %s/%s@%s — tried:\n' "${host%/}" "$repo" "$ref" >&2
printf ' https://github.com/%s/archive/refs/tags/%s.tar.gz\n' "$repo" "$ref" >&2 templates_archive_urls "$host" "$repo" "$ref" | sed 's/^/ /' >&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 '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 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 return 1
fi fi
tar -xzf "$TEMPLATES_TMP/templates.tar.gz" -C "$TEMPLATES_TMP" || { tar -xzf "$TEMPLATES_TMP/templates.tar.gz" -C "$TEMPLATES_TMP" || {
printf 'cannot extract the registry tarball from %s\n' "$got" >&2 printf 'cannot extract the registry tarball from %s\n' "$got" >&2
return 1 return 1
} }
# A GitHub archive holds exactly one top-level directory (<repo>-<ref>, # A source archive holds exactly one top-level directory — assert that
# slashes flattened) — assert that shape instead of assuming the name. # 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"/*/ set -- "$TEMPLATES_TMP"/*/
{ [ $# -eq 1 ] && [ -d "$1" ]; } || { { [ $# -eq 1 ] && [ -d "$1" ]; } || {
printf 'the registry tarball from %s does not hold exactly one top-level directory\n' "$got" >&2 printf 'the registry tarball from %s does not hold exactly one top-level directory\n' "$got" >&2
@ -128,6 +210,26 @@ templates_roles() {
done done
} }
# template_family <role> — directory names are the registry's family tag.
# workstation is the one intentional suffix-less machine role (#152 / epic D5).
template_family() {
case "$1" in
*-box) printf 'tenant\n' ;;
*-server|workstation) printf 'machine\n' ;;
*) return 1 ;;
esac
}
# templates_machine_roles <registry-dir> — only machine definitions, for the
# machine bootstrap's unknown-role refusal.
templates_machine_roles() {
local role
while IFS= read -r role; do
[ "$(template_family "$role" 2>/dev/null || true)" = "machine" ] || continue
printf '%s\n' "$role"
done < <(templates_roles "$1")
}
# template_parse_env <template.env> — parse against the allowlist. Sets # template_parse_env <template.env> — parse against the allowlist. Sets
# TPL_USER, TPL_CONTEXT_PATH, TPL_CLI_NAME, TPL_CLI_SRC, TPL_PATH_LINE, # TPL_USER, TPL_CONTEXT_PATH, TPL_CLI_NAME, TPL_CLI_SRC, TPL_PATH_LINE,
# TPL_NEEDS_NODE (default no), TPL_APT_EXTRAS. Every refusal names the # TPL_NEEDS_NODE (default no), TPL_APT_EXTRAS. Every refusal names the
@ -206,6 +308,62 @@ template_parse_env() {
done done
} }
# machine_template_parse_env <template.env> — the fleet-machine traits schema.
# The globals match bootstrap's table columns so a definition becomes a table
# row without changing any downstream trait behavior.
# shellcheck disable=SC2034
machine_template_parse_env() {
local file="$1" line key val n=0 seen=" " k ok
TPL_ROOT_DOOR="" TPL_HOST="" TPL_JOIN=""
[ -f "$file" ] || { printf 'template.env missing: %s\n' "$file" >&2; return 1; }
while IFS= read -r line || [ -n "$line" ]; do
n=$((n+1))
case "$line" in ''|'#'*) continue ;; esac
if [[ ! "$line" =~ ^([A-Z_]+)=\"(.*)\"$ ]]; then
printf 'template.env:%d: not KEY="value": %s\n' "$n" "$line" >&2
return 1
fi
key="${BASH_REMATCH[1]}" val="${BASH_REMATCH[2]}"
ok=""
for k in "${MACHINE_KEYS_REQUIRED[@]}"; do
[ "$key" = "$k" ] && ok=1
done
[ -n "$ok" ] || {
printf 'template.env:%d: unknown key: %s (allowed: %s)\n' \
"$n" "$key" "${MACHINE_KEYS_REQUIRED[*]}" >&2
return 1
}
case "$seen" in *" $key "*)
printf 'template.env:%d: duplicate key: %s\n' "$n" "$key" >&2
return 1 ;;
esac
seen="$seen$key "
case "$key" in
ROOT_DOOR) TPL_ROOT_DOOR="$val" ;;
HOST) TPL_HOST="$val" ;;
JOIN) TPL_JOIN="$val" ;;
esac
done < "$file"
for k in "${MACHINE_KEYS_REQUIRED[@]}"; do
case "$seen" in *" $k "*) ;; *)
printf 'template.env: missing required key: %s\n' "$k" >&2
return 1 ;;
esac
done
case "$TPL_ROOT_DOOR" in
open|closed) ;;
*) printf 'template.env: ROOT_DOOR: want open or closed, got: %s\n' "$TPL_ROOT_DOOR" >&2; return 1 ;;
esac
case "$TPL_HOST" in
yes|no) ;;
*) printf 'template.env: HOST: want yes or no, got: %s\n' "$TPL_HOST" >&2; return 1 ;;
esac
case "$TPL_JOIN" in
authkey|login) ;;
*) printf 'template.env: JOIN: want authkey or login, got: %s\n' "$TPL_JOIN" >&2; return 1 ;;
esac
}
# render_tenant_context <role> <creds.md> — the agent-context file's # render_tenant_context <role> <creds.md> — the agent-context file's
# content, on stdout: the one file every agent reads before touching # content, on stdout: the one file every agent reads before touching
# anything. The skeleton is MECHANISM and lives here once — the box#80 guard # anything. The skeleton is MECHANISM and lives here once — the box#80 guard
@ -248,13 +406,15 @@ EOF
# protects the registry, the mint-time parse protects a mint served through # protects the registry, the mint-time parse protects a mint served through
# RIG_TEMPLATES_REPO/_DIR that CI never saw. # RIG_TEMPLATES_REPO/_DIR that CI never saw.
template_lint() { template_lint() {
local dir="${1%/}" role local dir="${1%/}" role family
role="$(basename "$dir")" role="$(basename "$dir")"
[ -d "$dir" ] || { printf '%s: not a directory\n' "$dir" >&2; return 1; } [ -d "$dir" ] || { printf '%s: not a directory\n' "$dir" >&2; return 1; }
case "$role" in family="$(template_family "$role" 2>/dev/null || true)"
*-box|*-server) ;; [ -n "$family" ] || {
*) printf '%s: role directories carry a family suffix (-box for box tenants, -server for fleet machines — rig#76)\n' "$role" >&2; return 1 ;; printf '%s: role directories carry a family suffix (-box for box tenants, -server for fleet machines — rig#76; workstation is #152 machine carve-out)\n' "$role" >&2
esac return 1
}
if [ "$family" = "tenant" ]; then
template_parse_env "$dir/template.env" || return 1 template_parse_env "$dir/template.env" || return 1
[ -s "$dir/install.sh" ] \ [ -s "$dir/install.sh" ] \
|| { printf '%s: install.sh missing or empty\n' "$role" >&2; return 1; } || { printf '%s: install.sh missing or empty\n' "$role" >&2; return 1; }
@ -262,5 +422,16 @@ template_lint() {
|| { printf '%s: install.sh has no shebang\n' "$role" >&2; return 1; } || { printf '%s: install.sh has no shebang\n' "$role" >&2; return 1; }
grep -q '[^[:space:]]' "$dir/creds.md" 2>/dev/null \ grep -q '[^[:space:]]' "$dir/creds.md" 2>/dev/null \
|| { printf '%s: creds.md missing or blank (the context renderer splices it in — a blank paragraph would ship a context file with a hole)\n' "$role" >&2; return 1; } || { printf '%s: creds.md missing or blank (the context renderer splices it in — a blank paragraph would ship a context file with a hole)\n' "$role" >&2; return 1; }
else
machine_template_parse_env "$dir/template.env" || return 1
[ ! -e "$dir/creds.md" ] \
|| { printf '%s: creds.md is not allowed for machine roles (machines render no tenant context)\n' "$role" >&2; return 1; }
if [ -e "$dir/install.sh" ]; then
[ -s "$dir/install.sh" ] \
|| { printf '%s: install.sh is empty\n' "$role" >&2; return 1; }
head -n1 "$dir/install.sh" | grep -q '^#!' \
|| { printf '%s: install.sh has no shebang\n' "$role" >&2; return 1; }
fi
fi
return 0 return 0
} }

View file

@ -9,6 +9,8 @@ set -euo pipefail
HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)" HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
# shellcheck source=SCRIPTDIR/lib/runner-config.sh # shellcheck source=SCRIPTDIR/lib/runner-config.sh
. "$HERE/lib/runner-config.sh" . "$HERE/lib/runner-config.sh"
# shellcheck source=SCRIPTDIR/lib/admin-path.sh
. "$HERE/lib/admin-path.sh"
log() { printf 'rig-runner: %s\n' "$*"; } log() { printf 'rig-runner: %s\n' "$*"; }
warn() { printf 'rig-runner: WARNING: %s\n' "$*" >&2; } warn() { printf 'rig-runner: WARNING: %s\n' "$*" >&2; }
@ -85,6 +87,10 @@ VERSION="${VERSION#v}"
# --- guards ---------------------------------------------------------------- # --- guards ----------------------------------------------------------------
[ "$(id -u)" -eq 0 ] || die "must run as root" [ "$(id -u)" -eq 0 ] || die "must run as root"
# Root is not enough: the admin binaries must also be reachable (#139). This
# sits beside the root check so identity and capability are asserted together,
# and BEFORE any prompt or download — a token typed for a doomed run is waste.
require_admin_bins useradd
if [ -r /etc/os-release ]; then if [ -r /etc/os-release ]; then
# Sourced in a subshell: os-release defines VERSION (e.g. "13 (trixie)"), # Sourced in a subshell: os-release defines VERSION (e.g. "13 (trixie)"),
# which would clobber this script's $VERSION. # which would clobber this script's $VERSION.

View file

@ -1,5 +1,5 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# rig template-lint <role-dir>... — is this a valid tenant-role definition? # rig template-lint <role-dir>... — is this a valid role definition?
# #
# rig defines what a valid template is (the schema lives in # rig defines what a valid template is (the schema lives in
# lib/templates.sh, beside the mint-time parser that enforces it); the # lib/templates.sh, beside the mint-time parser that enforces it); the
@ -23,12 +23,15 @@ usage() {
cat <<'EOF' cat <<'EOF'
usage: rig template-lint <role-dir>... usage: rig template-lint <role-dir>...
Validate tenant-role definitions (the heavy-duty/rig-templates shape): Validate role definitions (the heavy-duty/rig-templates shape).
each <role-dir> must carry a family-suffixed name (rig#76), a template.env
that parses against the allowlist (KEY="value" only — the file is data, Tenant roles use a *-box directory, tenant template.env schema, a shebang
never sourced), an install.sh with a shebang, and a non-blank creds.md. install.sh, and non-blank creds.md. Machine roles use a *-server directory
Every refusal names the failing key or file. Exits non-zero if any (or exact name workstation), the ROOT_DOOR/HOST/JOIN schema, no creds.md,
definition fails; nothing is written. and an optional install.sh which must be non-empty and carry a shebang.
template.env is parsed as KEY="value" data and never sourced. Every refusal
names the failing key or file. Exits non-zero if any definition fails;
nothing is written.
EOF EOF
} }

View file

@ -11,6 +11,8 @@ set -euo pipefail
HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)" HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
# shellcheck source=SCRIPTDIR/lib/users-config.sh # shellcheck source=SCRIPTDIR/lib/users-config.sh
. "$HERE/lib/users-config.sh" . "$HERE/lib/users-config.sh"
# shellcheck source=SCRIPTDIR/lib/admin-path.sh
. "$HERE/lib/admin-path.sh"
log() { printf 'rig-users: %s\n' "$*"; } log() { printf 'rig-users: %s\n' "$*"; }
warn() { printf 'rig-users: WARNING: %s\n' "$*" >&2; } warn() { printf 'rig-users: WARNING: %s\n' "$*" >&2; }
@ -136,6 +138,10 @@ done <<< "$PARSED"
# --- guards ------------------------------------------------------------------ # --- guards ------------------------------------------------------------------
[ "$(id -u)" -eq 0 ] || die "must run as root" [ "$(id -u)" -eq 0 ] || die "must run as root"
# Root is not enough: the admin binaries must also be reachable (#139). This
# sits beside the root check so identity and capability are asserted together,
# and BEFORE any prompt or download — a token typed for a doomed run is waste.
require_admin_bins useradd usermod groupadd
# Identity management gates its INVOKER, not just its uid: %rig's sudoers rule # Identity management gates its INVOKER, not just its uid: %rig's sudoers rule
# is binary-scoped but not argument-scoped, so without this gate a rig-role # is binary-scoped but not argument-scoped, so without this gate a rig-role
@ -195,6 +201,26 @@ if [ "$NEED_SUDO" -eq 1 ] && ! command -v sudo >/dev/null 2>&1; then
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq sudo
CHANGED=1 CHANGED=1
fi fi
# visudo is checked HERE and not beside the root check, because until the block
# above has run there is a legitimate reason for it to be absent: sudo is not
# installed yet, and apply is what installs it. Above, a missing visudo would
# be indistinguishable from that, so the refusal would fire on a healthy box.
#
# Below, it is unambiguous. sudo is present, so `visudo` missing means only one
# thing: /usr/sbin is off PATH. And it MUST refuse here rather than be left to
# the sudoers block further down, because that block asks `command -v visudo`
# and treats false as "no sudo on the box means no role needed it" — which on a
# PATH-shorn root is FALSE TWICE. A role does need it, sudo is installed, and
# apply would finish reporting success having silently never written the
# sudoers drop-in: the users get their roles and not the escalation the roles
# are FOR. That is the failure this whole issue is about (a wrong effective
# state reported as success, #12), in its quietest form — the other three sites
# at least crash. Refusing before the first mutation is what keeps it loud.
#
# It sits before `groupadd` below, so nothing has been converged when it fires.
if [ "$NEED_SUDO" -eq 1 ]; then
require_admin_bins visudo
fi
# --- groups ------------------------------------------------------------------ # --- groups ------------------------------------------------------------------
groupadd -f rig-admin groupadd -f rig-admin

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.

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

@ -0,0 +1,129 @@
#!/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"
}
# CIBOX_BIN is a TEST-ONLY override, in the same spirit as bootstrap-undo.sh's
# RIG_FORGEJO_RUNNER_DIR: the production default is the only path the mechanism
# ever uses, but test/cli.sh must be able to drive this script on a box that
# already has a real runner installed. Without it the early-exit below fires
# against the host and the checksum checks silently test nothing (#136).
BIN="${CIBOX_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

@ -11,7 +11,7 @@ release (#105, and #107's debt).
- **A throwaway Debian 13 machine** you can format, reached as root. The - **A throwaway Debian 13 machine** you can format, reached as root. The
drill hardens its sshd, renames it, joins it to a tailnet, and installs drill hardens its sshd, renames it, joins it to a tailnet, and installs
box/Incus, Coolify and a GitHub runner on it. It is not coming back. box/Incus, Coolify and Actions runners on it. It is not coming back.
The machine is its own reset — there is no teardown script and no need The machine is its own reset — there is no teardown script and no need
for one. for one.
- **The pinned candidate refs, both of them.** `--rig-ref` and - **The pinned candidate refs, both of them.** `--rig-ref` and
@ -34,6 +34,18 @@ release (#105, and #107's debt).
and does something trivial (`echo drilled`). Tokens come from an and does something trivial (`echo drilled`). Tokens come from an
authenticated `gh`, or from `RUNNER_TOKEN` / `RUNNER_REMOVE_TOKEN`. authenticated `gh`, or from `RUNNER_TOKEN` / `RUNNER_REMOVE_TOKEN`.
Without a fork the leg **skips, loudly, into the record**. Without a fork the leg **skips, loudly, into the record**.
- **For leg 3's Forgejo half** (#129): `--forgejo-instance <url>` and
`--forgejo-runner-repo <owner>/<repo>`, where that repo carries the same
`workflow_dispatch` workflow — but with `runs-on: drill`, because a
Forgejo runner matches the bare label it registered with. Tokens come from
`FORGEJO_RUNNER_TOKEN` (a registration token) or `FORGEJO_API_TOKEN`, which
mints one and is also what dispatches the job. `--forgejo-ref` names the
branch to dispatch (default `main`): Forgejo's dispatch endpoint requires a
ref in the body, where GitHub's defaults to the repo's default branch.
Without an instance and a repo this half **skips, loudly and separately**.
Note there is no removal token — Forgejo has no deregistration endpoint, so
the leg removes locally and the record tells you to delete the stale runner
row by hand.
- **For leg 4** (coolify): a version pin, `--coolify-version 4.1.2`. - **For leg 4** (coolify): a version pin, `--coolify-version 4.1.2`.
No pin, no leg — rig's own `coolify install` refuses to default a No pin, no leg — rig's own `coolify install` refuses to default a
version and so does its drill. The skip is recorded. version and so does its drill. The skip is recorded.
@ -80,7 +92,11 @@ passes, failures and skips separately.
skip, exit 0) survives into the record as a SKIP, never a pass. skip, exit 0) survives into the record as a SKIP, never a pass.
3. **Runner lifecycle** — register against the fork, dispatch the drill 3. **Runner lifecycle** — register against the fork, dispatch the drill
workflow and watch the runner take it, deregister, and assert the workflow and watch the runner take it, deregister, and assert the
box's registration is actually gone. box's registration is actually gone. Runs **once per forge**: `rig runner`
against GitHub, then `rig forgejo-runner` against a Forgejo instance
(#129). Both families ship, so a release that evidences only one
evidences half of what it ships; each half skips separately, so a record
can honestly show one forge drilled and the other not.
4. **Coolify** — installed at the pin, `AUTOUPDATE=false` landed in the 4. **Coolify** — installed at the pin, `AUTOUPDATE=false` landed in the
effective `.env`, container running. effective `.env`, container running.

View file

@ -4,13 +4,16 @@
# ⚠ DESTRUCTIVE, AND MEANT TO BE. Run it on a THROWAWAY Debian machine you # ⚠ DESTRUCTIVE, AND MEANT TO BE. Run it on a THROWAWAY Debian machine you
# can format. It wipes any installed rig and reinstalls from the pinned # can format. It wipes any installed rig and reinstalls from the pinned
# ref, hardens sshd, sets the hostname, joins the tailnet, installs box # ref, hardens sshd, sets the hostname, joins the tailnet, installs box
# and its Incus stack, installs Coolify and a GitHub Actions runner. # and its Incus stack, installs Coolify and Actions runners (GitHub, and
# Forgejo when --forgejo-instance is given).
# Never run it on a machine you care about. # Never run it on a machine you care about.
# #
# TS_AUTHKEY=tskey-... bash drill/drill.sh \ # TS_AUTHKEY=tskey-... bash drill/drill.sh \
# --rig-ref release/0.4.0 --box-ref 0.9.0 \ # --rig-ref release/0.4.0 --box-ref 0.9.0 \
# --users ./drill-users --run-id drill-2026-07-24-a \ # --users ./drill-users --run-id drill-2026-07-24-a \
# --coolify-version 4.1.2 --runner-repo you/rig --yes # --coolify-version 4.1.2 --runner-repo you/rig \
# --forgejo-instance https://forgejo.example.com \
# --forgejo-runner-repo you/drill-probe --yes
# (--box-ref is a tag: since #103 the box that ships is the BOX_RELEASE tag.) # (--box-ref is a tag: since #103 the box that ships is the BOX_RELEASE tag.)
# rig's drill asserts CONVERGENCE — a machine reaches its role, idempotently. # rig's drill asserts CONVERGENCE — a machine reaches its role, idempotently.
# The legs (drills/README.md, issue #105): # The legs (drills/README.md, issue #105):
@ -22,6 +25,10 @@
# the isolation boundary is box's drill's assertion, not this one's). # the isolation boundary is box's drill's assertion, not this one's).
# 2. db — the real dump/restore round-trip, test/db-integration.sh. # 2. db — the real dump/restore round-trip, test/db-integration.sh.
# 3. runner lifecycle — register, take a job, deregister, against a fork. # 3. runner lifecycle — register, take a job, deregister, against a fork.
# Runs once per forge: `rig runner` against GitHub (--runner-repo), and
# `rig forgejo-runner` against a Forgejo instance (--forgejo-instance +
# --forgejo-runner-repo). Both forges ship, so both need evidence; each
# skips loudly and separately when its inputs are absent (#129).
# 4. coolify install — at a pinned version, AUTOUPDATE=false. # 4. coolify install — at a pinned version, AUTOUPDATE=false.
# #
# Execution order is 1, 4, 2, 3 — coolify's installer is what puts Docker on # Execution order is 1, 4, 2, 3 — coolify's installer is what puts Docker on
@ -59,6 +66,7 @@ BOXREF="${BOX_REF:-}"
TPLREPO="${RIG_TEMPLATES_REPO:-heavy-duty/rig-templates}" TPLREPO="${RIG_TEMPLATES_REPO:-heavy-duty/rig-templates}"
TPLREF="${RIG_TEMPLATES_REF:-}" TPLREF="${RIG_TEMPLATES_REF:-}"
TPL_SHA="" TPL_SHA=""
TPL_SOURCE="fetched"
ROLE=staging-server ROLE=staging-server
USERS_FILE="${DRILL_USERS_FILE:-}" USERS_FILE="${DRILL_USERS_FILE:-}"
RUN_ID="${DRILL_RUN_ID:-drill-$(date -u +%F)}" RUN_ID="${DRILL_RUN_ID:-drill-$(date -u +%F)}"
@ -66,6 +74,11 @@ RECORD="${DRILL_RECORD:-}"
COOLIFY_VERSION="${DRILL_COOLIFY_VERSION:-}" COOLIFY_VERSION="${DRILL_COOLIFY_VERSION:-}"
RUNNER_REPO="${DRILL_RUNNER_REPO:-}" RUNNER_REPO="${DRILL_RUNNER_REPO:-}"
RUNNER_WORKFLOW="${DRILL_RUNNER_WORKFLOW:-drill.yml}" RUNNER_WORKFLOW="${DRILL_RUNNER_WORKFLOW:-drill.yml}"
FJ_INSTANCE="${DRILL_FORGEJO_INSTANCE:-}"
FJ_RUNNER_REPO="${DRILL_FORGEJO_RUNNER_REPO:-}"
# The branch the dispatch names. Forgejo's dispatch endpoint requires a ref in
# the body — unlike GitHub's, which defaults to the repo's default branch.
FJ_REF="${DRILL_FORGEJO_REF:-main}"
YES=0 YES=0
while [ $# -gt 0 ]; do while [ $# -gt 0 ]; do
@ -82,7 +95,10 @@ while [ $# -gt 0 ]; do
--coolify-version) COOLIFY_VERSION="$2"; shift 2 ;; --coolify-version) COOLIFY_VERSION="$2"; shift 2 ;;
--runner-repo) RUNNER_REPO="$2"; shift 2 ;; --runner-repo) RUNNER_REPO="$2"; shift 2 ;;
--runner-workflow) RUNNER_WORKFLOW="$2"; shift 2 ;; --runner-workflow) RUNNER_WORKFLOW="$2"; shift 2 ;;
-h|--help) sed -n '2,33p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; --forgejo-instance) FJ_INSTANCE="$2"; shift 2 ;;
--forgejo-runner-repo) FJ_RUNNER_REPO="$2"; shift 2 ;;
--forgejo-ref) FJ_REF="$2"; shift 2 ;;
-h|--help) sed -n '2,40p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) echo "drill: unknown option: $1 (see --help)" >&2; exit 2 ;; *) echo "drill: unknown option: $1 (see --help)" >&2; exit 2 ;;
esac esac
done done
@ -107,6 +123,133 @@ phase(){ printf '\n\033[1m══ %s\033[0m\n' "$*"; }
LEG_NAMES=(); LEG_RESULTS=() LEG_NAMES=(); LEG_RESULTS=()
leg() { LEG_NAMES+=("$1"); LEG_RESULTS+=("$2"); } leg() { LEG_NAMES+=("$1"); LEG_RESULTS+=("$2"); }
# forgejo_run_verdict <pre_id> <tasks-json> — the verdict for OUR dispatch:
# success | failed | pending. Reads GET /repos/{o}/{r}/actions/tasks, whose
# shape is NOT GitHub's and was measured against forgejo.heavyduty.builders
# (8.0.3+gitea-1.22.0) on 2026-07-30 rather than read from the docs:
#
# * There is no `conclusion` field. `status` carries the terminal outcome
# directly ("success"), where GitHub splits status:completed +
# conclusion:success. Reading `conclusion` here gets an empty string on
# every run, which would grade a green job as failed.
# * `id` is a GLOBAL task id; the run's own URL ends in `run_number`. The
# pre-dispatch guard therefore compares `id`, exactly as the GitHub leg
# compares databaseId — an old run must never be read as this one.
# * The payload lists ASSIGNED tasks only. A run sitting queued is simply
# absent (measured: 200s of total_count 0 while the web UI showed the run
# as "job is not started"). So "no new id" is the ONLY signal that the
# runner never took the job — which is the verdict this leg exists for.
# * A task appears when ASSIGNED, so it can be seen mid-flight. A
# non-terminal status is pending, not failed; grading a running job as a
# failure would make the leg flaky inside its own watch window.
#
# EVERY entry is inspected, and the NEWEST id above pre_id decides — never
# entry[0]. actions/tasks accumulates, so the moment a repo is drilled twice
# our run shares the payload with older ones, and nothing documents the sort
# order. Reading the first entry made a green job report as a timeout, a false
# FAILURE on the gate this leg exists to provide (grok/kimi on !130).
#
# grep-and-sed, not jq: a throwaway drill machine has neither jq nor an
# authenticated forge CLI, the same constraint json_field() carries in
# commands/lib/runner-config.sh. `id` is a bare number, which json_field's
# quoted-value shape cannot read, so this reads both forms itself. Newlines are
# stripped first so a pretty-printed payload parses identically to a compact
# one — the instance documents neither.
forgejo_run_verdict() {
local pre="$1" file="$2" obj id best_id="" best_st=""
[ -r "$file" ] || { echo pending; return 0; }
while IFS= read -r obj; do
[ -n "$obj" ] || continue
id="$(printf '%s' "$obj" | grep -o '"id"[[:space:]]*:[[:space:]]*[0-9][0-9]*' \
| head -n1 | sed 's/.*:[[:space:]]*//')"
[ -n "$id" ] || continue
# Strictly newer than the pre-dispatch id. Equal is the run that was
# already there; lower is older still.
if [ -n "$pre" ]; then
[ "$id" -gt "$pre" ] 2>/dev/null || continue
fi
if [ -z "$best_id" ] || [ "$id" -gt "$best_id" ] 2>/dev/null; then
best_id="$id"
best_st="$(printf '%s' "$obj" | grep -o '"status"[[:space:]]*:[[:space:]]*"[^"]*"' \
| head -n1 | sed 's/.*:[[:space:]]*"//; s/"$//')"
fi
done <<EOF
$(tr -d '\n' < "$file" 2>/dev/null | grep -o '{[^{}]*}')
EOF
[ -n "$best_id" ] || { echo pending; return 0; }
case "$best_st" in
success) echo success ;;
failure | cancelled | skipped | timedout) echo failed ;;
*) echo pending ;;
esac
}
# forgejo_leg_row <install_ok> <status_ok> <took> <remove_ok> <absent_ok>
# The record row for the Forgejo runner leg. PASS requires the WHOLE lifecycle,
# not just the take-a-job outcome.
#
# Keying the row on <took> alone let it read "PASS — registered, took a job,
# removed" when install had failed, because the dispatched job only needs
# SOMETHING answering runs-on: drill — and this leg removes locally, telling the
# operator to delete the stale runner by hand, so a leftover drill-labeled
# runner from the previous drill is the designed-for aftermath rather than a
# contrived case (codex/grok/kimi on !130). drills/<v>.md is the release's
# durable evidence; a row claiming a lifecycle that did not happen is exactly
# what the gate exists to refuse.
#
# The drill's exit code was never wrong here — every one of those failures also
# called `no`. What was wrong is the row, and the row is what outlives the run.
forgejo_leg_row() {
local install_ok="$1" status_ok="$2" took="$3" remove_ok="$4" absent_ok="$5"
if [ "$install_ok" != 1 ] || [ "$status_ok" != 1 ] \
|| [ "$remove_ok" != 1 ] || [ "$absent_ok" != 1 ]; then
echo "FAIL — see Failed below"
return 0
fi
case "$took" in
success) echo "PASS — registered, took a job, removed (stale row needs deleting by hand)" ;;
none) echo "PARTIAL — registered and removed; took a job: not attempted (no FORGEJO_API_TOKEN)" ;;
*) echo "FAIL — see Failed below" ;;
esac
}
# forgejo_max_task_id <tasks-json> — the highest numeric task id in the payload,
# empty when there is none. This is the PRE-DISPATCH baseline, and it must fold
# max exactly as forgejo_run_verdict does: taking the first id instead names an
# OLD run as the baseline whenever the payload is not newest-first (the order is
# undocumented). A later poll that finds the same body then reads the PREVIOUS
# drill's run as this dispatch's result — a false PASS on the take-a-job
# assertion, which is worse than the false FAIL the same mistake caused inside
# the verdict (grok/kimi, !130). The GitHub leg is safe from this only because
# `gh run list --limit 1` contracts newest-first; this API contracts nothing.
forgejo_max_task_id() {
local file="$1" id best=""
[ -r "$file" ] || return 0
while IFS= read -r id; do
[ -n "$id" ] || continue
if [ -z "$best" ] || [ "$id" -gt "$best" ] 2>/dev/null; then best="$id"; fi
done <<EOF
$(tr -d '\n' < "$file" 2>/dev/null \
| grep -o '"id"[[:space:]]*:[[:space:]]*[0-9][0-9]*' | sed 's/.*:[[:space:]]*//')
EOF
printf '%s\n' "$best"
}
# forgejo_token_verdict <resolved_token> <api_token> — ok | mint-failed | no-source.
# #129's acceptance: "Token source present but the instance is unreachable ->
# the leg FAILS; it must not skip and must not pass". A mint that yields
# nothing — unreachable instance, under-scoped token, wrong repo — is a
# CONFIGURED leg failing, and reporting it as "no token source" both writes
# SKIPPED where the record owes a FAIL and sends the operator to check an env
# var they already set. Absent inputs are the only honest skip.
forgejo_token_verdict() {
if [ -n "$1" ]; then echo ok
elif [ -n "$2" ]; then echo mint-failed
else echo no-source
fi
}
# run_logged <log> <cmd...> — run a long command with its narration in a file # run_logged <log> <cmd...> — run a long command with its narration in a file
# and a dot every 5s on the terminal: a silent multi-minute apt/install run is # and a dot every 5s on the terminal: a silent multi-minute apt/install run is
# indistinguishable from a wedge, and that ambiguity has cost box whole # indistinguishable from a wedge, and that ambiguity has cost box whole
@ -274,8 +417,9 @@ emit_record() {
printf 'Run ID: %s. Host: %s, %s vCPU / %s GB RAM (%s).\n' "$RUN_ID" "${os:-unknown}" "$cpus" "$ram" "$virt" printf 'Run ID: %s. Host: %s, %s vCPU / %s GB RAM (%s).\n' "$RUN_ID" "${os:-unknown}" "$cpus" "$ram" "$virt"
printf 'Candidate refs: rig@%s (RIG_REF=%s), box@%s (BOX_REF=%s).\n' \ printf 'Candidate refs: rig@%s (RIG_REF=%s), box@%s (BOX_REF=%s).\n' \
"${RIG_SHA:-unresolved}" "$REF" "${BOX_SHA:-unresolved}" "$BOXREF" "${RIG_SHA:-unresolved}" "$REF" "${BOX_SHA:-unresolved}" "$BOXREF"
printf 'Template registry: %s@%s (ref %s) — the rig-templates the converge read (#110).\n' \ printf 'Template registry: %s@%s (ref %s, %s) — the rig-templates source the converge read (#110/#153).\n' \
"${TPLREPO:-heavy-duty/rig-templates}" "${TPL_SHA:-unresolved}" "${TPLREF:-unresolved}" "${TPLREPO:-heavy-duty/rig-templates}" "${TPL_SHA:-unresolved}" \
"${TPLREF:-unresolved}" "${TPL_SOURCE:-fetched}"
printf 'Instrument: drill/drill.sh, legs in execution order.\n\n' printf 'Instrument: drill/drill.sh, legs in execution order.\n\n'
printf '| Leg | Result |\n' printf '| Leg | Result |\n'
printf '| --- | --- |\n' printf '| --- | --- |\n'
@ -350,7 +494,7 @@ This will, ON THIS HOST ($(hostname)):
· run 'rig bootstrap $ROLE --users $USERS_FILE' — sshd hardening, hostname · run 'rig bootstrap $ROLE --users $USERS_FILE' — sshd hardening, hostname
change, tailnet join, box ($BOXREPO@$BOXREF) + its Incus stack — TWICE change, tailnet join, box ($BOXREPO@$BOXREF) + its Incus stack — TWICE
(the second run is the idempotence assertion) (the second run is the idempotence assertion)
· install Coolify${COOLIFY_VERSION:+ $COOLIFY_VERSION} and a GitHub runner${RUNNER_REPO:+ against $RUNNER_REPO} · install Coolify${COOLIFY_VERSION:+ $COOLIFY_VERSION}, a GitHub runner${RUNNER_REPO:+ against $RUNNER_REPO} and a Forgejo runner${FJ_INSTANCE:+ against $FJ_INSTANCE}${FJ_RUNNER_REPO:+ ($FJ_RUNNER_REPO)}
Only do this on a THROWAWAY machine you can format. Only do this on a THROWAWAY machine you can format.
EOF EOF
[ -t 0 ] || { echo "drill: no TTY to confirm on — pass --yes if you mean it." >&2; exit 2; } [ -t 0 ] || { echo "drill: no TTY to confirm on — pass --yes if you mean it." >&2; exit 2; }
@ -396,13 +540,17 @@ ok "installed tree confirms: $REPO@$REF (version $DRILL_VERSION)"
# through ref_sha like the two candidates above. # through ref_sha like the two candidates above.
if [ -z "$TPLREF" ]; then if [ -z "$TPLREF" ]; then
TPLREF="$(sed -n 's/^RIG_TEMPLATES_PIN=//p' "$RIG_TREE/commands/lib/templates.sh" 2>/dev/null | head -n1)" TPLREF="$(sed -n 's/^RIG_TEMPLATES_PIN=//p' "$RIG_TREE/commands/lib/templates.sh" 2>/dev/null | head -n1)"
if [ -n "$TPLREF" ] &&
[ -n "$(find "$RIG_TREE/templates@$TPLREF" -mindepth 2 -maxdepth 2 -type f -name template.env -print -quit 2>/dev/null)" ]; then
TPL_SOURCE="snapshot"
fi
fi fi
if [[ "$TPLREF" =~ ^[0-9a-f]{40}$ ]]; then if [[ "$TPLREF" =~ ^[0-9a-f]{40}$ ]]; then
TPL_SHA="${TPLREF:0:7}" TPL_SHA="${TPLREF:0:7}"
elif [ -n "$TPLREF" ]; then elif [ -n "$TPLREF" ]; then
TPL_SHA="$(ref_sha "$TPLREPO" "$TPLREF")" TPL_SHA="$(ref_sha "$TPLREPO" "$TPLREF")"
fi fi
inf "templates: $TPLREPO@${TPLREF:-unresolved} (${TPL_SHA:-unresolved})" inf "templates: $TPLREPO@${TPLREF:-unresolved} (${TPL_SHA:-unresolved}, $TPL_SOURCE)"
[ -n "$RECORD" ] || RECORD="$ROOT/drills/$DRILL_VERSION.md" [ -n "$RECORD" ] || RECORD="$ROOT/drills/$DRILL_VERSION.md"
# ============================================================================= # =============================================================================
@ -684,6 +832,139 @@ else
fi fi
fi fi
# =============================================================================
phase "Leg 3 — forgejo runner lifecycle against an instance"
# =============================================================================
# The same leg as above, for the other forge. Both forges ship a runner family
# (#109 added `rig forgejo-runner` beside `rig runner`), so a release that
# evidences only GitHub evidences half of what it ships (#129).
#
# Three things differ from the GitHub leg, all measured against
# forgejo.heavyduty.builders (8.0.3+gitea-1.22.0) on 2026-07-30, not read:
#
# * Scope is the TOKEN's, never a flag — `rig forgejo-runner install` refuses
# --repo on purpose (commands/forgejo-runner-install.sh:160). The repo here
# is only where the registration token is minted from, and where the
# dispatched workflow lives.
# * The mint path is /repos/<o>/<r>/actions/runners/registration-token. The
# instance's own swagger documents /repos/<o>/<r>/runners/registration-token
# — WITHOUT /actions/ — and that path 404s. Do not "fix" this to match the
# published API reference.
# * There is no deregistration endpoint, so there is no removal token and no
# remote deregistration: `rig forgejo-runner remove` is local-only by
# design (commands/forgejo-runner-remove.sh:7-11) and the runner row
# survives in the UI until a human deletes it. The record says so rather
# than implying a clean remote teardown the way the GitHub leg can.
#
# Tokens: FORGEJO_RUNNER_TOKEN (a registration token) is used directly; else
# FORGEJO_API_TOKEN mints one from FJ_RUNNER_REPO. Without an instance, a repo,
# or a token source the leg SKIPS loudly and the record says it did not run.
if [ -z "$FJ_INSTANCE" ] || [ -z "$FJ_RUNNER_REPO" ]; then
skip "forgejo runner lifecycle: no --forgejo-instance/--forgejo-runner-repo given — the leg did not run"
leg "forgejo runner lifecycle" "SKIPPED — no instance/repo provided"
else
fj_reg="${FORGEJO_RUNNER_TOKEN:-}"
if [ -z "$fj_reg" ] && [ -n "${FORGEJO_API_TOKEN:-}" ]; then
fj_reg="$(curl -fsSL -H "Authorization: token ${FORGEJO_API_TOKEN}" \
"${FJ_INSTANCE%/}/api/v1/repos/${FJ_RUNNER_REPO}/actions/runners/registration-token" 2>/dev/null \
| grep -o '"token"[[:space:]]*:[[:space:]]*"[^"]*"' | head -n1 \
| sed 's/.*:[[:space:]]*"//; s/"$//')"
fi
fj_tok_verdict="$(forgejo_token_verdict "$fj_reg" "${FORGEJO_API_TOKEN:-}")"
if [ "$fj_tok_verdict" = mint-failed ]; then
# Configured, and it did not work. Never a skip: see forgejo_token_verdict.
no "registration-token mint FAILED against ${FJ_INSTANCE} — is it reachable, and does FORGEJO_API_TOKEN own ${FJ_RUNNER_REPO}? (the token is never printed)"
leg "forgejo runner lifecycle ($FJ_RUNNER_REPO)" "FAIL — registration-token mint failed"
elif [ "$fj_tok_verdict" = no-source ]; then
skip "forgejo runner lifecycle: no FORGEJO_RUNNER_TOKEN and no FORGEJO_API_TOKEN to mint one — the leg did not run"
leg "forgejo runner lifecycle ($FJ_RUNNER_REPO)" "SKIPPED — no registration token source"
else
FJ_NAME="drill-$(hostname)-$$"
fj_install_ok=0 fj_status_ok=0 fj_remove_ok=0 fj_absent_ok=0
# The label MUST carry a docker:// image: forgejo-runner runs jobs in
# containers, and a bare label leaves runs-on matched but unrunnable.
if FORGEJO_RUNNER_TOKEN="$fj_reg" run_logged /tmp/drill-forgejo-runner-install.log \
rig forgejo-runner install --instance "$FJ_INSTANCE" --name "$FJ_NAME" \
--labels 'drill:docker://node:22-bookworm'; then
ok "rig forgejo-runner install --instance $FJ_INSTANCE exited 0 (registered as $FJ_NAME)"
fj_install_ok=1
else
no "forgejo-runner install FAILED — tail: $(tail -3 /tmp/drill-forgejo-runner-install.log | tr '\n' ' ')"
fi
if rig forgejo-runner status 2>/dev/null | grep -qF "${FJ_INSTANCE%/}"; then
ok "forgejo-runner status names the instance: $FJ_INSTANCE"; fj_status_ok=1
else
no "forgejo-runner status does not name ${FJ_INSTANCE}"
fi
fj_took=none
# Do NOT dispatch once install or status has failed. The job would be taken
# by whatever else answers runs-on: drill — a stale runner this leg's own
# hand-delete caveat leaves behind — and its success would be evidence about
# someone else's runner (codex/grok/kimi, !130).
if [ "$fj_install_ok" != 1 ] || [ "$fj_status_ok" != 1 ]; then
skip "took a job: not attempted — install or status failed, and a foreign runner answering 'drill' could only manufacture a false pass"
elif [ -n "${FORGEJO_API_TOKEN:-}" ]; then
fj_api="${FJ_INSTANCE%/}/api/v1/repos/${FJ_RUNNER_REPO}"
# Read the newest ASSIGNED task id BEFORE dispatching, same guard as the
# GitHub leg: an already-completed run must never be read as ours.
fj_pre_body="$(curl -fsSL -H "Authorization: token ${FORGEJO_API_TOKEN}" \
"$fj_api/actions/tasks" 2>/dev/null || echo '{}')"
printf '%s' "$fj_pre_body" > /tmp/drill-forgejo-pre.json
fj_pre="$(forgejo_max_task_id /tmp/drill-forgejo-pre.json)"
if curl -fsSL -o /dev/null -X POST -H "Authorization: token ${FORGEJO_API_TOKEN}" \
-H "Content-Type: application/json" -d "{\"ref\":\"${FJ_REF}\"}" \
"$fj_api/actions/workflows/${RUNNER_WORKFLOW}/dispatches" 2>/dev/null; then
inf "dispatched $RUNNER_WORKFLOW on $FJ_RUNNER_REPO — waiting for the runner to take it (≤5 min)…"
fj_took=timeout
for _i in $(seq 1 30); do
sleep 10
curl -fsSL -H "Authorization: token ${FORGEJO_API_TOKEN}" \
"$fj_api/actions/tasks" -o /tmp/drill-forgejo-tasks.json 2>/dev/null || continue
case "$(forgejo_run_verdict "${fj_pre:-}" /tmp/drill-forgejo-tasks.json)" in
success) fj_took=success; break ;;
failed) fj_took=failed; break ;;
*) : ;; # pending — queued, or assigned and still running
esac
done
else
fj_took=nodispatch
fi
case "$fj_took" in
success) ok "the forgejo runner took a job and it succeeded ($RUNNER_WORKFLOW)" ;;
failed) no "the dispatched job completed UNSUCCESSFULLY — the runner ran it, the workflow failed; read the run on $FJ_RUNNER_REPO" ;;
# A queued task is INVISIBLE in this API until a runner claims it, so a
# timeout means "nothing ever took it". Two causes, and the second one
# is not rig's: the runs-on label may not match, or the daemon's poller
# can go quiet — a restarted daemon claims a minutes-old backlog in
# about a second. Check 'systemctl restart forgejo-runner' before
# reading this as a rig defect.
timeout) no "the dispatched job was never taken within 5 min — check the workflow's runs-on is 'drill', then restart forgejo-runner and re-read (a quiet poller looks exactly like this)" ;;
nodispatch) no "could not dispatch $RUNNER_WORKFLOW on $FJ_RUNNER_REPO — does it carry that workflow, with workflow_dispatch, on its default branch?" ;;
esac
else
skip "took a job: not attempted — no FORGEJO_API_TOKEN to dispatch $RUNNER_WORKFLOW with"
fi
# No removal token exists on this forge — remove is local by design.
if rig forgejo-runner remove >/dev/null 2>&1; then
note "forgejo-runner removed locally — Forgejo has no deregistration endpoint, so DELETE the stale '$FJ_NAME' row under $FJ_RUNNER_REPO > Settings > Actions > Runners by hand"
fj_remove_ok=1
else
no "forgejo-runner remove FAILED"
fi
if rig forgejo-runner status >/dev/null 2>&1; then
no "forgejo-runner status still answers after remove — the removal did not take"
else
ok "forgejo-runner status confirms: nothing registered"; fj_absent_ok=1
fi
leg "forgejo runner lifecycle ($FJ_RUNNER_REPO)" \
"$(forgejo_leg_row "$fj_install_ok" "$fj_status_ok" "$fj_took" \
"$fj_remove_ok" "$fj_absent_ok")"
fi
fi
# ============================================================================= # =============================================================================
phase "Summary" phase "Summary"
# ============================================================================= # =============================================================================

View file

@ -113,6 +113,7 @@ Candidate refs: box@1a2b3c4 (BOX_REF=release/0.4.0), rig@5d6e7f8, cast@9a0b1c2.
| --host yes: pinned box installed, host stack up | PASS — box doctor clean | | --host yes: pinned box installed, host stack up | PASS — box doctor clean |
| `test/db-integration.sh` | PASS — 14 passed, 0 failed | | `test/db-integration.sh` | PASS — 14 passed, 0 failed |
| runner lifecycle against a fork | PASS — registered, took a job, deregistered clean | | runner lifecycle against a fork | PASS — registered, took a job, deregistered clean |
| forgejo runner lifecycle (you/drill-probe) | PASS — registered, took a job, removed (stale row needs deleting by hand) |
| coolify install (4.1.2) | PASS (6 min) | | coolify install (4.1.2) | PASS (6 min) |
Failed: `rig users apply` left one revoked key in `authorized_keys` Failed: `rig users apply` left one revoked key in `authorized_keys`

View file

@ -37,6 +37,11 @@ set -euo pipefail
REPO="${RIG_REPO:-heavy-duty/rig}" REPO="${RIG_REPO:-heavy-duty/rig}"
REF="${RIG_REF:-}" # empty = the latest release, resolved below REF="${RIG_REF:-}" # empty = the latest release, resolved below
# The forge this REPO lives on is RIG_HOST (#111), default https://github.com.
# Parallel to RIG_TEMPLATES_HOST — not the same variable, because the registry
# and rig itself may live on different forges. Default stays GitHub so every
# existing curl|bash one-liner is byte-unchanged; set
# RIG_HOST=https://forgejo.heavyduty.builders to install from this instance.
# cloud-init's runcmd runs with NO $HOME in the environment, and under set -u # cloud-init's runcmd runs with NO $HOME in the environment, and under set -u
# the expansions just below turned that into a death instead of an install — # the expansions just below turned that into a death instead of an install —
@ -101,11 +106,18 @@ warn_bootstrapped() { # $1 = what is about to happen
# (curl's %{redirect_url} is that header, parsed): no API, no token, no # (curl's %{redirect_url} is that header, parsed): no API, no token, no
# rate-limit pain. A repo with no releases redirects to /releases — not to # rate-limit pain. A repo with no releases redirects to /releases — not to
# /releases/tag/<tag> — so this returns 1 there instead of inventing a ref, # /releases/tag/<tag> — so this returns 1 there instead of inventing a ref,
# and the CALLER owns the loud story. test/release.sh extracts this function # and the CALLER owns the loud story. Host comes from RIG_HOST (default
# (awk, the valid_version idiom) and drives it against a stubbed curl. # GitHub); both GitHub and Forgejo serve the same /releases/latest →
# /releases/tag/<tag> redirect grammar, measured 2026-07-29 (#111).
# test/release.sh extracts this function (awk, the valid_version idiom) and
# drives it against a stubbed curl.
resolve_latest_tag() { resolve_latest_tag() {
local loc # Default is inlined (not $RIG_HOST_DEFAULT) so test/release.sh's awk
loc="$(curl -fsSI -o /dev/null -w '%{redirect_url}' "https://github.com/$1/releases/latest")" || return 1 # extract of this function stays self-contained — same discipline as
# valid_version.
local host="${RIG_HOST:-https://github.com}" loc
host="${host%/}"
loc="$(curl -fsSI -o /dev/null -w '%{redirect_url}' "$host/$1/releases/latest")" || return 1
case "$loc" in case "$loc" in
*/releases/tag/?*) printf '%s\n' "${loc##*/releases/tag/}" ;; */releases/tag/?*) printf '%s\n' "${loc##*/releases/tag/}" ;;
*) return 1 ;; *) return 1 ;;
@ -113,12 +125,69 @@ resolve_latest_tag() {
} }
# ref_candidate_urls <owner/repo> <ref> — the download candidates for an # ref_candidate_urls <owner/repo> <ref> — the download candidates for an
# explicit RIG_REF, in order: refs/tags first, so a tag always outranks a # explicit RIG_REF, in order. Host comes from RIG_HOST. Both GitHub and
# branch that happens to share its name (the pin must win), refs/heads as # Forgejo (measured 2026-07-29 on forgejo.heavyduty.builders 8.0.3) serve
# the fallback that keeps RIG_REF=main the dev channel. # the same two paths and the same disambiguation: refs/tags first so a pin
# always outranks a same-named branch, then refs/heads for RIG_REF=main.
# Host is the only forge-specific input — no second grammar (#111).
#
# The RELEASE channel (RIG_REF unset) must NOT use this list: a missing tag
# archive must fail loudly, never fall through to a same-named branch and
# still report the resolved tag in INSTALLED_FROM. Use release_tag_url.
ref_candidate_urls() { ref_candidate_urls() {
printf 'https://github.com/%s/archive/refs/tags/%s.tar.gz\n' "$1" "$2" local host="${RIG_HOST:-https://github.com}"
printf 'https://github.com/%s/archive/refs/heads/%s.tar.gz\n' "$1" "$2" host="${host%/}"
printf '%s/%s/archive/refs/tags/%s.tar.gz\n' "$host" "$1" "$2"
printf '%s/%s/archive/refs/heads/%s.tar.gz\n' "$host" "$1" "$2"
}
# release_tag_url <owner/repo> <tag> — the RELEASE channel is tag-only on
# every forge (#111 / #32). One URL, refs/tags only: if that archive is
# gone the install dies, it never quietly takes refs/heads/<tag>.
release_tag_url() {
local host="${RIG_HOST:-https://github.com}"
host="${host%/}"
printf '%s/%s/archive/refs/tags/%s.tar.gz\n' "$host" "$1" "$2"
}
# install_script_url — the curl|bash entrypoint URL for this REPO on RIG_HOST.
# GitHub serves raw files at raw.githubusercontent.com; Forgejo at
# /raw/branch/<ref>/<path>. The refusal hint and bin/rig usage() both print
# this, so a Forgejo install never tells the operator to hit a 404 (#111).
# REPO is the installer's global (RIG_REPO); tests that extract this function
# must set it.
install_script_url() {
local host="${RIG_HOST:-https://github.com}"
host="${host%/}"
case "$host" in
https://github.com|http://github.com|*//github.com)
printf 'https://raw.githubusercontent.com/%s/main/install.sh\n' "${REPO:-heavy-duty/rig}" ;;
*)
printf '%s/%s/raw/branch/main/install.sh\n' "$host" "${REPO:-heavy-duty/rig}" ;;
esac
}
# 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 ----------------------------------------------------------- # --- prerequisites -----------------------------------------------------------
@ -200,12 +269,15 @@ else
if [ -z "$REF" ]; then if [ -z "$REF" ]; then
log "resolving the latest release of $REPO" log "resolving the latest release of $REPO"
if ! REF="$(resolve_latest_tag "$REPO")"; then if ! REF="$(resolve_latest_tag "$REPO")"; then
warn "could not resolve the latest release of $REPO — either no release exists yet, or GitHub was unreachable." warn "could not resolve the latest release of $REPO — either no release exists yet, or ${RIG_HOST:-https://github.com} was unreachable."
warn "(rig has no release until 0.1.0 is cut — rig#32. Until then, install the development tree explicitly.)" warn "(install the development tree explicitly with RIG_REF=main when no release exists yet.)"
die "set RIG_REF: e.g. curl -fsSL https://raw.githubusercontent.com/$REPO/main/install.sh | RIG_REF=main bash" die "set RIG_REF: e.g. curl -fsSL $(install_script_url) | RIG_REF=main bash"
fi fi
log "latest release: $REF" log "latest release: $REF"
urls=("https://github.com/$REPO/archive/refs/tags/$REF.tar.gz") # Tag-only: the channel resolved a RELEASE tag, so the download is that
# tag's archive and nothing else. Falling through to refs/heads would
# install a branch while INSTALLED_FROM still names the tag (#111 review).
mapfile -t urls < <(release_tag_url "$REPO" "$REF")
else else
mapfile -t urls < <(ref_candidate_urls "$REPO" "$REF") mapfile -t urls < <(ref_candidate_urls "$REPO" "$REF")
fi fi
@ -221,7 +293,7 @@ else
fi fi
done done
[ -n "$got" ] \ [ -n "$got" ] \
|| die "failed to download $REPO@$REF — not a tag and not a branch (tried refs/tags then refs/heads)" || die "failed to download $REPO@$REF — no candidate URL worked (host ${RIG_HOST:-https://github.com}; tried ${urls[*]})"
log "extracting archive" log "extracting archive"
tar -xzf "$TMPDIR/rig.tar.gz" -C "$TMPDIR" \ tar -xzf "$TMPDIR/rig.tar.gz" -C "$TMPDIR" \
@ -249,6 +321,60 @@ set_exec() { # $1 = a rig tree: the executable bits install.sh owns
fi fi
} }
# snapshot_templates <rig-tree> — best-effort install-time cache of the exact
# registry pin carried by that tree. The pin remains the sole source of truth;
# the directory name makes a stale snapshot invisible after an upgrade.
# 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 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)."
return 0
fi
unpack="$TMPDIR/templates-unpack"
rm -rf "$unpack"
mkdir -p "$unpack"
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 < <(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
fi
if ! tar -xzf "$TMPDIR/templates.tar.gz" -C "$unpack"; then
warn "could not extract template registry snapshot from $got; rig installed without it (converge will retry the live fetch)."
return 0
fi
set -- "$unpack"/*/
if ! { [ $# -eq 1 ] && [ -d "$1" ]; }; then
warn "template registry snapshot from $got has an unexpected archive shape; rig installed without it (converge will retry the live fetch)."
return 0
fi
top="${1%/}"
if [ -z "$(find "$top" -mindepth 2 -maxdepth 2 -type f -name template.env -print -quit 2>/dev/null)" ]; then
warn "template registry snapshot from $got has no definitions; rig installed without it (converge will retry the live fetch)."
return 0
fi
rm -rf "$snapshot"
mv "$top" "$snapshot"
log "template registry snapshot installed: $repo@$pin"
}
# --- install into $DEST/versions/<version> ----------------------------------- # --- install into $DEST/versions/<version> -----------------------------------
VDIR="$DEST/versions/$new_ver" VDIR="$DEST/versions/$new_ver"
newly_installed=0 newly_installed=0
@ -259,6 +385,7 @@ if [ -d "$VDIR" ]; then
log "RIG_REINSTALL=1 — replacing the installed $new_ver tree" log "RIG_REINSTALL=1 — replacing the installed $new_ver tree"
stage="$VDIR.new.$$"; old="$VDIR.old.$$" stage="$VDIR.new.$$"; old="$VDIR.old.$$"
rm -rf "$stage" "$old" rm -rf "$stage" "$old"
snapshot_templates "$EXTRACTED"
set_exec "$EXTRACTED" set_exec "$EXTRACTED"
mv "$EXTRACTED" "$stage" mv "$EXTRACTED" "$stage"
# Swap by renames, delete LAST: rm-then-move leaves a hole the whole # Swap by renames, delete LAST: rm-then-move leaves a hole the whole
@ -276,6 +403,7 @@ if [ -d "$VDIR" ]; then
else else
log "installing $new_ver into $VDIR" log "installing $new_ver into $VDIR"
mkdir -p "$DEST/versions" mkdir -p "$DEST/versions"
snapshot_templates "$EXTRACTED"
set_exec "$EXTRACTED" set_exec "$EXTRACTED"
mv "$EXTRACTED" "$VDIR" mv "$EXTRACTED" "$VDIR"
newly_installed=1 newly_installed=1

File diff suppressed because it is too large Load diff

View file

@ -50,10 +50,10 @@ trap 'rm -rf "$WORK"' EXIT
# --- the functions under test, extracted ------------------------------------- # --- the functions under test, extracted -------------------------------------
FNS="$WORK/drill-fns.sh" FNS="$WORK/drill-fns.sh"
for fn in tree_of assert_installed_from classify_leg capture_state emit_record; do for fn in tree_of assert_installed_from classify_leg capture_state emit_record forgejo_run_verdict forgejo_token_verdict forgejo_max_task_id forgejo_leg_row; do
awk "/^${fn}\(\) \{/,/^\}/" "$ROOT/drill/drill.sh" >> "$FNS" awk "/^${fn}\(\) \{/,/^\}/" "$ROOT/drill/drill.sh" >> "$FNS"
done done
for fn in tree_of assert_installed_from classify_leg capture_state emit_record; do for fn in tree_of assert_installed_from classify_leg capture_state emit_record forgejo_run_verdict forgejo_token_verdict forgejo_max_task_id forgejo_leg_row; do
check "extraction guards the awk: ${fn}() landed" 0 "${fn}() {" grep -F "${fn}() {" "$FNS" check "extraction guards the awk: ${fn}() landed" 0 "${fn}() {" grep -F "${fn}() {" "$FNS"
done done
# shellcheck source=/dev/null # shellcheck source=/dev/null
@ -159,7 +159,7 @@ check "…and the diff names the drifted sshd keyword, not just 'differs'" 1 "pa
emit() { # emit <outfile> — emit_record with the harness globals staged emit() { # emit <outfile> — emit_record with the harness globals staged
DRILL_VERSION="9.9.9" RUN_ID="drill-2026-01-01-a" \ DRILL_VERSION="9.9.9" RUN_ID="drill-2026-01-01-a" \
REF="release/9.9.9" BOXREF="release/0.4.0" RIG_SHA="5d6e7f8" BOX_SHA="1a2b3c4" \ REF="release/9.9.9" BOXREF="release/0.4.0" RIG_SHA="5d6e7f8" BOX_SHA="1a2b3c4" \
TPLREPO="heavy-duty/rig-templates" TPLREF="9f8e7d6c5b4a39281706f5e4d3c2b1a098765432" TPL_SHA="9f8e7d6" \ TPLREPO="heavy-duty/rig-templates" TPLREF="9f8e7d6c5b4a39281706f5e4d3c2b1a098765432" TPL_SHA="9f8e7d6" TPL_SOURCE="snapshot" \
bash -c ' bash -c '
. "$1" . "$1"
pass=12 fail=1 skipped=1 pass=12 fail=1 skipped=1
@ -174,7 +174,7 @@ check "record: the version-and-date heading" 0 "# Release drill — 9.9.9 — "
check "record: the run ID that joins the family's records" 0 "Run ID: drill-2026-01-01-a" cat "$WORK/record.md" check "record: the run ID that joins the family's records" 0 "Run ID: drill-2026-01-01-a" cat "$WORK/record.md"
check "record: both pinned refs with their SHAs" 0 "rig@5d6e7f8 (RIG_REF=release/9.9.9)" cat "$WORK/record.md" check "record: both pinned refs with their SHAs" 0 "rig@5d6e7f8 (RIG_REF=release/9.9.9)" cat "$WORK/record.md"
check "record: …box's too" 0 "box@1a2b3c4 (BOX_REF=release/0.4.0)" cat "$WORK/record.md" check "record: …box's too" 0 "box@1a2b3c4 (BOX_REF=release/0.4.0)" cat "$WORK/record.md"
check "record: the template registry SHA rides alongside the pair (#110)" 0 "rig-templates@9f8e7d6 (ref 9f8e7d6c5b4a39281706f5e4d3c2b1a098765432)" cat "$WORK/record.md" check "record: the template registry SHA and actual source ride alongside the pair (#110/#153)" 0 "rig-templates@9f8e7d6 (ref 9f8e7d6c5b4a39281706f5e4d3c2b1a098765432, snapshot)" cat "$WORK/record.md"
check "record: one table row per leg, result verbatim" 0 "| re-converge (idempotence) | clean, no changes |" cat "$WORK/record.md" check "record: one table row per leg, result verbatim" 0 "| re-converge (idempotence) | clean, no changes |" cat "$WORK/record.md"
check "record: the numbers, skips counted apart from passes" 0 "12 passed, 1 failed, 1 skipped" cat "$WORK/record.md" check "record: the numbers, skips counted apart from passes" 0 "12 passed, 1 failed, 1 skipped" cat "$WORK/record.md"
check "record: a FAILED run still names what failed (evidence, not success)" 0 "FAIL: coolify container state: absent" cat "$WORK/record.md" check "record: a FAILED run still names what failed (evidence, not success)" 0 "FAIL: coolify container state: absent" cat "$WORK/record.md"
@ -201,6 +201,177 @@ check "an all-green record says every leg ran and passed" 0 "Every leg ran and e
# ============================================================================= # =============================================================================
# the shipped script itself # the shipped script itself
# ============================================================================= # =============================================================================
# =============================================================================
# forgejo_run_verdict — did OUR dispatched run land, and how (#129)
# =============================================================================
# The Forgejo half of the runner leg cannot reuse the GitHub reader. Measured
# against forgejo.heavyduty.builders (8.0.3+gitea-1.22.0) on 2026-07-30, a
# completed run in GET /repos/{o}/{r}/actions/tasks carries NO `conclusion`
# field at all — `status` holds the terminal outcome directly, where GitHub
# splits status:completed + conclusion:success. And `id` is a global task id
# (25) while the run's own URL ends in run_number (1), so the pre-dispatch
# guard has to compare `id`.
#
# The payload is also an ASSIGNED-task view: it reads total_count 0 for as
# long as a run sits queued (measured: 200s), so "no new id" is the ONLY
# signal that the runner never took the job. That is the verdict this leg
# exists to produce, which is why it gets its own function and its own tests.
FJ="$WORK/fj"; mkdir -p "$FJ"
printf '%s' '{"workflow_runs":[],"total_count":0}' > "$FJ/empty.json"
printf '%s' '{"workflow_runs":[{"id":25,"status":"success","run_number":1,"url":"https://f/o/r/actions/runs/1"}],"total_count":1}' > "$FJ/new-success.json"
printf '%s' '{"workflow_runs":[{"id":25,"status":"failure","run_number":1,"url":"https://f/o/r/actions/runs/1"}],"total_count":1}' > "$FJ/new-failure.json"
printf '%s' '{"workflow_runs":[{"id":25,"status":"cancelled","run_number":1,"url":"https://f/o/r/actions/runs/1"}],"total_count":1}' > "$FJ/new-cancelled.json"
printf '%s' '{"workflow_runs":[{"id":24,"status":"success","run_number":1,"url":"https://f/o/r/actions/runs/1"}],"total_count":1}' > "$FJ/stale-only.json"
check "verdict: an empty task list is PENDING, never a pass" 0 "pending" \
forgejo_run_verdict "" "$FJ/empty.json"
check "verdict: a queued run the runner never took stays PENDING" 0 "pending" \
forgejo_run_verdict "24" "$FJ/stale-only.json"
check "verdict: OUR new run, status success, is SUCCESS" 0 "success" \
forgejo_run_verdict "24" "$FJ/new-success.json"
check "verdict: status carries the outcome — failure is FAILED, not success" 0 "failed" \
forgejo_run_verdict "24" "$FJ/new-failure.json"
check "verdict: a cancelled run is FAILED, not silently passed" 0 "failed" \
forgejo_run_verdict "24" "$FJ/new-cancelled.json"
check "verdict: the first run ever (no pre-id) still resolves" 0 "success" \
forgejo_run_verdict "" "$FJ/new-success.json"
# A task appears in this payload the moment it is ASSIGNED, which can be before
# it finishes — so a non-terminal status must read as pending, not as a failure.
# Calling a still-running job "failed" would make the leg flaky in exactly the
# window the leg is watching.
printf '%s' '{"workflow_runs":[{"id":25,"status":"running","run_number":1}],"total_count":1}' > "$FJ/new-running.json"
check "verdict: an assigned-but-running task is PENDING, not FAILED" 0 "pending" \
forgejo_run_verdict "24" "$FJ/new-running.json"
# grok/kimi on !130: the reader must not stop at the FIRST run. actions/tasks
# accumulates — the moment a repo is drilled twice, our run shares the payload
# with older ones, and nothing documents the sort order. Reading entry[0] makes
# a green job read as a timeout, which is a FALSE FAILURE on the very gate this
# leg exists to provide.
printf '%s' '{"workflow_runs":[{"id":24,"status":"success"},{"id":25,"status":"success"}],"total_count":2}' > "$FJ/oldest-first.json"
printf '%s' '{"workflow_runs":[{"id":25,"status":"success"},{"id":24,"status":"success"}],"total_count":2}' > "$FJ/newest-first.json"
printf '%s' '{"workflow_runs":[{"id":25,"status":"running"},{"id":26,"status":"success"}],"total_count":2}' > "$FJ/ours-not-first.json"
printf '%s' '{"workflow_runs":[{"id":23,"status":"success"},{"id":24,"status":"failure"}],"total_count":2}' > "$FJ/all-stale.json"
# Pretty-printed: the instance may or may not compact its JSON, and a parser
# that silently depends on one-line objects is a latent failure (kimi, !130).
# Written HERE, like every other fixture: a suite that copies from a scratch
# path passes only on the box that built it (grok/kimi, !130 round 2).
printf '%s\n' '{
"workflow_runs": [
{"id": 24, "status": "success"},
{"id": 25, "name": "drill", "status": "success"}
],
"total_count": 2
}' > "$FJ/pretty.json"
check "verdict: ours is LAST in the payload — order must not decide" 0 "success" \
forgejo_run_verdict "24" "$FJ/oldest-first.json"
check "verdict: ours is FIRST in the payload — same answer" 0 "success" \
forgejo_run_verdict "24" "$FJ/newest-first.json"
check "verdict: a stale RUNNING entry ahead of ours does not mask it" 0 "success" \
forgejo_run_verdict "24" "$FJ/ours-not-first.json"
check "verdict: every entry at or below pre is stale — PENDING" 0 "pending" \
forgejo_run_verdict "24" "$FJ/all-stale.json"
check "verdict: a pretty-printed payload parses too" 0 "success" \
forgejo_run_verdict "24" "$FJ/pretty.json"
# The PRE-DISPATCH snapshot has the same multi-entry hazard as the verdict, and
# getting it wrong is worse: a `head -n1` pre-id on an oldest-first payload
# names an OLD run as the baseline, so a later poll that finds the same body
# reports the PREVIOUS drill's run as ours — a false PASS on the take-a-job
# assertion, where the entry[0] bug only produced a false failure (grok, !130).
# Both sides must fold max over every id, which is why they share one function.
check "max id: oldest-first payload yields the NEWEST id, not the first" 0 "25" \
forgejo_max_task_id "$FJ/oldest-first.json"
check "max id: newest-first payload yields the same answer" 0 "25" \
forgejo_max_task_id "$FJ/newest-first.json"
check "max id: an empty payload has no id at all" 0 "" \
forgejo_max_task_id "$FJ/empty.json"
check "max id: a pretty-printed payload folds too" 0 "25" \
forgejo_max_task_id "$FJ/pretty.json"
# The false PASS, pinned end to end: snapshot the oldest-first body, dispatch,
# the runner never takes it so the body is unchanged — the verdict must stay
# pending. With head -n1 this returned success.
# The two halves composed exactly as the leg composes them.
verdict_after_no_new_run() { forgejo_run_verdict "$(forgejo_max_task_id "$1")" "$1"; }
check "no new run after dispatch: max-id baseline keeps it PENDING (false-PASS guard)" 0 "pending" \
verdict_after_no_new_run "$FJ/oldest-first.json"
check "…and the same composition on a pretty payload" 0 "pending" \
verdict_after_no_new_run "$FJ/pretty.json"
# =============================================================================
# forgejo_leg_row — the row is the WHOLE lifecycle, not just the job
# =============================================================================
# codex/grok/kimi on !130: keying the record row on the take-a-job outcome alone
# lets it read "PASS — registered, took a job, removed" when install failed, so
# long as SOMETHING answered runs-on: drill. That is not contrived — this leg
# removes locally and tells the operator to delete the stale runner by hand, so
# a leftover drill-labeled runner from the previous drill is the DESIGNED-FOR
# aftermath, and it answers the fixture exactly.
#
# drills/<v>.md is the release's durable evidence. A row claiming a lifecycle
# that did not happen is precisely what the gate exists to refuse, so PASS
# requires every assertion, not just the interesting one.
check "leg row: everything succeeded is the only PASS" 0 "PASS" \
forgejo_leg_row 1 1 success 1 1
check "leg row: install failed cannot PASS, even when a foreign runner took the job" 0 "FAIL" \
forgejo_leg_row 0 1 success 1 1
check "leg row: status failed cannot PASS either" 0 "FAIL" \
forgejo_leg_row 1 0 success 1 1
check "leg row: remove failed cannot PASS" 0 "FAIL" \
forgejo_leg_row 1 1 success 0 1
check "leg row: a runner still registered after remove cannot PASS" 0 "FAIL" \
forgejo_leg_row 1 1 success 1 0
check "leg row: no dispatch attempted, everything else clean, is PARTIAL" 0 "PARTIAL" \
forgejo_leg_row 1 1 none 1 1
check "leg row: a job that was never taken is a FAIL" 0 "FAIL" \
forgejo_leg_row 1 1 timeout 1 1
check "leg row: PARTIAL requires a clean lifecycle too" 0 "FAIL" \
forgejo_leg_row 0 1 none 1 1
# The end-to-end shape codex/grok/kimi asked for, composed the way the leg
# composes it: a tasks payload carrying a NEWER successful run (as a foreign
# drill-labeled runner would produce) must still not yield a PASS row when the
# drill's own install failed. This is the exact false-evidence case.
row_after_failed_install() {
forgejo_leg_row 0 1 "$(forgejo_run_verdict "$(forgejo_max_task_id "$1")" "$1")" 1 1
}
printf '%s' '{"workflow_runs":[{"id":24,"status":"success"},{"id":99,"status":"success"}],"total_count":2}' \
> "$FJ/foreign-runner-took-it.json"
check "install failed + a newer successful run in the payload is still FAIL, never PASS" 0 "FAIL" \
row_after_failed_install "$FJ/foreign-runner-took-it.json"
# …and the same payload with a clean lifecycle is the PASS, so the check above
# is discriminating rather than always-FAIL.
row_after_clean_install() {
forgejo_leg_row 1 1 "$(forgejo_run_verdict "" "$1")" 1 1
}
check "…while the same payload with a clean lifecycle does PASS" 0 "PASS" \
row_after_clean_install "$FJ/foreign-runner-took-it.json"
# =============================================================================
# forgejo_token_verdict — a configured leg that cannot mint must FAIL, not SKIP
# =============================================================================
# #129's own acceptance: "Token source present but the instance is unreachable
# -> the leg FAILS; it must not skip and must not pass". A mint that returns
# nothing because the instance is unreachable, the token is under-scoped or the
# repo name is wrong is a CONFIGURED leg failing — reporting "no token source"
# sends the operator to check an env var they already set, and writes SKIPPED
# where the record owes a FAIL. That is the UNREADABLE-vs-NONE shape
# drills/README.md names.
check "token: a resolved registration token is ok" 0 "ok" \
forgejo_token_verdict "reg-tok" ""
check "token: an explicit token wins even with no API token" 0 "ok" \
forgejo_token_verdict "reg-tok" ""
check "token: no token at all and no API token is a genuine SKIP" 0 "no-source" \
forgejo_token_verdict "" ""
check "token: API token offered but mint produced nothing is a FAILURE" 0 "mint-failed" \
forgejo_token_verdict "" "api-tok"
# The anti-false-positive guard, stated as its own case: an OLD completed run
# with the SAME id as pre_id must never be read as this dispatch's result.
check "verdict: a pre-existing success with the pre-id is NOT our run" 0 "pending" \
forgejo_run_verdict "25" "$FJ/new-success.json"
# Arg refusals fire before the root check (repo doctrine, bootstrap.sh:114), # Arg refusals fire before the root check (repo doctrine, bootstrap.sh:114),
# which is what makes them provable here without a throwaway machine. # which is what makes them provable here without a throwaway machine.
check "drill.sh refuses to run without BOTH refs pinned (#103)" 2 "--box-ref" \ check "drill.sh refuses to run without BOTH refs pinned (#103)" 2 "--box-ref" \
@ -217,6 +388,14 @@ check "an unknown flag dies loudly, exit 2" 2 "unknown option" \
bash "$ROOT/drill/drill.sh" --frobnicate bash "$ROOT/drill/drill.sh" --frobnicate
check "--help prints the header and exits 0" 0 "THROWAWAY" \ check "--help prints the header and exits 0" 0 "THROWAWAY" \
bash "$ROOT/drill/drill.sh" --help bash "$ROOT/drill/drill.sh" --help
check "--forgejo-instance is a known flag (the leg's opt-in)" 2 "--users <path> is required" \
bash "$ROOT/drill/drill.sh" --rig-ref r --box-ref b --forgejo-instance https://f.example.com --yes
check "--forgejo-runner-repo is a known flag" 2 "--users <path> is required" \
bash "$ROOT/drill/drill.sh" --rig-ref r --box-ref b --forgejo-runner-repo o/r --yes
check "--help names the forgejo runner leg's flags" 0 "--forgejo-instance" \
bash "$ROOT/drill/drill.sh" --help
check "--forgejo-ref is a known flag (Forgejo's dispatch needs a ref)" 2 "--users <path> is required" \
bash "$ROOT/drill/drill.sh" --rig-ref r --box-ref b --forgejo-ref dev --yes
echo "---" echo "---"
echo "$PASS passed, $FAIL failed" echo "$PASS passed, $FAIL failed"

View file

@ -65,6 +65,18 @@ VER="$(cat "$ROOT/VERSION")"
WORK="$(mktemp -d)" WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT trap 'rm -rf "$WORK"' EXIT
# install.sh snapshots the pinned registry even for the local source channel.
# Serve a deterministic archive so this lifecycle remains fully offline.
SNAPBIN="$WORK/snapshot-bin"
mkdir -p "$SNAPBIN" "$WORK/snapshot-stage/rig-templates-pin/test-box"
printf 'USER="test"\n' > "$WORK/snapshot-stage/rig-templates-pin/test-box/template.env"
tar -czf "$WORK/snapshot.tar.gz" -C "$WORK/snapshot-stage" rig-templates-pin
cat > "$SNAPBIN/curl" <<'CURLEOF'
#!/usr/bin/env bash
cp "${SNAPSHOT_TARBALL:?}" "$4"
CURLEOF
chmod +x "$SNAPBIN/curl"
# tree_state <root> — what "changed nothing" must mean: every file's bytes, # tree_state <root> — what "changed nothing" must mean: every file's bytes,
# every path's type and mode, every symlink's target. Beat 3 captures this # every path's type and mode, every symlink's target. Beat 3 captures this
# before and after the re-run and diffs the two. # before and after the re-run and diffs the two.
@ -124,7 +136,10 @@ check "honesty: a really-gone path passes the absence assert" 0 "" \
# RIG_INSTALL_SOURCE is the supported local channel (its contract — dir, # RIG_INSTALL_SOURCE is the supported local channel (its contract — dir,
# tarball, loud refusal, no silent download fallback — is test/release.sh's); # tarball, loud refusal, no silent download fallback — is test/release.sh's);
# in CI $ROOT is $GITHUB_WORKSPACE, so what lands is the code under review. # in CI $ROOT is $GITHUB_WORKSPACE, so what lands is the code under review.
b1() { RIG_INSTALL_SOURCE="$ROOT" bash "$ROOT/install.sh"; } b1() {
PATH="$SNAPBIN:$PATH" SNAPSHOT_TARBALL="$WORK/snapshot.tar.gz" \
RIG_INSTALL_SOURCE="$ROOT" bash "$ROOT/install.sh"
}
check "beat 1: install.sh installs this checkout" 0 "done" b1 check "beat 1: install.sh installs this checkout" 0 "done" b1
# --- beat 2: assert what landed ---------------------------------------------- # --- beat 2: assert what landed ----------------------------------------------
@ -140,6 +155,9 @@ check "beat 2: rig --version answers through the whole chain" 0 "rig $VER" \
"$BINDIR/rig" --version "$BINDIR/rig" --version
check "beat 2: INSTALLED_FROM names the local source" 0 "local:$ROOT" \ check "beat 2: INSTALLED_FROM names the local source" 0 "local:$ROOT" \
cat "$DEST/versions/$VER/INSTALLED_FROM" cat "$DEST/versions/$VER/INSTALLED_FROM"
TPL_PIN="$(sed -n 's/^RIG_TEMPLATES_PIN=//p' "$ROOT/commands/lib/templates.sh")"
check "beat 2: pinned registry snapshot landed in the version tree" 0 "" \
test -f "$DEST/versions/$VER/templates@$TPL_PIN/test-box/template.env"
# --- beat 3: the converging re-run ------------------------------------------- # --- beat 3: the converging re-run -------------------------------------------
# "Ran twice without crashing" is the self-deception this beat exists to # "Ran twice without crashing" is the self-deception this beat exists to

View file

@ -42,8 +42,14 @@ FAKEHOME="$WORK/home"; mkdir -p "$FAKEHOME"
# inline; extract them here and drive them for real (the valid_version awk # inline; extract them here and drive them for real (the valid_version awk
# idiom from test/cli.sh), against a stub curl — never the network. # idiom from test/cli.sh), against a stub curl — never the network.
RL="$WORK/installer-fns.sh" RL="$WORK/installer-fns.sh"
awk '/^resolve_latest_tag\(\) \{/,/^\}/' "$ROOT/install.sh" > "$RL" # Grouped redirect — shellcheck SC2129 flags four individual >> to the same
awk '/^ref_candidate_urls\(\) \{/,/^\}/' "$ROOT/install.sh" >> "$RL" # file (crossed the threshold when release_tag_url joined the extract set).
{
awk '/^resolve_latest_tag\(\) \{/,/^\}/' "$ROOT/install.sh"
awk '/^ref_candidate_urls\(\) \{/,/^\}/' "$ROOT/install.sh"
awk '/^release_tag_url\(\) \{/,/^\}/' "$ROOT/install.sh"
awk '/^install_script_url\(\) \{/,/^\}/' "$ROOT/install.sh"
} > "$RL"
check "installer fns extracted (guards the awk)" 0 "redirect_url" cat "$RL" check "installer fns extracted (guards the awk)" 0 "redirect_url" cat "$RL"
STUB="$WORK/stub"; mkdir -p "$STUB" STUB="$WORK/stub"; mkdir -p "$STUB"
@ -93,14 +99,57 @@ check "resolve: a tagless releases/tag/ redirect fails" 1 "" \
check "resolve: a failing curl fails (network down is not a channel)" 1 "" \ check "resolve: a failing curl fails (network down is not a channel)" 1 "" \
rlt CURL_STUB_FAIL=1 rlt CURL_STUB_FAIL=1
rcu_line() { # rcu_line <n> — the nth candidate URL for an explicit ref rcu_line() { # rcu_line <n> [VAR=val ...] — the nth candidate URL for an explicit ref
bash -c 'set -euo pipefail local n="$1"; shift
. "$1"; ref_candidate_urls acme/widgets 1.2.3 | sed -n "${2}p"' _ "$RL" "$1" # shellcheck disable=SC2016
env "$@" bash -c 'set -euo pipefail
. "$1"; ref_candidate_urls acme/widgets 1.2.3 | sed -n "${2}p"' _ "$RL" "$n"
} }
check "candidates: refs/tags first — the pin outranks a same-named branch" 0 \ check "candidates: refs/tags first — the pin outranks a same-named branch" 0 \
"https://github.com/acme/widgets/archive/refs/tags/1.2.3.tar.gz" rcu_line 1 "https://github.com/acme/widgets/archive/refs/tags/1.2.3.tar.gz" rcu_line 1
check "candidates: refs/heads is the fallback" 0 \ check "candidates: refs/heads is the fallback" 0 \
"https://github.com/acme/widgets/archive/refs/heads/1.2.3.tar.gz" rcu_line 2 "https://github.com/acme/widgets/archive/refs/heads/1.2.3.tar.gz" rcu_line 2
# RIG_HOST is host-only (#111): Forgejo serves the same refs/{tags,heads}/
# pair (measured 2026-07-29). Host substituted; grammar unchanged.
check "candidates: Forgejo host uses the same refs/tags form" 0 \
"https://forgejo.example/acme/widgets/archive/refs/tags/1.2.3.tar.gz" \
rcu_line 1 RIG_HOST=https://forgejo.example
check "candidates: Forgejo host keeps refs/heads as fallback" 0 \
"https://forgejo.example/acme/widgets/archive/refs/heads/1.2.3.tar.gz" \
rcu_line 2 RIG_HOST=https://forgejo.example
# shellcheck disable=SC2016
check "candidates: Forgejo host emits exactly two candidates" 0 "2" \
env RIG_HOST=https://forgejo.example bash -c 'set -euo pipefail
. "$1"; ref_candidate_urls acme/widgets 1.2.3 | grep -c .' _ "$RL"
check "candidates: trailing slash on RIG_HOST is stripped" 0 \
"https://forgejo.example/acme/widgets/archive/refs/tags/1.2.3.tar.gz" \
rcu_line 1 RIG_HOST=https://forgejo.example/
# install_script_url — the curl|bash hint must match the forge (#111).
isu() {
# shellcheck disable=SC2016
env "$@" bash -c 'set -euo pipefail
REPO=heavy-duty/rig; . "$1"; install_script_url' _ "$RL"
}
check "install_script_url: GitHub default uses raw.githubusercontent.com" 0 \
"https://raw.githubusercontent.com/heavy-duty/rig/main/install.sh" isu
check "install_script_url: Forgejo uses /raw/branch/main/" 0 \
"https://forgejo.example/heavy-duty/rig/raw/branch/main/install.sh" \
isu RIG_HOST=https://forgejo.example
# resolve_latest_tag follows RIG_HOST too — the probe URL must name the forge.
rlt_log="$WORK/rlt-log"
: > "$rlt_log"
check "resolve: RIG_HOST is the releases/latest origin" 0 "0.2.0" \
rlt CURL_STUB_REDIRECT=https://forgejo.example/heavy-duty/rig/releases/tag/0.2.0 \
RIG_HOST=https://forgejo.example CURL_STUB_LOG="$rlt_log"
check "resolve: the probe hit the Forgejo host" 0 \
"https://forgejo.example/heavy-duty/rig/releases/latest" \
cat "$rlt_log"
# Forgejo's no-release path is a 404 (not GitHub's /releases redirect) —
# curl -f fails and || return 1 fires. Drive that branch under RIG_HOST.
check "resolve: Forgejo no-release is a failing curl (404), not a /releases redirect" 1 "" \
rlt RIG_HOST=https://forgejo.example CURL_STUB_FAIL=1
# --- the three channels, driven through the REAL installer ------------------- # --- the three channels, driven through the REAL installer -------------------
# Full install.sh runs against throwaway roots with the stub curl on PATH: the # Full install.sh runs against throwaway roots with the stub curl on PATH: the
@ -144,6 +193,27 @@ check "channel latest: the refusal says what is missing" 1 "no release" \
rinst "$H2" "$B2" CURL_STUB_REDIRECT=https://github.com/heavy-duty/rig/releases rinst "$H2" "$B2" CURL_STUB_REDIRECT=https://github.com/heavy-duty/rig/releases
check "channel latest: the refusal installed NOTHING" 1 "" test -e "$H2" check "channel latest: the refusal installed NOTHING" 1 "" test -e "$H2"
# Channel 1, regression — a resolved tag whose archive is gone must FAIL,
# never fall through to refs/heads/<tag> and still claim the release
# (claude REQUEST_CHANGES on !114: INSTALLED_FROM would name the tag for a
# branch tree). CURL_STUB_OK only matches heads — if the installer tries it,
# the install would succeed and this check would fail.
H2b="$WORK/h2b"; B2b="$WORK/b2b"; LOG2b="$WORK/log2b"
check "channel latest: missing tag archive does NOT fall through to heads" \
1 "no candidate URL worked" rinst "$H2b" "$B2b" \
CURL_STUB_REDIRECT=https://github.com/heavy-duty/rig/releases/tag/3.3.3 \
CURL_STUB_OK=refs/heads/3.3.3 CURL_STUB_LOG="$LOG2b"
check "channel latest: ...and installed NOTHING (branch was never taken)" 1 "" \
test -e "$H2b"
# The log also holds the releases/latest probe; the download tries are the
# archive URLs. Exactly one archive try, and it is refs/tags — never heads.
check "channel latest: ...exactly one archive URL was tried" 0 "1" \
grep -c '/archive/' "$LOG2b"
check "channel latest: ...that try was refs/tags" 0 "refs/tags/3.3.3" \
cat "$LOG2b"
check "channel latest: ...refs/heads was never consulted" 1 "" \
grep -q 'refs/heads/' "$LOG2b"
# Channel 2 — RIG_REF=<tag>: refs/tags wins, and the latest-release probe is # Channel 2 — RIG_REF=<tag>: refs/tags wins, and the latest-release probe is
# never consulted (a pin resolves nothing). # never consulted (a pin resolves nothing).
H3="$WORK/h3"; B3="$WORK/b3"; LOG3="$WORK/log3" H3="$WORK/h3"; B3="$WORK/b3"; LOG3="$WORK/log3"
@ -167,8 +237,36 @@ check "channel dev: ...then the branch URL" 0 "refs/heads/feature-x" \
# Neither a tag nor a branch: both candidates miss, and the die says so. # Neither a tag nor a branch: both candidates miss, and the die says so.
H5="$WORK/h5"; B5="$WORK/b5" H5="$WORK/h5"; B5="$WORK/b5"
check "channel: a ref that is neither tag nor branch dies naming both tries" \ check "channel: a ref that is neither tag nor branch dies naming the tries" \
1 "not a tag and not a branch" rinst "$H5" "$B5" RIG_REF=no-such-ref 1 "no candidate URL worked" rinst "$H5" "$B5" RIG_REF=no-such-ref
# Channel 4 — RIG_HOST=Forgejo: same refs/tags→refs/heads candidate order and
# the same /releases/latest redirect grammar (#111). The stub succeeds only
# when the refs/tags form is requested — a regression that still emitted the
# bare /archive/<ref> form would fail here.
H9="$WORK/h9"; B9="$WORK/b9"; LOG9="$WORK/log9"
check "channel forgejo latest: resolves and installs via refs/tags archive URL" 0 "done" \
rinst "$H9" "$B9" RIG_HOST=https://forgejo.example \
CURL_STUB_REDIRECT=https://forgejo.example/heavy-duty/rig/releases/tag/7.7.7-relflow \
CURL_STUB_OK='/archive/refs/tags/7.7.7-relflow.tar.gz' CURL_STUB_LOG="$LOG9"
check "channel forgejo latest: download URL is the refs/tags form" 0 \
"https://forgejo.example/heavy-duty/rig/archive/refs/tags/7.7.7-relflow.tar.gz" \
cat "$LOG9"
check "channel forgejo latest: the tree landed" 0 "" \
test -x "$H9/versions/7.7.7-relflow/bin/rig"
H10="$WORK/h10"; B10="$WORK/b10"
check "channel forgejo pinned: RIG_REF=main falls through to refs/heads" 0 "done" \
rinst "$H10" "$B10" RIG_HOST=https://forgejo.example RIG_REF=main \
CURL_STUB_OK='/archive/refs/heads/main.tar.gz'
check "channel forgejo pinned: the tree landed" 0 "" \
test -x "$H10/versions/7.7.7-relflow/bin/rig"
# Refusal hint on a non-GitHub host must not send the operator to
# raw.githubusercontent.com (that 404s from a Forgejo-only tree).
H11="$WORK/h11"; B11="$WORK/b11"
check "channel forgejo latest: no-release hint uses the Forgejo raw URL" \
1 "https://forgejo.example/heavy-duty/rig/raw/branch/main/install.sh" \
rinst "$H11" "$B11" RIG_HOST=https://forgejo.example \
CURL_STUB_REDIRECT=https://forgejo.example/heavy-duty/rig/releases
# --- the local channel: RIG_INSTALL_SOURCE (#106) ---------------------------- # --- the local channel: RIG_INSTALL_SOURCE (#106) ----------------------------
# A supported input, not test scaffolding — CI's `install:` job and test/cli.sh # A supported input, not test scaffolding — CI's `install:` job and test/cli.sh