Compare commits

..

193 commits
0.2.0 ... 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
Daniel Marin
c9df5f35db
Merge pull request #145 from codex-bot-andresmgsl/build/144-release-0-3-1
Some checks failed
release / release (push) Has been cancelled
chore: cut the 0.3.1 release
2026-07-25 11:32:17 +01:00
codex-bot-andresmgsl
c0aa5ce716 chore: refresh 0.3.1 release notes 2026-07-25 09:29:49 +00:00
codex-bot-andresmgsl
f885f4da27 chore: record 0.3.1 drill waiver 2026-07-25 09:28:22 +00:00
codex-bot-andresmgsl
bb54166bb1 chore: stamp 0.3.1 release candidate 2026-07-25 09:28:22 +00:00
Daniel Marin
5294b53f9f
Merge pull request #150 from claude-bot-andresmgsl/build/149-readme-quickstart-channels
docs: quick-start fence names the development channel and carries the release command below it
2026-07-25 02:14:13 +01:00
claude-bot-andresmgsl
897779b925 docs: quick-start fence names the development channel and carries the release command below it
The first README fence becomes two labelled, copyable commands — the
RIG_REF=main development command first (byte-identical to before, so the
#89 guard at test/cli.sh:514 still passes) and the bare latest-release
command below it, each preceded by a comment line naming its channel.

Refs #149

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 23:57:16 +00:00
Daniel Marin
e250f7f7da
Merge pull request #146 from claude-bot-andresmgsl/build/110-rig-templates
feat: role templates move to heavy-duty/rig-templates — pinned by default, overridable per mint
2026-07-25 00:37:40 +01:00
claude-bot-andresmgsl
af81f8f4cf docs: README points the tenant table at the registry; the drill-record doctrine and the changelog fragment (#110)
The security-trade paragraph lands verbatim in the README (its twin is in
rig-templates' README, per the issue's docs task), with the 2026-07-24
ruling recorded in place.
2026-07-24 23:14:40 +00:00
claude-bot-andresmgsl
e7f31046d9 chore: RIG_TEMPLATES_PIN names the seeded registry (rig-templates#1's head) (#110) 2026-07-24 23:13:17 +00:00
claude-bot-andresmgsl
79363c2631 test: the tenant surface re-proven against fixture registries — offline, by refusal and by identity (#110)
The suite drives the whole new surface without a network: the family-suffix
dispatch and hard cut, the marker guards firing with the registry
unreachable, the unknown-role refusal listing the resolved source, every
malformed-definition refusal named by key, DIR-vs-REF identity through a
stubbed curl (tags-first precedence pinned), the renderer's box#80 guard,
the one-line pin, and template-lint's whole gate.
2026-07-24 23:08:47 +00:00
claude-bot-andresmgsl
11b02eb070 feat(drill): the record cites the rig-templates SHA the converge read (#110)
Override via RIG_TEMPLATES_REF when the drill was pointed somewhere; else
the pin read from the INSTALLED candidate tree — what actually landed,
never this checkout's copy.
2026-07-24 23:02:12 +00:00
claude-bot-andresmgsl
b99d08ea3d feat(bootstrap): the tenant mechanism converges from fetched definitions — the agent case arms are cut (#110)
bootstrap-tenant.sh is one mechanism parameterized by a registry definition
(template.env parsed against the allowlist, install.sh run as root with the
tenant in its environment, creds.md spliced into the shared context
skeleton). staging-box stays in-tree — mechanism-adjacent, no agent. The
dispatch in bootstrap.sh moves to the '-box' family-suffix rule so a
template added to the registry is mintable with zero code changes here; an
unknown role's refusal lists what the resolved source actually contains,
naming the source. lib/tenant-config.sh — the per-tenant case-arm table —
is deleted: the hard cut, #76's discipline.
2026-07-24 23:00:41 +00:00
claude-bot-andresmgsl
c9c8ad9ba9 feat(templates): rig template-lint — the registry repo's CI gate, dispatched from bin/rig (#110) 2026-07-24 22:54:34 +00:00
claude-bot-andresmgsl
29563a1246 feat(templates): the registry lib — three-knob resolution, the allowlist parser, the definition lint (#110)
The role-template registry moves out of rig's tree (heavy-duty/rig-templates,
ruled 2026-07-24: pinned to the rig version by default, overridable per mint).
This is the schema-and-resolution half rig keeps: RIG_TEMPLATES_DIR >
RIG_TEMPLATES_REF > the in-tree RIG_TEMPLATES_PIN (the BOX_RELEASE
discipline), template.env parsed against an allowlist and never sourced,
every refusal naming the failing key, and the lint the registry repo's CI
will run on every definition.

The pin currently names the registry's pre-seed head; it bumps to the seeded
tree in this PR's course (the seed PR is the other half of the build task).
2026-07-24 22:53:48 +00:00
Daniel Marin
5e70c0bb66
Merge pull request #137 from codex-bot-andresmgsl/build/136-adopt-changelog-fragments
feat: adopt changelog fragments
2026-07-24 20:05:32 +01:00
codex-bot-andresmgsl
10975d9ac8 fix: complete ceremony 0.3.0 labels adoption 2026-07-24 18:37:44 +00:00
codex-bot-andresmgsl
89a7fcc5b6 chore: update ceremony pin to 0.3.0 2026-07-24 18:05:09 +00:00
codex-bot-andresmgsl
728b97df8a feat: convert unreleased changelog to fragments 2026-07-24 13:55:28 +00:00
codex-bot-andresmgsl
e22e0dd4a8 chore: bump ceremony pin to 0.2.0 2026-07-24 13:54:11 +00:00
Daniel Marin
20438f09e8
Merge pull request #127 from codex-bot-andresmgsl/build/63-bootstrap-undo
feat: add guarded bootstrap undo
2026-07-24 11:41:28 +01:00
codex-bot-andresmgsl
b6d580ea3e test: make bootstrap provenance count assertive 2026-07-24 10:17:22 +00:00
codex-bot-andresmgsl
53ff0daeff test: isolate bootstrap undo fixtures 2026-07-24 09:41:48 +00:00
codex-bot-andresmgsl
c0272cbae6 test: drive bootstrap undo safety gates 2026-07-24 09:41:48 +00:00
codex-bot-andresmgsl
8b4a55a712 feat: add guarded bootstrap undo 2026-07-24 09:41:48 +00:00
Daniel Marin
9cc7568ca5
Merge pull request #128 from claude-bot-andresmgsl/build/106-install-lifecycle
feat: CI drills the install lifecycle — four beats against a real tree
2026-07-24 10:35:48 +01:00
claude-bot-andresmgsl
5a8badd532 feat: CI drills the install lifecycle — four beats against a real tree (#106)
install from this checkout via RIG_INSTALL_SOURCE, assert what landed,
a converging re-run proven by an empty diff, uninstall --all ending in
the absence assert (! -e AND ! -L). test/install-lifecycle.sh carries
the beats plus the instrument-honesty checks (the diff and the absence
assert are broken against scratch trees on every run, so 'demonstrated,
not assumed' is mechanical); test/release.sh gains the local channel's
contract — dir, tarball, loud refusal, never a silent download fallback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 08:58:53 +00:00
Daniel Marin
51281cdd18
Merge pull request #126 from codex-bot-andresmgsl/build/119-scope-labels
feat: add missing scope labels
2026-07-24 09:57:03 +01:00
codex-bot-andresmgsl
4579e65de9 feat: add missing scope labels 2026-07-24 08:26:57 +00:00
Daniel Marin
243deb1a4c
Merge pull request #129 from claude-bot-andresmgsl/build/95-machine-id
feat(platform): stable machine ID — derived from /etc/machine-id, stored nowhere
2026-07-24 09:12:56 +01:00
Daniel Marin
7f19eed3d5
Merge pull request #134 from claude-bot-andresmgsl/build/133-drill-defaults-prose
docs(drill): the defaults are sane now, and that is exactly the danger
2026-07-24 09:12:13 +01:00
claude-bot-andresmgsl
506b7506a5 docs(platform): ID beside HOSTNAME — the slot vs the machine, the derivation, and the cloned-image caveat (#95)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 07:48:57 +00:00
claude-bot-andresmgsl
bd2918d0c5 test(platform): the identity contract, pinned — derivation, determinism, shape, confidentiality, loud degradation (#95)
Both fixture digests are computed outside the implementation, so a refactor
that changes the prefix, the hash or the slicing renames the whole fleet and
fails here. The negative half is the spec's: empty and 'uninitialized'
machine-ids must never be hashed (the collision id is asserted absent, not
just the unavailable line present), and the raw machine-id must never
appear in the output.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 07:48:45 +00:00
claude-bot-andresmgsl
f26ed51938 feat(platform): ID names the machine — a namespaced sha256 of /etc/machine-id, computed at run time, stored nowhere (#95)
Option A per triage's normalization on #95: derive, never mint. The
derivation is pinned — sha256("rig-machine-id:<machine-id>"), first 32 hex
as 8-4-4-4-12 — and the prefix is the contract that keeps the id
uncorrelatable with other tools' derivations, per machine-id(5)'s own
guidance not to expose the raw value.

Missing, empty and 'uninitialized' machine-id files degrade loudly to an
'unavailable (reason)' line: hashing nothing would hand every such machine
the same identity, the worst possible failure for an identity field.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 07:48:45 +00:00
claude-bot-andresmgsl
f9407aea43 docs(drill): the Leg 1 comment stops calling main box's default — it's the BOX_RELEASE pin
The round's one blocking point, from all three reviewers: drill.sh:387
still said the exported pin beats "box's default (main)", the exact
expired claim #133 exists to remove — bootstrap's unset path has been
BOX_REF="${BOX_REF:-$BOX_RELEASE}" since rig#103 landed. Rewritten like
the other two spots: the why survives, the fallback is named as the
BOX_RELEASE pin, main is gone. The pre-flight hazard-class block
(302-306) stays untouched per the spec; the wide grep
(default.*main|main.*default over drill/, drills/, README.md) now finds
only that protected block.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 07:47:43 +00:00
Daniel Marin
f00c90738b
Merge pull request #130 from codex-bot-andresmgsl/build/123-entry-templates
docs: add repository entry templates
2026-07-24 08:46:27 +01:00
claude-bot-andresmgsl
277d28e01e docs(drill): the defaults are sane now, and that is exactly the danger
drill/README.md and drill.sh's assert_installed_from comment claimed both
installers default to main 'until #103 lands'. #103 landed (box installs
the BOX_RELEASE pin), and rig's install.sh has resolved the latest release
since #32 — six days before the README was written. The behaviour was
always right; the reason given for it expired, so both places now carry
the durable reason: a sane default is what makes an unstated ref
invisible, and the clean-looking record it produces proves nothing about
the candidate.

The --box-ref example (three copies: README, file header, --help — the
header IS the --help via sed 2,33p, so the window's line count is
preserved) moves from release/0.10.0 to the 0.9.0 tag: after #103 the box
that ships is the BOX_RELEASE tag, so a release/… branch is the wrong pin
for box while staying right for rig. The hazard-class citations at the
pre-flight refusal are untouched — they cite #103 as the hazard, not as a
pending change.

Closes #133

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 07:16:42 +00:00
codex-bot-andresmgsl
b971471b52 docs: add repository entry templates 2026-07-24 07:02:46 +00:00
Daniel Marin
8dcf52940e
Merge pull request #122 from codex-bot-andresmgsl/build/103-box-ref-release-pin
fix: pin bootstrap to a released box
2026-07-24 08:01:14 +01:00
Daniel Marin
3698eb5fbe
Merge pull request #125 from claude-bot-andresmgsl/build/105-drill-harness
feat: drill/drill.sh — rig's drill gets its instrument; three prose legs become script
2026-07-24 08:00:08 +01:00
codex-bot-andresmgsl
c02226cb66 fix: propagate pinned box ref 2026-07-24 00:39:24 +00:00
claude-bot-andresmgsl
7b2de4a9e6 fix: the job poll cannot mistake an old run for the dispatched one, and --help covers its own header
The runner leg reads the newest run ID before dispatching and only
judges a run with a different ID — workflow_dispatch takes seconds to
materialize a run, and the previous run's 'completed' was one poll away
from being read as ours. --help's sed range stops where the header does.

(ceremony flow: issue #105)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 00:36:00 +00:00
claude-bot-andresmgsl
5bcd8853d4 docs: the run is written down, and the doctrine stops claiming there is no instrument
drill/README.md is the repeatable procedure #107's second checkbox asks
for: prerequisites (the throwaway machine, the tagged key, the fork's
drill workflow, the pins), the invocation, what each leg asserts, where
the record lands. drills/README.md's harness disclaimer flips to point
at the instrument, its legs list and example record match what
drill.sh actually runs and emits, and CONTRIBUTING's drill sentence
names the script. Changelog entry under Unreleased.

(ceremony flow: issue #105)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 00:34:30 +00:00
claude-bot-andresmgsl
77cb4bdd64 test: the instrument's honesty, proven without hardware
test/drill.sh awk-extracts the harness's decision functions (the
release.sh pattern) and drives them against fixtures: the ref refusal
names both refs, a loud skip never classifies as a pass, the idempotence
verdict is a real diff that goes non-empty when convergence is broken —
demonstrated mechanically on every CI run — and the record emitter
cannot produce a clean-sweep reading over a skipped leg. CI runs it in
the check job.

The tests caught three real harness bugs before any reviewer could:
printf eating a '- '-leading format as options (a silently empty Failed
section — the exact lie the record exists to prevent), tree_of trusting
GNU readlink -f's exit 0 on a dangling final component, and the arg
refusals sitting behind the root check in violation of the repo's own
validated-before-root doctrine.

(ceremony flow: issue #105)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 00:31:53 +00:00
claude-bot-andresmgsl
6241e87538 feat: drill/drill.sh — the instrument rig's drill gate never had
Box's shape, rig's legs: the reporting verbs and set -u-only discipline
(a failing check is data, not a crash), a fatal INSTALLED_FROM assertion
on BOTH pinned refs before anything is believed, convergence asserted on
effective state, idempotence decided by a mechanical capture-and-diff,
db driven through test/db-integration.sh with its loud-skip contract
kept, the runner lifecycle against a fork, a pinned coolify install, and
a record emitter that writes drills/<version>.md in the schema the
drill-recorded gate reads — skips counted and named, never folded into
passes.

The --host yes leg stops at 'the pinned box installed and its host stack
stands', in as many words in the output: the isolation boundary is box's
drill's assertion, joined to this record by the shared run ID.

(ceremony flow: issue #105)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 00:26:58 +00:00
Daniel Marin
df29d43a23
Merge pull request #121 from claude-bot-andresmgsl/build/120-kimi-panel
fix: kimi-bot-andresmgsl joins the review panel — the roster predated it joining the bench
2026-07-24 01:20:37 +01:00
codex-bot-andresmgsl
493ae31811 fix: pin bootstrap box release 2026-07-23 23:56:54 +00:00
claude-bot-andresmgsl
0b3c7cb055 fix: kimi-bot-andresmgsl joins the review panel — the roster predated it joining the bench
The ceremony conversion (#112) extracted the panel= line verbatim from the
pre-ceremony labels-reconcile.sh BOTS array, which predates kimi-bot joining
the family bench. A name missing from that line is a reviewer the machinery
never waits for: the handoff would report a full panel while one verdict
short — the exact defect cast@2612967 fixed after cast#143 shipped it.

labels.conf is the source of truth the reconciler reads; CONTRIBUTING
mirrors it for humans requesting reviewers by hand. Both move together.

Closes #120

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 23:53:30 +00:00
Daniel Marin
66d304f21f
Merge pull request #112 from claude-bot-andresmgsl/build/13-convert-rig
feat: convert rig to the shared ceremony — machinery by reference at 0.1.0
2026-07-23 12:58:33 +01:00
claude-bot-andresmgsl
3c72c1b8c3 docs: changelog — the conversion's Unreleased entries (ceremony#13)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 00:29:42 +00:00
claude-bot-andresmgsl
119fb68359 docs: CONTRIBUTING keeps only rig's specifics — the doctrine is the vendored .ceremony/ mirror (ceremony#13)
Root LABELS.md was rig's own copy of the family taxonomy doc; the
vendored .ceremony/LABELS.md is the driftless replacement, so the copy
goes. drills/README.md's gate pointer follows the guard upstream.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 00:29:20 +00:00
claude-bot-andresmgsl
36da497352 docs: vendor the ceremony doctrine — .ceremony/ mirrors heavy-duty/ceremony@0.1.0, root AGENTS.md routes to it (ceremony#13, ceremony#19)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 00:27:43 +00:00
claude-bot-andresmgsl
be71e1c8b2 feat: the machinery hands over — guard scripts deleted, CI pins ceremony's actions at 0.1.0 (ceremony#13)
changelog-armed returns (rig#44's revert, now version-keyed upstream);
docs-sync guards the doctrine mirror the next commit vendors.
test/release.sh keeps rig's own surfaces — installer channels and
latest-tag resolution; the machinery halves and the workflow-shape pins
are tested in ceremony's own test/. test/labels-reconcile.sh goes whole:
it drove the deleted reconciler.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 00:27:10 +00:00
claude-bot-andresmgsl
e6584ceb2b feat: labels.yml becomes the ceremony caller — panel and scope taxonomy extracted to labels.conf (ceremony#13)
The conf format takes no comment lines: every non-blank, non-panel line
must parse as name|color|description, so the file is data only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 00:26:11 +00:00
claude-bot-andresmgsl
d8a0e22894 feat: release.yml becomes the ceremony caller stub — both doors move upstream (ceremony#13)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 00:25:05 +00:00
Daniel Marin
595a152925
Merge pull request #109 from dan-claude-bot/feat/kimi-box-tenant
feat: kimi-box joins the box tenant roles — the Kimi CLI agent guest
2026-07-22 16:06:45 +01:00
dan-claude-bot
5bf0acd940 feat: kimi-box joins the box tenant roles — the Kimi CLI agent guest
The fourth agent tenant, through the one parameterized mechanism
(lib/tenant-config.sh + bootstrap-tenant.sh) — never a fifth
hand-maintained copy. The official installer (code.kimi.com/install.sh)
is uv-managed and lands kimi in ~/.local/bin, run as the tenant user
(grok's lesson: a root-owned install under a 0700 home is a CLI that
exists and cannot run); no node, uv brings its own Python. The context
file lands at ~/.kimi/AGENTS.md — the <dotdir>/AGENTS.md convention —
with an honest note that kimi documents only project-level AGENTS.md
today.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 15:03:25 +00:00
github-actions[bot]
7f8a0e0885 chore: bump main to 0.3.1-dev — a dev install must not impersonate 0.3.0 2026-07-21 18:07:01 +00:00
Daniel Marin
c6a3c23869
Merge pull request #101 from dan-claude-bot/release/0.3.0
release: 0.3.0
2026-07-21 19:06:51 +01:00
dan-claude-bot
9b7cb57005 docs: record the 0.3.0 drill as waived, with what that leaves unproven 2026-07-21 17:43:28 +00:00
dan-claude-bot
c77bc1a9bf release: 0.3.0 2026-07-21 17:43:28 +00:00
Daniel Marin
6b012ab548
Merge pull request #104 from dan-claude-bot/refactor/drills-per-version
refactor: one drill record per version, in drills/
2026-07-21 18:41:34 +01:00
dan-claude-bot
186d976686 fix: the guard's comment misstated set -e, and the form now matches its twins 2026-07-21 17:12:54 +00:00
dan-claude-bot
dbe04c7c36 test: the guard's verdict must match the tree, not always pass 2026-07-21 16:42:41 +00:00
dan-claude-bot
11dfeb2ecd refactor: one drill record per version, in drills/
Drill records move from sections inside drill/RUNS.md to one file per
version at drills/<version>.md.

The old guard had to parse headings: an em-dash prefix match, an optional
" — DATE" tail, a whole-version comparison so 0.3.0-rc1 could not satisfy
0.3.0, and a separate non-blank-body rule. All of it existed only because
records shared one file, and both sibling repos shipped a defect out of
that complexity in review — a `sed '/./,$!d'` extractor where `.` matches
a space (box#149, cast#138), and heading-grammar drift. One file per
version makes nearly all of it unrepresentable: 0.3.0.md and 0.3.0-rc1.md
are different files, and the filesystem does the whole-version comparison.

The awk drill_section() machinery is gone. What survives is the one rule
splitting the files does not make unrepresentable: a file of only
whitespace is not a record.

Plain drills/, NOT .drills/ — a dot-directory is invisible to globs
without dotglob, the cause of #70 here and box#116/box#118.

drill/RUNS.md is deleted; it was created in this same unmerged PR, held no
real records, and its useful reasoning moves to drills/README.md. (box
keeps ITS drill/RUNS.md — that one is a genuine harness log.)

Also corrects the ordering framing in CONTRIBUTING and the new README: the
three repos' drills are INDEPENDENT, run in any order. What makes that safe
is that each pins the same fixed set of candidate refs, so box and rig
measure the same pair — that, not sequencing, is what dissolves the mutual
recursion.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 16:35:51 +00:00
Daniel Marin
750d93deda
Merge pull request #102 from dan-claude-bot/feat/drill-gate
feat: CI refuses a release PR with no drill record
2026-07-21 17:23:34 +01:00
dan-claude-bot
b234f48e68 test: pin that whitespace is not a drill record 2026-07-21 15:59:57 +00:00
dan-claude-bot
77a9a1ad76 feat: CI refuses a release PR with no drill record
CONTRIBUTING has always required a real-hardware drill on a release, and
nothing enforced it — so no release in this family has ever carried one.
Every other ceremony step is checked by a script; the one that costs an
afternoon was checked by a reviewer remembering. A reviewer bot finally
blocked on it.

- drill/RUNS.md: rig's own run log, starting EMPTY of records. rig has no
  drill harness of its own yet; the harness lives in box's drill/ and this
  file is the record, not the instrument.
- .github/scripts/drill-recorded.sh: a -dev tree asserts nothing; a bare
  VERSION requires a non-empty '## Release drill — X.Y.Z' section, version
  matched WHOLE so an -rc1 record is not evidence for the final.
- Per-repo on purpose. A cross-repo lookup into box fails on a token, a fork
  checkout or a network blip, and all of those degrade to 'pass' on precisely
  the tree that ships — the UNREADABLE-vs-NONE shape #90 fixed.
- It asks for a RECORD, not a RESULT, so a maintainer waiver stays possible
  but has to be written down under that version.
- Fixtures carry their own VERSION and RUNS.md (heavy-duty/box#146: fixtures
  reading the repo's real VERSION exercised only the -dev branch and went red
  first while cutting a release).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 15:24:42 +00:00
Daniel Marin
b8ce8e5e1c
Merge pull request #100 from dan-claude-bot/docs/changelog-one-line
docs(changelog): one line per entry, and a pass over the whole file
2026-07-21 15:44:03 +01:00
dan-claude-bot
d69ecd1104 docs(changelog): one line per entry, and a pass over the whole file 2026-07-21 13:56:39 +00:00
Daniel Marin
9c82d56230
Merge pull request #93 from claude-bot-andresmgsl/fix/sshd-privsep-verdict
fix: don't read a missing /run/sshd as a broken sshd config
2026-07-21 14:07:35 +01:00
Daniel Marin
9adf45a229
Merge pull request #99 from dan-claude-bot/fix/changelog-monotonic
fix: catch a deleted or duplicated release heading in CHANGELOG.md
2026-07-21 00:36:37 +01:00
dan-claude-bot
1db7779b03 test: terminate the ci.yml step block at the job boundary too
The monotonic step is the LAST step of its job, so stopping only at the next
`- name:` ran the block into the job below and swallowed that job level `if:`.
Unanchored `grep -q "if:"` then fired on it — the same bug the scoping was
meant to fix, moved from "any step in the file" to "this step plus the head of
the next job".

Terminates on a new step OR a new job now, and the key is anchored so an `if:`
inside a `run:` line is not mistaken for a step condition.

Found by claude-bot-andresmgsl on heavy-duty/box#144; this port carried the
identical awk.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 20:56:57 +00:00
dan-claude-bot
c199e8fd04 fix(changelog-monotonic): report containment vacuous when the base IS HEAD
Dropping the pull_request gate made merge_base == HEAD a routine path rather
than a degradation, and the success line did not follow. On every push to main
the step printed "all N release heading(s) at the merge base are still present"
— a containment claim on the one event where deletion is undetectable, since
the comparison is the file against itself.

That is the dishonesty this PR fixed in the skip messages, surviving in the
success message. The line now has two forms: containment vacuous, naming
uniqueness as the half that ran, or the existing containment wording when a
real base exists. Both pinned, plus a negative that the two do not collapse.

The "an untouched branch passes" case turned out to be this exact shape — its
fixture never commits on 'work', so it was asserting containment on a
self-comparison. Its assertion moved to uniqueness's count, and a companion
case with a real base now carries the containment wording it used to claim.

Also scopes the ci.yml negative pin to the monotonic step's own block. As a
file-wide grep it forbade any FUTURE step from being pull_request-gated and
would have failed citing #98 when one legitimately was. A companion check
keeps the awk extractor from matching nothing and going tautological.

Ported from heavy-duty/box#144 (box@94d830c).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 20:49:02 +00:00
Daniel Marin
27a5fa282e
Merge pull request #97 from dan-claude-bot/fix/labels-sweep-on-labeled
fix(labels): sweep on `labeled` so the handoff is immediate
2026-07-20 21:32:44 +01:00
dan-claude-bot
7ffc30bacc fix(changelog-monotonic): check uniqueness before anything base-side (#98)
Uniqueness is a property of HEAD alone — no base ref, no merge base, no base
blob. It sat downstream of all three, so every degradation path returned
success on a tree carrying a duplicate.

The base-blob path was the worst: a branch that introduces CHANGELOG.md hit a
bare `exit 0` on a message that was true about deletion and silent about the
duplicate in front of it. STRICT could not reach it — STRICT guards the two
skip() calls, and that is not one of them.

That inverted the two halves. Deletion needs a diff to see; duplication is the
one changelog_section() actually mis-renders, stopping at the second heading
and truncating the release's real body. The half with the live extraction bug
behind it had the most ways to silently not run.

Moved, not rewritten. The skip messages now say containment skipped and that
uniqueness already passed. The CI step is no longer pull_request-only, with a
`github.ref_name` fallback because base_ref is empty on a push and a bare
`origin/` under STRICT would redden every push to main. The script is also now
100755, matching cast's copy of the same file.

Regression cases pin the ORDER, not the exit code: verified they go red
against the pre-fix script (7 failures) and green against the fixed one.

Same defect fixed upstream in heavy-duty/box#144 (heavy-duty/box#143), which
rig's copy of this script was ported from. Found by claude-bot-andresmgsl and
codex-bot-andresmgsl reviewing #99.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 20:22:43 +00:00
dan-claude-bot
0865ce6c02 fix(changelog): cite the sibling PRs, not the tracking issues
The "Landed in all three repos together" line pointed at the sibling
tracking issues rather than the sibling PRs. The entry already opens with
its own issue ref, so a reader following "landed together" was sent to more
issues and never reached the actual sibling changes.

All three PRs carried it identically because the three entries came from one
generator that took sibling references from its issue-number map, and
expanded them into the sentence without re-wrapping — which is also why the
line ran to 108 columns in a file that wraps at 83.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 20:07:58 +00:00
dan-claude-bot
b2e7febf08 fix: catch a deleted or duplicated release heading in CHANGELOG.md (#98)
The arming rule (#66) guards ONE heading — does the top section agree with
VERSION? — and is silent about the rest of the file. The failure that lives
there is an author adding an entry under `## Unreleased` who replaces the
shipped heading below it instead of inserting above it. git merges the
one-line edit cleanly, `changelog_armed()` stays green (correctly: the top
section is still right), and the shipped release loses its section entirely.
It surfaces a whole release later, when release.yml refuses to publish a
section `changelog_section()` can no longer find by heading.

"A heading disappeared" is a property of a DIFF, not of a tree, so this is
its own script rather than a clause in the arming check — which is also
driven from test/release.sh against constructed non-git VERSION + CHANGELOG
pairs that could not express it. The rule needs no tuning: release headings
are append-only, so SUPERSET is exact, and the ceremony's stamp passes by
construction because `Unreleased` fails the version shape.

Ported from heavy-duty/box#122, with box's second half intact: containment
cannot catch a DUPLICATED heading, since the duplicate is head-side surplus
and `comm -23` is blind to extras there — so uniqueness on HEAD is asserted
alongside it. rig's symptom differs from box's and the comments say so:
box's extractor re-arms on every `## ` line and ABSORBS what sits between
the copies, while rig's `changelog_section()` has `if (found) exit` and
TRUNCATES at the second copy, dropping the release's real body.

Wired on pull requests only (on a push to main the merge base is HEAD, so
the assert is vacuous), with CHANGELOG_MONOTONIC_STRICT=1 and fetch-depth: 0
so a checkout that cannot reach the base ref fails rather than skipping
quietly forever.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 20:00:48 +00:00
dan-claude-bot
23cca65855 fix(labels): sweep on labeled so the handoff is immediate
A review landing was never a trigger for the labels workflow, so the exact
moment `state:needs-human` became true — the third bot approving — fired
nothing, and the label waited on the `*/15` cron. That cron does not run at
its declared rate: measured across box, rig and cast over a two-hour window
on 2026-07-20, one scheduled run each against the eight `*/15` implies.

The obvious fix does not work. There is no `pull_request_review_target`, and
on fork PRs — all of them here — `pull_request_review` runs with a read-only
token and cannot label anything.

So the handoff wakes the sweep itself:

- `pull_request_target` also fires on `labeled`/`unlabeled`
- the author sets `state:needs-human` at handoff, as the third act after the
  round summary and the review request

The author's own label write fires the sweep that validates it — an
optimistic write, not a transfer of ownership. The reconciler confirms or
corrects it seconds later, and the cron falls back to a last resort. It
cannot loop: the reconciler writes with GITHUB_TOKEN, which does not create
workflow runs; agent writes use a PAT, which does.

`labels-reconcile.sh` is unchanged — it already recomputes every open PR
from scratch on every run, which is what makes the optimistic write safe.
The `scope` job is skipped on label events, where no path can have changed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 19:44:07 +00:00
Daniel Marin
ac1bb3bf76
Merge pull request #94 from dan-claude-bot/docs/contributing-blocker-axis
docs(contributing): document the blocker axis and merge-next ownership
2026-07-20 20:27:32 +01:00
Daniel Marin
9ba16946fd
Merge pull request #91 from codex-bot-andresmgsl/fix/issue-89-readme-channel
docs: align README quick start with main
2026-07-20 19:41:12 +01:00
dan-claude-bot
8b2ddbdd2d docs(contributing): document the blocker axis and merge-next ownership
The who-sets-what table is the day-to-day answer to "can I move this by
hand", and it never mentioned blocker:* -- a whole machine-owned family
added when state:needs-rebase was retired. merge-next was missing too, and
that is the one label whose ownership actually needs saying, because it is
the only one in the machine's vocabulary the machine deliberately does not
set.

Step 6 also read as though requesting the maintainer is sufficient to flip
state:needs-human. It is not: needs-human requires zero blockers, so the
request does nothing on a conflicted or red PR.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 18:40:12 +00:00
Daniel Marin
88d83a1aba
Merge pull request #90 from dan-claude-bot/fix/labels-two-axis
refactor(labels): split PR labels into state and blocker axes
2026-07-20 19:30:07 +01:00
dan-claude-bot
5eddf2e2ed fix(labels): a missing state label skips the edit, not the whole PR
Round 2 review (claude-bot, codex-bot — both raised this, independently).

The `return` added in round 1 aborted all of reconcile_pr, not just the
label edit. Everything below it is independent of the state:* taxonomy:
`merge-next` clearing and the stale sweep both stopped running. So a
cold-start repo left `merge-next` claiming "merge this one next" on a PR
the board had moved to the agent — the original false-invitation bug,
reintroduced inside the very fix meant to survive a cold start.

It was also a regression against main, not just a missed improvement:
the old code failed the `gh issue edit`, logged, and fell through to both
blocks. Round 1 turned a per-edit failure into a per-PR abort.

Now a `skip_edit` flag skips only the edit and control reaches the rest.

Also from review: drop the dead `"$desired"` term from the filter loop
(it was appended and then unconditionally continued past), and turn
`[ -n "$missing" ] && log` into a proper `elif` rather than an
&&-as-statement under `set -e`.

Adds the first four fixtures that exercise reconcile_pr itself, stubbing
run/gh to probe a cold-start repo against a bootstrapped one. Everything
before this tested pure functions, which is exactly why a per-PR return
got through: nothing could see it.

Fixtures 68 -> 72.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 18:02:03 +00:00
claude-bot-andresmgsl
2dc47112f2 fix: don't read a missing /run/sshd as a broken sshd config
`sshd -t` folds two questions into one exit code — is the merged config
parseable, and is the privilege-separation directory there. Both call
sites ran it as `sshd -t 2>/dev/null` and read any non-zero exit as the
first question's answer, discarding the line that named the second.

Bootstrap aborted with "sshd rejects the merged config", a verdict sshd
never reached, and sent the operator to audit /etc/ssh files that were
never broken. /run is a tmpfs and /run/sshd is ssh.service's
RuntimeDirectory, so it is legitimately absent under socket activation
on a box whose SSH door is serving connections normally.

Classification is now a pure, sourceable sshd_privsep_gap: the status is
the verdict, the text only classifies a failure, so a passing sshd -t is
never diverted. sshd_config_ok repairs the gap with an idempotent
install -d and retests once. A genuine parse refusal still refuses and
the rollback is untouched.

Refusals now carry sshd's own stderr. users-close-root had the identical
three lines and now reaches the shared judgement through lib/sshd.sh
instead of keeping a second copy of it.

Fixes #92

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 17:58:10 +00:00
codex-bot-andresmgsl
491055c4cf docs: point stable installs at matching README 2026-07-20 17:50:18 +00:00
dan-claude-bot
b07e734fc0 fix(labels): survive a repo that lacks the new blocker labels
Round 1 review (claude-bot, codex-bot — both raised 1 and 2).

1. `gh issue edit --add-label` rejects the WHOLE call on one unknown
   label name, and the blocker:* labels are created only by the
   dispatch-only bootstrap. So the first sweep after this lands would
   have converged NOTHING on exactly the PRs this change exists to heal,
   surfacing only as a WARNING in a cron log. Batching state and blockers
   into one edit for anti-flicker is what widened that blast radius.
   Every label about to be ADDED is now filtered against the repo's real
   label set, read once per sweep; removals need no filter because they
   are built from has_label. An unreadable label set does not filter, so
   a failed read cannot silently strip the board.

2. blocker:unrequested fired only on MISSING, so a round whose approvals
   all staled behind a push — with nothing re-requested — carried no
   blocker at all, though the agent owes exactly the same ask. Now
   MISSING or STALE: both mean this head has no verdict from that
   reviewer.

3. LABELS.md: restore the substantive "Leaves when" text for
   state:addressing, and widen the blocker:unrequested row to name both
   shapes now that (2) changes what the label means.

Fixtures 66 -> 68.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 17:49:44 +00:00
dan-claude-bot
4f40cfabf2 fix(labels): distinguish an unreadable check rollup from no checks
When `gh pr view` failed, the fallback left the `statusCheckRollup` key
absent, and `(.statusCheckRollup // [])` collapsed that into the same
NONE as a PR with genuinely no checks. NONE blocks nothing, so an API
hiccup presented the PR as mergeable by a human — an unknown certified
as green, the shape #87 exists to stop, in the one place it never looked.

checks_state now returns UNREADABLE for an absent key versus NONE for a
present-but-empty array, and the sweep leaves an UNREADABLE PR alone
rather than recomputing on facts it did not read. Not a blocker on
purpose: blocking would flap the board on one bad call.

Fixtures 64 -> 66.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 17:38:32 +00:00
dan-claude-bot
4ea3579c2e docs(changelog): record that CI never ran the label fixtures
The CI wiring is independently valuable from the two-axis refactor and
was missing its own entry: rig's label state machine gates every PR and
its fixtures had never executed in CI, including for #88 which merged
today reporting 51 passing fixtures.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 17:34:21 +00:00
codex-bot-andresmgsl
b88495105d test: tighten README channel contract 2026-07-20 17:32:43 +00:00
dan-claude-bot
3f6f05766f refactor(labels): split PR labels into state and blocker axes
Retires `state:needs-rebase` in favour of two independent axes: `state:*`
(whose ball, exactly one) and `blocker:*` (what is in the way, additive).
One rule joins them: `state:needs-human` requires zero blockers.

The single-label design projected independent facts — mergeability, check
status, review round — onto one totally-ordered value, so one always won
and the rest vanished. Every precedence bug this machine has had lived on
that ordering. Blockers are a set, so there is no precedence between them
to get wrong.

`state:bots-reviewing` tightens to mean strictly "a request is live"; a
ready PR nobody was asked to review is now `state:addressing` +
`blocker:unrequested`. The reconciler strips the retired
`state:needs-rebase` on sight via a RETIRED array.

Also wires test/labels-reconcile.sh into CI, where it had never run.

Fixtures 51 -> 64.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 17:30:53 +00:00
codex-bot-andresmgsl
32e17246a2 docs: align README quick start with main 2026-07-20 17:30:11 +00:00
Daniel Marin
616ada76b9
Merge pull request #88 from dan-claude-bot/fix/labels-mergeability-aware
fix(labels): `state:needs-human` means a human could merge it right now
2026-07-20 18:02:57 +01:00
dan-claude-bot
108a944e1c chore(labels): converge reconciler and fixtures on the canonical text
box, rig and cast carry the same reconciler, and the three had drifted to
functionally identical but textually different versions of checks_state
after the same fix was reached independently in each repo. Comment prose
and one fixture helper name differed, so the files were no longer
byte-identical and the convergence check had nothing to compare.

Standardised on the cast wording for checks_state and on the canonical
test/labels-reconcile.sh, which carries the same 51 fixtures under
overlap_() where this repo had named the helper drained_(). No behaviour
change: the diff to the reconciler is comments only, and the suite is
unchanged at 51 passed, 0 failed.

Only this repo's own scope:* rows are preserved as local content.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 16:44:19 +00:00
dan-claude-bot
57e37363f4 fix(labels): order check runs by one consistent quantity — when they began
The dating expression took the newest stamp each run carries. That reads
`completedAt` for a finished run and `startedAt` for a live one, so the
comparison comes down to "when this one ended" against "when that one
began" — which is not an ordering on runs at all.

A run cancelled by the concurrency group does not stop the instant its
replacement starts: the runner has to receive the signal and wind down. So
`predecessor.completedAt > successor.startedAt` is the ordinary case, not a
corner. On the box#137 tip that motivated the supersede rule the window was
13s wide — the superseding run started 15:19:38, the run it cancelled did
not finish until 15:19:51 — and for that whole window the dying predecessor
out-dated its own live replacement, so `last` discarded the replacement and
judged the corpse.

Both round-3 failure modes came back inside that window, narrowed rather
than closed: a draining CANCELLED predecessor reported FAILURE and sent the
agent to fix nothing, and a draining SUCCESS predecessor reported SUCCESS —
mergeable, all bots approve, state:needs-human — over a tree whose merge
button branch protection had already disabled. #136 again, one field over.

Dated by `first` of the preference-ordered stamps rather than `max` of
them: start time if the run recorded one, falling back only if it did not.
The sentinel filtering is unchanged, and finished runs still date by
completion when that is all they carry, so the supersede rule keeps the
case it exists for.

Found independently by claude-bot-andresmgsl and codex-bot-andresmgsl.
No existing fixture could express it — `run_()` carries no startedAt, so
every supersede fixture spaced the predecessor's completion safely before
the successor's start, the same blind spot as round 3 one field over. New
`drained_()` helper pins both directions; fixtures 48 -> 51 (with the
reverse-direction in-flight fixture ported from cast#128).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 16:32:53 +00:00
dan-claude-bot
800571fdfb fix(labels): date a check run by when it started, not by a zero completion
The supersede collapse added in the previous commit dated each run by
`.completedAt // .startedAt // .createdAt`. A run still in flight has no
completion, but `gh` does not omit the field: its Go struct marshals the
zero time as the string "0001-01-01T00:00:00Z", and jq's `//` falls
through null and false only. The sentinel was therefore taken as the sort
key, and it sorts before every real timestamp — so the LIVE re-run became
the oldest entry in its context, `last` discarded it, and the run it
superseded was judged instead.

That restored #136 through the fix for it: a green context with a
replacement mid-flight reported SUCCESS, so a PR read mergeable, green,
all bots approve — state:needs-human — while branch protection had the
merge button disabled. It also narrowed rather than removed the flap the
supersede rule exists to prevent: between "run A cancelled by the
concurrency group" and "run B finishes", the PR reported FAILURE and the
agent was sent to fix something that was not broken.

Runs are now dated by the newest timestamp they actually carry, with both
spellings of absent discarded (null, and the zero sentinel). Entries that
carry no usable timestamp sort LAST rather than first: something we cannot
date is most likely the thing just created, and treating it as newest
keeps an undateable in-flight run from being discarded in favour of a
stale success. Every ambiguity resolves toward "not settled".

Found independently by claude-bot-andresmgsl and codex-bot-andresmgsl.
The fixtures could not have caught it: `run_()` always emits a real
completedAt, so every supersede fixture was a race between two finished
runs, and the bug lived in the one shape the helper could not express.
New `inflight_()` helper covers it; fixtures 44 -> 48.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 16:18:50 +00:00
dan-claude-bot
a0951eacf8 fix(labels): unrecognised check outcomes block, and STALE outranks MISSING
Round 2 review found two ways the "a human could merge this right now"
invariant still leaked, both of which let state:needs-human land on a PR
the button would refuse.

The check-rollup classifier enumerated the outcomes that block and
defaulted the rest to SUCCESS, so ERROR, CANCELLED and STALE fell through
into green. Inverted to an allow-list of the outcomes that DON'T block
(SUCCESS, NEUTRAL, SKIPPED, plus the pending set); everything else,
including an outcome neither enum has today, blocks. The rollup mixes
CheckRun.conclusion with StatusContext.state and an outcome the list
forgets is one we cannot certify as mergeable — a false FAILURE parks the
PR on the agent, a false SUCCESS invites a bad merge. The classifier also
moved out of main() into checks_state(), which is why no fixture caught
this: it was inline in the fetch loop and the jq itself was untestable.

Once CANCELLED blocks, superseded runs must be dropped first — a re-run
does not evict the run it replaced, and judging every entry would strand
every re-run PR in needs-rebase. Each context now collapses to its newest
entry, keyed on workflow + job name because a bare job name is only
unique within its workflow.

decide_state() returned from inside the bot loop on the first MISSING, so
a STALE belonging to a later bot in BOTS was never read: a round that was
both unfinished and staled came out needs-human with nothing bound to the
head. The whole round is now collected before any precedence is applied,
STALE ahead of MISSING. The MISSING-yields-to-an-explicit-human-request
rule is untouched.

Fixtures 29 -> 44, pinning the whole check-outcome enum, the supersede
rule in both orders, and the mixed round at both ends of BOTS.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 16:05:39 +00:00
dan-claude-bot
ef798f302c fix(labels): state:needs-human means a human could merge it right now
Ported from heavy-duty/box#137 (heavy-duty/box#136) so the three repos'
reconcilers stay byte-identical. The state machine here was byte-identical to
box's before this change, and remains so after -- only the scope:* taxonomy
differs, correctly.

decide_state() derived state from three inputs -- draft flag, requested
reviewers, submitted reviews -- and read NOTHING about mergeability or checks.
With the `if requested "$HUMAN"` short-circuit at the top of its precedence,
the label was sticky: once the maintainer was requested, a PR read
state:needs-human through conflicts, through red CI, through a force-push that
staled every approval.

This repo paid for it directly. During the ten-PR batch merged today, every
merge re-conflicted the PRs below it through CHANGELOG.md, and each kept its
state:needs-human label throughout -- inviting merges that could not happen.
It was caught only by opening them one at a time, which is the work the label
exists to save.

The rule the label now keeps: state:needs-human means a human could merge this
RIGHT NOW, so anything making that false outranks the request that put it
there.

  CONFLICTING or failing checks -> state:needs-rebase (new; the agent's to fix)
  approvals staled by a push    -> state:addressing   (nobody reviewed this tree)

An UNFINISHED round still yields to an explicit human request -- MISSING
(nobody has reviewed yet) is a different fact from STALE (everyone reviewed
something else). UNKNOWN mergeability is NOT treated as unmergeable: GitHub
reports it for about a minute after every merge, and flapping every open PR
through needs-rebase on each merge would be worse than the bug. A failed read
degrades to the same "do not know" value.

Also adds merge-next: queue order is intent, so the reconciler never sets it,
only CLEARS it once the PR stops being mergeable-by-a-human.

Fixtures 19 -> 29, including that UNKNOWN does not trigger needs-rebase and a
draft outranks a conflict. No live dry-run evidence here -- this repo has no
open PRs right now -- so the fixtures and box's live dry-run are the proof.

Closes #87

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 15:26:54 +00:00
Daniel Marin
762cc9653e
Merge pull request #84 from dan-claude-bot/feat/manifest-provenance
feat: /etc/rig/manifest — which rig converged this machine, and when
2026-07-20 14:06:01 +01:00
dan-claude-bot
d2e03ce993 fix: read a manifest's final line when the file has no trailing newline
manifest_value, manifest_has and manifest_foreign each read the file with a
bare `while read`, which stops at EOF without ever handing over a populated
partial line. An unterminated final line therefore read as ABSENT — and
absent is exactly the input both convergence rules key off, so the file's
last line was the one least able to survive the miss.

Three failures, in descending order of how much they cost:

- A file truncated mid-write ends AT bootstrapped_at, so the unreadable line
  is the birth stamp itself. Rule 1 saw no at-stamp and regenerated the pair
  as now() — overwriting the one field that can never be reconstructed. A
  fixture born 2020-01-01 came back stamped with the current clock.
- A whole file whose last line is converged_at read as empty, so Rule 2's
  one-time repair re-fired on EVERY run: the render stopped being a function
  of (existing file, running version) and the clock reached the file after
  all. This is the crux property of the feature, broken by a missing byte.
- manifest_foreign dropped an unterminated foreign line entirely, so the
  writer ate a later command's provenance — the exact preservation contract
  the function exists to keep.

The idiom is the repo's own: lib/users-config.sh:49 reads
`|| [ -n "$line" ]` for the same reason.

Reading the line correctly also repairs the file, since the rewritten copy
is newline-terminated — asserted as "the source plus the newline it was
missing, and nothing else", because a plain ends-in-newline check stays
green on an implementation that drops the final line.

7 tests, each observed RED against the unfixed reader.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 12:48:39 +00:00
dan-claude-bot
5c40e906a1 feat: /etc/rig/manifest — which rig converged this machine, and when
A rig-managed machine recorded nothing about its own provenance. The entire
durable output of a bootstrap run was one line in /etc/rig/role, and that line
says what the box IS, never what built it. VERSION was read in exactly one
place (bin/rig:9, for --version) and reports the currently INSTALLED tree, not
the one that ran; there was no timestamp anywhere in the codebase.

bootstrap now stamps a second file beside the marker: schema=1, a birth pair
(bootstrapped_by/_at, pinned forever) and a latest pair (converged_by/_at).
key=value, one per line, 0644 — the one file that must stay readable on the
most broken machine in the fleet, where there is no YAML parser and no jq.
`rig manifest [<key>]` reads it back.

Only DECIDED facts go in, which is what keeps bootstrap.sh:3's convergence
contract intact: bootstrapped_* is first-write-wins, and converged_* updates
only when the version actually differs — it is the time the converging version
last changed, not the time of the last run. The renderer is pure, so a re-run
by the same rig is byte-identical no matter where the clock is, and the
cmp-guard stays silent. OBSERVED facts (cores, RAM, disk, kernel) stay out:
they go stale on their own and belong to `rig platform` (#64).

/etc/rig/role is untouched.

Closes #61

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 12:48:39 +00:00
Daniel Marin
d6e8cdda06
Merge pull request #74 from dan-claude-bot/feat/platform
feat: rig platform — what is this machine, computed not stored
2026-07-20 13:46:00 +01:00
dan-claude-bot
e11247b467 docs: the provenance comment matches #61's pinned contract, not the old story
Review found `commands/platform.sh` still telling future readers that a
missing converged_* pair is a legitimate state on a freshly bootstrapped box.
It is not, and the rest of this PR already says so: #61's writer records both
pairs equally at bootstrap, so no writer produces that manifest -- its absence
means partial or hand-edited. README and the fixtures were corrected in an
earlier commit of this series; the comment beside the code was not, which
left the one artifact a future change actually reads while editing this block
still arguing the retired position.

Also corrects two examples that went stale under this PR rather than in it.
#76 and #77 merged while this was in review, so the marker example in the
same file -- and the sample `rig platform` output in README -- still showed
`role=dev class=human host=yes join=authkey`, a spelling bootstrap no longer
writes. Now `role=dev-server root-door=closed host=yes join=authkey`, with a
note that the renderer prints whatever fields a marker carries, so a
pre-rename box still shows its own class= line unchanged.

Comments and docs only; no behaviour change. Same defect class both times --
prose that contradicts the contract the tests pin -- which is why the sweep
went wider than the one site reported.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 12:41:30 +00:00
Daniel Marin
92cdb327b9
Merge pull request #81 from dan-claude-bot/test/at-risk-gate-pin
test: pin the at-risk gate's floor at one operator
2026-07-20 13:36:55 +01:00
Daniel Marin
b5e01296ee
Merge pull request #73 from dan-claude-bot/fix/users-apply-empty-file
fix: gate 'users apply' on an empty file that would revoke everyone
2026-07-20 13:36:35 +01:00
dan-claude-bot
d3577b9954 docs: a fresh bootstrap writes both pairs equal, not a missing converged_*
The previous commit's test and README described a freshly bootstrapped
machine as lacking converged_* and rendering 'not recorded'. That pins the
wrong contract: #61 states plainly that "on a fresh machine both pairs are
written with equal values", and rule 2 only suppresses converged_* churn on
a later same-version re-run.

So two identical lines mean bootstrapped-and-never-re-converged, and a
manifest missing the pair is partial or hand-edited rather than fresh --
no writer produces it. The graceful degradation stays for that case, still
without backfilling from birth, which would invent a convergence that never
happened.

The reader itself was already correct; this fixes the fixture and the prose
that claimed otherwise.

Found in review of #74.

Refs #64
2026-07-20 12:29:02 +00:00
dan-claude-bot
91a8905a71 fix: platform reads #61's actual manifest schema, not invented keys
The reader asked for `version` and `bootstrapped`. #61 specifies `schema`,
`bootstrapped_by`/`bootstrapped_at` and `converged_by`/`converged_at` — so
no writer would ever have produced the keys being read, and the day #61
landed this command would have rendered 'unknown' with the timestamp
omitted, forever, with nothing to say why.

Keyed to #61's spelling, with fixtures carrying that schema verbatim so
the contract is pinned rather than assumed. Birth and latest are reported
separately and neither is inferred from the other: under #61 rule 2
converged_* is written only when the version differs, so its absence is a
legitimate state on a freshly bootstrapped box, printed 'not recorded'
rather than backfilled from birth. A manifest whose schema this rig does
not know is named as such instead of being half-read in silence.

Found in review of #74.

Refs #64
2026-07-20 12:28:51 +00:00
dan-claude-bot
3cfbb8921c fix: platform reads a manifest whose last line lacks a newline
`while IFS='=' read -r k v` drops an unterminated final line: read returns
1 at EOF even having filled k and v. A manifest ending `bootstrapped=...Z`
with no trailing \n rendered `RIG 1.2.3` with the timestamp silently gone
— the version read fine, so nothing looked wrong.

Guarded with `|| [ -n "$k" ]`, the same shape parse_users_file already
uses (lib/users-config.sh:47). #61's writer should not have to know
whether this reader tolerates a missing newline.

Also make human_b fall back like human_kb. With numfmt absent, memory
degraded to a raw number while disk printed 'unknown' beside it, from data
already in hand.

Both found in review of #74.

Refs #64
2026-07-20 12:27:39 +00:00
dan-claude-bot
75ef386601 feat: rig platform — what is this machine, computed not stored
rig read no hardware at all. The single exception was `uname -m` in
runner-install.sh, used to pick a runner tarball and then discarded — so
"is this the 32GB one, or the M900?" was a question you answered by
logging in and running free -h, nproc, df -h and uname -r by hand, four
commands deep, on a machine you were already unsure about.

`rig platform` prints hostname, OS, kernel, CPU, memory, disk and
virtualization, then a provenance block: which rig, when, and the role
marker's traits.

It COMPUTES rather than stores, and that is the design rather than an
implementation detail. Specs change without rig doing anything — RAM
added, root disk resized, the unattended-upgrades bootstrap itself
enables patching the kernel — so a stored spec is stale the moment the
machine changes, and refreshing one on every run would collide with
bootstrap's "safe to re-run; a second run changes nothing" contract.
Nothing is written, so nothing can go stale.

The corollary is deliberate: reading only /proc, uname, /etc/os-release,
df and systemd-detect-virt means no root, no network, and it runs on a
pristine Debian box rig has never bootstrapped — useful for deciding
what to converge a machine into, not only for auditing it afterwards.
That also makes it the rare rig command the harness can RUN for real
rather than grep: the tests assert the answer describes the actual test
machine (kernel and hostname compared against independently computed
values), and assert it writes nothing.

Both known traps are handled explicitly. /etc/os-release is sourced in a
SUBSHELL — it defines VERSION, NAME and ID and would otherwise clobber
same-named script variables, the form every other site in this tree uses
and test/cli.sh already greps for. systemd-detect-virt exits non-zero on
bare metal while printing 'none', a normal answer that set -e would
otherwise turn into a failed run, so it is wrapped in `|| true`.

Provenance is read, never written, and degrades per file.
/etc/rig/manifest is #61 and does not exist yet, so that line reads
'not bootstrapped' on every machine today; the command ships complete
without it and neither blocks the other.

Named `platform` and not `status`: `users status` and `runner status`
cross-check recorded against live state and print DRIFT, and a command
that records nothing cannot drift, so calling it status would borrow a
promise it structurally cannot make. It also leaves `rig status` free
for the machine-wide roll-up it will eventually want to be.

Refs #64
2026-07-20 12:27:39 +00:00
dan-claude-bot
8d4f6da64c test: pin the at-risk gate's floor at one operator
`if [ "$AT_RISK" -gt 0 ]` could be mutated to `-gt 1` and the whole suite
stayed green — a users file that revokes the last remaining operator would
have proceeded without asking, which is the single-operator box the gate
exists for.

Neither neighbouring pin bit: the condition grep pins the gate's trigger
(zero users AND a readable ledger), and the deferred-threshold negative only
matches a comparison against a $-variable, so a literal floor slipped past
both.

Pinned as a pattern rather than the literal line, so `${AT_RISK}` or extra
spacing does not fail a correct gate while any other floor does; `-ge 1` is
accepted as the same statement spelled differently.

Closes #78

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 12:25:32 +00:00
dan-claude-bot
8d03184e55 fix: gate 'users apply' on an empty file that would revoke everyone
A users file naming zero users is a valid instruction to revoke every
operator on the box, and it is indistinguishable from the file a stray '>'
produces. The per-user warnings apply already emitted arrive after the
decision and scale wrong: twenty operators is twenty lines of scrollback,
so the signal was loudest exactly where it read as noise.

The /etc/rig/users ledger draws the line apply needs. An empty file against
an empty ledger is an unambiguous no-op; against a populated one it closes
every named door. Only the second now stops, states how many operators are
at risk, and requires explicit consent: --yes, RIG_YES=1 (the
installer-family variable bin/rig's uninstall_confirm already reads), or a
y on a TTY. Without a terminal and without consent it exits 2 in that same
refusal's words, rather than assume a yes it cannot ask for or hang on a
prompt nothing can answer.

A confirmation, not bootstrap's flat refusal of the same file (#57/#59):
bootstrap asserts who lives on a box, apply converges, and converging to
zero stays a legitimate de-provisioning. Ledger entries already marked
revoked do not count toward the number, so a second identical run stays the
silent no-op convergence promises.

Mass revocation below the empty-file bright line is deliberately still
ungated — that needs a threshold someone has to justify.

Refs #65
2026-07-20 12:25:09 +00:00
Daniel Marin
30ccb638dc
Merge pull request #82 from dan-claude-bot/test/read-guard-sweep-bin-rig
test: widen the read-guard sweep to bin/ and to plain-statement reads
2026-07-20 13:24:23 +01:00
Daniel Marin
1ad90410bf
Merge pull request #72 from dan-claude-bot/fix/uninstall-confirm-eof
fix: uninstall_confirm swallows Ctrl-D — the abort was silent
2026-07-20 13:23:50 +01:00
dan-claude-bot
d144ae379c test: widen the read-guard sweep to bin/ and to plain-statement reads
The #43 sweep (test/cli.sh:705) matched the literal `-rsp` spelling and
scanned commands/ only. #68 was a plain `read -r reply` in bin/rig, so it
missed on BOTH axes — the spelling and the path — and the bug survived
until a drill hit it.

The class is the shape, not the flags: any `read` run as a plain statement
under `set -euo pipefail` kills the shell at EOF, before the `case` that
would have printed the abort. The failure is silent and exits 1, which is
also what a normal refusal exits, so an exit-code assertion passes against
it.

The new sweep anchors `read` at the start of a statement across bin/ and
commands/, whatever its flags or arity, and subtracts only the two shapes
that are safe by construction: a `||` guard (the cure itself) and a `<<<`
here-string (which cannot return non-zero). `while`/`if ! ` heads need no
subtraction — the anchor already excludes them.

Guard against reintroduction, not a live fix: the tree is clean once #68's
one-token fix lands. Mutation-verified — a bare `read -r foo` planted in
bin/rig gives 404 passed, 1 failed, and the old #43 sweep stays green on
the same tree, which is precisely the gap being closed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 12:18:51 +00:00
dan-claude-bot
880e95df21 fix: uninstall_confirm swallows Ctrl-D — the abort was silent
uninstall_confirm() read the operator's answer unguarded:

    read -r reply
    case "$reply" in y|Y|yes|YES|Yes) return 0 ;; *) die "aborted." ;; esac

bin/rig runs under `set -euo pipefail`, and both call sites (the
single-version and the --all confirms) invoke the function as a plain
statement — nothing suppresses errexit. Ctrl-D makes `read` return
non-zero, so the shell died AT THE READ and the case on the next line
was never evaluated: `die "aborted."` could not fire. The operator saw
the question, pressed Ctrl-D, and got nothing — no message, exit 1, at
exactly the moment the tool had asked whether to delete their install.

It failed closed, so nothing was ever wrongly removed; the damage was
that rig went silent at the one moment silence is unreadable.

The fix is `read -r reply || reply=""` — commands/db.sh:152's spelling
for the identical [y/N] confirm one file away. Empty routes through the
existing `*)` arm, so EOF aborts through the same path a bare Enter
already does: exactly one "aborted." message, no second die to keep in
sync.

test/cli.sh gains the first drills of the interactive path, which was
structurally untested (every existing uninstall check goes through
--force or RIG_YES, which is why this survived): `y` and Ctrl-D driven
through a real pty via util-linux `script`, guarded by a command -v
skip. They assert the MESSAGE, never the exit code — the unfixed code
also exits 1, so an exit-code assertion is green against the bug.

Mutation-verified: with `|| reply=""` reverted, 403 passed / 1 failed,
the single failure being `output missing 'aborted.'`; restored, 404
passed / 0 failed.

Refs #68
2026-07-20 12:18:33 +00:00
Daniel Marin
f972a6994c
Merge pull request #71 from dan-claude-bot/fix/shellcheck-dotglob
fix: lint .github/scripts with dotglob, and assert the sweep is total
2026-07-20 13:16:56 +01:00
dan-claude-bot
26ff38390b fix: lint .github/scripts with dotglob, and assert the sweep is total
CI's shellcheck step set `globstar` and globbed `bin/* **/*.sh`. Globs do
not match dot-prefixed names without `dotglob`, so `**/` never descended
into `.github/` and two tracked scripts were linted by nothing:
`.github/scripts/labels-reconcile.sh` and `.github/scripts/release-lib.sh`.

release-lib.sh is the one that matters: it holds `changelog_section`, which
release.yml sources to build the published release body and which
test/release.sh's `changelog_armed` guard calls to decide whether main is
armed. The script that decides both what ships and whether the changelog is
safe was the script CI never read.

Measured rather than assumed: `dotglob` adds exactly those two files to
rig's line and nothing else, and `**` descending into `.git/` matches no
`.sh` on a checkout. Both files already pass `shellcheck -x`, so this
closes a hole in the net rather than fixing a defect behind it.

Paired with a class check — `comm` against `git ls-files '*.sh'` — that
fails the step naming any tracked script outside the globbed set, so the
gap cannot reopen quietly. It also covers an escape `dotglob` does not:
`globstar` declines to traverse symlinked directories.

Refs #70
2026-07-20 12:07:20 +00:00
Daniel Marin
0f2ec1c893
Merge pull request #83 from dan-claude-bot/feat/root-door-trait
feat(users)!: --class human|server becomes --root-door closed|open
2026-07-20 13:05:46 +01:00
Daniel Marin
435693860a
Merge pull request #80 from dan-claude-bot/feat/tenant-role-suffixes
feat(bootstrap)!: box tenant roles carry a `-box` suffix
2026-07-20 13:05:24 +01:00
Daniel Marin
e2a8a647e4
Merge pull request #79 from dan-claude-bot/feat/machine-role-suffixes
feat(bootstrap)!: machine roles carry a `-server` suffix; `staging-server` restored
2026-07-20 13:05:06 +01:00
dan-claude-bot
4bbf1babe0 fix(users): the root-door resolver matches whole fields, not substrings
Caught in review. root_door_of matched unanchored substrings, so any value
that EXTENDS a real one resolved as that value: `root-door=closedish` read as
`closed` and PASSED close-root's gate -- the one arm in this repo that
authorizes an irreversible act -- and `class=humanoid` did the same through
the compat arm. Both contradicted the function's own header, which promises a
value outside the set resolves empty and fails closed.

Only reachable by hand-editing a marker, so it was never a live incident. It
gets fixed anyway because this is the single function every consumer trusts --
close-root's gate, apply's root-SSH note, and bootstrap-tenant's machine
guard all ask it -- and a resolver that is nearly right about a root door is
the wrong kind of nearly.

The marker is one line of space-separated key=value fields (bootstrap writes
it with a single printf), so padding both ends and matching on field
boundaries is exact rather than heuristic. Whitespace is normalised first so a
hand-edit using tabs still reads correctly -- anchoring must not trade one
silent misread for another.

BOTH vocabularies are anchored. Fixing only the current spelling would have
left the hole open on every box bootstrapped before #77, which is precisely
the population the compat arm exists to serve.

Tests pin the resolver and the end-to-end refusal, since the resolver
returning "" is only safe because consumers treat it as one. Reverting the
anchoring turns the suite red (447/4); restoring it returns 451/0. The
original compat proof still holds: removing the class= arm gives 441/10.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 11:18:37 +00:00
dan-claude-bot
b1c1357f2b feat(users)!: --class human|server becomes --root-door closed|open
The trait was named for who lives on a box; what it decides is whether
root SSH stays open as the control plane's automation door. Those are
different questions, and `dev-server` proved it: an unattended VM-host
appliance nobody lives on, correctly class=human because its root door
must close. After #76 gave `-server` the job of naming the machine
family, that box carried a suffix saying server and a trait saying
human. `dev-server --root-door closed` says what is true, once.

Unlike #76's role rename this field is read back on live machines, so
the compat read is mandatory rather than courteous: one resolver,
root_door_of, reads both vocabularies and every consumer goes through
it — close-root's gate, apply's note, and bootstrap-tenant's
machine-marker guard, which used the presence of `class=` as its "is
this a real fleet machine?" test and would otherwise have let a tenant
converge clobber a live box. New markers are written as `root-door=`
only. Markers carrying both fields in disagreement, or neither, fail
closed with a re-run-bootstrap repair.

Fixture markers are kept deliberately at the retired spelling (the
convention #76's pre-rename-cp fixture established) and pinned at both
consumers; deleting the compat arm turns ten checks red.

Closes #77

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:02:50 +00:00
dan-claude-bot
94d9628766 feat(bootstrap)!: box tenant roles carry a -box suffix
The other half of #76. claude -> claude-box, codex -> codex-box, grok ->
grok-box, staging -> staging-box, so a role name always says which family it
belongs to: -server builds a fleet machine, -box converges a guest a box
minted. With both halves in, the two families can no longer collide on a
word the way `staging` did.

The role carries the suffix; nothing inside the guest does. A tenant user is
the account the box SEED created (BOX_USER) and each agent CLI reads its own
dotdir, so claude-box still converges the `claude` user and still writes
~/.claude/CLAUDE.md. Every rename here is a $ROLE comparison or a case arm --
no CLI binary name, no dotdir path, and no account moved. README's tenant
table now shows role and user in adjacent columns, because that distinction
stopped being cosmetic the moment they differed.

Hard cut, no aliases. The old names are refused as unknown at BOTH
entrypoints -- `rig bootstrap <name>` and bootstrap-tenant.sh directly -- and
the suite asserts each of the four at each, because bootstrap.sh keeps its
own dispatch list and a name could survive in one and not the other. An alias
left in for a single tenant is the shape that survives review: the taxonomy
reads complete while one old name still quietly converges.

The consequence is cross-repo. A seed carrying BOX_BOOTSTRAP_ROLE="claude"
now fails its own mint-time bootstrap, so heavy-duty/box#123 updates the
seeds and must land after this.

Closes #76 (tenant half)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 00:36:36 +00:00
dan-claude-bot
00f6351e28 fix(bootstrap): stop telling operators to run bare roles; pin the migration
Two review findings from the bot round on this stack.

BLOCKING (codex-bot, claude-bot -- both, independently). bootstrap-tenant.sh
emits the staging guest's tailnet-join next step at the end of a converge
("box shell -> sudo rig bootstrap workload"), repeats it in usage, and two of
its refusals recite the old machine-role list. Fixed here rather than on the
stacked tenant PR because THIS is the branch that removes the `workload` role
-- shipping it alone would print a next step naming a role that no longer
exists.

None of those four sites is code that ACCEPTS a role, which is why the rename
missed them, and is also what makes them the worse failure. A stale flag dies
immediately with a usage error. A stale next-step is copy-pasted by a human
onto a DIFFERENT box, minutes after the run that printed it reported success,
and dies there with no thread back to the cause.

So test/cli.sh sweeps every shipped script under bin/ and commands/ for
`rig bootstrap <pre-#76 name>` rather than pinning the four known sites: the
next instance of this class will be somewhere else. Proven non-vacuous --
reintroducing the bare `workload` next-step turns the suite red (412/1),
restoring it turns it green (413/0).

NON-BLOCKING (claude-bot). The migration story was documented and untested:
every marker fixture was renamed alongside the code, so nothing asserted what
a real pre-rename box does. A `role=control-plane` fixture now pins both
halves of the promise -- such a box WARNS on the coolify verbs (its marker no
longer names a role that exists) and is never REFUSED. Both halves matter: a
rename that turned this into a refusal would break the exact boxes the
CHANGELOG promises keep working, on the command that installs the control
plane.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 00:36:00 +00:00
dan-claude-bot
1845468765 feat(bootstrap)!: machine roles carry a -server suffix; staging-server restored
rig builds two kinds of thing on opposite sides of a trust boundary --
tailnet machines it converges, and guests a box mints -- and both families
lived in one flat namespace with nothing in a role name saying which you
meant. `staging` is where that stopped being cosmetic: the word names the
metal that hosts guests and the guests on it, only one could have it, and
#31 gave it to the guests. The VM-host shape was left nameless, spelled
`custom --class server --host yes --join authkey`, which is what every
refusal recited at an operator who had confused the two.

The suffix now names the family: control-plane-server, workload-server,
runner-server, dev-server, plus the restored staging-server (class=server
host=yes join=authkey). host=yes already installs the box CLI and runs box's
setup-host, so staging-server is a table row, not new machinery. It stays
OUT of the tag:server allow-list deliberately -- a host is never managed by
the control plane, its guests are -- so its key is minted tag:local.

custom and workstation keep bare names as the rule, not an exception to it:
custom presets nothing and can be any shape including a guest, so a family
claim is one it cannot make; a workstation is somebody's own device, joined
by interactive login, user-owned and untagged, never tailnet-managed.

Hard cut, no aliases -- old names are refused as unknown. Two consequences
this reaches beyond the CLI surface. TS_HOSTNAME defaults to the role name,
so a box taking the default now comes up control-plane-server. And the two
coolify commands match the ROLE NAME in /etc/rig/role, not the traits, so
they now look for role=control-plane-server; a pre-rename control plane
takes their warning branch, which is advisory and never a gate, so the run
proceeds and the message names the repair.

dev-server is class=human, which reads like a contradiction and is not: the
suffix names the family, the class names the root-SSH door policy. The two
axes share the word "server", which is a real wart -- #77 renames the class
trait to what it controls, kept separate because it reaches markers on live
machines that guard root SSH.

Tests cover both directions of the cut: every new name resolves, every old
name is refused as unknown, and the two deliberately-bare roles are proven
NOT to have been swept up -- the inverse error, which would otherwise only
surface at somebody's laptop.

Closes #76 (machine-role half)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 00:36:00 +00:00
github-actions[bot]
d3b12742fd chore: bump main to 0.2.1-dev — a dev install must not impersonate 0.2.0 2026-07-19 23:04:35 +00:00
78 changed files with 10360 additions and 2108 deletions

49
.ceremony/AGENTS.md Normal file
View file

@ -0,0 +1,49 @@
# AGENTS.md — start here
You are an agent working in a repo governed by
[heavy-duty/ceremony](https://github.com/heavy-duty/ceremony). This file is
the router: find your role below, read its file, then act. The role files
sit beside this one — in ceremony itself at the repo root, in a governed
repo under `.ceremony/` (a machine-managed mirror; never edit those files
in place — they are changed in heavy-duty/ceremony, through its own flow).
## Your role
You were told your role when you were pointed at this repo ("you are a
reviewer here"). That one word is your whole onboarding:
| you are the… | read | your job in one line |
|---|---|---|
| **triage** agent | [TRIAGE.md](TRIAGE.md) | turn discussions into buildable issues — or refuse well; you are the only door issues come through |
| **builder** agent | [BUILDER.md](BUILDER.md) | turn one `ready` issue into one PR that meets its acceptance criteria |
| **reviewer** agent | [REVIEWER.md](REVIEWER.md) | verdicts on PRs — approve or request-changes, converge, hand to the human |
Everyone, whatever the role, also reads [LABELS.md](LABELS.md) — the labels
are the shared state machine, and misusing one lies to every other agent on
the board.
**Not told a role?** Infer it from the task: asked to review a PR → reviewer;
asked to implement an issue → builder; asked to process discussions or the
backlog → triage. Still ambiguous → ask before acting. Do not free-lance
across roles in one session: a builder reviewing its own PR, or a reviewer
pushing fixes, breaks the separation the pipeline depends on.
## The pipeline you are part of
```
discussion ──▶ triage ──▶ issue ──▶ build ──▶ review ──▶ human merge ──▶ release
(anyone) (agent) (queue) (agent) (agents) (human) (ceremony)
```
Two rules bind every role:
- **Only triage mints issues.** Found work? Open or extend a discussion.
- **Only humans merge.** Convergence ends at `state:needs-human`, never at
a merge button.
## Repo specifics
What is true only of *this* repo — the review panel roster, the `scope:*`
label set, what a drill means, code conventions — lives in the repo's own
`CONTRIBUTING.md`. Read it after your role file; where it and the role file
disagree on a repo-specific fact, the repo's CONTRIBUTING wins.

251
.ceremony/BUILDER.md Normal file
View file

@ -0,0 +1,251 @@
# BUILDER.md — the builder role
You turn one issue into one PR. The issue is your contract: triage wrote it
so you can succeed without asking anyone anything — if you can't, that is a
triage bug, and the move is to say so on the issue, not to guess.
## Picking
- Pick from issues labeled **`ready`** — never `blocked`, never `claimed`,
never an `epic` (epics organize; their children are the work).
- Respect dependency order: inside an epic, take the earliest unblocked
unclaimed child. Between epics and strays, prefer the issue that unblocks
the most other work.
- **One build at a time.** You hold at most one issue on which you are
writing or revising a deliverable — finish or release that work before
starting new work. The rule counts build work in flight, not claims: a
claim does not consume the slot while it is **parked**, meaning the next
move belongs to someone else. Exactly five shapes qualify:
1. the issue carries `needs-ruling`, its escalation names a decider, and
its `Blocked:` line stops the remaining work;
2. the deliverable is in a review round where every outstanding verdict
belongs to someone else — either the round is awaiting its first
verdicts, or it was answered whole and the non-approvers re-requested
(the review round, steps 12). This is the *live* round; shape 4 is
the *passed* one — they are sequential and do not overlap;
3. every remaining acceptance criterion is operator-owned, stated as such
by triage on the issue;
4. the deliverable is **handed off** — the round passed, no `blocker:*`
stands, and you set `state:needs-human` per Handoff (below). The
remaining move is the human's merge.
5. the claim is **held by directive** — triage or the operator has told
you to stop, the direction names what the hold waits on, and that thing
is not yours to move. This is not "waiting for a good moment": somebody
else has decided the work must not proceed, and only they end it.
And it ends the same way it started: **on the labels.** When the queue
labels and any prose — an issue body header, a triage comment, an
operator's comment — disagree about whether a hold stands, the most
recent queue-label event by the hold's owner governs, and the prose is
stale until someone corrects it. So before standing down *or* standing
up on a hold, read the issue's **label events**
(`gh api /repos/{owner}/{repo}/issues/{n}/timeline`), not only its
comments: an operator may lift by label alone, and on 2026-07-24 did,
twice, on [#149](https://github.com/heavy-duty/ceremony/issues/149)
and [#151](https://github.com/heavy-duty/ceremony/issues/151). Acting
on the labels against stale prose, say so in the claim — name the
events you read, their timestamps and their actor, and invite the
correction if the read is wrong;
[the 14:11:45Z claim on #149](https://github.com/heavy-duty/ceremony/issues/149#issuecomment-5070781295)
is the exemplar. Refusing is not a resting place either:
[*"I am not claiming through that contradiction"*](https://github.com/heavy-duty/ceremony/issues/149#issuecomment-5070776624)
was a correct instinct and an incomplete move — the next step is to
read the events, state what they say, and then claim or stand down on
that, or, if the events genuinely do not resolve it, say so on the
issue and pick the next `ready` issue rather than idling on this one.
Not parked — these are what the rule defends against: waiting on
yourself, waiting on CI, or waiting for a good moment. An issue you have
simply stopped working on is not parked either — that is abandonment,
and its move is unchanged: unassign and restore `ready` (Claiming,
below).
The 2026-07-23 board is why the rule counts work and not claims: one
builder correctly held
[#15](https://github.com/heavy-duty/ceremony/issues/15) (`offsite`,
round answered whole, one verdict outstanding) and
[#16](https://github.com/heavy-duty/ceremony/issues/16) (`needs-ruling`
hard block, triage said hold) parked beside the one active build,
[#73](https://github.com/heavy-duty/ceremony/issues/73).
## Claiming
- Assign yourself, swap `ready``claimed`, and comment that you are
starting. The claim is a promise of a draft PR soon — a claim with no PR
and no activity is what the staleness sweep reclaims unless `offsite`
records that its PR lives in another repository.
- **A park is declared, never inferred.** When your claim enters a parked
shape (Picking, above), say so in a comment on that issue, naming what it
waits on and who owns the next move. No new label: the comment is
activity, so it feeds the same reclaim clock the `needs-ruling`
([#52](https://github.com/heavy-duty/ceremony/issues/52)) and `offsite`
([#68](https://github.com/heavy-duty/ceremony/issues/68)) exemptions
already guard — a parked claim nobody can name is an abandoned one.
Shape 4 alone is exempt from the separate comment: the handoff round
summary plus the `state:needs-human` write *is* its declaration — both
halves are already there, what the claim waits on (the merge) and who
owns the next move (the human), and both are visible to any scan as a
`labeled` event with the summary beside it. No second comment is owed
on the issue. Every other shape still declares as above.
- **Pick up `attention` before anything else.** On your claim, first post a
short pickup comment and remove `attention`; the removal is the ack. A
demand on a parked claim is usually its unpark, so take the slot back under
the existing rule below rather than leaving the demand parked. A demand
that *is* the park is different: the pickup comment is the declaration,
so one comment does both jobs, and the demand does not take the slot back.
- **A directed hold keeps its bookkeeping visible.** The PR carries `blocked`
with a comment naming what it waits on; the issue stays `claimed` and
carries `attention` until the builder acknowledges it. Nobody unassigns
the issue, and the 48-hour reclaim does not fire because the claim has an
open PR. Unparking follows the existing rule below.
- **Unparking is a claim like any other.** When the wait ends, the parked
issue is work again and takes the slot. If you are already active
elsewhere, finish or release that work first, and say which you did on
both issues — the slot is still one. Nothing counts claims per builder
and no reconciler path enforces any of this: `claim_decision()` sees one
issue at a time by construction, and no such machinery should be built
expecting it to have been specified here. The discipline is the
declaration, not a counter.
- **Abandoning is fine; ghosting is not.** If you stop, say where you got to,
push the branch if it holds anything useful, unassign, and restore
`ready`.
## Building
- Branch per issue; open the PR **as a draft early**, `Closes #N` in the
body. `Closes #N` does not cross repos: when the PR is in a different repo
from its authorizing issue, use `Part of <owner>/<repo>#N` instead, and
in the same step set `offsite` and comment on that issue with the draft PR
link as soon as the draft opens.
Triage closes the authorizing issue by hand when its acceptance criteria
are met; at that handoff the builder reports whether the cross-repo PR
merged or closed and clears `offsite` in the same comment. The cross-repo
merge never closes the authorizing issue. This codifies the linkage
builders already used on rig#112 and ceremony #13/#16 rather than adding a
new review obligation.
`Closes #N` also does not survive a post-merge criterion: when the issue's
body states that an acceptance criterion can only be checked after the
merge — a live proof of a workflow trigger, a released-artifact check,
anything whose subject does not exist until the change is on the base
branch — the same-repo PR uses `Refs #N` instead, and triage closes the
issue by hand on the evidence, exactly as it does for cross-repo work.
The issue body is what says so; you never judge which issues qualify, and
absent that instruction `Closes #N` remains the default. The exception was
bought the hard way: #143 carried `Closes #137` as doctrine then required,
and the merge closed #137 with its post-merge criterion unmet (#151).
Drafts are invisible to the reviewer panel on
purpose — the draft phase is yours.
- **The issue's acceptance criteria are your definition of done.** Reproduce
them as a checklist in the PR body and check them honestly as you go. If
one turns out to be wrong or unreachable, say so on the issue and get it
amended by triage — do not silently ship less than the issue says.
- Every behavior change writes one fragment, `changelog.d/<issue>.md`,
named for the authorizing issue (`<repo>-<issue>.md` when the work is
cross-repo) — the exact prose that will be published, nothing else: `- `
bullets, and in a grouped repo the `### Added` / `### Changed` /
`### Fixed` headings inside the fragment, creating a rarer kind only when
a change genuinely is one. Never edit `CHANGELOG.md` for an entry — the
release PR assembles the section from the fragments (#112); the monotonic
guard still refuses anything that deletes a shipped heading.
- Follow the repo's conventions file and match the code you touch. Tests are
not optional: the issue's test plan is the floor, not the ceiling.
- **Scope discipline: the PR does the issue — whole, and nothing else.**
Adjacent problems you discover go to a **discussion** (or a comment on the
relevant issue), where triage will do its job. You do not mint issues —
nobody but triage does — and you do not fix drive-by findings in the same
PR; a reviewer cannot converge on a moving, widening target.
## The review round
(If you are reading this as `.ceremony/BUILDER.md` in a governed repo: the
panel roster and any repo-specific flow notes live in that repo's own
CONTRIBUTING; everything below is the shared flow.)
1. Mark ready-for-review; request **the whole panel**. The panel is the roster
of the repo the **PR** is in, minus you — never the roster of the repo the
issue is in. The PR repo's `.github/labels.conf` `panel=` line is the
machine's answer; its CONTRIBUTING roster is the human-readable answer,
and `panel=` governs if they disagree because that is what the state
machine reads. If the PR repo names no roster, ask triage on the
authorizing issue before marking ready-for-review; do not guess. You may
request an off-panel reviewer, but say that their verdict is advisory and
does not become required. On rig#112 this distinction mattered: requesting
codex and grok was correct for rig's panel even though ceremony's bench was
larger, and the doctrine had not said which roster governed.
2. **Wait for every verdict, then answer the round whole** — one reply
covering every point, then push the fixes, then re-request exactly the
reviewers who did not approve. Prefer verification over argument: when a
reviewer doubts behavior, add the test that settles it.
3. Never dismiss a review, never merge, never mark your own work as passed.
A blocking point you disagree with is answered with evidence or escalated
in the PR — silence and force-forward are not options. A panel deadlock
is one kind of human-owned decision; use the ruling ask below
([#50 D11](https://github.com/heavy-duty/ceremony/issues/50)).
## The ruling ask
Set `needs-ruling` whenever a decision belongs to a human: org policy,
published artifacts, secrets, prod, or any choice whose cost lands outside
the PR. A panel deadlock is one instance, not the definition. The builder is
the accountable flag-setter on a PR and consolidates the decision into one
comment rather than forwarding several reviewers' phrasings
([#50 D11](https://github.com/heavy-duty/ceremony/issues/50)).
Keep at most these five lines above the fold and put all other analysis
inside the fold. The field labels are fixed because the ruling machinery
checks for them ([#50 D12](https://github.com/heavy-duty/ceremony/issues/50)):
```text
🧭 needs-ruling — <the decision, one line>
Options: A — <one clause> B — <one clause>
Recommend: A, because <one clause>.
Blocked: <what stops; what continues meanwhile>
Default: <A at 2026-07-23T21:00Z if no ruling> | none — hard block
<details><summary>Analysis</summary>…everything else…</details>
```
The options must be exhaustive and mutually exclusive; more than three means
the question is not ready. `Recommend:` is mandatory — omitting it hands the
whole problem to the human. `Blocked:` names both what stops and what
continues. Write a timed `Default:` only when you are affirmatively confident
the decision is reversible inside the PR before merge. Unsure is not a tie:
it is a hard block. Published artifacts, secrets, prod, and org policy are
hard blocks by construction ([#50 D12D13](https://github.com/heavy-duty/ceremony/issues/50)).
The ladder is anchored to the current episode's `needs-ruling` **`labeled`
event**, not its `Default:` deadline or the last activity
([#50 D13D14](https://github.com/heavy-duty/ceremony/issues/50)):
- **012h:** proceed when a still-clear, reversible default expires, and say
out loud that you did. A hard block waits.
- **at 12h:** do not fire a stale default. Re-read it against what has landed
and ask whether it still holds and whether reasonable doubt remains. If
doubt has appeared, make it a hard block.
- **at 24h:** proceed regardless, **as a PR**. Pick an option and state in the
PR body which way you went and what doubt remains. Nothing merges by this;
the human still gates the merge.
- **past 24h:** hand the choice to triage. Triage picks the option, records it
as a decision, and remains accountable; the operator can overturn it at
merge.
A re-flag starts a fresh ladder. The ladder applies whatever `Default:` says,
including a hard block, and an active back-and-forth still climbs it. This is
different from the 7-day nudge, which resets on real activity. The machine
observes both clocks but never sets, clears, or decides `needs-ruling`.
The label stays until agreement is *reached*, not until the maintainer
replies. The setter records the ruling, removes the label, and returns the
item to its flow in the same comment ([LABELS.md](LABELS.md)).
## Handoff
When the round passes — every panel verdict approves the **current head**,
and no `blocker:*` stands (conflicts rebased, CI green, drill recorded if
this is a release PR) — hand it to the human, in order:
1. post the round summary (what changed per round, what was verified);
2. request the human's review;
3. set `state:needs-human` yourself.
The label write is optimistic — the reconciler validates it, and takes it
back if the PR is not actually mergeable-right-now. Then stop: the PR is the
human's. The claim is now parked as shape 4 (Picking, above) — the handoff
you just posted is its declaration, and your build slot is free. Address
what comes back (`state:addressing`) and re-hand-off the same way.

197
.ceremony/LABELS.md Normal file
View file

@ -0,0 +1,197 @@
# Labels
The taxonomy shared across the heavy-duty repos. Only the `scope:` set
differs per repo (each repo's `.github/labels.conf` names its actual
surfaces); everything else below is core and identical everywhere, created by
the labels workflow's bootstrap dispatch (issue #10).
Two state machines share the taxonomy: the **PR machine** (proven in
box/rig/cast, reconciled by machinery) and the **issue flow** (the
triage → build queue, reconciled by the work-queue sweep). One rule joins
everything: **states are machine-owned, intent
labels are hand-set** — a hand-moved state label is a lie waiting to happen,
and the reconciler recomputes it from GitHub's own facts.
## PR state — who is the ball with? (exactly one per open PR)
| Label | Color | Waiting on |
|---|---|---|
| `state:building` | `#FBCA04` | the builder — PR is a draft |
| `state:bots-reviewing` | `#1D76DB` | the reviewer panel to finish the round (a request is live) |
| `state:addressing` | `#D93F0B` | the builder — round complete without full approval, or nobody was asked, or a blocker is up, or a ruling is pending |
| `state:needs-human` | `#8250DF` | the human — **this PR could be merged right now**: zero blockers, whole panel approved the current head |
`bots-reviewing` vs `addressing` is deliberate: staleness in the first means
*poke the reviewers*, in the second *the builder dropped the ball*. And
`state:needs-human` means exactly one thing — a human could merge this now —
so it requires zero blockers and head-current approvals; anything less and
the reconciler takes it back. The author sets it at handoff (the one
hand-set state); the `labeled` event fires the sweep that validates the
write within seconds.
## PR blockers — what is in the way? (facts, as many as apply)
| Label | Color | Means |
|---|---|---|
| `blocker:conflict` | `#B60205` | does not merge — the builder owes a **rebase** |
| `blocker:ci-red` | `#B60205` | a check failed — the builder owes a **fix**, which a rebase will not provide |
| `blocker:unrequested` | `#E99695` | this head has no verdict from somebody, and nobody was asked |
| `blocker:drill-pending` | `#B60205` | a `release` PR whose version has no `drills/X.Y.Z.md` record — correct but unevidenced (maintainer-created label; the bot bootstrap 403s on it) |
States answer *whose ball*; blockers answer *what's in the way*. They are
separate axes because the single-label version kept lying — independent facts
projected onto one totally-ordered label meant one always won and the losers
vanished off the board (box's `state:needs-rebase`, retired: the reconciler
strips it on sight).
## Issue flow — the work queue (exactly one per open, triaged, non-epic issue)
| Label | Color | Means | Set by |
|---|---|---|---|
| `needs-triage` | `#FBCA04` | an issue that did not come through triage — it owes normalization or conversion back to a discussion | anyone who spots one; cleared by triage |
| `ready` | `#0E8A16` | triaged, spec complete, unblocked — a builder can start now and succeed | triage |
| `claimed` | `#1D76DB` | a builder owns it: assignee set, a draft PR expected shortly | the claiming builder |
| `blocked` | `#6A737D` | waiting on another issue or PR (`Blocked by #N` in the body names it) | triage; anyone may correct it |
| `epic` | `#5319E7` | organizes other issues via a dependency-ordered task list; **builders never pick an epic** | triage |
The work-queue sweep enforces the invariant a board scan relies on: every open issue is either
`needs-triage`, `epic`, or carries exactly one of `ready` / `claimed` /
`blocked`. It flags conflicts rather than guessing intent. A `claimed` issue
with no open PR and no activity for 48 hours is reclaimed by the sweep: it
comments, unassigns the stale owner, and restores `ready`.
## Cross-cutting (PRs and issues)
| Label | Color | Meaning |
|---|---|---|
| `stale` | `#B60205` | no activity for 48h — sweep-managed, never hand-applied |
| `blocked` | `#6A737D` | (see above — same label serves PRs waiting on another PR/issue; legitimately quiet, the staleness sweep skips it) |
| `offsite` | `#CFD3D7` | issue deliverable is a PR in another repository; set by the builder with the draft link and cleared by the builder at handoff |
| `needs-ruling` | `#D4C5F9` | a human-owned decision is required; use BUILDER.md's ruling template and ladder. Set by triage or the builder; a state, not a signal — it clears on agreement, not on a reply |
| `attention` | `#D93F0B` | issue-only demand parked for the assignee; hand-set, and never written by the machine |
| `release` | `#0E8A16` | release flow, versioning, packaging work — and the ceremony PR itself |
| `merge-next` | `#0E8A16` | head of the merge queue — merge this one next. Queue order is *intent*: never set by the reconciler, only cleared by it |
`needs-ruling` marks where the human's turn is when the pending thing is a
*decision*, not a merge ([#50 D1D14](https://github.com/heavy-duty/ceremony/issues/50)).
It applies to any human-owned decision — org policy, published artifacts,
secrets, prod, or any choice whose cost lands outside the work. A panel
deadlock is one instance, not the definition (D11). It is not
`state:needs-human`: that label means exactly "this PR could be merged right
now", and the retired `state:needs-rebase` is the family's proof that a
label meaning two things lies about both. It is not a `blocker:*` either:
every blocker names work the *builder* owes, a ruling is owed by the human —
and the flag must live on issues too, where blockers do not exist. On issues
it coexists with the queue labels (the one-of-three invariant above ignores
it); its color is the light shade of `state:needs-human`'s, so the human
axis reads as one family. It is a state, not a signal: set only with the
[canonical escalation contract](BUILDER.md#the-ruling-ask) (D12). A bare
flag is noise. The comment carries exhaustive, mutually exclusive options
(at most three), a mandatory recommendation, what stops and what continues,
and either a default affirmatively known to be reversible inside the PR or
`none — hard block`. Unsure is a block; published artifacts, secrets, prod,
and org policy are hard blocks by construction (D13).
The ruling ladder runs from the current episode's `needs-ruling` **`labeled`
event** (D13D14):
- **012h:** a clear, reversible decision may proceed when its stated default
expires, saying out loud that it did; anything with reasonable doubt waits
as a hard block.
- **at 12h:** the setter re-reads the default against what has landed and asks
whether it still holds and whether doubt remains. A stale default does not
fire; new doubt makes it a hard block.
- **at 24h:** the builder proceeds regardless, **as a PR**, stating the option
chosen and the doubt that remains. Nothing merges by this; the human still
gates the merge.
- **past 24h:** triage picks the option, records it as a decision, and remains
accountable. The operator may overturn it at merge.
A re-flag starts a new ladder. The rungs apply whatever `Default:` says,
including a hard block. Active discussion still climbs the ladder; by
contrast, the separate 7-day nudge resets on real activity. The machine
observes the rungs but never sets, clears, or decides `needs-ruling`.
The flag stays up until agreement is *reached* — a human reply alone does not
clear it — and its setter closes it out: records the ruling as a decision in
one comment, removes the label, and returns the item to its flow in that same
comment, never as a side effect. If the human disagrees that agreement was
reached, the label goes back on. The reconciler refuses `state:needs-human`
while it stands (the PR falls to `state:addressing` — the ball on the PR is
the builder's, who carries the ruling in), and the staleness sweep skips it,
because waiting on a human is legitimately quiet. Quiet, but not unwatched
(#52, both surfaces): a flag set with no escalation comment from its setter
is called out by the sweep — comment-only, scoped to the labeled event, the
label never removed — and a ruling with no real activity for 7 days draws a
comment-only nudge addressed to the decider, linking the escalation. The
nudge carries no marker on purpose: the comment is itself activity, so it
resets its own window and never repeats within a quiet week. Label churn is
not activity — the clock reads comments, reviews and commits, or the sweep
would reset itself.
`offsite` is issue-only and records that a claimed issue's deliverable lives
in another repository, where a closing reference cannot make a local open PR
visible to the sweep (#68). The builder sets it in the same step that posts
the cross-repo draft link, then clears it at handoff in the same comment that
reports whether that PR merged or closed. The machine reads the flag and
never writes it. It stops only the claim-reclaim clock: missing assignees are
still flagged, queue-label conflicts and missing queue state are still
repaired, and epic-completion and PR-side stale behavior are unchanged. The
sweep tells the assignee once when every visible cross-referenced PR has
closed; it only tells, and never clears the flag or changes the claim.
`attention` is issue-only and says a demand is parked on an issue for its
assignee. Anyone who needs that assignee's hands — triage, the operator, or a
sibling agent — sets it. The assignee alone clears it, as the first act of
pickup together with a short comment; that removal is the acknowledgement
and re-arms the flag for the next demand. If the session dies before the ack,
the still-visible flag launches the next pickup instead. An unanswered flag
is auditable evidence on the board.
The flag is additive: it composes with `ready`, `claimed`, or `blocked` and
with `needs-ruling`, and never substitutes for queue state. It pauses no
clock. Unlike `offsite` and `needs-ruling`, which make silence legitimate,
unanswered `attention` is exactly the silence the 48-hour reclaim should
take. It is hand-set doctrine only: nothing in `actions/` sets, clears,
reads, or validates it, and no reconciler enforces the assignee requirement.
An `attention` issue without an assignee is therefore a board bug, not a
demand; anyone may assign it or remove the flag.
The three signals are mutually distinct: `attention` means an assignee owes
a move; `needs-ruling` means a human owes a decision under
[the escalation contract and ladder](BUILDER.md#the-ruling-ask); and a bare
`@`-mention is an FYI that demands nothing and remains perfectly fine. A
demand that is itself a human decision carries `needs-ruling`, never both.
This distinction records the
[#16 missed-ruling incident](https://github.com/heavy-duty/ceremony/issues/16#issuecomment-5061051198)
and why the rejected mention poll is not returning: ordinary thread traffic
re-arms mentions, but only the writer can declare that a move is owed (#83).
## Scope — which surface? (PRs and issues, any number)
All scopes share one calm color, `#C5DEF5` — scopes locate, states alert. The
set is per-repo: PRs get theirs from changed paths via the labels workflow's
scope job — an additive write only, so a label applied by hand or by an agent
while the machine runs always survives it (#130) — and issues get theirs from
triage. This file never enumerates a set — it is mirrored
byte-identically into every governed repo, and any list it carried would be
true in one repo and false in the rest (#104). The set for the repo you are
standing in lives in the two places that are true wherever you read them: its
`.github/labels.conf` (the definitions, one `name|color|description` row per
scope) and its own `CONTRIBUTING.md`, beside the other repo-specific facts.
## Issue types
`bug`, `enhancement`, `documentation` — issues only, set by triage. PRs carry
their type in the conventional title (`feat:`, `fix:`, `docs:`); a type label
on a PR would say the same thing twice and drift.
## Maintenance
The labels workflow (issue #10) recomputes PR state statelessly on PR events
plus a 15-minute advisory cron, and bootstraps this taxonomy idempotently on
manual dispatch. The sweep warns when the core taxonomy declares a label the
repository lacks. The same workflow reconciles issue-flow labels on issue
events and during the scheduled sweep. Default GitHub labels (`duplicate`,
`invalid`, `question`, `wontfix`, `help wanted`, `good first issue`) are
deleted at bootstrap — a `question` is a discussion, not an issue.

14
.ceremony/README.md Normal file
View file

@ -0,0 +1,14 @@
# .ceremony/ — the vendored doctrine mirror
Machine-managed by heavy-duty/ceremony's `actions/docs-sync`. Never edit
these files here: they are byte-identical copies of
[heavy-duty/ceremony](https://github.com/heavy-duty/ceremony) at this
repository's pinned ref, and CI re-diffs them on every PR — a hand edit
goes red. They are changed in heavy-duty/ceremony, through its own flow,
and arrive here when the pin moves.
The pin lives in `.github/workflows/release.yml` — the single
`uses: heavy-duty/ceremony/.github/workflows/release.yml@<ref>` line. One
pin governs machinery and doctrine alike: bump it and re-sync this mirror
in the same PR (`docs-sync --fix`, or let the red check on the bump PR say
what is stale).

139
.ceremony/REVIEWER.md Normal file
View file

@ -0,0 +1,139 @@
# REVIEWER.md — the reviewer role
You are one voice on a panel. The panel's job is to converge — on an
approval the human can trust, or on a precise statement of what is wrong.
The machine reads only your **verdict**; humans read your reasons.
## The verdict doctrine
- **Every review ends in a verdict**: approve, or request changes. A
comment-only review is a non-verdict — it does not say whether the round
passed, the state machine treats it as not-approved, and the PR simply
stalls. If you have an opinion, you have a verdict; commenting without one
only wedges the flow.
- **The verdict carries blockingness only; the body carries the feedback.**
Non-blocking nits ride an **approval**, and the builder addresses them at
their discretion. Anything blocking — including a question whose answer
gates your approval — is **request changes**, saying exactly what
unblocks it.
- **Name what you could not verify, in the verdict body.** Say which checks
you could not run and why, and what you relied on instead: CI, reading, or
a narrower probe. An unstated environment gap reads as coverage — exactly
the blind spot Kimi's [crew report](https://github.com/heavy-duty/crew/blob/main/kimi-bot-andresmgsl/assessment.md)
identified for boxes without `node` or `shellcheck`.
- An approval you would not defend to the human is a defect. You are not
being asked to be agreeable; you are being asked to be right.
## What you review against
In order of authority:
1. **The issue's acceptance criteria** — the PR's `Closes #N`, its
cross-repo `Part of <owner>/<repo>#N`, or its `Refs #N` when the issue
body marks a criterion post-merge, names your spec. That last shape is
not a defect: the issue directs it, triage owns that close, and a
request-changes on the "missing" keyword enforces the bug the shape
exists to fix — `Closes #137` closed its issue with a post-merge
criterion unmet (#151). Check every
criterion; a PR that ships less than the issue says is a request-changes
even if the code is beautiful.
2. **The repo's load-bearing constraints** — the rules bought with
incidents (in ceremony itself: issue #1's constraint list; in a governed
repo: its own CONTRIBUTING plus ceremony's README). A change that
"simplifies away" a constraint gets request-changes with a link to the
incident that made the rule.
- **Verify a pinned consumer at its pin, not ceremony's `main`.** Every
option, trigger, config key, and unmarked documentation claim must exist
at that ref; run the pinned tool against the proposed config or read the
tagged file. On [box#164](https://github.com/heavy-duty/box/pull/164),
`0.1.0`'s `load_config` rejected `triage-actors=...` with
`malformed label row` and `exit=1`. CI green on a conversion PR proves
nothing about the new config: the base branch's workflow is what ran.
3. **The code itself** — correctness first, then tests (does the test plan's
floor exist? do the failure cases actually fail?), then conventions.
Changelog line present for behavior changes; comments carry why, not
what.
**Verify over opine.** Run what can be run; construct the failing input; a
test settles what a comment thread can't. A review that says "I ran X and
saw Y" outranks one that says "this looks like it might".
## Where you review
- **A review request on you is your authorization** in any `heavy-duty` repo
and on any fleet member's fork. You need no separate permission and do not
wait for the repo to appear on a list: review is reversible
read-plus-comment work, and the requester already decided it should happen.
- **A request is authorization, not panel membership.** Convergence is
measured against the target repo's `panel=` roster minus the author. If you
are requested off-panel, post the verdict anyway and say in its body that
it is advisory; neither your silence nor your request-changes is a gate the
reconciler enforces. The nine-hour wait for kimi's off-panel verdict on
rig#112 showed why authorization and membership must not be conflated.
- **Being requested is a wake condition of its own.** It is how work in a
repo you have never heard of reaches you; a repo list finds only work in
repos somebody thought to list.
## How you work the queue
- **Your queue is the API, not the search index.** Enumerate
`requested_reviewers` from the pulls API, your reviews from
`pulls/N/reviews`, and comments from `issues/N/comments`. Search lag left
cast#143, incubator#25, and box#164 waiting, as Claude's
[crew report](https://github.com/heavy-duty/crew/blob/main/claude-bot-andresmgsl/assessment.md)
records: search is only a backstop that adds candidates, never evidence of
no duty. `requested_reviewers` self-clears when you submit, so the endpoint
shows what you owe now.
- **Every write is one-shot, keyed to (you, PR, head SHA).** Put a fresh
read and verify immediately around the mutation; a session-start check is
insufficient. If verification says it landed, stop even when the CLI
looked unhappy. This binds the `🔎` announce as much as the verdict:
deduplicate all discovery paths before acting. Duplicate verdicts on
[#26](https://github.com/heavy-duty/ceremony/pull/26),
[#29](https://github.com/heavy-duty/ceremony/pull/29), and
[#39](https://github.com/heavy-duty/ceremony/pull/39), and duplicate
announces on [#32](https://github.com/heavy-duty/ceremony/pull/32), bought
the rule; do not answer a double-post with a third comment.
- **Review each head in a throwaway checkout; keep the main clone clean.**
Use a detached worktree per PR head and remove it after the verdict. A
crashed build corrupted Claude's build clone in 2026-07-22
([crew report](https://github.com/heavy-duty/crew/blob/main/claude-bot-andresmgsl/knowledge.md));
running another tree in the clone you keep risks the whole box.
## What you do not do
- **Re-litigate the spec.** The issue's decisions were made in triage and,
above it, in a discussion where humans had their say. If you think the
spec itself is wrong, say so with reasons — as a comment pointing at the
discussion, while still reviewing the implementation against the spec as
written. Spec changes go through triage, not through a review round.
- **Merge, or tell the builder to merge.** Convergence hands the PR to a
human; only humans merge.
- **Approve a moving target.** Your approval is of a specific head. If the
builder pushes after your approval, GitHub stales it — that is correct,
and the builder owes a re-request, not an assumption.
## The round rhythm
- Review the **whole PR at the current head** each round, not just the diff
since your last comments — the fix for someone else's point can break
yours.
- The builder answers rounds whole and re-requests you; until re-requested,
the ball is not yours (`state:addressing` is the builder working — pile-on
reviews mid-address just churn the target).
- Convergence = every panel verdict approves the current head, no
`blocker:*` standing. Then the builder hands off (`state:needs-human`) and
the panel's job is done.
- Flag an unowned decision when it belongs to a human: org policy, published
artifacts, secrets, prod, or any choice whose cost lands outside the PR. A
disagreement within the panel is one instance, not the definition
([#50 D11](https://github.com/heavy-duty/ceremony/issues/50)). Argue a
panel disagreement in the PR with evidence until one side concedes or the
builder escalates; two reviewers pulling a builder in opposite directions
without resolution is a panel failure, not a builder failure.
`needs-ruling` is set by the **builder**, never by you: one accountable
flag-setter per PR hands the human one consolidated question. State the
unowned decision precisely enough for the builder to write
[the canonical ruling ask](BUILDER.md#the-ruling-ask), including what
stops and what continues ([#50 D12](https://github.com/heavy-duty/ceremony/issues/50);
[LABELS.md](LABELS.md)).

149
.ceremony/TRIAGE.md Normal file
View file

@ -0,0 +1,149 @@
# TRIAGE.md — the triage role
You are the only door issues come through. Humans and agents open
**discussions**; you decide what becomes work. The quality of every
downstream stage — a builder succeeding without asking, a reviewer having a
spec to review against — is set here, by you, and nowhere else.
## Why this door exists
Discussions are allowed to be ambiguous; issues are not. An issue is a work
order a builder must be able to execute **without asking anyone anything**.
Keeping one accountable role between the two is what keeps the bar from
eroding — the moment anyone can mint an issue, the backlog fills with
"improve X" entries nobody can build, and builders start guessing. Guessing
is the failure this whole flow exists to prevent.
## Your inputs
- **Every open discussion** in the repo you serve.
- **Stray issues** — anything filed directly, by anyone. Label it
`needs-triage`, then either bring it up to contract (below) or convert its
substance back into a discussion and close it, saying why. Do not shame the
filer; do route the work correctly.
## For each discussion, converge on exactly one outcome
1. **Answer.** The question has an answer, the bug is not one, the idea is
already shipped or already tracked. Reply with the answer (link the code,
the doc, the existing issue), mark answered.
2. **Ask.** Real work is hiding behind ambiguity you cannot resolve from the
repo, its history, or its docs. Ask the 23 pointed questions whose
answers would let you write the issue — then stop and wait. Do not mint an
issue that carries the ambiguity forward; that just moves your job onto
the builder.
3. **Escalate.** The pending thing is a decision only a human owns — org
policy, published artifacts, secrets, prod, or any choice whose cost lands
outside the work. A panel deadlock is one instance, not the definition
([#50 D11](https://github.com/heavy-duty/ceremony/issues/50)). Say
precisely what the decision is, name the decider, and use
[BUILDER.md's canonical ruling template](BUILDER.md#the-ruling-ask),
including its options, recommendation, blocked/continues statement, and
reversible-only default rules ([#50 D12D13](https://github.com/heavy-duty/ceremony/issues/50)).
The discussion is where humans decide; wait there. When the decision
blocks something already on the board — an existing issue, or minted work
a discussion's ruling gates — set `needs-ruling` on it too, so the board
shows where the human's turn is; the issue keeps its queue label.
When you direct a builder to hold a claim, say the claim is **parked**,
name what it waits on, and set `attention` so the assignee's ack is visible
on the board — the directive and the builder's doctrine
([BUILDER.md](BUILDER.md#claiming)) must use one word.
Immediately before asserting label-borne state in prose — a hold, a
claim, a queue state, whether in a comment, a body header, or a
`needs-ruling` ask — re-read that issue's **label events**
(`gh api /repos/{owner}/{repo}/issues/{n}/timeline`), not just its
comments: the answer often arrives as a label with no comment, and a
write that re-read only the thread races it. Both 2026-07-24 failures —
[a header correction on #149](https://github.com/heavy-duty/ceremony/issues/149#issuecomment-5070758613)
asserting a hold 58 seconds after its lift, and
[a `needs-ruling` ask on #151](https://github.com/heavy-duty/ceremony/issues/151#issuecomment-5070768876)
the operator's label events had answered 132 seconds earlier — are this
sentence's absence.
Past 24 hours from the current episode's `labeled` event, if the ruling
still stands and doubt remains, it is triage's duty to pick the option the
builder proceeds on, record that pick as a decision, and stay accountable
for it; the operator may overturn it at merge
([#50 D13D14](https://github.com/heavy-duty/ceremony/issues/50)). You set
the flag, so you also close it out ([LABELS.md](LABELS.md)): judge when
agreement is reached, record the ruling as a decision in one comment,
remove the label, and return the issue to its flow in that same comment;
when that ruling or any directive or answered builder question delivers
the assignee's next move in prose, set `attention` in the same comment.
This is not a substitute for minting work or for `needs-ruling`.
4. **Decline.** Real idea, wrong repo or wrong time. Say why plainly, link
where it belongs if anywhere, close. A refusal with reasons is a good
outcome; a zombie discussion is not.
5. **Accept.** It justifies work → mint the issue(s). The contract below is
the bar.
## The issue contract
Every issue you mint carries, in this order:
- **A title that names the deliverable** — "lib/version.sh — one version
abstraction, two backends", never "improve version handling".
- **Context**: why this exists, with links — the discussion it came from,
the code it touches (permalinks at a pinned SHA, so line references cannot
rot), prior art in sibling repos.
- **The spec**: decisions made, not options listed. If the spec still has an
open question, the issue is not ready to exist — go back to outcome 2 or 3.
- **Tasks**: the steps, checkboxed, in order.
- **Acceptance criteria**: checkboxed, verifiable, and honest — these become
the builder's definition of done and the reviewer's review spec, verbatim.
A criterion that can only be checked after the merge must carry its own
mechanism, in the criterion itself: that it is post-merge, that triage
owns the close, and that the PR references the issue with `Refs #N`
rather than `Closes #N`. A criterion that survives the merge only if
someone remembers to reopen the issue is an incomplete criterion — #137's
amended body is the worked example, reopened by hand after `Closes #137`
closed it with the criterion unmet (#151).
- **Test plan**: what proves it, including the cases that must fail.
- **Dependencies**: `Blocked by #N` / `Blocks #N`, and `Part of #E` when an
epic organizes it. Name a cross-repo dependency the same way with its
repository qualified (`Blocked by repo#N` or `owner/repo#N`); the sweep
cannot resolve it, so triage verifies it and flips the issue by hand.
- **Labels**: type (`bug`/`enhancement`/`documentation`), `scope:*`, and
exactly one of `ready` / `blocked` (see [LABELS.md](LABELS.md)).
The bar, stated once: **a competent builder who has read only this issue and
the repo can succeed.** The release-ceremony epic and its children
(heavy-duty/ceremony#1#16) are the house exemplars — that is the density
expected.
## Multi-issue work
When an acceptance produces more than one issue, mint an **epic** (`epic`
label): the approach, the decisions, the constraint list, and a
dependency-ordered task list of child issues. Children reference the epic;
the epic's checklist is the progress view. Builders never pick the epic
itself. Keep the checklist current — a stale epic misleads every scan.
## Backlog hygiene
- **Dedup before minting** — search issues *and* closed issues; extend or
reopen before duplicating.
- The issue-flow sweep flips `blocked``ready` when every named dependency
lands, and flags a blocked issue whose dependency declaration is unreadable.
- The sweep reclaims abandoned claims after 48 hours: `claimed` + no open PR
+ no activity → comment, unassign, restore `ready`.
- Automation never guesses intent. Resolve the conflict comments it leaves on
malformed queue states, and close or extend completed epics when nudged.
- **Close obsolete issues** with the reason and a link to what obsoleted
them. Every label on every open issue stays true; the board is only worth
scanning if it does not lie.
- **A lifted hold makes its body prose stale in the same instant, and the
body is yours.** The "stays true" bar above extends past the labels to
the prose that describes them: when a hold lifts, correcting the body
header that described it is your move in the same tick — not the
builder's, and not left for the next reader to diff. On
[#149](https://github.com/heavy-duty/ceremony/issues/149) the lift
arrived by label alone and the body said held for the next five and a
half minutes; two builders read that window to opposite conclusions.
## What you never do
- Write code, review code, or build the thing yourself.
- Assign a builder — builders pick and claim ([BUILDER.md](BUILDER.md)).
- Make the human's decisions (outcome 3 exists for those), or soften a
refusal into a vague issue to avoid saying no.
- Mint an issue to "discuss" something — that is a discussion.

20
.github/DISCUSSION_TEMPLATE/ideas.yml vendored Normal file
View file

@ -0,0 +1,20 @@
# Copied from heavy-duty/ceremony@0.3.0 .github/DISCUSSION_TEMPLATE/ideas.yml (heavy-duty/ceremony#24). Hand-maintained: no guard checks this against ceremony.
# Light on purpose: discussions are where ambiguity is ALLOWED — a form
# that demands rigor at the door defeats the room's purpose (heavy-duty/ceremony#24,
# decision 4). If these prompts fight the flow in practice, delete them
# before adding fields to them.
body:
- type: textarea
attributes:
label: What's the itch?
description: >-
Vague is fine — a bug, an idea, a "we should…". Triage turns this
into work (or an answer); you don't have to.
validations:
required: false
- type: textarea
attributes:
label: What would "done" feel like?
description: Optional — a sketch of the outcome, if you can already see it.
validations:
required: false

16
.github/DISCUSSION_TEMPLATE/q-a.yml vendored Normal file
View file

@ -0,0 +1,16 @@
# Copied from heavy-duty/ceremony@0.3.0 .github/DISCUSSION_TEMPLATE/q-a.yml (heavy-duty/ceremony#24). Hand-maintained: no guard checks this against ceremony.
# Light on purpose — same reasoning as ideas.yml (heavy-duty/ceremony#24, decision 4):
# optional prompts only, delete before hardening.
body:
- type: textarea
attributes:
label: What's the question?
description: Ask it plainly — context and links help, none are required.
validations:
required: false
- type: textarea
attributes:
label: What have you tried or read already?
description: Optional — saves the answerer a lap.
validations:
required: false

14
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View file

@ -0,0 +1,14 @@
# Copied from heavy-duty/ceremony@0.3.0 .github/ISSUE_TEMPLATE/config.yml (heavy-duty/ceremony#24). Hand-maintained: no guard checks this against ceremony.
# The new-issue interception (heavy-duty/ceremony#24, decision 1): interception over
# instruction — prose alone has already proven insufficient everywhere else
# in this org. With blank issues disabled and exactly one form, a "New
# issue" click shows the discussion link first; that auto-suggest is native
# GitHub behavior, no automation needed.
blank_issues_enabled: false
contact_links:
- name: Ideas, bugs, questions — start a Discussion
url: https://github.com/heavy-duty/rig/discussions
about: >-
Humans (and agents) never file issues here — discussions are where
intent lives. Triage converges every discussion to an outcome, and
only triage mints issues (.ceremony/TRIAGE.md).

76
.github/ISSUE_TEMPLATE/work-order.yml vendored Normal file
View file

@ -0,0 +1,76 @@
# Copied from heavy-duty/ceremony@0.3.0 .github/ISSUE_TEMPLATE/work-order.yml (heavy-duty/ceremony#24). Hand-maintained: no guard checks this against ceremony.
# The issue contract (.ceremony/TRIAGE.md "The issue contract") as a form, so every
# minted issue lands with the same bones and a later parser (heavy-duty/ceremony#18) can key on
# stable section headings. Consistency, not a gate: the form applies no
# labels — queue labels are triage's explicit act (.ceremony/LABELS.md), and heavy-duty/ceremony#18's
# sweep is what catches non-triage authors, so the form must not pre-judge
# that (heavy-duty/ceremony#24, decision 2).
name: Work order (triage only)
description: >-
The issue contract (.ceremony/TRIAGE.md) as a form. Only triage mints issues —
everyone else starts a Discussion. Triage may still compose by hand when
the form fights it: `gh issue create --body-file` bypasses forms and stays
legitimate for the triage identity.
body:
- type: markdown
attributes:
value: >-
**The title names the deliverable** — "lib/version.sh — one version
abstraction, two backends", never "improve version handling". The
bar for everything below, stated once: a competent builder who has
read only this issue and the repo can succeed (.ceremony/TRIAGE.md). Labels
(type, `scope:*`, `ready`/`blocked`) are your explicit act after
minting — this form applies none.
- type: textarea
id: context
attributes:
label: Context
description: >-
Why this exists, with links — the discussion it came from, the code
it touches (permalinks at a pinned SHA, so line references cannot
rot), prior art in sibling repos.
validations:
required: true
- type: textarea
id: spec
attributes:
label: Spec
description: >-
Decisions made, not options listed. If the spec still has an open
question, the issue is not ready to exist.
validations:
required: true
- type: textarea
id: tasks
attributes:
label: Tasks
description: The steps, checkboxed, in order.
placeholder: "- [ ] the first step"
validations:
required: true
- type: textarea
id: acceptance-criteria
attributes:
label: Acceptance criteria
description: >-
Checkboxed, verifiable, and honest — these become the builder's
definition of done and the reviewer's review spec, verbatim.
placeholder: "- [ ] a verifiable outcome"
validations:
required: true
- type: textarea
id: test-plan
attributes:
label: Test plan
description: What proves it, including the cases that must fail.
validations:
required: true
- type: textarea
id: dependencies
attributes:
label: Dependencies
description: >-
`Blocked by #N` / `Blocks #N`, and `Part of #E` when an epic
organizes it — or state "None".
validations:
required: true

25
.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"]
@ -21,3 +23,24 @@
"scope:installer": "scope:installer":
- changed-files: - changed-files:
- any-glob-to-any-file: ["install.sh"] - any-glob-to-any-file: ["install.sh"]
"scope:platform":
- changed-files:
- any-glob-to-any-file: ["commands/platform.sh"]
"scope:drill":
- changed-files:
- any-glob-to-any-file: ["drill/**", "drills/**"]
"scope:docs":
- changed-files:
- any-glob-to-any-file:
- "README.md"
- "CONTRIBUTING.md"
- "docs/**"
- ".github/ISSUE_TEMPLATE/**"
- ".github/DISCUSSION_TEMPLATE/**"
- ".github/pull_request_template.md"
"scope:labels":
- changed-files:
- any-glob-to-any-file:
- ".github/labels.conf"
- ".github/labeler.yml"
- ".github/workflows/labels.yml"

12
.github/labels.conf vendored Normal file
View file

@ -0,0 +1,12 @@
panel=cluade-reviewer-andresmgsl codex-reviewer-andresmgsl grok-reviewer-andresmgsl kimi-reviewer-andresmgsl
triage-actors=dan-claude-bot cluade-reviewer-andresmgsl
scope:bootstrap|C5DEF5|bootstrap — hardening a pristine server into a node
scope:users|C5DEF5|users-* — class model, apply/status, close-root
scope:runner|C5DEF5|runner-* / forgejo-runner-* — CI runner lifecycle, either forge
scope:coolify|C5DEF5|coolify-* — Coolify and backup install
scope:db|C5DEF5|db.sh — dump/restore
scope:installer|C5DEF5|install.sh — how rig lands on a machine
scope:platform|C5DEF5|rig platform — machine facts and identity, converged or not
scope:drill|C5DEF5|drill/ and drills/ — the harness, its legs and its records
scope:docs|C5DEF5|README, CONTRIBUTING, docs/ — the prose contract
scope:labels|C5DEF5|the labels workflow, the reconciler, labels.conf and labeler.yml

21
.github/pull_request_template.md vendored Normal file
View file

@ -0,0 +1,21 @@
<!-- Copied from heavy-duty/ceremony@0.3.0 .github/pull_request_template.md (heavy-duty/ceremony#24). Hand-maintained: no guard checks this against ceremony.
Short on purpose: a template longer than a small PR gets deleted by
reflex, not read (heavy-duty/ceremony#24). One issue, one PR — draft while building. -->
Closes #
## Acceptance criteria
<!-- The issue's checkboxes, VERBATIM — reviewers diff this against the
issue. Check them honestly as you go; a criterion you can't check goes back
to the issue for triage to amend, not silently unshipped. -->
## Changelog
- [ ] One `changelog.d/<issue>.md` fragment containing the exact publishable
prose — or no behavior change, stated here.
## Round log
<!-- Append each round's summary here: what changed, what was verified.
Rounds are answered whole — one reply covering every point. -->

View file

@ -1,228 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# labels-reconcile.sh — the automation LABELS.md promises: state labels are
# written by machinery, never by hand. Every run derives each open PR's
# state:* from GitHub's own facts (draft flag, requested reviewers, submitted
# reviews) and converges the labels to it, so a killed run or a hand-moved
# label heals on the next pass. Stale is judged from real activity — commits,
# comments, reviews — never from label churn, or the sweep would un-stale its
# own mark every tick.
#
# The verdict contract (CONTRIBUTING.md): reviews end in approve or
# request-changes. Some live bots are comment-only and post agreement as a
# COMMENTED review — a non-verdict this machine refuses to guess about (body
# parsing is a heuristic, and a wrong guess promotes an unapproved PR). The
# judgment call belongs to the PR AUTHOR, who reads the round and escalates
# by requesting the human's review — an explicit request is a fact, and it is
# the one this machine trusts (see decide_state's top precedence). The
# machine auto-requests the human only in the no-judgment-needed case: three
# formal head-current approvals. Any approval that counts must be bound to
# the CURRENT head SHA: GitHub keeps approvals alive across pushes, and a
# stale approval must never promote unreviewed code to the human.
#
# DRY_RUN=1 narrates every mutation instead of performing it (how this script
# is rehearsed against the live repo). A workflow_dispatch run also bootstraps
# the taxonomy (label create --force) — that heal is dispatch-only; the cron
# sweep tolerates a missing label rather than recreating it.
#
# The state machine below is pure (globals in, state out) and covered by
# fixture tests in test/labels-reconcile.sh.
HUMAN="${HUMAN_REVIEWER:-danmt}"
BOTS=(claude-bot-andresmgsl codex-bot-andresmgsl grok-bot-andresmgsl)
STATES=(state:building state:bots-reviewing state:addressing state:needs-human)
STALE_AFTER=$((48 * 3600))
log() { printf 'labels: %s\n' "$*"; }
run() { # every mutation goes through here — DRY_RUN=1 logs instead of doing
if [ -n "${DRY_RUN:-}" ]; then log "DRY_RUN: $*"; else "$@"; fi
}
# ---------------------------------------------------------------------------
# The state machine. Pure functions over four globals, set per PR:
# DRAFT true|false
# HEAD_SHA the PR's current head commit
# REQUESTED newline-separated logins with a review currently requested
# REVIEWS_JSON JSON array of submitted (non-PENDING) reviews
# ---------------------------------------------------------------------------
requested() { grep -qxF "$1" <<<"$REQUESTED"; }
bot_verdict() { # $1 = login → MISSING | BLOCK | APPROVE | STALE | FEEDBACK
local review state commit
review="$(jq -c --arg u "$1" \
'[.[] | select(.user.login == $u)] | sort_by(.submitted_at) | last // empty' \
<<<"$REVIEWS_JSON")"
if [ -z "$review" ]; then echo MISSING; return; fi
state="$(jq -r '.state' <<<"$review")"
commit="$(jq -r '.commit_id' <<<"$review")"
case "$state" in
CHANGES_REQUESTED)
# blocks at ANY head — GitHub's own semantic: only a newer review
# from the same reviewer clears it
echo BLOCK ;;
APPROVED)
if [ "$commit" = "$HEAD_SHA" ]; then echo APPROVE; else echo STALE; fi ;;
*)
# COMMENTED and anything else: a non-verdict. The machine does not
# read bodies — if the comment is really an agreement, the AUTHOR
# says so by requesting the human's review.
echo FEEDBACK ;;
esac
}
human_request_needed() { # 0 when needs-human requires a FRESH human request
# already requested → the handoff is live; head-current human approval →
# nothing left to ask. Anything else (never reviewed, an old comment, an
# approval of an older head) stalls the handoff unless we request —
# guarding on "has the human ever reviewed" wedged exactly that way.
if requested "$HUMAN"; then return 1; fi
if [ "$(bot_verdict "$HUMAN")" = APPROVE ]; then return 1; fi
return 0
}
decide_state() { # → the one state:* label this PR should carry
if [ "$DRAFT" = true ]; then echo state:building; return; fi
# an explicit human request outranks the bot rounds — it is the final
# gate, and a maintainer pulling a PR to themselves early counts too
if requested "$HUMAN"; then echo state:needs-human; return; fi
local b v verdicts=""
for b in "${BOTS[@]}"; do
if requested "$b"; then echo state:bots-reviewing; return; fi
done
for b in "${BOTS[@]}"; do
v="$(bot_verdict "$b")"
if [ "$v" = MISSING ]; then echo state:bots-reviewing; return; fi
verdicts="$verdicts $v"
done
case "$verdicts" in
# FEEDBACK = a comment with no verdict → the agent owes the round-reply.
# STALE = a verdict for an older head → the agent owes a re-request.
*BLOCK* | *FEEDBACK* | *STALE*) echo state:addressing; return ;;
esac
# the bots all approve — but if the human's standing word is
# changes-requested (and nobody re-requested them yet), the agent owes
# fixes, not the human a nag
if [ "$(bot_verdict "$HUMAN")" = BLOCK ]; then
echo state:addressing
else
echo state:needs-human
fi
}
# ---------------------------------------------------------------------------
# The sweep: fetch facts, decide, converge. One PR's failure never aborts the
# others — each PR reconciles in a subshell and a failure just logs.
# ---------------------------------------------------------------------------
bootstrap_labels() { # dispatch-only: ~20 upserts is too chatty for every cron tick
while IFS='|' read -r name color desc; do
[ -n "$name" ] || continue
run gh label create "$name" -R "$REPO" --color "$color" --description "$desc" --force
done <<'EOF'
state:building|FBCA04|PR is a draft — the coding agent is still building
state:bots-reviewing|1D76DB|Waiting on the bot reviewers to finish the round
state:addressing|D93F0B|All bots reviewed — coding agent owes the single reply + fixes
state:needs-human|8250DF|All bots approve — waiting on the human reviewer
stale|B60205|No activity for 48h — needs a poke (sweep-managed)
blocked|6A737D|Waiting on another PR or issue to land first
release|0E8A16|Release flow and version/packaging work
scope:bootstrap|C5DEF5|bootstrap — hardening a pristine server into a node
scope:users|C5DEF5|users-* — class model, apply/status, close-root
scope:runner|C5DEF5|runner-* — GitHub runner lifecycle
scope:coolify|C5DEF5|coolify-* — Coolify and backup install
scope:db|C5DEF5|db.sh — dump/restore
scope:installer|C5DEF5|install.sh — how rig lands on a machine
EOF
}
has_label() { grep -qxF "$1" <<<"$LABELS"; }
reconcile_pr() { # $1 = PR number; relies on the globals set from its fetch
local n="$1" desired remove s args last_activity age
desired="$(decide_state)"
# encode the runbook's last step for the no-judgment case: three formal
# head-current approvals → the human is asked, once. The guard asks whether
# a FRESH human review is needed for THIS head — never "has the human ever
# reviewed", which wedged the handoff after any earlier human comment.
# Idempotent (a live request suppresses it); race-free via the shared
# concurrency group in labels.yml. With a comment-only bot on the panel
# this path stays cold and the AUTHOR requests the human.
if [ "$desired" = state:needs-human ] && human_request_needed; then
run gh api "repos/$REPO/pulls/$n/requested_reviewers" -f "reviewers[]=$HUMAN" --silent
log "#$n: requested $HUMAN (round passed)"
fi
# ---- converge the state:* labels ----
remove=""
for s in "${STATES[@]}"; do
if [ "$s" != "$desired" ] && has_label "$s"; then remove="$remove,$s"; fi
done
remove="${remove#,}"
if ! has_label "$desired" || [ -n "$remove" ]; then
args=(--add-label "$desired")
[ -n "$remove" ] && args+=(--remove-label "$remove")
if run gh issue edit "$n" -R "$REPO" "${args[@]}" >/dev/null; then
log "#$n: state -> $desired${remove:+ (cleared $remove)}"
else
# a deleted label must not wedge the sweep — dispatch heals the taxonomy
log "#$n: WARNING: label edit failed (missing label? run the workflow manually to bootstrap)"
fi
fi
# ---- stale: real activity only, and blocked is legitimately quiet ----
last_activity="$(
{
jq -r '.created_at' <<<"$PR_JSON"
jq -r '.[].submitted_at' <<<"$REVIEWS_JSON"
gh api --paginate "repos/$REPO/issues/$n/comments" --jq '.[].created_at'
gh api --paginate "repos/$REPO/pulls/$n/comments" --jq '.[].created_at'
gh api --paginate "repos/$REPO/pulls/$n/commits" --jq '.[].commit.committer.date'
} | sort | tail -n1
)"
age=$((NOW - $(date -d "$last_activity" +%s)))
if has_label blocked || [ "$age" -le "$STALE_AFTER" ]; then
if has_label stale; then
run gh issue edit "$n" -R "$REPO" --remove-label stale >/dev/null
log "#$n: unstale"
fi
elif ! has_label stale; then
run gh issue edit "$n" -R "$REPO" --add-label stale >/dev/null
log "#$n: stale ($((age / 3600))h quiet)"
fi
}
main() {
REPO="${REPO:?set REPO to owner/name}"
NOW="$(date +%s)"
if [ "${GITHUB_EVENT_NAME:-}" = workflow_dispatch ]; then
log "workflow_dispatch: bootstrapping the taxonomy"
bootstrap_labels
fi
local n
for n in $(gh pr list -R "$REPO" --state open --limit 100 --json number --jq '.[].number'); do
(
PR_JSON="$(gh api "repos/$REPO/pulls/$n")"
DRAFT="$(jq -r '.draft' <<<"$PR_JSON")"
HEAD_SHA="$(jq -r '.head.sha' <<<"$PR_JSON")"
LABELS="$(jq -r '.labels[].name' <<<"$PR_JSON")"
REQUESTED="$(jq -r '.requested_reviewers[].login' <<<"$PR_JSON")"
# PENDING reviews are unsubmitted drafts in someone's browser — not a verdict
REVIEWS_JSON="$(gh api --paginate "repos/$REPO/pulls/$n/reviews" --jq '.[]' \
| jq -s '[.[] | select(.state != "PENDING")]')"
reconcile_pr "$n"
) || log "#$n: reconcile failed — continuing with the remaining PRs"
done
log "reconciled."
}
# sourced by test/labels-reconcile.sh for the fixture tests; executed in CI
if [ "${BASH_SOURCE[0]}" = "$0" ]; then
main "$@"
fi

View file

@ -1,23 +0,0 @@
#!/usr/bin/env bash
# Release plumbing shared by .github/workflows/release.yml and the test
# harness (test/release.sh) — pure functions, sourced, never executed on
# their own (repo precedent: labels-reconcile.sh's decide_state, the
# commands/lib/*.sh parsers).
# changelog_section <file> <version>
#
# Print the BODY of that version's CHANGELOG.md section: everything between
# its heading and the next '## ' heading (or EOF). A release heading is
# stamped '## <version> — <date>' and the Unreleased one is bare
# '## Unreleased'; the second field is the version either way, so both
# shapes match. The heading itself is not printed — the release title
# already names the version — and leading blank lines are dropped. Empty
# output means "no such section", which release.yml turns into a refusal: a
# tag with no changelog entry must not ship an empty release.
changelog_section() {
awk -v ver="$2" '
/^## / { if (found) exit; found = ($2 == ver); next }
found && !body && /^[[:space:]]*$/ { next }
found { body = 1; print }
' "$1"
}

View file

@ -8,20 +8,109 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with:
# changelog-monotonic and changelog-assembled compare HEAD against
# the merge base; a
# checkout that cannot resolve it is a hard failure in CI, not
# a skip (a guard that can quietly stop guarding is the failure
# shape these checks exist to refuse).
fetch-depth: 0
- name: shellcheck - name: shellcheck
# -x follows the `source=SCRIPTDIR/...` directives into commands/lib/. # -x follows the `source=SCRIPTDIR/...` directives into commands/lib/.
# globstar so a script in a new subdirectory is linted without anyone # globstar so a script in a new subdirectory is linted without anyone
# remembering to edit this list; bin/* covers the extensionless entrypoints. # remembering to edit this list; bin/* covers the extensionless entrypoints.
# The file list is printed so under-coverage shows up in the log. # dotglob because globs skip dot-prefixed names: without it `**/` never
# descends into `.github/`, so `.github/scripts/*.sh` — release-lib.sh
# among them — was swept up by nothing (#70). It also makes `**`
# descend into `.git/`, which holds no tracked `.sh` on a checkout.
# 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
# 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: |
shopt -s globstar if ! command -v shellcheck >/dev/null 2>&1; then
sudo apt-get update
sudo apt-get install -y shellcheck
fi
shopt -s globstar dotglob
files=(bin/* **/*.sh) files=(bin/* **/*.sh)
printf 'shellcheck: %s\n' "${files[@]}" printf 'shellcheck: %s\n' "${files[@]}"
uncovered=$(comm -23 <(git ls-files '*.sh' | sort) <(printf '%s\n' "${files[@]}" | sort))
if [ -n "$uncovered" ]; then
printf 'tracked .sh files the glob does not lint:\n%s\n' "$uncovered" >&2
exit 1
fi
shellcheck -x "${files[@]}" shellcheck -x "${files[@]}"
- name: cli tests - name: cli tests
run: bash test/cli.sh run: bash test/cli.sh
- name: release-flow tests - name: release tests — rig's own surfaces
run: bash test/release.sh run: bash test/release.sh
# The drill harness's honesty: refusals, the skip/pass/fail classifier,
# the idempotence capture-and-diff, the record emitter. Network-free and
# root-free — the live four-leg run is a release's drill, not CI's.
- name: drill harness tests — the instrument itself
run: bash test/drill.sh
# The release guards, doctrine in heavy-duty/ceremony's README (#13's
# conversion). Each one's war story — why it exists, what it refuses —
# lives with its implementation upstream; the six pins below and the
# 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
# unconditional form rig#44 reverted — this is its correct return).
- uses: https://forgejo.heavyduty.builders/heavy-duty/ceremony/actions/changelog-armed@0.3.0
# changelog-monotonic: no shipped heading deleted or duplicated
# (#98, box#122). Strict by default: an unresolvable base ref is red,
# never a quiet skip — hence the fetch-depth: 0 above.
- uses: https://forgejo.heavyduty.builders/heavy-duty/ceremony/actions/changelog-monotonic@0.3.0
# changelog-assembled: a release's stamped section must exactly match
# the fragments it consumed. Vacuous on non-release PRs.
- uses: https://forgejo.heavyduty.builders/heavy-duty/ceremony/actions/changelog-assembled@0.3.0
# drill-recorded: a release version carries drills/<version>.md
# (rig's drill meaning: drills/README.md). Vacuous on -dev trees.
- uses: https://forgejo.heavyduty.builders/heavy-duty/ceremony/actions/drill-recorded@0.3.0
# runner-isolated: PR-triggered workflows never execute unreviewed
# branch code on a self-hosted runner.
- 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
# pin read from release.yml (ceremony#19) — a hand edit or a
# half-done pin bump goes red here.
- 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
# beats box and cast already run in CI (#106): install from this checkout,
# assert what landed, a converging re-run proven by an EMPTY DIFF (never an
# exit code), uninstall --all ending in the absence assert (`! -e` AND
# `! -L` — only the second sees a dangling symlink). Separate from `check`
# for the same reason db-integration is: fast feedback first. The runner's
# real $HOME is the point — no throwaway roots here; the suite refuses to
# run where a rig is already installed, so it cannot eat a real install.
install:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: the install lifecycle — four beats against a real tree
run: bash test/install-lifecycle.sh
# Kept SEPARATE from `check` on purpose: this job pulls a Postgres image and # Kept SEPARATE from `check` on purpose: this job pulls a Postgres image and
# stands up throwaway containers, and a slow image pull must never delay the # stands up throwaway containers, and a slow image pull must never delay the

View file

@ -1,56 +1,22 @@
name: labels name: labels
# The automation LABELS.md promises. Two halves: # The automation LABELS.md promises, now implemented upstream
# scope — path-derived scope:* labels on PRs (actions/labeler) # (heavy-duty/ceremony#13): scope labeling and the state reconciler live in
# reconcile — the state:* machine + the stale sweep (.github/scripts/labels-reconcile.sh) # the reusable workflow this caller pins. Rig keeps the triggers and
# # permissions (a called workflow cannot define them), its path map in
# pull_request_target, not pull_request: every PR here arrives from a fork, # .github/labeler.yml, and its panel + scope taxonomy in .github/labels.conf.
# where pull_request (and pull_request_review) run with a READ-ONLY token and
# cannot label anything. _target is safe in this workflow because no PR code
# is ever checked out or executed — labeler reads changed paths via the API,
# and reconcile checks out the BASE branch only. Keep it that way.
#
# Review-submitted transitions (bots finishing a round) ride the cron: there
# is no pull_request_review_target, so the 15-minute tick is the wake signal —
# the same cadence the reviewer bots poll at.
on: on:
schedule: schedule: [{cron: "*/15 * * * *"}] # advisory; the handoff label is the real wake
- cron: "*/15 * * * *" workflow_dispatch: # bootstraps missing labels on a fresh repo
workflow_dispatch: # also bootstraps missing labels — run once on a fresh repo
pull_request_target: pull_request_target:
types: [opened, reopened, ready_for_review, converted_to_draft, synchronize] types: [opened, reopened, ready_for_review, converted_to_draft, synchronize, labeled, unlabeled, review_requested, review_request_removed]
issues:
types: [opened, edited, assigned, unassigned, labeled, unlabeled, closed, reopened]
permissions: permissions:
contents: read contents: read
checks: read
statuses: read
issues: write issues: write
pull-requests: write pull-requests: write
jobs: jobs:
scope: labels:
if: github.event_name == 'pull_request_target' uses: heavy-duty/ceremony/.github/workflows/labels.yml@0.3.0
runs-on: ubuntu-latest
concurrency:
group: labels-scope-${{ github.event.pull_request.number }}
cancel-in-progress: true
steps:
- uses: actions/labeler@v5
with:
# additive only — a hand-applied scope must survive the machine
sync-labels: false
reconcile:
runs-on: ubuntu-latest
# ONE shared group: every reconcile sweeps every open PR, so cron and
# PR-event runs must serialize or two sweeps race the same PR's labels
# and both pass the request-the-human-once guard. GitHub keeps at most
# one queued run per group (older queued runs are superseded), which
# coalesces bursts instead of piling them up.
concurrency:
group: labels-reconcile
cancel-in-progress: false
steps:
- uses: actions/checkout@v4 # base branch only — never the PR's code
- name: reconcile state + stale
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
run: bash .github/scripts/labels-reconcile.sh

View file

@ -1,238 +1,22 @@
name: release name: release
# Two ways in, one release out (#47; box#96's design — the merge path — on # The ceremony moved upstream (heavy-duty/ceremony — the doctrine is its
# top of #32/box#83's tag flow, kept verbatim as the fallback): # README; ceremony#13 is this conversion). Both doors — merge and tag push —
# # live in the reusable workflow this caller pins; rig keeps only what a
# - MERGE (the paved road): a release is a PR — `release: X.Y.Z`, carrying # called workflow cannot define: triggers and permissions.
# the `release` label, bumping VERSION and stamping CHANGELOG.md's # Triggers and permissions MUST live here (a called workflow cannot define them):
# Unreleased section — and MERGING it is the ship decision. The
# release-on-merge job asserts its way to certainty, then tags the merge
# commit and publishes, same job. No separate, silent-when-forgotten
# tagging step: a forgotten tag produces no red X, a failed run on main
# does — of two unreliabilities, pick the loud one.
# - TAG PUSH (the manual fallback and backfill): tag the merge commit bare
# `X.Y.Z` (no `v` prefix — box's tag scheme) and push; the release job
# below turns it into the GitHub release.
#
# Either way the body is the changelog section — the curated prose, never
# the auto-generated PR list — and no assets are uploaded on purpose: for a
# pure-bash tree, GitHub's source tarball for the tag IS the package
# (install.sh downloads archive/refs/tags/<tag>).
on: on:
# ONE push key, both filters — YAML maps are last-key-wins, so a second # ONE push key, both filters — YAML maps are last-key-wins; a second sibling
# sibling `push:` would silently REPLACE the first and kill a door # `push:` silently replaces the first and kills a door (rig's review catch).
# (grok's round-2 catch: the tag fallback had stopped triggering).
push: push:
# Every tag, not a shape filter: a tag that mismatches VERSION must fail tags: ["**"] # every tag — a wrong tag must FAIL the assert loudly,
# LOUDLY below, not be silently skipped by a pattern that didn't match. # never be skipped by a shape filter that didn't match
tags: ['**']
# The merge-is-the-release path (#47) rides pushes to MAIN, not
# pull_request events: a pull_request run from a public FORK gets a
# READ-ONLY GITHUB_TOKEN — `permissions:` cannot raise that ceiling —
# and every ceremony PR this org merges is cross-repo from the bot
# fork; the tag create would 403 after green asserts. A push to main
# is an in-repo event with the full write token, whoever authored the
# PR. The jobs split on the pushed ref.
branches: [main] branches: [main]
permissions: permissions:
contents: write # the tag ref, the release publish, the bump push contents: write # tag ref create + release create + the bump push
# Two consumers (a declared permissions: block zeroes every unspecified pull-requests: write # decide's label read; the bump-fallback `gh pr create`
# scope): the decide step's label read (commits/<sha>/pulls) and the bump issues: write # --label on that fallback PR rides the issues API
# fallback's `gh pr create --label`.
pull-requests: write
# ...and the --label on that fallback PR rides the ISSUES API (labels.yml
# grants the same pair for the same reason).
issues: write
jobs: jobs:
release: release:
# The tag-push path — a pushed TAG ref. The merge path (a pushed main uses: heavy-duty/ceremony/.github/workflows/release.yml@0.3.0
# head) is release-on-merge below; the two doors split on the ref.
if: startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# The tag names a tree; the tree names its own version. When they
# disagree, creating a release would put a version label on a tree
# that is not that version — exactly the lie the release flow exists
# to end — so: fail, create nothing.
- name: assert the tag matches the tree's VERSION
run: |
ver="$(cat VERSION)"
if [ "$GITHUB_REF_NAME" != "$ver" ]; then
echo "tag '$GITHUB_REF_NAME' != VERSION '$ver' — refusing to create a release for a tree that says it is something else" >&2
exit 1
fi
- name: create the release from the changelog section
env:
GH_TOKEN: ${{ github.token }}
run: |
. .github/scripts/release-lib.sh
notes="$(changelog_section CHANGELOG.md "$GITHUB_REF_NAME")"
if [ -z "$notes" ]; then
echo "CHANGELOG.md has no '## $GITHUB_REF_NAME' section — stamp the Unreleased section in the release PR before tagging" >&2
exit 1
fi
gh release create "$GITHUB_REF_NAME" --verify-tag \
--title "$GITHUB_REF_NAME" --notes "$notes"
# The merge path (#47; box#96): the `release` label is the intent, the
# VERSION transition is the interlock. Four asserts in order, each
# fail-loud and creating NOTHING, then tag + publish in this same job.
# Same-job is load-bearing: the tag is created with GITHUB_TOKEN via the
# API, and GITHUB_TOKEN-created refs do not fire `on: push: tags`
# workflows — so the publish MUST live here (nothing else would run), and
# the fallback job above CANNOT double-publish off our tag. A manually
# pushed tag racing this run is caught by the nothing-exists assert.
# NOTE: test/release.sh pins this block by awk-ing from
# 'release-on-merge:' to EOF — keep it the last job.
release-on-merge:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
env:
MERGE_SHA: ${{ github.sha }}
BASE_SHA: ${{ github.event.before }}
steps:
- uses: actions/checkout@v4
with: with:
# The pushed head is what ships. Full history so the before-side version-source: file
# VERSION is readable for the interlock below.
ref: ${{ github.sha }}
fetch-depth: 0
# The decide step — asserts 1+2 fused, because the `release` label
# carries TWO legitimate meanings (LABELS.md: "release flow and
# version/packaging work"): the ceremony PR that ships a version, and
# ordinary work ON the release machinery — this very PR included.
# The version tells them apart. A `-dev` VERSION left UNTOUCHED by the
# PR is release-flow work: a green no-op, not a red run on main every
# time the flow itself is improved. Everything in between is a
# half-ceremony and dies loudly:
# -dev, unchanged → work under the label: NOTICE + green no-op
# -dev, changed → still a dev tree, so still work — the
# post-release bump PR above all (bare -> -dev
# after every release): green NOTICE no-op
# bare, unchanged,
# already released → work merged in the post-release window
# (ceremony landed, the -dev bump has not):
# NOTICE + green no-op
# bare, unchanged,
# never released → the label says ship, the tree names an
# unshipped version this PR did not mint:
# genuinely ambiguous, refuse
# bare, changed → the ceremony: proceed
- name: 'decide: ceremony, or release-flow work under the label?'
id: decide
env:
GH_TOKEN: ${{ github.token }}
run: |
ver="$(cat VERSION)"
# event.before is all-zeros on a branch-create push; the merge
# commit's first parent is main the instant before, either way.
if ! git cat-file -e "$BASE_SHA" 2>/dev/null; then BASE_SHA="$(git rev-parse "$MERGE_SHA^1")"; fi
base_ver="$(git show "$BASE_SHA:VERSION")"
case "$ver" in
*-dev)
if [ "$base_ver" = "$ver" ]; then
echo "NOTICE: VERSION '$ver' is -dev and unchanged by this PR — release-flow work under the release label, not a ceremony. Nothing to publish."
echo "ceremony=no" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "NOTICE: VERSION changed ('$base_ver' -> '$ver') and still ends -dev — a dev tree is by definition not a release. This is work (the post-release bump, a renumber); nothing to publish."
echo "ceremony=no" >> "$GITHUB_OUTPUT"
exit 0 ;;
esac
if [ "$base_ver" = "$ver" ]; then
if gh release view "$ver" -R "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
echo "NOTICE: VERSION '$ver' is already released and unchanged by this PR — release-flow work merged in the post-release window (before the -dev bump). Nothing to publish."
echo "ceremony=no" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "VERSION '$ver' is bare, unchanged by this PR, and never released — the label says ship but this PR did not mint the version. Refusing to guess." >&2
exit 1
fi
# The version transitioned — now the LABEL, the operator's declared
# intent, read via the API because a push event carries no PR
# payload (and the PR itself lives on a fork — see the trigger
# comment). No release-labeled PR behind this commit = a version
# transition nobody declared: refuse.
if ! gh api "repos/$GITHUB_REPOSITORY/commits/$MERGE_SHA/pulls" \
-q '[.[] | select(.merged_at != null) | .labels[].name] | index("release") != null' | grep -qx true; then
echo "VERSION transitioned ('$base_ver' -> '$ver') but no merged, release-labeled PR is behind this commit — a release is a labeled ceremony PR (#47), not a bare push. Refusing." >&2
exit 1
fi
echo "ceremony=yes" >> "$GITHUB_OUTPUT"
# Assert 3 — the changelog names exactly this version, and the one
# extractor (shared with the tag job and test/release.sh) gets a
# non-empty body out of it. The notes are kept for the publish.
- name: assert the changelog section for this version extracts
if: steps.decide.outputs.ceremony == 'yes'
run: |
. .github/scripts/release-lib.sh
ver="$(cat VERSION)"
changelog_section CHANGELOG.md "$ver" > "$RUNNER_TEMP/notes.md"
if [ ! -s "$RUNNER_TEMP/notes.md" ]; then
echo "CHANGELOG.md has no '## $ver' section at the merge commit — the ceremony PR must stamp it; refusing to publish an empty release" >&2
exit 1
fi
cat "$RUNNER_TEMP/notes.md"
# Assert 4 — nothing exists yet, tag or release: a re-run of this job
# (or a manual tag that beat it) must refuse, not clobber.
- name: assert no tag and no release exist yet (idempotent re-runs)
if: steps.decide.outputs.ceremony == 'yes'
env:
GH_TOKEN: ${{ github.token }}
run: |
ver="$(cat VERSION)"
if git ls-remote --exit-code origin "refs/tags/$ver" >/dev/null 2>&1; then
echo "tag '$ver' already exists — this release already happened (or is mid-flight on the manual path); refusing to re-release" >&2
exit 1
fi
if gh release view "$ver" -R "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
echo "release '$ver' already exists — refusing to re-release" >&2
exit 1
fi
# Act — tag the merge commit via the API, then publish with the notes
# assert 3 extracted. (GITHUB_TOKEN-created tag: no recursive
# workflow runs — see the job comment.)
- name: tag the merge commit and publish the release
if: steps.decide.outputs.ceremony == 'yes'
env:
GH_TOKEN: ${{ github.token }}
run: |
ver="$(cat VERSION)"
gh api -X POST "repos/$GITHUB_REPOSITORY/git/refs" \
-f ref="refs/tags/$ver" -f sha="$MERGE_SHA"
gh release create "$ver" --verify-tag \
--title "$ver" --notes-file "$RUNNER_TEMP/notes.md" \
-R "$GITHUB_REPOSITORY"
# The post-release bump, folded into the release act (#47 followup —
# operator decision: a mechanical one-liner deserves no PR of its
# own). X.Y.(Z+1)-dev is arithmetic, not judgment: derived, committed
# straight to main with this job's token. A GITHUB_TOKEN push fires
# no workflows (anti-recursion), so the bump triggers neither this
# door nor a red run; and should branch protection ever refuse the
# direct push, the step opens the bump PR itself and says so, loudly,
# instead of leaving main armed to impersonate the release.
- name: bump main to the next -dev — the release re-arms main itself
if: steps.decide.outputs.ceremony == 'yes'
env:
GH_TOKEN: ${{ github.token }}
run: |
ver="$(cat VERSION)"
next="$(printf '%s' "$ver" | awk -F. '{ printf "%s.%s.%s-dev", $1, $2, $3 + 1 }')"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git fetch origin main
git checkout -B main origin/main
printf '%s\n' "$next" > VERSION
git add VERSION
git commit -m "chore: bump main to $next — a dev install must not impersonate $ver"
if ! git push origin main; then
echo "direct push refused (branch protection?) — opening the bump PR instead" >&2
git checkout -b "chore/bump-$next"
git push origin "chore/bump-$next"
gh pr create -R "$GITHUB_REPOSITORY" --head "chore/bump-$next" \
--title "chore: bump main to $next" \
--body "The post-release re-arm, opened by release.yml because the direct push was refused. One file, one line." \
--label release
fi

7
AGENTS.md Normal file
View file

@ -0,0 +1,7 @@
# AGENTS.md — start at .ceremony/
This repository is governed by
[heavy-duty/ceremony](https://github.com/heavy-duty/ceremony). Read
`.ceremony/AGENTS.md` first — it routes you to your role file, vendored
beside it. Repo specifics (the review panel roster, the scope labels, what
a drill means here, code conventions) live in CONTRIBUTING.md.

View file

@ -4,232 +4,100 @@ History before 0.1.0 lives in git — rig grew its version surface (`VERSION`,
`rig --version`, the side-by-side `versions/<v>` install layout; #35/#36) `rig --version`, the side-by-side `versions/<v>` install layout; #35/#36)
on the way to cutting its first release, and this file starts there. on the way to cutting its first release, and this file starts there.
## Unreleased ## 0.3.1 — 2026-07-24
### Added
- GitHub entry templates route humans to Discussions and prefill triage work orders and pull requests (#123)
- Platform, drill, docs and labels changes receive dedicated scope labels (#119)
- The `changelog-armed` guard returns, version-keyed (#112, ceremony#13)
- The `.ceremony/` doctrine mirror, verified by `docs-sync` on every PR (#112, ceremony#19)
- `rig template-lint` validates role definitions; rig-templates CI runs it on every PR (#110)
- Drill records cite the rig-templates SHA the converge read (#110)
- `kimi-box` joins the box tenant roles — the Kimi CLI agent guest (#109)
- CI drills the install lifecycle against a real tree — install from the checkout, converge to an empty diff, uninstall to proven absence (#106)
- `drill/drill.sh` — the drill has an instrument: pinned-ref assertion, a mechanical idempotence diff, and a `drills/<version>.md` record emitter (#105)
- `rig platform` prints a stable machine `ID`, derived from `/etc/machine-id`, never the raw value (#95)
- `rig bootstrap --undo` removes only a tailnet join rig can prove it made (#63)
### Changed
- Changelog entries land in per-issue fragments assembled by the release PR (#136)
- Release and labels machinery is consumed from heavy-duty/ceremony@0.1.0 by reference — the workflows shrink to caller stubs, the guard scripts and their tests move upstream (#112, ceremony#13)
- Agent-tenant definitions live in heavy-duty/rig-templates, pinned in-tree and overridable per mint (`RIG_TEMPLATES_DIR`/`_REF`/`_REPO`); the in-tree case arms are gone, `staging-box` stays (#110)
- `bootstrap --host yes` installs a pinned box release instead of `main` (#103)
### Fixed
- The quick-start fence names its channel and carries the release command beside it (#149)
- The drill's docs no longer claim both installers default to `main` — box installs the `BOX_RELEASE` pin, rig the latest release, and its `--box-ref` example is now a tag (#133)
- `kimi-bot-andresmgsl` is on the review panel — the roster predated it joining the bench (#120)
## 0.3.0 — 2026-07-21
### Fixed
- Deleting a shipped release heading from `CHANGELOG.md` is caught on every PR
(#98, heavy-duty/box#122)
- The heading-uniqueness check no longer sits behind git conditions it does not
need (#98, heavy-duty/box#143)
- An unreadable check rollup no longer reads as "nothing is failing" (#90)
- CI runs `test/labels-reconcile.sh`, which it had never run (#90)
- `state:needs-human` no longer appears on PRs a human cannot merge
(#87, heavy-duty/box#136)
- A missing `/run/sshd` no longer reads as a broken sshd config (#92)
- CI's shellcheck sweep reaches `.github/scripts/` (#70)
- Ctrl-D at the `rig uninstall` confirm aborts out loud (#68)
- `users apply` tells "revoke everyone" apart from a truncated users file (#65)
### Added
- CI refuses a release PR with no drill record at `drills/<version>.md`
- `rig platform` — what this machine is, computed at run time, stored nowhere
(#64)
- `/etc/rig/manifest` records which rig converged a machine, and when (#61)
### Changed
- `state:needs-human` is set at handoff, not by the cron (#96)
- PR labels split into two axes: `state:*` (whose ball) and `blocker:*` (what
is in the way) (heavy-duty/box#137)
- BREAKING: `--class human|server` is now `--root-door closed|open`; old
markers still resolve (#77)
- BREAKING: the box tenant roles carry a `-box` suffix (#76)
- BREAKING: machine roles carry a `-server` suffix, and `staging-server` is
back (#76)
- Changelog entries are one line each, and the whole file now follows the rule
(#100)
## 0.2.0 — 2026-07-19 ## 0.2.0 — 2026-07-19
### Added ### Added
- **`users apply` grants the box *tier*, not just its socket** (#49) — role - `users apply` grants the box *tier*, not just its socket (#49)
`box` resolved to exactly one action, `usermod -aG incus`. That is the
socket; it is step 1 of the five `box grant` performs, so every box-role
user still needed an admin to run `box grant <user>` by hand before their
first `box new` would do anything but refuse ("your project has no box-net
profile"), and until that admin arrived they held an `incus` membership
with no converged project — incus-user would lazily hand them a stock
unhardened NAT bridge, which is worse than no grant at all. On `host=yes`
apply now calls `box grant` per box-role user, after `useradd` (grant
refuses an unknown account) and with the group ADD deferred to grant, so a
grant that fails partway can take the socket back with it. Failures split
the way the `host=` guard beside them already splits: a missing `box` CLI
on `host=yes` dies (a broken VM host), a per-user grant failure warns and
continues (one box-role user must not stop apply for the fleet). `host=no`
and marker-less boxes keep their existing skip-with-warning. An
`incus-admin` member is warned, not fatal — `box grant` refuses them today,
which heavy-duty/box#99 fixes box-side with no rig change needed.
### Changed ### Changed
- **BREAKING: `rig bootstrap` takes the users file, and requires it** (#51) — - BREAKING: `rig bootstrap` takes the users file, and requires it (#51)
bootstrap already knew everything else about what a box *is* (class, host,
join, hostname) and wrote `/etc/rig/role` to say so; the users file was the
last piece of that answer it did not take, so bring-up was two commands and
the second was the forgettable one. `--users <path>` now runs the `users
apply` convergence as bootstrap's **final phase** — after the traits, after
the verified tailnet join, after the role marker (apply *reads* that
marker), and after the `host=yes` box install (so box-role users find the
`incus` group box's own `setup-host` built). One command, and the box has
its people on it. The file is still passed per invocation and **never
persisted**; `--users -` is refused, because bootstrap's stdin belongs to
the pre-auth key prompt.
**Migration: every existing `rig bootstrap` invocation must add `--users
<path>` or `--no-users`.** Omitting both is now a usage error (exit 2)
naming both flags, and passing both is a usage error too. Scripted
bring-up that already ran `rig users apply` as a separate step can either
fold it in (`--users ./users`, and drop the separate call) or keep the old
shape verbatim by adding `--no-users`. Required on `class=server` as well
as `class=human`: a server nobody logs into routinely is exactly where
shared-root access rots, and per-human accounts keep attribution intact
for the times someone does go in — so the complete path is the default
path, and skipping it is deliberate rather than an omission that looks
identical to forgetting. The box TENANT roles (`claude|codex|grok|
staging`) take neither flag: a guest is minted non-interactively by box,
never joins the tailnet, and has no SSH door of its own — entry is `box
shell`, gated by the host's `incus` grants.
A bad users file is caught **up front** now (the same parser apply uses,
before `apt`, the hostname change, and any spent pre-auth key), and on
`host=yes` with `RIG_SKIP_BOX_INSTALL=1` a box-role user with no `incus`
group refuses immediately instead of a hundred lines later — the one case
where the outcome is already certain. rig still never installs Incus and
never calls `box setup-host` on its own account; every other way that step
can fail lands in `users apply`'s existing refusal, unchanged.
### Fixed ### Fixed
- **A release no longer disarms the changelog under the PRs still in - A release no longer disarms the changelog under the PRs still in flight (#67)
flight** (#67) — the ceremony stamps `## Unreleased` to - A `host=no` box with an `incus` group no longer hands out the bare socket
`## X.Y.Z — YYYY-MM-DD` and stops. Every PR authored before that merge (#58)
wrote its entry under `## Unreleased`; with the heading gone, git files - Dropping the box role revokes through `box`, not behind its back (#50)
the entry under whatever now occupies the position — the release that - `rig bootstrap` refuses a users file that names no users (#57)
already shipped. There is no conflict, because the stamped heading and
the incoming entry never overlap textually, so the one signal an author
relies on ("git told me to look") is absent exactly when the outcome is
wrong. It happened here: #60's #58 entry landed inside `## 0.1.0` at
`67386b4` and was repaired two minutes later by `0ff520c`; #54 would
have filed a **BREAKING** entry the same way. The published release body
is never affected — `release.yml` extracts it from the tree at the tag,
before the late merges land — so the only file that drifts is the one
only maintainers read, which is why it survived a whole release batch
unnoticed. Fixed in both halves the failure has. The ceremony now
**re-arms**: it adds a fresh empty `## Unreleased` above the section it
just stamped, so a late merge has somewhere correct to land with no
author action. That belongs to the ceremony step in
[CONTRIBUTING.md](CONTRIBUTING.md), not to `release.yml` — no workflow
has ever touched the heading; the stamping was always by hand, and the
`-dev` re-arm the workflow does perform was only ever about `VERSION`.
And `test/release.sh` now keys its guard to `VERSION` rather than
demanding a literal heading: a stamped top section is legal exactly when
`VERSION` is bare, and the moment it carries `-dev` — main, where
feature PRs merge — the top section must be `## Unreleased`. That
distinguishes the two states the old check collapsed into one, so it
catches a disarmed main **without** re-breaking the ceremony's own tree
the way the pre-#44 guard did. The rule is proven against seven
constructed `VERSION` + `CHANGELOG.md` pairs, including a re-armed
ceremony whose top section is legitimately empty — the state the old
non-empty assert would have rejected. box and cast carry the same flow
and the same exposure (`heavy-duty/box#96`); cast is disarmed on `main`
as of this writing and is getting the sibling fix.
- **A `host=no` box with an `incus` group no longer hands out the bare
socket** (#58) — `users apply` consulted the `host=` trait only when group
`incus` was ABSENT (die on `host=yes`, skip on `host=no`). When the group
was PRESENT the trait was never asked, so a `host=no` or marker-less box
that nonetheless carried the group — `box setup-host` ran, then the box was
re-bootstrapped with other traits — gave every box-role user a bare
`usermod -aG incus`: the socket with no tier behind it, which `incus-user`
answers by lazily building an UNHARDENED project under whoever opens it
(`incusbr-<uid>`, NAT on v4 and v6, no ACL, no `dns.mode=none`, no port
isolation). The marker now decides in BOTH directions, through one new pure
gate (`assert_marker_hosts_vms`, testable against fixture markers non-root
like `assert_marker_human`): the box role applies only where the box CLAIMS
to host VMs, so the verdict is identical whether or not the group exists.
The machine deliberately does not overrule the marker — but the skip is not
silent either: when the group exists and the trait disagrees, the warning
names the contradiction and `rig bootstrap` as the repair. On such a box
exact-membership convergence now strips box-role users out of `incus`, on
the same reasoning: a membership inherited from a previous life is the same
half-grant as a freshly added one.
- **Dropping the box role revokes through `box`, not behind its back**
(#50) — `users apply` converged group `incus` with a bare `gpasswd -d`,
the same move it makes for `rig-admin` and `rig`. Those two are rig's;
`incus` is box's, and `box revoke` does strictly more with it: it says
out loud that supplementary groups are read at LOGIN, so a session the
dropped operator already holds keeps the Incus socket until it dies, and
hands over `loginctl terminate-user <user>` as the remedy. rig logged
`removed <user> from incus` and moved on, so an operator who dropped
someone from the users file and watched apply succeed believed the VM
access was gone — and was wrong for as long as that user held a session.
Both removal paths (the per-user convergence and the dropped-user sweep)
now call `box revoke`, which keeps one owner for the group. Never
`--purge`: that deletes the user's boxes, images and project, and
destroying someone's running machines is not a convergence step — it
stays an explicit admin act. The exit code is not trusted (#12's lesson):
a revoke that returns 0 with the membership still standing has not closed
the socket, and rig falls back to removing the group itself, as it also
does where box is not installed. Every fallback path carries the session
warning, because the silence was the bug.
- **`rig bootstrap` refuses a users file that names no users** (#57) — an
empty, comments-only or whitespace-only file is not a parse error, so it
passed pre-flight, converged nothing, and left the box root-only: the exact
outcome `--no-users` exists to make explicit, reached by the flag added to
guarantee the opposite. Bootstrap's pre-flight now catches the zero-user
parse — before `apt`, the hostname change, or a spent pre-auth key — and
refuses, naming `--no-users` as the way to ask for a root-only box out loud.
Scoped to `rig bootstrap`'s contract only: a standalone `rig users apply`
against an emptied file is a real de-provisioning operation and is
unchanged.
## 0.1.0 — 2026-07-19 ## 0.1.0 — 2026-07-19
### Fixed ### Fixed
- **The release suite accepts the ceremony's own tree** (#44) — - The release suite accepts the ceremony's own tree (#44)
`test/release.sh` demanded a literal `## Unreleased` heading in the real - The installer survives an environment with no `$HOME` (#39, #41)
`CHANGELOG.md`, extracting non-empty and containing `#32`. All three are - Headless credential prompts refuse loudly instead of dying silently (#42)
false by construction on the `release: X.Y.Z` tree the ceremony's own PR
produces (it stamps that heading into `## X.Y.Z — date`), so the first
real release PR turned CI red and the flow blocked itself — invisible to
both fork rehearsals, which tag a branch (`release.yml` runs; `ci.yml`
never does). The guard now asserts what it was for: whatever the TOP
`## ` section is — `Unreleased` between releases, the stamped version on
and right after one — the exact `changelog_section` the workflow runs
extracts it non-empty. The rotting issue-number grep is gone.
- **The installer survives an environment with no `$HOME`** (#39) —
cloud-init's `runcmd` runs `install.sh` with no `$HOME` set, and under
`set -u` the first expansion died with a bash unbound-variable stack
instead of an install — found live by box#88's template seed, which
pins `HOME=/root` as its own scar. The installer now derives the home
from `getent` for the effective user (root included) before any path
is built from `$HOME`, and when getent has no answer either it refuses
by name. Driven with a shim getent both ways: the derived-home install
lands, the no-answer refusal is pinned. (#41 — merged without its
entry; restored here at the release gate.)
- **Headless credential prompts refuse loudly instead of dying silently**
(#42) — the interactive credential prompts (`TS_AUTHKEY` in `bootstrap`,
`RUNNER_TOKEN` in `runner install`, `RUNNER_REMOVE_TOKEN` in
`runner remove`, and both tokens in `runner repoint` — a site the new
no-bare-read test caught after the issue counted three) were bare
`read -rsp`: with stdin not a tty (CI,
`box exec`, any script), `read` fails, `set -e` ends the run, and the
log just *stops* — exit 1, no last word, measured live in the
2026-07-19 release drill. Each prompt now checks for a tty first and
dies naming the variable that unblocks an unattended run (`runner
remove` also names `--local`), and every `read` is `|| die`-guarded so
EOF at a real prompt gets the same courtesy. `db.sh` already held the
line here; now all of rig does.
### Added ### Added
- **Merging a release-labeled PR IS the release — and the release re-arms - Merging a release-labeled PR IS the release, and the release re-arms main
main itself** (#47) — the rig twin of heavy-duty/box#96, born of the (#47)
ceremony retro: the tag was a separate, manual, silent-when-forgotten - Tagged releases, and an installer that installs them (#32)
step, and a forgotten tag produces no red X. `release.yml` now fires on
pushes to main (fork-sourced ceremony PRs get a read-only token on
`pull_request` events), reading the transition from the push itself:
`event.before` to the pushed head. A decide step answers four states —
release-flow *work* merged under the `release` label (`-dev` endstates,
the post-release window) no-ops green with a NOTICE; the two genuinely
ambiguous bare states refuse loudly; a true transition then requires a
merged, `release`-labeled PR behind the commit (read via the API — the
label is the operator's declared intent). Then, in the same job, it
API-creates the tag at the merge commit, publishes with the extracted
notes — and bumps main to `X.Y.(Z+1)-dev` itself, direct push with a
loud open-a-PR fallback, so no follow-up bump PR exists on the paved
road. A `GITHUB_TOKEN`-created tag never fires the tag-push trigger, so
the paths cannot double-publish — and that tag-push path survives intact
as the documented manual fallback and backfill.
- **Tagged releases, and an installer that installs them** (#32) — the rig
half of the flow designed in heavy-duty/box#83, near-verbatim. A release
is a PR, then a tag: the `release: X.Y.Z` PR bumps `VERSION` and stamps
this file's Unreleased section with version + date; the merge commit is
tagged bare `X.Y.Z` (box's tag scheme — no `v` prefix). `release.yml`
turns the tag into the GitHub release — after asserting tag == `VERSION`
(mismatch fails loudly and creates nothing) — with that version's section
of this file as the body, extracted by the same `changelog_section` the
test harness drives. No assets: for a pure-bash tree, GitHub's source
tarball for the tag IS the package. `install.sh` now defaults to the
**latest release**: the tag is resolved by following the
`releases/latest` redirect and reading the `Location` header — no API, no
token — and the download is `archive/refs/tags/<tag>.tar.gz`. `RIG_REF`
picks the other two channels: a tag pins (`refs/tags` outranks a
same-named branch), a branch (`RIG_REF=main`) tracks the development
tree. Until 0.1.0 is cut the default channel has nothing to resolve and
dies saying exactly that, naming `RIG_REF=main` as the way to install
today — it never falls back to main silently, because "I installed the
latest release" must not quietly mean "I installed whatever main was that
second". Step 5 of #32 — pinning `BOX_REF` in the host-installs-box path
— stays open until box cuts its next tagged release.

View file

@ -1,104 +1,113 @@
# Contributing # Contributing
How change lands in this repo. The short version: PRs are born as drafts, This repo is governed by
three reviewer bots take the first rounds, a human takes the last word — and [heavy-duty/ceremony](https://github.com/heavy-duty/ceremony). **Agents:
labels tell you where everything is without opening anything. read [`.ceremony/AGENTS.md`](.ceremony/AGENTS.md) first** — it routes you to
your role file (builder, reviewer, triage), vendored beside it,
byte-identical to ceremony at the pin named in
[`.github/workflows/release.yml`](.github/workflows/release.yml) and
guarded by the `docs-sync` step in CI. The review-round doctrine — drafts,
whole-round replies, verdicts, the handoff — lives there and in
[`.ceremony/LABELS.md`](.ceremony/LABELS.md); this file keeps only what is
genuinely rig's.
## The PR loop ## The PR loop, rig specifics
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. **Open as a draft** while you build. Drafts are invisible to the reviewer 2. **The review panel** (`.github/labels.conf`'s `panel=` line):
bots on purpose. `cluade-reviewer-andresmgsl`, `codex-reviewer-andresmgsl`,
3. **When it's ready**: mark ready-for-review and request all three bots — `grok-reviewer-andresmgsl`, `kimi-reviewer-andresmgsl`
`claude-bot-andresmgsl`, `codex-bot-andresmgsl`, `grok-bot-andresmgsl`. the required verdicts for a PR are the panel minus its author. The
They poll roughly every 15 minutes. maintainer (`danmt`) takes the last word and merges.
4. **Rounds are answered whole.** Wait until all three have reviewed, then 3. **Checks must be green**: `shellcheck`, `bash test/cli.sh` and
answer the entire round in a **single reply**, push the fixes, and
re-request the bots that didn't approve. Prefer verification over
argument: a test settles what a comment thread can't.
5. **Reviews end in a verdict.** A reviewer — bot or human — either
**approves** or **requests changes**, never a bare comment. A
comment-only review is a non-verdict: it doesn't say whether the round
passed, and the state machine (and anyone scanning the board) has to
guess. The verdict carries *blockingness only*, the body carries the
feedback: non-blocking nits ride an **approval** and the author addresses
them at their discretion; anything blocking — including a question that
gates the verdict — is **request changes**, saying what unblocks it. The
reconciler treats a comment-only review as not-approved, so commenting
without a verdict only stalls the PR. The machine never reads review
bodies: when a comment-only reviewer's line is really an agreement, that
judgment belongs to the **author** — escalate by requesting the
maintainer's review (step 6), and the reconciler flips the label on that
request, because an explicit request is a fact it can trust.
6. **When the round passes, the author hands the PR to the maintainer** by
requesting their review — that request is what flips `state:needs-human`.
With three formal head-current approvals the labels workflow requests it
automatically; when part of the panel is comment-only, reading their
agreement is the author's judgment, so the author makes the request.
7. **Checks must be green**: `shellcheck`, `bash test/cli.sh` and
`bash test/release.sh` locally mirror what CI runs; the db dump/restore `bash test/release.sh` locally mirror what CI runs; the db dump/restore
round-trip (`test/db-integration.sh`) executes in CI where Docker is round-trip (`test/db-integration.sh`) executes in CI where Docker is
present. present. The release guards (`changelog-armed`, `changelog-monotonic`,
8. **Feature PRs land their changelog entry as part of the PR** (box's `changelog-assembled`, `drill-recorded`, `runner-isolated`, `docs-sync`)
convention): add it under `CHANGELOG.md`'s `## Unreleased` heading — run as ceremony's pinned actions.
that section becomes the release notes verbatim when a release is cut. 4. **Feature PRs land their changelog entry as part of the PR**: write
`changelog.d/<issue>.md` — the release PR assembles those fragments into
the release notes verbatim.
## Changelog entries
Every PR that changes behaviour writes one `changelog.d/<issue>.md` fragment.
The fragment keeps the relevant `### Added` / `### Changed` / `### Fixed`
heading above its entry. One line is the whole rule — if it wraps more than
twice in your editor, cut it down.
- **Say what changed, and stop.** Why it was wrong, how it was found, what it
cost, what it implies — that belongs in the PR body and the commit message,
which is where anyone chasing the reasoning already goes. This file answers
one question: what is different in this version.
- **Any word that can be removed, is removed.**
- **Lead with the surface, not the mechanism.** "`state:needs-human` is set at
handoff" beats "the labels workflow now also wakes on `labeled`".
- **Cite the issue or PR**`(#96)` — and let the reader follow it for the
rest.
- **Mark a breaking change** with a leading `BREAKING:`.
- Group under `### Added` / `### Changed` / `### Fixed` / `### Removed`.
- No bold run-in headings, no sub-paragraphs, no code blocks, no prose essays.
Good:
- `state:needs-human` is set at handoff, not by the cron (#96)
- An unreadable check rollup no longer reads as "nothing is failing" (#90)
- BREAKING: `--class human|server` is now `--root-door closed|open` (#77)
Not an entry — that is a PR body:
- **`state:needs-human` no longer waits on the cron to become true** (#96) —
the labels workflow now also wakes on `pull_request_target: labeled` and
`unlabeled`, and the author sets it themselves when handing a PR over. A
review landing was never a trigger. There is no `pull_request_review_target`,
and on fork PRs — which is all of them here — ...
## Releasing ## Releasing
A release is a PR, and merging it is the release (#47; box#96's design, on A release is a PR, and merging it is the release. The ceremony — the two
top of #32/box#83's tag flow): doors, the decide table, the stamps, the post-release re-arm — is
heavy-duty/ceremony's machinery, consumed by reference:
[its README](https://github.com/heavy-duty/ceremony/blob/main/README.md)
is the doctrine, `.github/workflows/release.yml` here is the ≤20-line
caller pinning it, and the guards run in `ci.yml` from the same pin.
Bare `X.Y.Z` tags, no `v`; the tag's source tarball is the package
`install.sh` downloads — rig ships no other artifact. Each release deliberately
bumps and drills the `BOX_RELEASE` pin in `commands/bootstrap.sh`; it must never
float to a moving ref.
1. A small PR — `release: X.Y.Z`, carrying the `release` label — bumps What stays rig's is the **drill** — the real-hardware gate before the
`VERSION` from `X.Y.Z-dev` and stamps `CHANGELOG.md`'s Unreleased handoff of a release PR, run by `drill/drill.sh` (#105): `rig bootstrap`
section as `## X.Y.Z — YYYY-MM-DD`. **Then re-arm the file in the same converging the machine to its role twice with the second run diffed empty,
PR**: add a fresh, empty `## Unreleased` immediately above the section `test/db-integration.sh`, the runner lifecycle against a fork, a coolify
you just stamped (#66). Stamping alone *disarms* main — a PR authored install. Rig's drill asserts **convergence** (a machine reaches its role,
before the release and merged after it wrote its entry under idempotently), it runs `--host yes` with `BOX_REF=release/<box-version>` so
`## Unreleased`, and with that heading gone git files the entry under it exercises the box that will actually ship, and drills that share a
whatever now occupies the position, which is the release that already substrate share **one run ID** so the per-repo records can be joined after
shipped. It lands cleanly, with no conflict and nothing for the author the fact. The full meaning — the fixed candidate-ref pinning that dissolves
to notice, so the empty section is the only thing standing between a the box↔rig recursion, the per-version record files, the waiver rule — is
late merge and a changelog that misattributes a shipped release. No [`drills/README.md`](drills/README.md); the `drill-recorded` guard enforces
workflow does this for you: `release.yml` re-arms `VERSION`, never the the record on every release tree.
changelog. `test/release.sh` enforces the pairing — whenever `VERSION`
ends in `-dev` the top section must be `## Unreleased`. CI green on it,
same loop as any PR.
2. Merge it — that IS the ship decision. `release.yml`'s
`release-on-merge` job asserts, in order, fail-loud, creating nothing:
the merged tree's `VERSION` is non-`-dev`; this PR is the one that
changed it (a mislabeled ordinary PR fails here); the changelog section
for that version extracts non-empty; no tag or release exists yet.
Then, same job, it tags the merge commit bare `X.Y.Z` (no `v` prefix —
box's tag scheme) and publishes the GitHub release with that section as
the body. No assets — the source tarball for the tag is the package
`install.sh` downloads.
3. The release re-arms main itself: the same workflow run bumps `VERSION`
to `X.Y.(Z+1)-dev` and pushes the commit straight to main — no
follow-up PR (it opens one only if branch protection refuses the
direct push, loudly). A dev install therefore never impersonates the
release in the `versions/<v>` layout. On the *manual* tag path the
bump stays yours: open the one-line PR after publishing.
Manual fallback (and backfill): if the merge-path run fails, fix what it
named, then tag the merge commit `X.Y.Z` by hand and push the tag — the
original tag-push job still turns any correct tag into the release, and
the merge path's nothing-exists-yet assert keeps the two from
double-publishing.
## Labels — who sets what ## Labels — who sets what
The full taxonomy lives in [LABELS.md](LABELS.md). What matters day to day is The taxonomy and state machine are
who sets each kind — most of it is machinery, and hand-moving a [`.ceremony/LABELS.md`](.ceremony/LABELS.md); rig's `scope:*` rows live in
machine-owned label just gets corrected on the next pass: `.github/labels.conf` (reconciled by the labels caller) and their path map
in `.github/labeler.yml`. What matters day to day is who sets each kind —
most of it is machinery, and hand-moving a machine-owned label just gets
corrected on the next pass:
| Labels | Set by | | Labels | Set by |
|---|---| |---|---|
| `state:*` | the labels workflow ([.github/workflows/labels.yml](.github/workflows/labels.yml)) — recomputed from GitHub's own facts every 15 minutes and on PR events. Never by hand. | | `state:*` | the labels workflow ([.github/workflows/labels.yml](.github/workflows/labels.yml)) — recomputed from GitHub's own facts on PR events (label changes included) and every 15 minutes. Machine-owned, with one exception: the author sets `state:needs-human` at handoff and the workflow reconciles it. Otherwise never by hand. Exactly one per PR: *whose ball is it.* |
| `blocker:*` | the same workflow, from the same facts — *what is in the way.* Any number per PR, or none. Never by hand: applying one does not stop a merge, and removing one does not unblock anything. Fix the thing and the next sweep drops the label. |
| `stale` | the same workflow — 48h without commits, comments, or reviews. `blocked` PRs are exempt: they are quiet legitimately. | | `stale` | the same workflow — 48h without commits, comments, or reviews. `blocked` PRs are exempt: they are quiet legitimately. |
| `scope:*` on PRs | actions/labeler, from the changed paths ([.github/labeler.yml](.github/labeler.yml)). Additive — you may add more, the machine won't remove them. | | `scope:*` on PRs | actions/labeler, from the changed paths ([.github/labeler.yml](.github/labeler.yml)). Additive — you may add more, the machine won't remove them. |
| `scope:*` on issues | you, when opening or triaging — issues have no paths to derive from. | | `scope:*` on issues | you, when opening or triaging — issues have no paths to derive from. |
| `blocked`, `release` | you — automation never guesses intent. | | `blocked`, `release` | you — automation never guesses intent. |
| `merge-next` | you or the agent owning the queue. Which PR lands first is a judgement about how they conflict, so the workflow never sets it — it only **clears** it, the moment the PR stops being something a human could merge. |
| `bug` / `enhancement` / `documentation` | you, on issues only — a PR's type already lives in its title. | | `bug` / `enhancement` / `documentation` | you, on issues only — a PR's type already lives in its title. |
## Issues ## Issues

View file

@ -1,87 +0,0 @@
# Labels
How this repo uses GitHub labels. The taxonomy is shared across the
heavy-duty repos (box, rig, cast) — only the `scope:` set differs per repo,
because it names this repo's actual surfaces.
## State — who is the ball with? (PRs, exactly one)
Every open PR carries exactly one `state:` label, and it answers the only
question a board scan actually asks: *who is this PR waiting on?* The states
mirror the review loop this repo runs — PRs open as drafts, three reviewer
bots pick up ready PRs with reviews requested, each round is answered in a
single reply, and a human takes the final review.
| Label | Color | Waiting on | Enters when | Leaves when |
|---|---|---|---|---|
| `state:building` | `#FBCA04` | the coding agent, still building | PR opened as draft | marked ready + bot reviews requested |
| `state:bots-reviewing` | `#1D76DB` | the reviewer bots to finish the round | ready with reviews requested, or fixes pushed and reviews re-requested | all three bots have reviewed the round |
| `state:addressing` | `#D93F0B` | the coding agent to reply and push fixes | all bots reviewed the round, not all approved | the single round-reply is posted and fixes pushed |
| `state:needs-human` | `#8250DF` | the human reviewer | the human review is requested — by the author when the round passes, or automatically on three formal head-current approvals | merged — or changes requested, which cycles back to `state:addressing` |
`bots-reviewing` and `addressing` are deliberately distinct: staleness in the
first means *poke the bots*, staleness in the second means *the agent dropped
the ball*. Collapsing them loses exactly the information a sweep needs.
## Cross-cutting (PRs and issues)
| Label | Color | Meaning |
|---|---|---|
| `stale` | `#B60205` | No activity for 48h. Sweep-managed, never hand-applied. `state:building` + `stale` is precisely a forgotten draft. |
| `blocked` | `#6A737D` | Waiting on another PR or issue to land first. Quiet *legitimately* — the staleness sweep skips it. |
| `release` | `#0E8A16` | Release flow, versioning, and packaging work. |
## Scope — which surface? (PRs and issues, any number)
All scopes share one calm color, `#C5DEF5` — scopes locate, states alert.
| Label | Covers |
|---|---|
| `scope:bootstrap` | `commands/bootstrap.sh` — hardening a pristine server into a node |
| `scope:users` | `commands/users-*` — the class model, apply/status, close-root |
| `scope:runner` | `commands/runner-*` — GitHub runner install/remove/repoint/status |
| `scope:coolify` | `commands/coolify-*` — Coolify and its backup install |
| `scope:db` | `commands/db.sh` — dump/restore and the round-trip proof |
| `scope:installer` | `install.sh` — how rig itself lands on a machine |
## Issue types
`bug`, `enhancement`, `documentation` — issues only. PRs carry their type in
the conventional title (`feat:`, `fix:`, `docs:`), so typing a PR with a label
would just say the same thing twice, drifting apart eventually.
## Maintenance
State labels are written by automation, never by hand. Every state above is
derivable from GitHub's own facts — the draft flag, requested reviewers,
review states, push timestamps — so the labels workflow
([.github/workflows/labels.yml](.github/workflows/labels.yml)) recomputes the
state and reconciles labels statelessly, on a 15-minute cron plus PR events.
A hand-moved label is a lie waiting to happen; the workflow asserts the
effective state instead. `scope:` labels on PRs are applied from the changed
paths by actions/labeler ([.github/labeler.yml](.github/labeler.yml));
[CONTRIBUTING.md](CONTRIBUTING.md) says who sets what.
The same workflow bootstraps the taxonomy: a manual dispatch creates any
missing label idempotently. To create them by hand (needs push access):
```sh
gh label create "state:building" --color FBCA04 --description "PR is a draft — the coding agent is still building" --force
gh label create "state:bots-reviewing" --color 1D76DB --description "Waiting on the bot reviewers to finish the round" --force
gh label create "state:addressing" --color D93F0B --description "All bots reviewed — coding agent owes the single reply + fixes" --force
gh label create "state:needs-human" --color 8250DF --description "All bots approve — waiting on the human reviewer" --force
gh label create "stale" --color B60205 --description "No activity for 48h — needs a poke (sweep-managed)" --force
gh label create "blocked" --color 6A737D --description "Waiting on another PR or issue to land first" --force
gh label create "release" --color 0E8A16 --description "Release flow and version/packaging work" --force
gh label create "scope:bootstrap" --color C5DEF5 --description "bootstrap — hardening a pristine server into a node" --force
gh label create "scope:users" --color C5DEF5 --description "users-* — class model, apply/status, close-root" --force
gh label create "scope:runner" --color C5DEF5 --description "runner-* — GitHub runner lifecycle" --force
gh label create "scope:coolify" --color C5DEF5 --description "coolify-* — Coolify and backup install" --force
gh label create "scope:db" --color C5DEF5 --description "db.sh — dump/restore" --force
gh label create "scope:installer" --color C5DEF5 --description "install.sh — how rig lands on a machine" --force
# delete is not an upsert: a label that is already gone exits non-zero. Swallow
# that, so this block converges on re-run instead of erroring after first success.
for L in duplicate invalid question wontfix "help wanted" "good first issue"; do
gh label delete "$L" --yes 2>/dev/null || true
done
```

850
README.md

File diff suppressed because it is too large Load diff

View file

@ -1 +1 @@
0.2.0 0.3.2-dev

131
bin/rig
View file

@ -13,7 +13,8 @@ usage() {
usage: rig <command> [args] usage: rig <command> [args]
commands: commands:
bootstrap <control-plane|workload|runner|dev|workstation|custom> bootstrap <control-plane-server|workload-server|runner-server|staging-server|
dev-server|workstation|custom>
(--users <path> | --no-users) (--users <path> | --no-users)
[--hostname <name>] [--class <human|server>] [--host <yes|no>] [--hostname <name>] [--class <human|server>] [--host <yes|no>]
[--join <authkey|login>] [--join <authkey|login>]
@ -27,17 +28,28 @@ commands:
overrides its trait, and custom states all of them. Prompts for a overrides its trait, and custom states all of them. Prompts for a
single-use TAGGED tailnet pre-auth key (TS_AUTHKEY env overrides the single-use TAGGED tailnet pre-auth key (TS_AUTHKEY env overrides the
prompt); the key's tags are the tailnet tag, verified after join — prompt); the key's tags are the tailnet tag, verified after join —
only control-plane and workload may carry tag:server. join=login only control-plane-server and workload-server may carry tag:server.
The '-server' suffix names the FAMILY (a fleet machine, vs the '-box'
tenant roles); custom and workstation take none. join=login
(workstation) needs no key: interactive login, node must come up (workstation) needs no key: interactive login, node must come up
untagged. Run as root. untagged. Run as root.
bootstrap <claude|codex|grok|staging> [--user <name>] bootstrap --undo
The box TENANT roles: converge a box-minted guest. claude|codex|grok Leave the tailnet only when /etc/rig/role proves rig performed the
land the agent toolbelt (git, gh, tmux, docker), the agent's CLI on join, then remove that marker. Refuses for pre-existing or old unknown
the system PATH, and the agent-context file (with the box#80 guard: joins, and while a GitHub runner is installed. Run as root.
never run box setup-host inside a box). staging lands box#69's server bootstrap <role>-box [--user <name>]
posture — docker + sshd hardening; its tailnet join stays operator-run The box TENANT roles: converge a box-minted guest. The '-box' suffix
via 'rig bootstrap workload'. Creds-free and non-interactive: box names the family (a guest, vs the '-server' machine roles above).
auto-runs these at mint. Run as root, inside the box. The agent tenants (claude-box, codex-box, grok-box, kimi-box, …) are
DEFINED in the heavy-duty/rig-templates registry — resolved via
RIG_TEMPLATES_DIR > RIG_TEMPLATES_REF > the in-tree pin — and land
the agent toolbelt (git, gh, tmux, docker), the agent's CLI on the
system PATH, and the agent-context file (with the box#80 guard:
never run box setup-host inside a box). staging-box, in rig's own
tree, lands box#69's server posture — docker + sshd hardening; its
tailnet join stays operator-run via 'rig bootstrap workload-server'.
Creds-free and non-interactive: box auto-runs these at mint. Run as
root, inside the box.
coolify install --version <pin> coolify install --version <pin>
Pinned Coolify install (AUTOUPDATE=false). Control-plane box only. Pinned Coolify install (AUTOUPDATE=false). Control-plane box only.
coolify backup install [options] coolify backup install [options]
@ -50,6 +62,15 @@ commands:
writes a gzipped SQL artifact (--no-owner --no-acl, so it restores writes a gzipped SQL artifact (--no-owner --no-acl, so it restores
onto a different instance); `restore` loads one back, connecting as onto a different instance); `restore` loads one back, connecting as
the container's own superuser, behind a confirm gate. Run as root. the container's own superuser, behind a confirm gate. Run as root.
platform
What is this machine: hostname, OS, kernel, CPU, memory, disk and
virtualization, then rig's own provenance (which rig, when, and the
role marker bootstrap wrote). Computed at run time from /proc, uname,
/etc/os-release, df and systemd-detect-virt and stored NOWHERE — a
spec goes stale the moment someone adds RAM, so there is nothing to
go stale here. Writes nothing, needs no root, makes no network call,
and therefore also runs on a pristine Debian box rig has never
bootstrapped, where the provenance block reads 'not bootstrapped'.
runner install --repo <owner/repo> [options] runner install --repo <owner/repo> [options]
GitHub Actions runner as a systemd service under an unprivileged GitHub Actions runner as a systemd service under an unprivileged
user — outbound-only, no Docker. Prompts for the short-lived user — outbound-only, no Docker. Prompts for the short-lived
@ -66,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,
@ -78,6 +116,19 @@ commands:
Shut root SSH on a class=human box once an admin key works. Refuses Shut root SSH on a class=human box once an admin key works. Refuses
on class=server — root there is the control plane's automation door — on class=server — root there is the control plane's automation door —
and while no admin holds a key. Run as root. and while no admin holds a key. Run as root.
template-lint <role-dir>...
Validate tenant-role definitions (the heavy-duty/rig-templates
shape): template.env against the allowlist (data, never sourced),
install.sh present with a shebang, creds.md non-blank. Every refusal
names the failing key. The registry repo's CI runs this on every PR;
no root, no network, no writes.
manifest [<key>]
Print /etc/rig/manifest — which rig converged this machine and when
(bootstrapped_by/_at pin the FIRST convergence forever; converged_by/_at
name the latest). With a key, print that value alone for shell callers.
Written by 'rig bootstrap' only; read-only here, and no root needed.
Provenance is DECIDED, so it is stored; the machine's specs are
OBSERVED, so they are not — those are 'rig platform'.
versions versions
List the installed rig versions — install.sh lands each one side by List the installed rig versions — install.sh lands each one side by
side at <root>/versions/<v>, a 'current' symlink tracks the default side at <root>/versions/<v>, a 'current' symlink tracks the default
@ -101,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
} }
@ -237,7 +290,12 @@ uninstall_confirm() { # $1 = question
fi fi
local reply local reply
printf 'rig: %s? [y/N] ' "$1" printf 'rig: %s? [y/N] ' "$1"
read -r reply # `|| reply=""` is load-bearing under `set -e`: Ctrl-D makes read return
# non-zero, and as a plain-statement call site this function would die HERE,
# before the case — the abort would be real but completely silent. Empty
# falls through to the `*)` arm, so EOF aborts with exactly one message,
# the same spelling commands/db.sh uses for the same [y/N] shape.
read -r reply || reply=""
case "$reply" in y|Y|yes|YES|Yes) return 0 ;; *) die "aborted." ;; esac case "$reply" in y|Y|yes|YES|Yes) return 0 ;; *) die "aborted." ;; esac
} }
@ -374,6 +432,10 @@ case "$cmd" in
;; ;;
esac esac
;; ;;
platform)
shift
exec "$ROOT/commands/platform.sh" "$@"
;;
runner) runner)
shift shift
sub="${1:-}" sub="${1:-}"
@ -400,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:-}"
@ -422,6 +515,14 @@ case "$cmd" in
;; ;;
esac esac
;; ;;
template-lint)
shift
exec "$ROOT/commands/template-lint.sh" "$@"
;;
manifest)
shift
exec "$ROOT/commands/manifest.sh" "$@"
;;
versions) versions)
shift shift
cmd_versions "$@" cmd_versions "$@"

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)

10
changelog.d/README.md Normal file
View file

@ -0,0 +1,10 @@
# changelog.d/ — the next release's section, one fragment per issue
Machine-assembled by `bin/changelog-assemble` (#112): every PR that changes
behavior writes one file here — `<issue>.md`, the exact prose that will be
published, nothing else — and the release PR folds them all into the next
`## X.Y.Z — DATE` section of `CHANGELOG.md`, consuming them. Distinct
filenames never conflict, which is this directory's whole reason to exist.
This README is the marker that keeps the directory tracked when it holds no
fragments (#112 D1) — `changelog-armed` refuses a tree without it; do not
delete it.

View file

@ -1,27 +1,42 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# rig bootstrap <claude|codex|grok|staging> — the box TENANT roles: what a # rig bootstrap <role>-box — the box TENANT roles ('-box' names the family: a
# box-minted guest becomes (issue #31). box mints the thin, creds-free seed # guest, vs the '-server' machine roles): what a box-minted guest becomes
# (base image, user, rig preinstalled — heavy-duty/box#81); rig converges the # (issue #31). box mints the thin, creds-free seed (base image, user, rig
# tenant content that used to live in the templates' cloud-init, idempotent and # preinstalled — heavy-duty/box#81); rig converges the tenant content that
# effective-state asserted, so an EXISTING box can be re-run to a new spec # used to live in the templates' cloud-init, idempotent and effective-state
# instead of re-minted. One mechanism, parameterized per tenant through # asserted, so an EXISTING box can be re-run to a new spec instead of
# lib/tenant-config.sh — never four hand-maintained copies. # re-minted.
#
# One MECHANISM, parameterized per tenant by a fetched DEFINITION (#110): the
# agent-tenant registry lives in heavy-duty/rig-templates — one directory per
# role (template.env, install.sh, creds.md), resolved through lib/templates.sh
# (RIG_TEMPLATES_DIR > RIG_TEMPLATES_REF > the in-tree pin) — so adding a
# tenant is a data PR there, never an edit here (#109 is the scar: adding
# kimi, pure data, meant editing six files in this repo). staging-box is the
# one in-tree tenant: it is mechanism-adjacent (sshd hardening, docker — no
# agent, no CLI, no context file), so it converges from rig's own tree.
# #
# Creds-free BY CONTRACT: box auto-runs these at mint ('box exec … rig # Creds-free BY CONTRACT: box auto-runs these at mint ('box exec … rig
# bootstrap claude'), so every path here is non-interactive and nothing joins # bootstrap claude-box'), so every path here is non-interactive and nothing joins
# or admits — no tailnet, no keys, no prompts. staging's tailnet join stays # or admits — no tailnet, no keys, no prompts. That is also why the registry
# operator-run ('rig bootstrap workload' through 'box shell'), exactly the # fetch is UNAUTHENTICATED: a mint holds nothing to authenticate with.
# creds split box#69 designed. # staging-box's tailnet join stays operator-run ('rig bootstrap
# workload-server' through 'box shell'), exactly the creds split box#69
# designed.
# Convergent: safe to re-run; a second run changes nothing. # Convergent: safe to re-run; a second run changes nothing.
set -euo pipefail 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/tenant-config.sh # shellcheck source=SCRIPTDIR/lib/templates.sh
. "$HERE/lib/tenant-config.sh" # tenant_user / tenant_context_path / 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 . "$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 tenant) . "$HERE/lib/sshd.sh" # harden_sshd (the staging-box tenant)
# shellcheck source=SCRIPTDIR/lib/manifest.sh
. "$HERE/lib/manifest.sh" # manifest_stamp — provenance, written beside the marker
log() { printf 'rig-bootstrap: %s\n' "$*"; } log() { printf 'rig-bootstrap: %s\n' "$*"; }
warn() { printf 'rig-bootstrap: WARNING: %s\n' "$*" >&2; } warn() { printf 'rig-bootstrap: WARNING: %s\n' "$*" >&2; }
@ -29,28 +44,39 @@ die() { printf 'rig-bootstrap: ERROR: %s\n' "$1" >&2; exit "${2:-1}"; }
usage() { usage() {
cat <<'EOF' cat <<'EOF'
usage: rig bootstrap <claude|codex|grok|staging> [--user <name>] usage: rig bootstrap <role>-box [--user <name>]
Box TENANT roles — what a box-minted guest becomes. box mints the thin, Box TENANT roles — what a box-minted guest becomes. box mints the thin,
creds-free seed (base image, user, rig preinstalled); this converges the creds-free seed (base image, user, rig preinstalled); this converges the
tenant on top, and re-runs converge an existing box to a new spec. tenant on top, and re-runs converge an existing box to a new spec.
claude|codex|grok the agent tenants: base tooling (git, gh, tmux, …), <role>-box an agent tenant DEFINED IN THE REGISTRY
(heavy-duty/rig-templates — claude-box, codex-box,
grok-box, kimi-box, …): base tooling (git, gh, tmux, …),
docker, the agent's CLI on the system PATH, and the docker, the agent's CLI on the system PATH, and the
agent-context file — including the box#80 guard: never agent-context file — including the box#80 guard: never
run `box setup-host` or the drill inside a box. run `box setup-host` or the drill inside a box.
staging the server tenant (box#69's posture): docker + sshd staging-box the server tenant (box#69's posture), in rig's own
hardening. The tailnet workload join is deliberately tree: docker + sshd hardening. The tailnet workload
NOT here — it holds a credential, so it stays join is deliberately NOT here — it holds a credential,
operator-run: `box shell``sudo rig bootstrap so it stays operator-run: `box shell``sudo rig
workload` with a tagged pre-auth key. bootstrap workload-server` with a tagged pre-auth key.
--user <name> the tenant user the box seed created (default: the --user <name> the tenant user the box seed created (default: the
role's name; staging defaults to `ops`) definition's USER; staging-box defaults to `ops`)
The registry source is three knobs, precedence high to low:
RIG_TEMPLATES_DIR a local folder (no fetch — the offline/test path, and
"try a template before it exists anywhere")
RIG_TEMPLATES_REF a ref of RIG_TEMPLATES_REPO (default
heavy-duty/rig-templates), fetched as a tarball
(neither set) the ref pinned in rig's tree (lib/templates.sh
RIG_TEMPLATES_PIN — bumped by ordinary rig PR, so a
rig release freezes the mechanism+registry pair)
Tenant roles are creds-free and non-interactive by contract — box auto-runs Tenant roles are creds-free and non-interactive by contract — box auto-runs
them at mint (`box exec … rig bootstrap claude`). They take none of the them at mint (`box exec … rig bootstrap claude-box`). They take none of the
machine-role traits (--hostname/--class/--host/--join): a tenant is a guest, machine-role traits (--hostname/--root-door/--host/--join): a tenant is a guest,
not a tailnet machine. Run as root, inside the box. not a tailnet machine. Run as root, inside the box.
EOF EOF
} }
@ -58,36 +84,48 @@ 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:-}"
case "$ROLE" in case "$ROLE" in
claude|codex|grok|staging) shift ;; staging-box) shift ;;
*-box)
# The family suffix is the whole gate here — WHICH '-box' roles exist is
# the resolved registry's fact, checked below, so a template added to the
# registry is mintable with zero code changes in rig (#110).
shift ;;
-h|--help) usage; exit 0 ;; -h|--help) usage; exit 0 ;;
"") usage >&2; die "tenant role required (claude|codex|grok|staging)" 2 ;; "") usage >&2; die "tenant role required (a '-box' role from the template registry, or staging-box)" 2 ;;
*) die "unknown tenant role: $ROLE (want claude|codex|grok|staging)" 2 ;; *) die "unknown tenant role: $ROLE — tenant roles carry the '-box' family suffix (#76); the machine roles are control-plane-server|workload-server|runner-server|staging-server|dev-server|workstation|custom" 2 ;;
esac esac
# The suffix rule above admits ANY '-box' name, so the charset is pinned
# before the name is ever used as a path component: a crafted role dies HERE,
# never in a registry lookup (the valid_version discipline, bin/rig).
[[ "$ROLE" =~ ^[a-z][a-z0-9-]*-box$ ]] \
|| die "invalid tenant role name: '$ROLE' — must match ^[a-z][a-z0-9-]*-box\$" 2
TENANT_USER="$(tenant_user "$ROLE")" TENANT_USER_OVERRIDE=""
while [ $# -gt 0 ]; do while [ $# -gt 0 ]; do
case "$1" in case "$1" in
-h|--help) usage; exit 0 ;; -h|--help) usage; exit 0 ;;
--user) --user)
[ $# -ge 2 ] || die "--user needs a value" 2 [ $# -ge 2 ] || die "--user needs a value" 2
TENANT_USER="$2"; shift 2 ;; TENANT_USER_OVERRIDE="$2"; shift 2
--hostname|--class|--host|--join) # Same charset the users file enforces, for the same reasons (a leading
# '-' reads as a usermod flag; '|', ':' corrupt things downstream).
# Checked HERE, at parse — the definition's USER is checked by the
# parser — so the refusal needs no registry and no network.
[[ "$TENANT_USER_OVERRIDE" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]] \
|| die "invalid user: '$TENANT_USER_OVERRIDE' — must match ^[a-z_][a-z0-9_-]{0,31}\$" 2 ;;
--hostname|--root-door|--host|--join)
# The machine-role traits, refused with a story rather than "unknown # The machine-role traits, refused with a story rather than "unknown
# flag": a tenant is a guest, not a tailnet machine — its shape comes # flag": a tenant is a guest, not a tailnet machine — its shape comes
# from the box seed, and the one trait-shaped thing a staging guest # from the box seed, and the one trait-shaped thing a staging-box guest
# eventually does (join the tailnet as a workload) is deliberately not # eventually does (join the tailnet as a workload) is deliberately not
# here: it holds a credential, so it stays operator-run. # here: it holds a credential, so it stays operator-run.
die "tenant roles have no traits: $1 belongs to the machine roles (control-plane|workload|runner|dev|workstation|custom). A tenant box's shape comes from its seed; staging's tailnet join is operator-run via 'rig bootstrap workload'." 2 ;; die "tenant roles have no traits: $1 belongs to the machine roles (control-plane-server|workload-server|runner-server|staging-server|dev-server|workstation|custom). A tenant box's shape comes from its seed; staging-box's tailnet join is operator-run via 'rig bootstrap workload-server'. The METAL that hosts these guests is 'rig bootstrap staging-server'." 2 ;;
--ts-tag) --ts-tag)
[ $# -ge 2 ] && shift [ $# -ge 2 ] && shift
die "--ts-tag is gone and tenant roles never join the tailnet anyway. staging's join is operator-run via 'rig bootstrap workload', where the tag comes from the pre-auth key." 2 ;; die "--ts-tag is gone and tenant roles never join the tailnet anyway. staging-box's join is operator-run via 'rig bootstrap workload-server', where the tag comes from the pre-auth key." 2 ;;
*) die "unknown flag: $1" 2 ;; *) die "unknown flag: $1" 2 ;;
esac esac
done done
# Same charset the users file enforces, for the same reasons (a leading '-'
# reads as a usermod flag; '|', ':' corrupt things downstream).
[[ "$TENANT_USER" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]] \
|| die "invalid user: '$TENANT_USER' — must match ^[a-z_][a-z0-9_-]{0,31}\$" 2
# --- guards ------------------------------------------------------------------ # --- guards ------------------------------------------------------------------
# A tenant role converges a box GUEST. A box already carrying a machine-role # A tenant role converges a box GUEST. A box already carrying a machine-role
@ -97,32 +135,82 @@ done
# markers (repo precedent: the coolify marker warning). Two refusals, one # markers (repo precedent: the coolify marker warning). Two refusals, one
# tolerance: # tolerance:
# - host=yes → refuse, every tenant: a VM HOST is the opposite of a guest. # - host=yes → refuse, every tenant: a VM HOST is the opposite of a guest.
# Names the staging rename out loud — before #31, `staging` was the VM-host # Names the staging PAIR out loud, because whoever lands here has the two
# PRESET; that shape is now spelled through the traits. # halves confused: the metal is `staging-server`, the guest `staging-box`.
# - class= (agent tenants) → refuse: an agent box is never a tailnet machine. # - a root-door policy (agent tenants) → refuse: an agent box is never a
# - class=server with host=no (staging only) → PROCEED, and leave the marker # tailnet machine.
# alone: that is the staging guest AFTER its operator-run workload join, and # - root-door=open with host=no (staging-box only) → PROCEED, and leave the
# marker alone: that is the guest AFTER its operator-run workload join, and
# re-converging docker+hardening on it is exactly what convergence is for. # re-converging docker+hardening on it is exactly what convergence is for.
# ONLY that shape — any other class (say class=human, via `custom`) is a # ONLY that shape — any other door policy (say root-door=closed, via
# machine rig built on purpose, and staging hardening it with server rules # `custom`) is a machine rig built on purpose, and staging-box hardening it
# would die with server-specific messaging on a box that was never one. # with open-door rules would die with root-door=open-specific messaging on a
# box that was never one.
#
# "Names a root-door policy" IS this guard's "is this a machine marker?" test —
# a tenant marker deliberately carries none — so it must be asked through
# root_door_of, which reads the pre-#77 `class=` spelling as well as the current
# `root-door=` one. Pattern-matching the marker for one spelling is what this
# guard used to do, and after the rename that is a fail-OPEN bug in the
# dangerous direction: every box bootstrapped in the OTHER vocabulary stops
# looking like a machine, the refusals below never fire, and a tenant converge
# clobbers a real fleet box's marker. The resolver is the only reader.
MARKER_PATH="${RIG_ROLE_MARKER:-/etc/rig/role}" MARKER_PATH="${RIG_ROLE_MARKER:-/etc/rig/role}"
EXISTING_MARKER="$(read_role_marker "$MARKER_PATH")" EXISTING_MARKER="$(read_role_marker "$MARKER_PATH")"
EXISTING_ROOT_DOOR="$(root_door_of "$EXISTING_MARKER")"
case "$EXISTING_MARKER" in case "$EXISTING_MARKER" in
*host=yes*) *host=yes*)
die "this box hosts VMs (${EXISTING_MARKER}) — a tenant role converges box GUESTS, never the host under them. Note: before rig#31, 'staging' was the VM-host preset; that shape is now 'rig bootstrap custom --class server --host yes --join authkey' (or 'dev --class server')." ;; die "this box hosts VMs (${EXISTING_MARKER}) — a tenant role converges box GUESTS, never the host under them. You want the other half of the pair: the metal is 'rig bootstrap staging-server', and the guests it mints are 'staging-box'." ;;
*class=*) esac
if [ "$ROLE" != "staging" ]; then if [ -n "$EXISTING_ROOT_DOOR" ]; then
if [ "$ROLE" != "staging-box" ]; then
die "this box already carries a machine role (${EXISTING_MARKER}) — the agent tenants converge box guests, never tailnet machines. If this really is a guest, remove ${MARKER_PATH} and re-run." die "this box already carries a machine role (${EXISTING_MARKER}) — the agent tenants converge box guests, never tailnet machines. If this really is a guest, remove ${MARKER_PATH} and re-run."
fi fi
case "$EXISTING_MARKER" in # A `conflict` marker lands here too, and refuses: a box whose two door
*class=server*) ;; # claims disagree is emphatically not the one shape staging-box tolerates.
*) if [ "$EXISTING_ROOT_DOOR" != "open" ]; then
die "this box carries a non-server machine role (${EXISTING_MARKER}) — staging tolerates only the workload-joined guest (class=server host=no). If this really is a staging guest, remove ${MARKER_PATH} and re-run." ;; die "this box carries a machine role whose root door is not open (${EXISTING_MARKER}) — staging-box tolerates only the workload-joined guest (root-door=open host=no, or its pre-#77 spelling class=server). If this really is a staging-box guest, remove ${MARKER_PATH} and re-run."
esac ;; fi
esac fi
# --- the definition ----------------------------------------------------------
# Resolved and parsed BEFORE the root check (but after the marker guards,
# which need no definition and must stay refusable with no registry in
# reach), so the two refusals a definition can earn — unknown role (listing
# what the resolved source actually contains) and malformed data (naming the
# failing key) — are testable non-root, offline, via RIG_TEMPLATES_DIR
# fixtures. The parse is the mint's
# own guard, deliberately duplicating the registry CI's lint: CI protects the
# registry, this protects a mint served through RIG_TEMPLATES_REPO/_DIR that
# CI never saw. template.env is parsed, NEVER sourced — a definition cannot
# execute arbitrary shell through its data file; install.sh is the one
# deliberately executable part, and it runs only after the root check below.
trap '[ -n "$TEMPLATES_TMP" ] && rm -rf "$TEMPLATES_TMP"' EXIT
TPL_DIR=""
if [ "$ROLE" = "staging-box" ]; then
TENANT_USER="${TENANT_USER_OVERRIDE:-ops}" # box#69's ops
else
templates_resolve \
|| die "cannot resolve the template registry ($(templates_source_desc)) — see above" 2
TPL_DIR="$REGISTRY_DIR/$ROLE"
if [ ! -f "$TPL_DIR/template.env" ]; then
die "unknown tenant role: $ROLE — the resolved registry ($(templates_source_desc)) defines: $(templates_roles "$REGISTRY_DIR" | tr '\n' ' ')— and staging-box is in rig's own tree. A misconfigured RIG_TEMPLATES_REPO/_REF/_DIR looks exactly like this; check the source before the spelling." 2
fi
template_parse_env "$TPL_DIR/template.env" \
|| die "invalid definition for $ROLE in $(templates_source_desc) — the failing key is named above. The registry's CI lints every PR ('rig template-lint'); a malformed definition reaching a mint means the source above was never linted." 2
TENANT_USER="${TENANT_USER_OVERRIDE:-$TPL_USER}"
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.
@ -156,34 +244,60 @@ 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})"
apt-get update -qq apt-get update -qq
case "$ROLE" in if [ "$ROLE" = "staging-box" ]; then
claude)
# The claude tenant keeps zsh (its shell UX ships with the box); the
# remaining list is the shared agent toolbelt the templates carried.
apt-get install -y -qq git gh curl ca-certificates gnupg ripgrep jq tmux age unzip build-essential zsh ;;
codex|grok)
apt-get install -y -qq git gh curl ca-certificates gnupg ripgrep jq tmux age unzip build-essential ;;
staging)
# openssh-server: the hardening drop-in below targets /etc/ssh/sshd_config.d/, # openssh-server: the hardening drop-in below targets /etc/ssh/sshd_config.d/,
# which only exists once the package is installed — pristine container/VM # which only exists once the package is installed — pristine container/VM
# images (and thin seeds) do not ship it. # images (and thin seeds) do not ship it.
apt-get install -y -qq curl ca-certificates tmux openssh-server ;; apt-get install -y -qq curl ca-certificates tmux openssh-server
esac else
# 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
# 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
apt-get install -y -qq git gh curl ca-certificates gnupg ripgrep jq tmux age unzip build-essential cron $TPL_APT_EXTRAS
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
# the operator's git credential lands. # the operator's git credential lands.
command -v tmux >/dev/null 2>&1 || die "tmux missing after package install — 'box tmux' (box#65) needs it" command -v tmux >/dev/null 2>&1 || die "tmux missing after package install — 'box tmux' (box#65) needs it"
if [ "$ROLE" != "staging" ]; 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 ------------------------------------------------------------------
# Every tenant gets docker (the templates all carried it; staging's guests run # Every tenant gets docker (the templates all carried it; staging-box's workloads run
# their workloads in it). Docker's own installer, convergence-guarded — its # their workloads in it). Docker's own installer, convergence-guarded — its
# script is not a no-op when docker exists, so rig supplies the guard. # script is not a no-op when docker exists, so rig supplies the guard.
if ! command -v docker >/dev/null 2>&1; then if ! command -v docker >/dev/null 2>&1; then
@ -214,17 +328,19 @@ else
warn "no docker group after install — skipping the ${TENANT_USER} group add; check docker's install" warn "no docker group after install — skipping the ${TENANT_USER} group add; check docker's install"
fi fi
# --- node (claude, codex) ---------------------------------------------------- # --- node (definitions carrying NEEDS_NODE="yes") ----------------------------
# Codex is an npm global needing Node 22+ (the SCOPED @openai/codex — verified # An npm-installed CLI needs Node 22+ (codex — the SCOPED @openai/codex,
# upstream when the template was written); the claude tenant ships node as part # verified upstream when the template was written); claude ships node as part
# of its toolbelt, same pin. grok's CLI is a self-contained binary: no node. # of its toolbelt, same pin. Whether a tenant needs it is the DEFINITION's
# fact (NEEDS_NODE), never a role list here — grok's CLI is a self-contained
# binary and kimi's is uv-managed Python, so both say no.
node_ok() { node_ok() {
command -v node >/dev/null 2>&1 || return 1 command -v node >/dev/null 2>&1 || return 1
local major local major
major="$(node --version 2>/dev/null | sed -E 's/^v([0-9]+)\..*$/\1/')" major="$(node --version 2>/dev/null | sed -E 's/^v([0-9]+)\..*$/\1/')"
[ "${major:-0}" -ge 22 ] 2>/dev/null [ "${major:-0}" -ge 22 ] 2>/dev/null
} }
if [ "$ROLE" = "claude" ] || [ "$ROLE" = "codex" ]; then if [ "$ROLE" != "staging-box" ] && [ "$TPL_NEEDS_NODE" = "yes" ]; then
if node_ok; then if node_ok; then
log "node $(node --version) already present" log "node $(node --version) already present"
else else
@ -236,46 +352,53 @@ if [ "$ROLE" = "claude" ] || [ "$ROLE" = "codex" ]; then
fi fi
# --- the agent CLI ----------------------------------------------------------- # --- the agent CLI -----------------------------------------------------------
# Per-agent install, shared discipline: install only when the CLI is absent # Per-definition install, shared discipline: install only when the CLI is
# (upgrades are the CLI's own business), then put it on the SYSTEM path — # absent (upgrades are the CLI's own business) — presence is CLI_SRC when the
# 'box exec <box> -- <cli> …' runs a NON-interactive shell that reads no rc # definition names one, `command -v` when it does not (an npm global's path
# files, so a PATH export alone is invisible to it (the #15 lesson) — and # is the prefix's fact, not the data file's) — then put it on the SYSTEM
# assert it ANSWERS as the tenant user: a CLI that exists but cannot run is # path: 'box exec <box> -- <cli> …' runs a NON-interactive shell that reads
# what cost the last drill (the grok template's scar). # no rc files, so a PATH export alone is invisible to it (the #15 lesson).
# And assert it ANSWERS as the tenant user: a CLI that exists but cannot run
# is what cost the last drill (the grok-box template's scar).
#
# install.sh — the definition's one executable part — runs AS ROOT with the
# tenant named in its environment (TENANT_USER/TENANT_HOME/TENANT_GROUP/ROLE);
# each definition drops to the tenant user itself (runuser -l) where the
# vendor's layout demands it, because some installs are inherently root's
# (codex's npm -g writes the global prefix). This is the trade #110 states in
# bold — a registry definition executes as root inside every future mint —
# and it is why install.sh diffs there are the highest-trust review surface
# in the org, why the default ref is a reviewed in-tree pin, and why the data
# file beside it is parsed rather than sourced.
CLI="" CLI_SRC="" CLI="" CLI_SRC=""
case "$ROLE" in if [ "$ROLE" != "staging-box" ]; then
claude) CLI="$TPL_CLI_NAME"
CLI=claude CLI_SRC="$TENANT_HOME/.local/bin/claude" # '~/' in CLI_SRC is data — expanded to the tenant home HERE, by string
if [ ! -e "$CLI_SRC" ]; then # substitution, never by the shell (hence the literal quoted tilde, SC2088).
log "installing the Claude Code CLI as ${TENANT_USER}" # shellcheck disable=SC2088
runuser -l "$TENANT_USER" -c 'curl -fsSL https://claude.ai/install.sh | bash' case "$TPL_CLI_SRC" in
else '~/'*) CLI_SRC="$TENANT_HOME/${TPL_CLI_SRC#'~/'}" ;;
log "claude CLI already installed" *) CLI_SRC="$TPL_CLI_SRC" ;;
fi ;;
codex)
CLI=codex
if ! command -v codex >/dev/null 2>&1; then
log "installing the Codex CLI (npm global)"
npm install -g @openai/codex
else
log "codex CLI already installed"
fi
CLI_SRC="$(npm prefix -g)/bin/codex" ;;
grok)
# The OFFICIAL installer (x.ai/cli/install.sh): installs the CLI as `grok`,
# a SYMLINK under $HOME/.grok/bin pointing into its versioned download dir.
# Run it AS the tenant user, never root: a symlink into root's 0700 home
# would be a CLI that exists and cannot run.
CLI=grok CLI_SRC="$TENANT_HOME/.grok/bin/grok"
if [ ! -e "$CLI_SRC" ]; then
log "installing the Grok CLI as ${TENANT_USER}"
runuser -l "$TENANT_USER" -c 'curl -fsSL https://x.ai/cli/install.sh | bash'
else
log "grok CLI already installed"
fi ;;
staging) ;; # no agent lives on the staging tenant
esac esac
if [ -n "$CLI" ]; then installed=""
if [ -n "$CLI_SRC" ]; then
[ -e "$CLI_SRC" ] && installed=1
elif command -v "$CLI" >/dev/null 2>&1; then
installed=1
fi
if [ -z "$installed" ]; then
log "installing the ${CLI} CLI (${ROLE}'s install.sh)"
TENANT_USER="$TENANT_USER" TENANT_HOME="$TENANT_HOME" \
TENANT_GROUP="$TENANT_GROUP" ROLE="$ROLE" \
bash "$TPL_DIR/install.sh" \
|| die "${ROLE}'s install.sh failed — the definition is $(templates_source_desc)"
else
log "${CLI} CLI already installed"
fi
if [ -z "$CLI_SRC" ]; then
CLI_SRC="$(command -v "$CLI" 2>/dev/null || true)"
[ -n "$CLI_SRC" ] || die "the ${CLI} installer put no '${CLI}' on root's PATH and the definition names no CLI_SRC — upstream layout changed?"
fi
[ -e "$CLI_SRC" ] || die "the ${CLI} installer produced no ${CLI_SRC} — upstream layout changed?" [ -e "$CLI_SRC" ] || die "the ${CLI} installer produced no ${CLI_SRC} — upstream layout changed?"
ln -sf "$CLI_SRC" "/usr/local/bin/$CLI" ln -sf "$CLI_SRC" "/usr/local/bin/$CLI"
# One capture serves both the assert and the log line; emptiness IS the # One capture serves both the assert and the log line; emptiness IS the
@ -284,27 +407,23 @@ if [ -n "$CLI" ]; then
[ -n "$CLI_VER" ] || die "'$CLI --version' does not answer for ${TENANT_USER} — the CLI landed but cannot run; check /usr/local/bin/$CLI and its target" [ -n "$CLI_VER" ] || die "'$CLI --version' does not answer for ${TENANT_USER} — the CLI landed but cannot run; check /usr/local/bin/$CLI and its target"
log "${CLI} CLI on the system PATH and answering (${CLI_VER})" log "${CLI} CLI on the system PATH and answering (${CLI_VER})"
# The interactive-shell PATH exports the templates carried, converged as # The interactive-shell PATH export the templates carried, converged as a
# literal rc lines (written once, never duplicated). Single quotes are the # literal rc line (written once, never duplicated). The definition's
# point: the line must expand in the USER's shell, not here. # PATH_LINE is DATA, appended verbatim: it must expand in the USER's
# shellcheck disable=SC2016 # shell, not here.
case "$ROLE" in append_line_once "$TENANT_HOME/.bashrc" "$TPL_PATH_LINE"
claude)
append_line_once "$TENANT_HOME/.bashrc" 'export PATH="$HOME/.local/bin:$PATH"' ;;
codex)
append_line_once "$TENANT_HOME/.bashrc" 'export PATH="$(npm prefix -g)/bin:$PATH"' ;;
grok)
append_line_once "$TENANT_HOME/.bashrc" 'export PATH="$HOME/.grok/bin:$PATH"' ;;
esac
fi fi
# --- the agent-context file -------------------------------------------------- # --- the agent-context file --------------------------------------------------
# The one file every agent reads before touching anything. Rendered from # The one file every agent reads before touching anything. The skeleton —
# lib/tenant-config.sh — the box#80 guard note ("never run box setup-host or # including the box#80 guard note ("never run box setup-host or the drill
# the drill inside a box; the box you are in is not a host you own") lives # inside a box; the box you are in is not a host you own") — is MECHANISM,
# there ONCE, for all agents, instead of copy-pasted per template. cmp-guarded # rendered from lib/templates.sh ONCE for all agents, never copy-pasted per
# like every file rig converges. # template; only the creds paragraph is the definition's (creds.md).
if CTX_PATH="$(tenant_context_path "$ROLE" "$TENANT_HOME")"; then # cmp-guarded like every file rig converges. staging-box has no agent and no
# context file.
if [ "$ROLE" != "staging-box" ]; then
CTX_PATH="$TENANT_HOME/$TPL_CONTEXT_PATH"
CTX_DIR="$(dirname "$CTX_PATH")" CTX_DIR="$(dirname "$CTX_PATH")"
if [ ! -d "$CTX_DIR" ]; then if [ ! -d "$CTX_DIR" ]; then
mkdir -p "$CTX_DIR" mkdir -p "$CTX_DIR"
@ -314,7 +433,7 @@ if CTX_PATH="$(tenant_context_path "$ROLE" "$TENANT_HOME")"; then
# its ownership is converged on every run, not only on creation. # its ownership is converged on every run, not only on creation.
chown "$TENANT_USER:$TENANT_GROUP" "$CTX_DIR" chown "$TENANT_USER:$TENANT_GROUP" "$CTX_DIR"
CTX_TMP="$(mktemp)" CTX_TMP="$(mktemp)"
render_tenant_context "$ROLE" > "$CTX_TMP" render_tenant_context "$ROLE" "$TPL_DIR/creds.md" > "$CTX_TMP"
if ! cmp -s "$CTX_TMP" "$CTX_PATH" 2>/dev/null; then if ! cmp -s "$CTX_TMP" "$CTX_PATH" 2>/dev/null; then
install -m 0644 -o "$TENANT_USER" -g "$TENANT_GROUP" "$CTX_TMP" "$CTX_PATH" install -m 0644 -o "$TENANT_USER" -g "$TENANT_GROUP" "$CTX_TMP" "$CTX_PATH"
log "agent-context file written: ${CTX_PATH}" log "agent-context file written: ${CTX_PATH}"
@ -324,50 +443,31 @@ if CTX_PATH="$(tenant_context_path "$ROLE" "$TENANT_HOME")"; then
rm -f "$CTX_TMP" rm -f "$CTX_TMP"
fi fi
# --- claude shell niceties --------------------------------------------------- # --- staging-box server posture --------------------------------------------------
# The claude template shipped zsh + oh-my-zsh + tmux mouse mode; they move with
# the tenant. oh-my-zsh is a cosmetic EXTRA: its failure warns, never aborts a
# bootstrap whose real work (CLI, context, docker) already converged.
if [ "$ROLE" = "claude" ]; then
if [ "$(getent passwd "$TENANT_USER" | cut -d: -f7)" != "/usr/bin/zsh" ]; then
chsh -s /usr/bin/zsh "$TENANT_USER"
log "login shell set to zsh for ${TENANT_USER}"
else
log "login shell already zsh for ${TENANT_USER}"
fi
if [ ! -d "$TENANT_HOME/.oh-my-zsh" ]; then
log "installing oh-my-zsh for ${TENANT_USER}"
# Single quotes on purpose: the $(...) must run in the USER's shell.
# shellcheck disable=SC2016
runuser -l "$TENANT_USER" -c 'RUNZSH=no CHSH=no sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)"' \
|| warn "oh-my-zsh install failed — cosmetic only; continuing"
else
log "oh-my-zsh already installed"
fi
# After oh-my-zsh (it rewrites .zshrc on first install).
# shellcheck disable=SC2016
append_line_once "$TENANT_HOME/.zshrc" 'export PATH="$HOME/.local/bin:$PATH"'
append_line_once "$TENANT_HOME/.tmux.conf" 'set -g mouse on'
fi
# --- staging server posture --------------------------------------------------
# box#69's posture, minus the join: docker (above) + sshd hardening, through # box#69's posture, minus the join: docker (above) + sshd hardening, through
# the SAME code the machine roles use (lib/sshd.sh) — the staging guest is a # the SAME code the machine roles use (lib/sshd.sh) — the staging-box guest is a
# workload server in waiting, and its door must never be password-open even # workload server in waiting, and its door must never be password-open even
# before the operator joins it. class=server: root SSH stays the control # before the operator joins it. root-door=open: root SSH stays the control
# plane's future automation door. # plane's future automation door.
if [ "$ROLE" = "staging" ]; then if [ "$ROLE" = "staging-box" ]; then
harden_sshd server harden_sshd open
fi fi
# --- role marker -------------------------------------------------------------- # --- role marker --------------------------------------------------------------
# Same ground truth the machine roles write, tenant-shaped: no class= (a tenant # Same ground truth the machine roles write, tenant-shaped: no root-door trait
# has no root-door policy of its own — close-root fails closed on it), and # at all (a tenant has no root-door policy of its own — close-root fails closed
# host=no so `rig users apply` box-role gating keeps working. staging SKIPS the # on it), and host=no so `rig users apply` box-role gating keeps working.
# write when a machine marker is already present: after the operator-run # staging-box SKIPS the write when a machine marker is already present: after
# workload join, the workload marker is the truer statement and rig never # the operator-run workload join, the workload marker is the truer statement and
# clobbers state a joined box earned. # rig never clobbers state a joined box earned.
if [ -z "$EXISTING_MARKER" ] || [ "${EXISTING_MARKER#*class=}" = "$EXISTING_MARKER" ]; then #
# "Is a machine marker already here?" is the same question the guard above
# asks, and is asked the same way — through root_door_of, so both the current
# `root-door=` spelling and the pre-#77 `class=` one count (#77). Testing for
# one spelling would let this write CLOBBER a marker written in the other, which
# on a joined workload box means silently replacing its root-door policy with a
# tenant line that close-root then refuses on.
if [ -z "$EXISTING_ROOT_DOOR" ]; then
MARKER_TMP="$(mktemp)" MARKER_TMP="$(mktemp)"
printf 'role=%s tenant=yes host=no\n' "$ROLE" > "$MARKER_TMP" printf 'role=%s tenant=yes host=no\n' "$ROLE" > "$MARKER_TMP"
if ! cmp -s "$MARKER_TMP" "$MARKER_PATH" 2>/dev/null; then if ! cmp -s "$MARKER_TMP" "$MARKER_PATH" 2>/dev/null; then
@ -382,9 +482,34 @@ else
log "machine role marker present (${EXISTING_MARKER}); leaving it alone" log "machine role marker present (${EXISTING_MARKER}); leaving it alone"
fi fi
# --- provenance manifest ------------------------------------------------------
# A tenant gets a manifest, in the SAME /etc/rig/manifest, through the same
# writer — and UNCONDITIONALLY, outside the marker gate above (#61's open
# question, answered here).
#
# The reason the marker needs that gate is that it holds TRAITS, and a guest's
# traits and the traits it earns after an operator-run `rig bootstrap workload`
# join are two different, competing statements about one box — so the marker
# has to pick, and it picks the truer one. Provenance has no such conflict.
# "Which rig converged this guest, and when" is a fact whichever bootstrap ran,
# and the two-pair shape composes across them exactly as designed: a staging
# guest later joined as a workload keeps the TENANT bootstrap as its birth —
# that genuinely is when this machine was first converged — and the machine
# bootstrap moves converged_* forward. Skipping the write on a joined guest
# would lose the birth stamp that only this run knows.
#
# One file, not a tenant-shaped second one: the manifest answers a question
# about the MACHINE, and a guest is a machine. The marker already carries
# `tenant=yes` for anyone who needs to know which kind.
if manifest_stamp "$(manifest_running_version "$HERE/..")"; then
log "provenance manifest written: $(manifest_path)"
else
log "provenance manifest already current"
fi
log "done — tenant ${ROLE}, user ${TENANT_USER}" log "done — tenant ${ROLE}, user ${TENANT_USER}"
if [ "$ROLE" = "staging" ]; then if [ "$ROLE" = "staging-box" ]; then
log "next (operator-run, holds a credential): box shell → sudo rig bootstrap workload --hostname <name> with a tagged pre-auth key" log "next (operator-run, holds a credential): box shell → sudo rig bootstrap workload-server --hostname <name> with a tagged pre-auth key"
else else
log "next: creds stay with the operator — ${CLI} authenticates through its own interactive login when a human decides" log "next: creds stay with the operator — ${CLI} authenticates through its own interactive login when a human decides"
fi fi

74
commands/bootstrap-undo.sh Executable file
View file

@ -0,0 +1,74 @@
#!/usr/bin/env bash
# rig bootstrap --undo — remove only off-box state rig can prove it created.
set -euo pipefail
log() { printf 'rig-bootstrap: %s\n' "$*"; }
die() { printf 'rig-bootstrap: ERROR: %s\n' "$*" >&2; exit 1; }
MARKER="${RIG_ROLE_MARKER:-/etc/rig/role}"
[ "$(id -u)" -eq 0 ] || die "must run as root"
[ -e "$MARKER" ] || die "no /etc/rig/role marker — refusing to touch the tailnet"
runner_installed=0
if [ -n "${RIG_RUNNER_DIR:-}" ]; then
[ -e "$RIG_RUNNER_DIR/.runner" ] && runner_installed=1
else
for runner_config in /home/*/actions-runner/.runner /root/actions-runner/.runner; do
[ -e "$runner_config" ] && runner_installed=1
done
compgen -G '/etc/systemd/system/actions.runner.*.service' >/dev/null \
&& runner_installed=1
fi
if [ "$runner_installed" -eq 1 ]; then
die "a GitHub runner is installed — run 'rig runner remove' first so undo does not leave a ghost runner in the repository"
fi
# The same hazard, the other forge (#109): leaving the tailnet under a live
# Forgejo runner strands a registration this box can no longer serve, and
# Forgejo has no deregistration endpoint — so the ghost it leaves is one
# somebody has to delete BY HAND in the instance's admin UI. That makes the
# refusal more load-bearing here than for GitHub, not less.
#
# RIG_FORGEJO_RUNNER_DIR mirrors RIG_RUNNER_DIR above so tests can point this
# at a fixture. The glob covers the tenant default (`ci`) and the dedicated
# account alike, because both are reachable defaults of `install --user`.
forgejo_runner_installed=0
if [ -n "${RIG_FORGEJO_RUNNER_DIR:-}" ]; then
[ -e "$RIG_FORGEJO_RUNNER_DIR/.runner" ] && forgejo_runner_installed=1
else
for runner_config in /home/*/forgejo-runner/.runner /root/forgejo-runner/.runner; do
[ -e "$runner_config" ] && forgejo_runner_installed=1
done
[ -e /etc/systemd/system/forgejo-runner.service ] && forgejo_runner_installed=1
fi
if [ "$forgejo_runner_installed" -eq 1 ]; then
die "a Forgejo runner is installed — run 'rig forgejo-runner remove' first so undo does not leave a ghost runner in the instance"
fi
join_by=""
while IFS= read -r field; do
case "$field" in
join-by=*) join_by="${field#join-by=}" ;;
esac
done < <(tr '[:space:]' '\n' < "$MARKER")
case "$join_by" in
rig) ;;
preexisting)
die "the tailnet join predates this bootstrap run (join-by=preexisting), so rig will not remove state it did not create; run 'tailscale logout' by hand if that is intended" ;;
"")
die "the role marker predates join-by provenance, so rig cannot prove it made this tailnet join and will not remove it; re-run bootstrap to write a current marker, or run 'tailscale logout' by hand" ;;
*)
die "the role marker has unknown join-by=$join_by, so rig cannot prove it made this tailnet join and will not remove it; run 'tailscale logout' by hand if that is intended" ;;
esac
# The same back-out/keep law as first-join verification: logout is earned only
# when the marker proves rig performed the join. Preserve the marker on failure
# so the operation remains retryable and never reports a half-undone machine.
if ! tailscale logout; then
die "tailscale logout failed; role marker kept so 'rig bootstrap --undo' can be retried"
fi
rm -f -- "$MARKER"
log "tailnet join removed; role marker removed"

View file

@ -10,10 +10,14 @@ HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
. "$HERE/lib/sshd.sh" # harden_sshd — shared with the staging tenant . "$HERE/lib/sshd.sh" # harden_sshd — shared with the staging tenant
# shellcheck source=SCRIPTDIR/lib/users-config.sh # shellcheck source=SCRIPTDIR/lib/users-config.sh
. "$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
. "$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
# bad --class — before apt, before the tailnet join, before a pre-auth key is # bad --root-door — before apt, before the tailnet join, before a pre-auth key is
# spent — instead of at the very end of a run the operator already paid for. # spent — instead of at the very end of a run the operator already paid for.
log() { printf 'rig-bootstrap: %s\n' "$*"; } log() { printf 'rig-bootstrap: %s\n' "$*"; }
@ -22,13 +26,19 @@ die() { printf 'rig-bootstrap: ERROR: %s\n' "$1" >&2; exit "${2:-1}"; }
usage() { usage() {
cat <<'EOF' cat <<'EOF'
usage: rig bootstrap <control-plane|workload|runner|dev|workstation|custom> usage: rig bootstrap <control-plane-server|workload-server|runner-server|
staging-server|dev-server|workstation|custom>
(--users <path> | --no-users) (--users <path> | --no-users)
[--hostname <name>] [--class <human|server>] [--hostname <name>] [--root-door <closed|open>]
[--host <yes|no>] [--join <authkey|login>] [--host <yes|no>] [--join <authkey|login>]
rig bootstrap <claude|codex|grok|staging> [--user <name>] rig bootstrap <role>-box [--user <name>]
(the box TENANT roles — see their own --help; they take (the box TENANT roles — the agent tenants come from the
no --users, see below) heavy-duty/rig-templates registry, staging-box from
rig's own tree; see their own --help — they take no
--users, see below)
rig bootstrap --undo
leave the tailnet only when the role marker proves rig
performed the join, then remove the role marker
--users the users file this box's operators come from — REQUIRED. It is --users the users file this box's operators come from — REQUIRED. It is
applied as bootstrap's last phase, exactly as `rig users apply applied as bootstrap's last phase, exactly as `rig users apply
@ -38,19 +48,23 @@ usage: rig bootstrap <control-plane|workload|runner|dev|workstation|custom>
tailnet and leaves the box with root as its only door. tailnet and leaves the box with root as its only door.
--hostname system + tailnet hostname (default: the role name; custom has --hostname system + tailnet hostname (default: the role name; custom has
no default and requires it) no default and requires it)
--class who lives here — human|server. Decides root SSH's fate after --root-door what happens to root SSH after the users phase — closed|open.
the users phase: human closes it (`rig users close-root`), closed: `rig users close-root` shuts it once named operators can
server keeps it as the control plane's automation door. get in. open: it stays, as the control plane's automation door.
(Named --class human|server before #77 — same trait, renamed for
what it decides rather than for who lives on the box. Markers
written by the old flag are still read.)
--host does this box host VMs (box/Incus) — yes|no --host does this box host VMs (box/Incus) — yes|no
--join how it enters the tailnet — authkey|login --join how it enters the tailnet — authkey|login
One of --users/--no-users is required on every role, class=server included: One of --users/--no-users is required on every role, root-door=open included:
a box nobody logs into routinely is exactly where shared-root access rots, a box nobody logs into routinely is exactly where shared-root access rots,
and per-human accounts keep attribution intact for the times someone does go and per-human accounts keep attribution intact for the times someone does go
in. So the complete path is the default path and skipping it is a deliberate in. So the complete path is the default path and skipping it is a deliberate
--no-users, not an omission. --no-users, not an omission.
--users does NOT reach the box TENANT roles (claude|codex|grok|staging). A --users does NOT reach the box TENANT roles (any '-box' name, e.g.
claude-box, staging-box). A
tenant is a box-minted GUEST: box auto-runs its bootstrap at mint, tenant is a box-minted GUEST: box auto-runs its bootstrap at mint,
non-interactively, with no file to hand it; the guest never joins the tailnet non-interactively, with no file to hand it; the guest never joins the tailnet
and has no SSH door of its own — entry is `box shell`, gated by the HOST's and has no SSH door of its own — entry is `box shell`, gated by the HOST's
@ -60,23 +74,40 @@ operator file has nothing to converge in there.
Roles are presets over the three traits; any flag overrides its trait. Roles are presets over the three traits; any flag overrides its trait.
custom presets nothing and requires --hostname plus all three traits. custom presets nothing and requires --hostname plus all three traits.
role class host join role root-door host join
control-plane server no authkey control-plane-server open no authkey
workload server no authkey workload-server open no authkey
runner server no authkey runner-server open no authkey
dev human yes authkey staging-server open yes authkey
workstation human yes login dev-server closed yes authkey
workstation closed yes login
The former staging VM-host preset is now spelled through the traits: THE SUFFIX NAMES THE FAMILY, not the door policy. '-server' means this role builds a
'custom --class server --host yes --join authkey' (or 'dev --class server'). fleet MACHINE — a tailnet node rig converges; '-box' (the tenant roles) means a
'staging' names the box TENANT role today — the guest, not the host. GUEST a box mints. Two families lived in one flat namespace and nothing in a
name said which you were asking for; 'staging' made that concrete by naming
both the metal and the guests on it.
custom no suffix: it presets nothing and can be any shape, a guest
included, so a family claim would be one it cannot make.
workstation no suffix: somebody's own device, not fleet infrastructure —
it joins by interactive login and comes up user-owned and
untagged, and the tailnet never manages it.
'dev-server --root-door closed' says what is true and says it once: the suffix
names the FAMILY (a fleet machine), the trait names the DOOR (operators enter a
dev box as themselves, so 'users close-root' shuts its door). Until #77 this
trait was '--class human|server', which named the wrong axis — who lives on the
box — and left 'dev-server' reading as a class=human contradiction. Nobody
lives on a dev box; what makes it different is that its root door closes.
The tailnet tag is NOT a rig argument. A pre-auth key is minted WITH its tags, The tailnet tag is NOT a rig argument. A pre-auth key is minted WITH its tags,
so the key is the single source of truth: rig no longer requests a tag it might so the key is the single source of truth: rig no longer requests a tag it might
disagree with. After the box joins, rig reads the tag control actually GRANTED disagree with. After the box joins, rig reads the tag control actually GRANTED
(tailscale status .Self.Tags) and asserts on THAT — an untagged key is refused (tailscale status .Self.Tags) and asserts on THAT — an untagged key is refused
outright, and only control-plane and workload may carry tag:server (they are outright, and only control-plane-server and workload-server may carry
the only shapes the control plane manages). Mint a correctly-tagged key. tag:server (they are the only shapes the control plane manages). Mint a
correctly-tagged key.
join=authkey: provide the single-use tailscale pre-auth key via the TS_AUTHKEY join=authkey: provide the single-use tailscale pre-auth key via the TS_AUTHKEY
env var, or enter it at the interactive prompt. Used once, never written to disk. env var, or enter it at the interactive prompt. Used once, never written to disk.
@ -90,31 +121,58 @@ 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
control-plane|workload|runner|dev|workstation|custom) shift ;; --undo)
claude|codex|grok|staging) shift
[ $# -eq 0 ] || die "bootstrap --undo takes no arguments" 2
exec "$HERE/bootstrap-undo.sh" ;;
control-plane-server|workload-server|runner-server|staging-server|dev-server|workstation|custom) shift ;;
*-box)
# The box TENANT roles (#31) are a different family — guests a box mints, # The box TENANT roles (#31) are a different family — guests a box mints,
# never tailnet machines — and live in their own mechanism, one script # never tailnet machines — and live in their own mechanism, one script
# parameterized per tenant. Dispatched here so `rig bootstrap <role>` # parameterized per DEFINITION fetched from the template registry (#110;
# stays the single entrypoint for both families. # staging-box stays in-tree). Dispatched on the FAMILY SUFFIX (#76), not
# an enumerated list: which '-box' roles exist is the registry's fact, so
# a template added there is mintable with zero code changes here.
# `rig bootstrap <role>` stays the single entrypoint for both families.
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|workload|runner|dev|workstation|custom — or a tenant role: claude|codex|grok|staging)" 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|workload|runner|dev|workstation|custom — or a tenant role: claude|codex|grok|staging)" 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).
# Roles are presets, nothing more: every behavior below keys off the traits, # Roles are presets, nothing more: every behavior below keys off the traits,
# so a flag override changes behavior without a new role, and custom exists # so a flag override changes behavior without a new role, and custom exists
# for the shape nobody foresaw — it declares nothing and must state all three. # for the shape nobody foresaw — it declares nothing and must state all three.
CLASS="" HOST="" JOIN="" ROOT_DOOR="" HOST="" JOIN=""
case "$ROLE" in case "$ROLE" in
control-plane) CLASS=server HOST=no JOIN=authkey ;; control-plane-server) ROOT_DOOR=open HOST=no JOIN=authkey ;;
workload) CLASS=server HOST=no JOIN=authkey ;; workload-server) ROOT_DOOR=open HOST=no JOIN=authkey ;;
runner) CLASS=server HOST=no JOIN=authkey ;; runner-server) ROOT_DOOR=open HOST=no JOIN=authkey ;;
dev) CLASS=human HOST=yes JOIN=authkey ;; # The unattended VM host — the shape #31 retired when 'staging' moved to the
workstation) CLASS=human HOST=yes JOIN=login ;; # tenant family, restored under a name that cannot be confused with its own
# guests. host=yes is the whole point: it is what installs the box CLI and
# runs box's setup-host further down, so this is a table row, not machinery.
staging-server) ROOT_DOOR=open HOST=yes JOIN=authkey ;;
dev-server) ROOT_DOOR=closed HOST=yes JOIN=authkey ;;
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.
@ -132,11 +190,11 @@ while [ $# -gt 0 ]; do
--hostname) --hostname)
[ $# -ge 2 ] || die "--hostname needs a value" 2 [ $# -ge 2 ] || die "--hostname needs a value" 2
TS_HOSTNAME="$2"; shift 2 ;; TS_HOSTNAME="$2"; shift 2 ;;
--class) --root-door)
[ $# -ge 2 ] || die "--class needs a value" 2 [ $# -ge 2 ] || die "--root-door needs a value" 2
case "$2" in case "$2" in
human|server) CLASS="$2" ;; closed|open) ROOT_DOOR="$2" ;;
*) die "bad --class: $2 (want human|server)" 2 ;; *) die "bad --root-door: $2 (want closed|open)" 2 ;;
esac esac
shift 2 ;; shift 2 ;;
--host) --host)
@ -172,7 +230,7 @@ done
if [ "$ROLE" = "custom" ]; then if [ "$ROLE" = "custom" ]; then
MISSING="" MISSING=""
[ -n "$TS_HOSTNAME" ] || MISSING="$MISSING --hostname" [ -n "$TS_HOSTNAME" ] || MISSING="$MISSING --hostname"
[ -n "$CLASS" ] || MISSING="$MISSING --class" [ -n "$ROOT_DOOR" ] || MISSING="$MISSING --root-door"
[ -n "$HOST" ] || MISSING="$MISSING --host" [ -n "$HOST" ] || MISSING="$MISSING --host"
[ -n "$JOIN" ] || MISSING="$MISSING --join" [ -n "$JOIN" ] || MISSING="$MISSING --join"
[ -z "$MISSING" ] || die "role custom has no presets; missing:$MISSING" 2 [ -z "$MISSING" ] || die "role custom has no presets; missing:$MISSING" 2
@ -188,10 +246,10 @@ fi
# --- who lives here (#51) ----------------------------------------------------- # --- who lives here (#51) -----------------------------------------------------
# The users file is the last piece of "what this box is" that bootstrap did not # The users file is the last piece of "what this box is" that bootstrap did not
# take, and it is REQUIRED rather than optional: a bootstrapped box with no # take, and it is REQUIRED rather than optional: a bootstrapped box with no
# users converges to a box only root can enter, and on class=human that is a # users converges to a box only root can enter, and on root-door=closed that is a
# half-built machine waiting for a second command the operator has to remember # half-built machine waiting for a second command the operator has to remember
# (`rig users close-root` is itself gated behind "once your admin key works" — # (`rig users close-root` is itself gated behind "once your admin key works" —
# which needs an admin to exist). class=server gets the same requirement on # which needs an admin to exist). root-door=open gets the same requirement on
# purpose: a server nobody logs into routinely is exactly where shared-root # purpose: a server nobody logs into routinely is exactly where shared-root
# access rots, and per-human accounts keep attribution intact for the times # access rots, and per-human accounts keep attribution intact for the times
# someone does go in. # someone does go in.
@ -348,9 +406,9 @@ EOF
# The whole block lives in lib/sshd.sh, shared with the staging TENANT role — # The whole block lives in lib/sshd.sh, shared with the staging TENANT role —
# one drop-in, one converger, never two copies drifting apart. Everything the # one drop-in, one converger, never two copies drifting apart. Everything the
# block learned the hard way (00- beats cloud-init's 50- under first-wins, # block learned the hard way (00- beats cloud-init's 50- under first-wins,
# validate-then-restart, assert sshd -T not the file, the class-gated # validate-then-restart, assert sshd -T not the file, the root-door-gated
# permitrootlogin acceptance) moved with it, verbatim. # permitrootlogin acceptance) moved with it, verbatim.
harden_sshd "$CLASS" harden_sshd "$ROOT_DOOR"
# --- system hostname ---------------------------------------------------------- # --- system hostname ----------------------------------------------------------
# Set the SYSTEM hostname too, not just the tailnet one. Until 2026-07-12 rig # Set the SYSTEM hostname too, not just the tailnet one. Until 2026-07-12 rig
@ -430,25 +488,26 @@ verify_effective_tag() {
fi fi
# tag:server policy is DERIVED, not a trait: it means "the control plane # tag:server policy is DERIVED, not a trait: it means "the control plane
# manages this box", and only control-plane and workload are shapes the # manages this box", and only control-plane-server and workload-server are shapes the
# control plane manages. Everything else refuses it on the EFFECTIVE tag — # control plane manages. Everything else refuses it on the EFFECTIVE tag —
# strictly stronger than the old request-time check, which only guarded the # strictly stronger than the old request-time check, which only guarded the
# tag rig HOPED for. The fleet has been bitten both ways: a runner carrying # tag rig HOPED for. The fleet has been bitten both ways: a runner-server carrying
# tag:server extends every server grant to repo-controlled code, and a # tag:server extends every server grant to repo-controlled code, and a
# staging host carrying it extends them to a box the control plane does not # staging host carrying it extends them to a box the control plane does not
# even know. Refused, never warned; rig can DETECT this but cannot FIX it, # even know. Refused, never warned; rig can DETECT this but cannot FIX it,
# so each refusal names its repair. # so each refusal names its repair.
if printf '%s\n' "$tags" | grep -qx 'tag:server'; then if printf '%s\n' "$tags" | grep -qx 'tag:server'; then
case "$ROLE" in case "$ROLE" in
control-plane|workload) ;; control-plane-server|workload-server) ;;
runner) runner-server)
die "role runner joined with tag:server (effective tags: $(printf '%s' "$tags" | tr '\n' ' ')). The key you used grants tag:server to repo-controlled code; that must never happen. Re-run bootstrap with a key minted for a CI tag (e.g. tag:ci)." ;; die "role runner-server joined with tag:server (effective tags: $(printf '%s' "$tags" | tr '\n' ' ')). The key you used grants tag:server to repo-controlled code; that must never happen. Re-run bootstrap with a key minted for a CI tag (e.g. tag:ci)." ;;
*) *)
# This arm now also owns the VM-host shape the old staging preset # This arm owns the VM-host shape too — 'staging-server' by name now,
# covered (custom/dev --class server): a host is never managed by the # plus custom/dev-server --root-door open: a host is never managed by the
# control plane — its guest VMs are — so tag:server is refused there # control plane — its guest VMs are — so tag:server is refused there
# like everywhere else outside control-plane|workload. # like everywhere else outside the two control-plane-managed shapes.
die "role $ROLE joined with tag:server (effective tags: $(printf '%s' "$tags" | tr '\n' ' ')). Only control-plane and workload are managed by the control plane; tag:server on this box extends every server grant to it. Re-run bootstrap with a key minted for a non-server tag (e.g. tag:local)." ;; # Mint the metal's key with tag:local.
die "role $ROLE joined with tag:server (effective tags: $(printf '%s' "$tags" | tr '\n' ' ')). Only control-plane-server and workload-server are managed by the control plane; tag:server on this box extends every server grant to it. Re-run bootstrap with a key minted for a non-server tag (e.g. tag:local)." ;;
esac esac
fi fi
@ -505,6 +564,7 @@ if ! command -v tailscale >/dev/null 2>&1; then
log "installing tailscale" log "installing tailscale"
curl -fsSL https://tailscale.com/install.sh | sh curl -fsSL https://tailscale.com/install.sh | sh
fi fi
JOIN_BY=preexisting
if tailscale status >/dev/null 2>&1; then if tailscale status >/dev/null 2>&1; then
log "tailnet already joined; skipping tailscale up (no pre-auth key needed)" log "tailnet already joined; skipping tailscale up (no pre-auth key needed)"
# ...but skipping `tailscale up` also skipped --hostname, so the TAILNET name # ...but skipping `tailscale up` also skipped --hostname, so the TAILNET name
@ -545,6 +605,7 @@ elif [ "$JOIN" = "login" ]; then
log "joining tailnet as ${TS_HOSTNAME} (interactive login; follow the URL tailscale prints)" log "joining tailnet as ${TS_HOSTNAME} (interactive login; follow the URL tailscale prints)"
tailscale up --hostname="$TS_HOSTNAME" tailscale up --hostname="$TS_HOSTNAME"
verify_user_owned back-out verify_user_owned back-out
JOIN_BY=rig
else else
# env override, else prompt; never touches disk. The prompt only fires on a # env override, else prompt; never touches disk. The prompt only fires on a
# tty: with no terminal, a bare `read` exits non-zero and `set -e` would end # tty: with no terminal, a bare `read` exits non-zero and `set -e` would end
@ -565,26 +626,61 @@ else
log "joining tailnet as ${TS_HOSTNAME} (tag comes from the pre-auth key)" log "joining tailnet as ${TS_HOSTNAME} (tag comes from the pre-auth key)"
tailscale up --authkey="$TS_AUTHKEY" --hostname="$TS_HOSTNAME" tailscale up --authkey="$TS_AUTHKEY" --hostname="$TS_HOSTNAME"
verify_effective_tag back-out verify_effective_tag back-out
JOIN_BY=rig
fi fi
# --- role marker -------------------------------------------------------------- # --- role marker --------------------------------------------------------------
# /etc/rig/role is the traits' ground truth for later rig commands (`rig users` # /etc/rig/role is the traits' ground truth for later rig commands (`rig users`
# reads class from it to decide root SSH's fate). Written only AFTER the tag # reads root-door= from it to decide root SSH's fate). Written only AFTER the
# verification, so a marker never describes a box that failed to become what it # tag verification, so a marker never describes a box that failed to become what
# claims — and cmp-guarded like every file rig converges. # it claims — and cmp-guarded like every file rig converges.
#
# The line is written in the CURRENT vocabulary only — `root-door=`, never the
# `class=` it replaced (#77). Writing both would keep an old rig reading a new
# marker, but it would also entrench the retired spelling on every box rig ever
# converges, and make the both-fields-disagree case reachable from rig's own
# hand instead of only from a text editor. The compat obligation runs the other
# way and is discharged in root_door_of: NEW rig reads OLD markers, because
# those exist in the field by the thousand and nothing will rewrite them.
MARKER=/etc/rig/role MARKER=/etc/rig/role
MARKER_TMP="$(mktemp)" MARKER_TMP="$(mktemp)"
printf 'role=%s class=%s host=%s join=%s\n' "$ROLE" "$CLASS" "$HOST" "$JOIN" > "$MARKER_TMP" printf 'role=%s root-door=%s host=%s join=%s join-by=%s\n' \
"$ROLE" "$ROOT_DOOR" "$HOST" "$JOIN" "$JOIN_BY" > "$MARKER_TMP"
if ! cmp -s "$MARKER_TMP" "$MARKER" 2>/dev/null; then if ! cmp -s "$MARKER_TMP" "$MARKER" 2>/dev/null; then
mkdir -p /etc/rig mkdir -p /etc/rig
install -m 0644 "$MARKER_TMP" "$MARKER" install -m 0644 "$MARKER_TMP" "$MARKER"
log "role marker written: role=$ROLE class=$CLASS host=$HOST join=$JOIN" log "role marker written: role=$ROLE root-door=$ROOT_DOOR host=$HOST join=$JOIN join-by=$JOIN_BY"
else else
log "role marker already current" log "role marker already current"
fi fi
rm -f "$MARKER_TMP" rm -f "$MARKER_TMP"
# --- box install (host-class only) ------------------------------------------- # --- provenance manifest ------------------------------------------------------
# /etc/rig/manifest records WHICH rig converged this machine and WHEN (#61).
# The marker above says what the box IS; this says what BUILT it — two files,
# two jobs, and the marker is deliberately untouched (it has six readers,
# install.sh:82-90 among them).
#
# Placed HERE, immediately after the marker, so the two agree by construction
# and both inherit the marker's discipline verbatim: written only AFTER the tag
# verification, so neither ever describes a box that failed to become what it
# claims. A manifest that survives a failed run is worse than no manifest — it
# is a confident wrong answer. It deliberately does NOT trail the box install
# and the users phase below: those are the host EXTRA and the operator phase,
# and a box whose people failed to converge was still converged BY this rig at
# this time. Stamping provenance is not a claim that everything after it
# succeeded — the marker beside it makes exactly the same claim, and the two
# landing together is what keeps them readable as one statement.
#
# The version stamped is the one that RAN, captured now — not what `rig
# --version` would answer after a later upgrade.
if manifest_stamp "$(manifest_running_version "$HERE/..")"; then
log "provenance manifest written: $(manifest_path)"
else
log "provenance manifest already current"
fi
# --- box install (host=yes only) -------------------------------------------
# A host=yes box exists to run guest boxes, so bootstrap finishes the job rather # A host=yes box exists to run guest boxes, so bootstrap finishes the job rather
# than printing a to-do: it installs the box CLI globally and lets box's OWN # than printing a to-do: it installs the box CLI globally and lets box's OWN
# setup-host build the Incus stack. Placed AFTER the role marker write on # setup-host build the Incus stack. Placed AFTER the role marker write on
@ -609,7 +705,9 @@ rm -f "$MARKER_TMP"
# hardening + the tailnet, and box is the host EXTRA, so a failed box install # hardening + the tailnet, and box is the host EXTRA, so a failed box install
# must never abort a bootstrap that otherwise fully succeeded. # must never abort a bootstrap that otherwise fully succeeded.
# #
# PIN POINTS: BOX_REPO / BOX_REF override the source (default heavy-duty/box@main). # PIN POINTS: BOX_REPO / BOX_REF override the source (default
# heavy-duty/box@0.9.0). BOX_RELEASE is bumped deliberately when rig releases,
# after the pinned combination has passed the release drill.
# BOX_YES=1 makes box's installer non-interactive AND keeps setup-host (so the # BOX_YES=1 makes box's installer non-interactive AND keeps setup-host (so the
# Incus stack is actually built, not just the CLI dropped on PATH). # Incus stack is actually built, not just the CLI dropped on PATH).
# #
@ -622,24 +720,114 @@ rm -f "$MARKER_TMP"
# on box PR #71. Until that merges, box's root install lands in /root and non-root # on box PR #71. Until that merges, box's root install lands in /root and non-root
# users cannot reach it, so this step is only fully correct once box#71 is merged. # users cannot reach it, so this step is only fully correct once box#71 is merged.
if [ "$HOST" = "yes" ]; then if [ "$HOST" = "yes" ]; then
BOX_RELEASE=0.9.0
BOX_REPO="${BOX_REPO:-heavy-duty/box}" BOX_REPO="${BOX_REPO:-heavy-duty/box}"
BOX_REF="${BOX_REF:-main}" 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 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 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
@ -659,20 +847,24 @@ 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
# --- users (the last phase, and it must be last) ------------------------------- # --- users (the last phase, and it must be last) -------------------------------
# Ordering is a correctness property, not a preference. `users apply` READS # Ordering is a correctness property, not a preference. `users apply` READS
# /etc/rig/role: class= decides which root-SSH note it prints, and host= decides # /etc/rig/role: root-door= decides which root-SSH note it prints, and host= decides
# what an absent incus group means (refuse on yes, skip the box role with a # what an absent incus group means (refuse on yes, skip the box role with a
# warning on no). Run before the marker write, apply would see no marker at all # warning on no). Run before the marker write, apply would see no marker at all
# and warn "re-run rig bootstrap so this box knows what it is" — in the middle # and warn "re-run rig bootstrap so this box knows what it is" — in the middle
@ -702,25 +894,36 @@ 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" ]; then if [ "$ROLE" = "control-plane-server" ]; then
log "next: rig coolify install --version <pin>" log "next: rig coolify install --version <pin>"
elif [ "$ROLE" = "runner" ]; then elif [ "$ROLE" = "runner-server" ]; then
log "next: rig runner install --repo <owner/repo> --version <pin>" log "next: rig runner install --repo <owner/repo> --version <pin>"
fi fi
# Every class gets operators: humans always enter as themselves and elevate via # Every box gets operators: humans always enter as themselves and elevate via
# sudo — a shared root login is unattributable. What differs by class is root # sudo — a shared root login is unattributable. What differs, per the root-door
# SSH's fate once named users exist. With --users the accounts exist already, so # trait, is root SSH's fate once named users exist. With --users the accounts exist already, so
# the note that used to point at the missing command now points at what is left # the note that used to point at the missing command now points at what is left
# to do; --no-users still owes the box its people, and says so. # to do; --no-users still owes the box its people, and says so.
if [ -n "$USERS_FILE" ]; then if [ -n "$USERS_FILE" ]; then
if [ "$CLASS" = "human" ]; then if [ "$ROOT_DOOR" = "closed" ]; then
log "next: 'rig users close-root' once your admin key works — verify you can SSH in as an admin FIRST" log "next: 'rig users close-root' once your admin key works — verify you can SSH in as an admin FIRST"
else else
log "operators are converged; root SSH stays — it is the control plane's automation door" log "operators are converged; root SSH stays — it is the control plane's automation door"
fi fi
else else
if [ "$CLASS" = "human" ]; then if [ "$ROOT_DOOR" = "closed" ]; then
log "--no-users: this box has no named operators — root is its only door. When you want them: rig users apply --file <users-file>, then 'rig users close-root' once your admin key works" log "--no-users: this box has no named operators — root is its only door. When you want them: rig users apply --file <users-file>, then 'rig users close-root' once your admin key works"
else else
log "--no-users: this box has no named operators — root SSH is its only door, and it stays (the control plane's automation door). For named logins: rig users apply --file <users-file>" log "--no-users: this box has no named operators — root SSH is its only door, and it stays (the control plane's automation door). For named logins: rig users apply --file <users-file>"

View file

@ -77,10 +77,13 @@ done
# certainly means the wrong SSH session — but the marker is advisory and may be # certainly means the wrong SSH session — but the marker is advisory and may be
# absent, so WARN, never die, and warn before the root check so the harness can # absent, so WARN, never die, and warn before the root check so the harness can
# prove it non-root (RIG_ROLE_MARKER points it at fixtures, repo precedent). # prove it non-root (RIG_ROLE_MARKER points it at fixtures, repo precedent).
# Matches the ROLE NAME, so #76's rename reaches it the same way it reaches
# `coolify install` — a pre-rename marker takes the warning branch, which is
# the hard cut behaving as designed rather than a regression.
MARKER_LINE="$(read_role_marker "${RIG_ROLE_MARKER:-/etc/rig/role}")" MARKER_LINE="$(read_role_marker "${RIG_ROLE_MARKER:-/etc/rig/role}")"
case "$MARKER_LINE" in case "$MARKER_LINE" in
""|"role=control-plane"|"role=control-plane "*) ;; ""|"role=control-plane-server"|"role=control-plane-server "*) ;;
*) warn "this box's role marker says '${MARKER_LINE}' — not a control-plane box. The nightly dump targets Coolify's own database, which lives on role control-plane; if this is the wrong box, stop here and re-check your SSH session." ;; *) warn "this box's role marker says '${MARKER_LINE}' — not a control-plane box. The nightly dump targets Coolify's own database, which lives on role control-plane-server; if this is the wrong box, stop here and re-check your SSH session." ;;
esac esac
# --- guards ---------------------------------------------------------------- # --- guards ----------------------------------------------------------------

View file

@ -51,10 +51,17 @@ fi
# are: the harness proves it non-root, and reading a 0644 file needs no # are: the harness proves it non-root, and reading a 0644 file needs no
# privilege. RIG_ROLE_MARKER overrides the path so tests point it at fixtures # privilege. RIG_ROLE_MARKER overrides the path so tests point it at fixtures
# (repo precedent: users-apply, users-close-root). # (repo precedent: users-apply, users-close-root).
#
# This match is on the ROLE NAME, which #76's rename therefore reaches: a box
# bootstrapped before the rename carries 'role=control-plane' and now takes the
# warning branch. That is the hard cut behaving as designed — the marker is
# advisory, the run still proceeds, and the warning names the re-bootstrap that
# makes the marker true again. Nothing here is load-bearing enough to justify
# carrying the old name forever.
MARKER_LINE="$(read_role_marker "${RIG_ROLE_MARKER:-/etc/rig/role}")" MARKER_LINE="$(read_role_marker "${RIG_ROLE_MARKER:-/etc/rig/role}")"
case "$MARKER_LINE" in case "$MARKER_LINE" in
""|"role=control-plane"|"role=control-plane "*) ;; ""|"role=control-plane-server"|"role=control-plane-server "*) ;;
*) warn "this box's role marker says '${MARKER_LINE}' — not a control-plane box. Coolify belongs on role control-plane; if this is the wrong box, stop here and re-check your SSH session. Repurposing it on purpose? Re-run 'rig bootstrap control-plane' first so the marker tells the truth." ;; *) warn "this box's role marker says '${MARKER_LINE}' — not a control-plane box. Coolify belongs on role control-plane-server; if this is the wrong box, stop here and re-check your SSH session. Repurposing it on purpose? Re-run 'rig bootstrap control-plane-server' first so the marker tells the truth." ;;
esac esac
[ "$(id -u)" -eq 0 ] || die "must run as root" [ "$(id -u)" -eq 0 ] || die "must run as root"

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
}

185
commands/lib/manifest.sh Normal file
View file

@ -0,0 +1,185 @@
#!/usr/bin/env bash
# /etc/rig/manifest — PROVENANCE: which rig converged this machine, and when
# (#61). Sourced by bootstrap.sh, bootstrap-tenant.sh and the test harness.
#
# The file is `key=value`, one per line — not JSON, not YAML. Same constraint
# stated three times in the tree already (lib/users-config.sh:6-12,
# lib/runner-config.sh:6 and :24): a rig-bootstrapped box has no YAML parser
# and no jq, which is why json_field() is grep-and-sed. This is the one file
# that must stay readable on the most broken machine in the fleet, so `read`
# parses it for free.
#
# WHAT GOES IN HERE: facts that are DECIDED. Which rig ran, and when it ran.
# Facts that are OBSERVED — cores, RAM, disk, kernel — belong to `rig platform`
# (#64), which computes them fresh and stores nothing. That split is not
# tidiness: a stored spec goes stale on its own (someone adds RAM; the
# unattended-upgrades bootstrap.sh itself enables patches the kernel), and
# refreshing it on every run collides head-on with bootstrap.sh:3 —
# "Convergent: safe to re-run; a second run changes nothing." Keeping only
# immutable content removes that problem instead of managing it.
#
# NEVER A CREDENTIAL. This file is 0644 and world-readable by design — it is
# an audit record, and an audit record nobody can read is not one. Later
# commands may append their own provenance (runner_installed_at,
# coolify_installed_at, box_version), subject to the same two rules: the EVENT
# of installing something, never its current state, and never a secret. Same
# law runner-install.sh:190 already states for `.rig-labels` — "box-local
# metadata, never a credential."
# The schema version, an INTEGER, independent of the rig versions recorded in
# the file. Bumped only when a key is REMOVED or REPURPOSED — adding a key is
# not a bump, and readers must ignore keys they do not know, so a newer rig's
# manifest stays readable to an older one. No `schema=` line means pre-manifest.
MANIFEST_SCHEMA=1
# The five keys rig's provenance block owns. Everything else in the file is a
# later command's line and is preserved verbatim (see manifest_foreign).
MANIFEST_KEYS='schema bootstrapped_by bootstrapped_at converged_by converged_at'
# manifest_path — where the manifest lives. RIG_MANIFEST overrides it so tests
# point at fixtures (repo precedent: RIG_ROLE_MARKER, bin/rig:148).
manifest_path() {
printf '%s' "${RIG_MANIFEST:-/etc/rig/manifest}"
}
# manifest_value <path> <key> — the value of one key, or nothing when the file
# or the key is absent. First occurrence wins. NO policy here: what a missing
# key MEANS is each caller's call (this reader only reads — repo precedent:
# read_role_marker).
manifest_value() {
[ -r "$1" ] || return 0
local k v
while IFS='=' read -r k v || [ -n "$k" ]; do
[ "$k" = "$2" ] || continue
printf '%s' "$v"
return 0
done < "$1"
}
# manifest_has <path> <key> — is the key PRESENT, regardless of its value.
# Separate from manifest_value because "absent" and "present but empty" are
# different answers and command substitution collapses both to the empty
# string. Key comparison is a string equality, never a pattern: `rig manifest`
# passes operator input straight in, and a key of `.*` must find nothing rather
# than match the first line.
manifest_has() {
[ -r "$1" ] || return 1
local k
while IFS='=' read -r k _ || [ -n "$k" ]; do
[ "$k" = "$2" ] && return 0
done < "$1"
return 1
}
# manifest_foreign <path> — every line rig's provenance block does NOT own,
# in file order. A newer rig (or a later command) may have written keys this
# one has never heard of; rewriting the file must not eat them, or the schema's
# "readers ignore keys they do not know" promise would only hold for readers
# and not for the writer.
manifest_foreign() {
[ -r "$1" ] || return 0
local line key
while IFS= read -r line || [ -n "$line" ]; do
[ -n "$line" ] || continue
key="${line%%=*}"
case " $MANIFEST_KEYS " in
*" $key "*) continue ;;
esac
printf '%s\n' "$line"
done < "$1"
}
# manifest_render <path> <version> <now> — the WHOLE convergence contract, as
# a pure text→text function: existing file + running version + a clock reading
# in, the file's desired content out. No side effects, so the harness proves
# the rules non-root against fixtures (repo precedent: parse_users_file,
# assert_marker_human).
#
# Purity is what makes convergence testable rather than asserted. The rendered
# content is a function of (existing file, running version) ALONE — <now> is
# consulted only on the paths that were going to change anyway — so calling
# this twice with two DIFFERENT clock readings must produce byte-identical
# output. test/cli.sh pins exactly that, which is stronger than re-running the
# writer fast enough that the second matches by luck.
#
# Rule 1 — bootstrapped_* is FIRST-WRITE-WINS. Birth is pinned forever. If the
# file already carries a bootstrapped_at, both birth fields are preserved
# verbatim. Regenerating it as now() on every run would make every re-run a
# diff, which is the exact trap that keeping specs out of this file closed.
#
# Rule 2 — converged_* updates ONLY when the version actually differs.
# converged_at is "the time the converging version last changed", NOT the time
# of the last run. If it tracked every run it would be a clock, and a clock in
# a cmp-guarded file makes every re-run a fake change. So: compare the running
# version against the recorded converged_by; equal means the pair is already
# true and is copied through untouched.
#
# Under those two rules a re-run by the SAME rig renders byte-identical content
# and the cmp-guard stays silent, while a re-converge by a DIFFERENT rig
# renders a real diff — and the guard firing there is correct, not spurious. It
# was only ever the clock that was the fake change, never the version.
manifest_render() {
local path="$1" ver="$2" now="$3"
local b_by b_at c_by c_at
b_by="$(manifest_value "$path" bootstrapped_by)"
b_at="$(manifest_value "$path" bootstrapped_at)"
c_by="$(manifest_value "$path" converged_by)"
c_at="$(manifest_value "$path" converged_at)"
# Rule 1. bootstrapped_at is the field that decides, because it is the one
# that can never be reconstructed: a machine's birth version can at least be
# guessed at, its birth INSTANT cannot. So an existing at-stamp pins the
# pair, and a birth-stamp with no birth version records `unknown` rather
# than backfilling today's version as if it had always been there — a
# manifest that lies about which rig built the box is worse than one that
# admits it does not know.
if [ -z "$b_at" ]; then
b_by="$ver"; b_at="$now"
elif [ -z "$b_by" ]; then
b_by=unknown
fi
# Rule 2. The empty-at case is a one-time repair of a truncated file, not a
# clock: once written it satisfies the equality on every later run.
if [ "$c_by" != "$ver" ] || [ -z "$c_at" ]; then
c_by="$ver"; c_at="$now"
fi
printf 'schema=%s\n' "$MANIFEST_SCHEMA"
printf 'bootstrapped_by=%s\n' "$b_by"
printf 'bootstrapped_at=%s\n' "$b_at"
printf 'converged_by=%s\n' "$c_by"
printf 'converged_at=%s\n' "$c_at"
manifest_foreign "$path"
}
# manifest_running_version <rig root> — the version that IS RUNNING, captured
# at run time from the tree's own VERSION file. NOT `rig --version` read back
# later: a machine outlives the rig that built it, so what is installed today
# answers a different question than what converged it. First line only — a
# stray second line would inject a bogus key into a key=value file.
manifest_running_version() {
local v=""
[ -r "$1/VERSION" ] && v="$(head -n1 "$1/VERSION")"
printf '%s' "${v:-unknown}"
}
# manifest_stamp <version> — the writer. Renders, cmp-guards like every file
# rig converges, installs 0644 beside the role marker and the users ledger.
# Returns 0 when it CHANGED the file and 1 when the file was already current,
# so the caller owns the log line (and, under set -e, must call it in an `if`).
manifest_stamp() {
local path tmp rc
path="$(manifest_path)"
tmp="$(mktemp)"
manifest_render "$path" "$1" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$tmp"
if cmp -s "$tmp" "$path" 2>/dev/null; then
rc=1
else
mkdir -p "$(dirname "$path")"
install -m 0644 "$tmp" "$path"
rc=0
fi
rm -f "$tmp"
return "$rc"
}

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

@ -6,12 +6,57 @@
# a hardening block is drift by construction, the same law that keeps rig's # a hardening block is drift by construction, the same law that keeps rig's
# hands off Incus. Callers provide log/warn/die. # hands off Incus. Callers provide log/warn/die.
# harden_sshd <human|server> — install the 00-rig.conf hardening drop-in, # sshd_privsep_gap <status> <stderr> — true when a FAILED `sshd -t` failed for
# the missing privilege-separation directory rather than for anything in the
# config. Pure and sourceable (repo precedent: parse_users_file, deny_verdict)
# so the distinction is provable without root or a live sshd.
#
# `sshd -t` folds two questions into one exit code: is the merged config
# parseable, and is /run/sshd there. The second is not a fact about the config
# at all — /run is a tmpfs and /run/sshd is ssh.service's RuntimeDirectory,
# which systemd creates with that unit and removes when it stops, so the
# directory is legitimately absent under socket activation (ssh.socket, the
# default on current Debian/Ubuntu) on a box whose SSH door is serving
# connections perfectly well. Reading that as a config refusal aborted
# bootstrap with a verdict sshd never reached (#92).
#
# The STATUS is the verdict; this text match only classifies a failure. A
# passing `sshd -t` is never diverted here, whatever its output happens to say.
sshd_privsep_gap() {
[ "$1" -ne 0 ] || return 1
case "$2" in
*"Missing privilege separation directory"*) return 0 ;;
*) return 1 ;;
esac
}
# sshd_config_ok — validate the merged config, repairing a privsep gap once and
# retesting. Returns sshd's verdict and leaves sshd's own stderr in $sshd_err
# for the caller's refusal message: a message that asserts a cause must carry
# the evidence for it, or the operator greps /etc/ssh blind (#92).
#
# The repair is `install -d`, which is idempotent and creates exactly what
# systemd would. It does not survive a reboot and is not meant to — by then the
# ssh unit has recreated it.
sshd_config_ok() {
local rc
sshd_err="$(sshd -t 2>&1)"; rc=$?
if sshd_privsep_gap "$rc" "$sshd_err"; then
install -d -m 0755 /run/sshd
sshd_err="$(sshd -t 2>&1)"; rc=$?
fi
return "$rc"
}
# harden_sshd <closed|open> — install the 00-rig.conf hardening drop-in,
# validate the merged config before touching the daemon, restart only when the # validate the merged config before touching the daemon, restart only when the
# drop-in actually changed, and assert the EFFECTIVE config (sshd -T), with the # drop-in actually changed, and assert the EFFECTIVE config (sshd -T), with the
# permitrootlogin acceptance gated on the class passed in. # permitrootlogin acceptance gated on the ROOT-DOOR policy passed in (#77; this
# argument was <human|server> before the trait was renamed for what it decides).
# Callers pass their own trait value, never a marker read: bootstrap knows its
# root-door from its flags, and the staging tenant is open by construction.
harden_sshd() { harden_sshd() {
local class="$1" local root_door="$1"
local dropin=/etc/ssh/sshd_config.d/00-rig.conf local dropin=/etc/ssh/sshd_config.d/00-rig.conf
local legacy_dropin=/etc/ssh/sshd_config.d/99-rig.conf local legacy_dropin=/etc/ssh/sshd_config.d/99-rig.conf
local tmp backup eff local tmp backup eff
@ -39,10 +84,10 @@ EOF
# leaves no listener and no way back in. `sshd -t` parses everything sshd # leaves no listener and no way back in. `sshd -t` parses everything sshd
# would parse — our drop-in, cloud-init's, and any third-party file — so a # would parse — our drop-in, cloud-init's, and any third-party file — so a
# broken neighbour is caught here rather than after the door has shut. # broken neighbour is caught here rather than after the door has shut.
if ! sshd -t 2>/dev/null; then if ! sshd_config_ok; then
if [ -n "$backup" ]; then cp -a "$backup" "$dropin"; else rm -f "$dropin"; fi if [ -n "$backup" ]; then cp -a "$backup" "$dropin"; else rm -f "$dropin"; fi
rm -f "$tmp" "$backup" rm -f "$tmp" "$backup"
die "sshd rejects the merged config; drop-in rolled back, daemon untouched. Run 'sshd -t' to see which file is bad." die "sshd rejects the merged config; drop-in rolled back, daemon untouched: $sshd_err"
fi fi
rm -f "$backup" rm -f "$backup"
@ -59,22 +104,22 @@ EOF
eff="$(sshd -T 2>/dev/null)" || die "sshd -T failed; refusing to claim a hardened box" eff="$(sshd -T 2>/dev/null)" || die "sshd -T failed; refusing to claim a hardened box"
echo "$eff" | grep -qx 'passwordauthentication no' \ echo "$eff" | grep -qx 'passwordauthentication no' \
|| die "sshd still resolves passwordauthentication=yes — a drop-in is beating ${dropin}; check ls /etc/ssh/sshd_config.d/" || die "sshd still resolves passwordauthentication=yes — a drop-in is beating ${dropin}; check ls /etc/ssh/sshd_config.d/"
# The permitrootlogin acceptance is CLASS-gated, because `no` means opposite # The permitrootlogin acceptance is ROOT-DOOR-gated, because `no` means
# things on the two classes. class=human: `no` is the post-`rig users # opposite things on the two policies. root-door=closed: `no` is the post-`rig
# close-root` state — strictly harder than the prohibit-password this function # users close-root` state — strictly harder than the prohibit-password this
# installs. Hardening must never read a closed door as a broken one, and it # function installs. Hardening must never read a closed door as a broken one,
# cannot reopen one either: by first-wins its own drop-in loses to # and it cannot reopen one either: by first-wins its own drop-in loses to
# 00-rig-users.conf. class=server: root SSH is the control plane's automation # 00-rig-users.conf. root-door=open: root SSH is the control plane's automation
# door (Coolify SSHes in as root), so `no` is not hardening — it is fleet # door (Coolify SSHes in as root), so `no` is not hardening — it is fleet
# management silently dead, and the likely culprit is a drop-in left over from # management silently dead, and the likely culprit is a drop-in left over from
# a former class=human life on a repurposed box. rig can DETECT that but must # a former root-door=closed life on a repurposed box. rig can DETECT that but
# not FIX it: silently reopening a root door is worse than a loud stop, so # must not FIX it: silently reopening a root door is worse than a loud stop, so
# same doctrine as the tag checks — detect, refuse, and name the repair. # same doctrine as the tag checks — detect, refuse, and name the repair.
if [ "$class" = "human" ]; then if [ "$root_door" = "closed" ]; then
echo "$eff" | grep -qxE 'permitrootlogin (no|prohibit-password|without-password)' \ echo "$eff" | grep -qxE 'permitrootlogin (no|prohibit-password|without-password)' \
|| die "sshd still permits root password login — check ls /etc/ssh/sshd_config.d/" || die "sshd still permits root password login — check ls /etc/ssh/sshd_config.d/"
elif echo "$eff" | grep -qx 'permitrootlogin no'; then elif echo "$eff" | grep -qx 'permitrootlogin no'; then
die "sshd resolves permitrootlogin=no, but this is a class=server box: root SSH is the control plane's automation door, and with it shut the fleet cannot manage this box. Likely cause: a leftover /etc/ssh/sshd_config.d/00-rig-users.conf from a former class=human life ('rig users close-root' ran here once). Remove that drop-in and re-run bootstrap." die "sshd resolves permitrootlogin=no, but this is a root-door=open box: root SSH is the control plane's automation door, and with it shut the fleet cannot manage this box. Likely cause: a leftover /etc/ssh/sshd_config.d/00-rig-users.conf from a former root-door=closed life ('rig users close-root' ran here once). Remove that drop-in and re-run bootstrap."
else else
echo "$eff" | grep -qxE 'permitrootlogin (prohibit-password|without-password)' \ echo "$eff" | grep -qxE 'permitrootlogin (prohibit-password|without-password)' \
|| die "sshd still permits root password login — check ls /etc/ssh/sshd_config.d/" || die "sshd still permits root password login — check ls /etc/ssh/sshd_config.d/"

437
commands/lib/templates.sh Normal file
View file

@ -0,0 +1,437 @@
#!/usr/bin/env bash
# The tenant-template REGISTRY (#110): resolve where role definitions come
# from, parse a definition's template.env against an allowlist, and lint a
# whole definition. Sourced by bootstrap-tenant.sh (the mint-time consumer)
# and template-lint.sh (the registry repo's CI gate) — pure functions plus
# one pin, no side effects at source time (repo precedent: runner-config,
# and the tenant-config table this lib replaces).
#
# The registry moved out of rig's tree so mechanism and data can move at
# different cadences (#109 is the evidence: adding kimi — pure data — meant
# editing six files here). rig keeps the mechanism and this schema; the
# definitions live in heavy-duty/rig-templates, one directory per role:
#
# <role>/template.env KEY="value" data, parsed against the allowlist
# below and NEVER sourced — a definition cannot
# execute shell through its data file
# <role>/install.sh the CLI install (the one inherently executable part)
# <role>/creds.md the per-vendor creds-free paragraph the context
# renderer splices in
#
# THE SOURCE IS FOUR KNOBS plus the installed pin snapshot, precedence
# _DIR > _REF > snapshot > pin fetch:
# RIG_TEMPLATES_DIR a local folder — bypasses the fetch entirely (the
# offline-test path, and "try a template before it
# exists anywhere")
# RIG_TEMPLATES_REF a ref in the registry repo, fetched as a tarball at
# bootstrap time (the same shape as the rig preinstall)
# RIG_TEMPLATES_REPO which repo that ref lives in (default
# heavy-duty/rig-templates)
# RIG_TEMPLATES_HOST which FORGE that repo lives on (default
# https://github.com) — see templates_archive_urls
# and, absent both overrides, the snapshot installed beside this file when it
# matches the PIN below, then a live fetch of that pin as the fallback.
# The default registry ref a mint converges — the BOX_RELEASE discipline
# (#103): one line, bumped deliberately by ordinary rig PR after review, so a
# rig release freezes the mechanism+registry pair and a newer rig matches
# newer templates by default (ruled 2026-07-24 on #110: pinned, not
# main-tracked). RIG_TEMPLATES_REF overrides it per mint.
#
# Currently the seed tree (rig-templates#1's head — fetchable from the
# upstream archive already, an ancestor of its main once merged): the four
# agent tenants ported byte-equivalent from the case arms this PR cut.
RIG_TEMPLATES_PIN=be749f7fd1ff8dd7c2359bbce7fd6abd3f403eb0
# The forge the registry lives on. GitHub by default, so every existing caller
# is byte-unchanged; overridable because a self-hosted Forgejo is a different
# origin AND a different URL grammar (#109).
RIG_TEMPLATES_HOST_DEFAULT="https://github.com"
# templates_archive_urls <host> <repo> <ref> — the source-tarball candidates
# for <ref>, in order, one per line. A PURE function (no network, no globals):
# test/cli.sh lifts it and drives it against both forges, the resolve_latest_tag
# precedent in install.sh.
#
# The two forges are not URL-compatible, and the difference is not cosmetic:
#
# GitHub three forms, refs/tags FIRST so a tag always outranks a branch
# sharing its name (a pin must win), refs/heads as the fallback
# that keeps a branch ref working, then the bare form a commit SHA
# downloads through.
# Forgejo ONE form. /archive/<ref>.tar.gz resolves tags, branches and SHAs
# alike, and the refs/{tags,heads}/ paths are not served at all —
# emitting them would mean two guaranteed 404s ahead of every fetch
# and a failure message listing URLs that never could have worked.
#
# Measured against forgejo.heavyduty.builders, not inferred from the docs.
templates_archive_urls() {
local host="${1%/}" repo="$2" ref="$3"
case "$host" in
https://github.com|http://github.com|*//github.com)
printf '%s/%s/archive/refs/tags/%s.tar.gz\n' "$host" "$repo" "$ref"
printf '%s/%s/archive/refs/heads/%s.tar.gz\n' "$host" "$repo" "$ref"
printf '%s/%s/archive/%s.tar.gz\n' "$host" "$repo" "$ref" ;;
*)
printf '%s/%s/archive/%s.tar.gz\n' "$host" "$repo" "$ref" ;;
esac
}
# The template.env schema. Grammar: blank lines, '#' comments, and
# KEY="value" — nothing else. Parsed by regex, never sourced.
TEMPLATE_KEYS_REQUIRED=(USER CONTEXT_PATH CLI_NAME PATH_LINE)
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
# messages and logs: a misconfigured RIG_TEMPLATES_REPO must be visible in
# the unknown-role refusal rather than looking like a typo.
# The host rides every non-snapshot description: a registry served from the
# wrong FORGE fails exactly like a misspelled repo, and naming only the repo
# would send the reader hunting for a typo that is not there (#109).
templates_source_desc() {
local host="${RIG_TEMPLATES_HOST:-$RIG_TEMPLATES_HOST_DEFAULT}"
if [ -n "${RIG_TEMPLATES_DIR:-}" ]; then
printf 'local dir %s (RIG_TEMPLATES_DIR)' "$RIG_TEMPLATES_DIR"
elif [ -z "${RIG_TEMPLATES_REF:-}" ] && templates_snapshot_usable; then
printf '%s@%s (snapshot)' \
"${RIG_TEMPLATES_REPO:-heavy-duty/rig-templates}" \
"$RIG_TEMPLATES_PIN"
else
printf '%s/%s@%s%s' \
"${host%/}" \
"${RIG_TEMPLATES_REPO:-heavy-duty/rig-templates}" \
"${RIG_TEMPLATES_REF:-$RIG_TEMPLATES_PIN}" \
"$([ -n "${RIG_TEMPLATES_REF:-}" ] && printf ' (RIG_TEMPLATES_REF)' || printf ' (the in-tree pin)')"
fi
}
# 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
# $(…) call site would run the fetch in a subshell and lose TEMPLATES_TMP,
# the path the caller's cleanup trap must rm). RIG_TEMPLATES_DIR wins and is
# used as-is; otherwise the repo@ref tarball is fetched and extracted under
# a temp dir, recorded in TEMPLATES_TMP. Candidate URLs come from
# templates_archive_urls, which is forge-aware — see there for why the two
# forges cannot share one list.
#
# Failure lists every URL tried: the fetch is unauthenticated by contract
# (box auto-runs bootstrap at mint, holding nothing), so "is the repo public
# and the ref real" is the whole diagnosis.
#
# On a SELF-HOSTED forge there is a third way to fail that reads exactly like
# the other two, so the refusal names it: an instance with
# REQUIRE_SIGNIN_VIEW=true serves 404 for public repos to anonymous callers —
# the same status a wrong ref gets. A mint holds no credentials and never
# will, so such an instance cannot host a registry until it serves public
# repos anonymously (#109).
TEMPLATES_TMP=""
# shellcheck disable=SC2034 # REGISTRY_DIR is this function's OUTPUT, read by the sourcing script
REGISTRY_DIR=""
templates_resolve() {
local repo ref host url got=""
if [ -n "${RIG_TEMPLATES_DIR:-}" ]; then
[ -d "$RIG_TEMPLATES_DIR" ] || {
printf 'RIG_TEMPLATES_DIR is not a directory: %s\n' "$RIG_TEMPLATES_DIR" >&2
return 1
}
REGISTRY_DIR="$RIG_TEMPLATES_DIR"
return 0
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}"
ref="${RIG_TEMPLATES_REF:-$RIG_TEMPLATES_PIN}"
host="${RIG_TEMPLATES_HOST:-$RIG_TEMPLATES_HOST_DEFAULT}"
command -v curl >/dev/null 2>&1 || { printf 'curl is required to fetch the template registry\n' >&2; return 1; }
command -v tar >/dev/null 2>&1 || { printf 'tar is required to extract the template registry\n' >&2; return 1; }
TEMPLATES_TMP="$(mktemp -d)"
while IFS= read -r url; do
if curl -fsSL "$url" -o "$TEMPLATES_TMP/templates.tar.gz" 2>/dev/null; then got="$url"; break; fi
done < <(templates_archive_urls "$host" "$repo" "$ref")
if [ -z "$got" ]; then
printf 'cannot fetch the template registry %s/%s@%s — tried:\n' "${host%/}" "$repo" "$ref" >&2
templates_archive_urls "$host" "$repo" "$ref" | sed 's/^/ /' >&2
printf 'the fetch is unauthenticated by contract (a mint holds no credentials): the repo must be public and the ref must exist. RIG_TEMPLATES_DIR=<dir> bypasses the fetch.\n' >&2
case "${host%/}" in
https://github.com|http://github.com) ;;
*) printf 'on a self-hosted forge, check the instance serves PUBLIC repos to anonymous callers too: Forgejo with REQUIRE_SIGNIN_VIEW=true answers 404 for a public repo, which is indistinguishable from a wrong ref above (set FORGEJO__service__REQUIRE_SIGNIN_VIEW=false).\n' >&2 ;;
esac
return 1
fi
tar -xzf "$TEMPLATES_TMP/templates.tar.gz" -C "$TEMPLATES_TMP" || {
printf 'cannot extract the registry tarball from %s\n' "$got" >&2
return 1
}
# A source archive holds exactly one top-level directory — assert that
# SHAPE, never the name, because the name is the forge's choice and the two
# disagree: GitHub writes <repo>-<ref> (slashes flattened), Forgejo writes
# bare <repo>. Globbing for the shape is what makes this line survive a
# forge swap untouched.
set -- "$TEMPLATES_TMP"/*/
{ [ $# -eq 1 ] && [ -d "$1" ]; } || {
printf 'the registry tarball from %s does not hold exactly one top-level directory\n' "$got" >&2
return 1
}
# shellcheck disable=SC2034 # the function's output global, read by the sourcing script
REGISTRY_DIR="${1%/}"
}
# templates_roles <registry-dir> — the roles a registry defines: its
# immediate subdirectories that carry a template.env. This list IS the
# unknown-role refusal's body, so it reflects what the resolved source
# actually contains — never a hardcoded set.
templates_roles() {
local d
for d in "$1"/*/; do
[ -f "$d/template.env" ] || continue
basename "$d"
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
# 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
# failing key (or line): the box.env discipline — a definition is data, and
# bad data is refused loudly, never executed to find out.
# shellcheck disable=SC2034 # the TPL_* globals are this function's OUTPUT, read by the sourcing script
template_parse_env() {
local file="$1" line key val n=0 seen=" " k ok
TPL_USER="" TPL_CONTEXT_PATH="" TPL_CLI_NAME="" TPL_CLI_SRC=""
TPL_PATH_LINE="" TPL_NEEDS_NODE="no" TPL_APT_EXTRAS=""
[ -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 "${TEMPLATE_KEYS_REQUIRED[@]}" "${TEMPLATE_KEYS_OPTIONAL[@]}"; do
[ "$key" = "$k" ] && ok=1
done
[ -n "$ok" ] || { printf 'template.env:%d: unknown key: %s (allowed: %s %s)\n' \
"$n" "$key" "${TEMPLATE_KEYS_REQUIRED[*]}" "${TEMPLATE_KEYS_OPTIONAL[*]}" >&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
USER) TPL_USER="$val" ;;
CONTEXT_PATH) TPL_CONTEXT_PATH="$val" ;;
CLI_NAME) TPL_CLI_NAME="$val" ;;
CLI_SRC) TPL_CLI_SRC="$val" ;;
PATH_LINE) TPL_PATH_LINE="$val" ;;
NEEDS_NODE) TPL_NEEDS_NODE="$val" ;;
APT_EXTRAS) TPL_APT_EXTRAS="$val" ;;
esac
done < "$file"
for k in "${TEMPLATE_KEYS_REQUIRED[@]}"; do
case "$seen" in *" $k "*) ;; *)
printf 'template.env: missing required key: %s\n' "$k" >&2; return 1 ;;
esac
done
# Value shapes — each refusal names its key. USER shares the charset the
# users file enforces (a leading '-' reads as a usermod flag; '|', ':'
# corrupt things downstream).
[[ "$TPL_USER" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]] \
|| { printf 'template.env: USER: invalid user name: %s (want ^[a-z_][a-z0-9_-]{0,31}$)\n' "$TPL_USER" >&2; return 1; }
case "$TPL_CONTEXT_PATH" in
/*|*..*|'') printf 'template.env: CONTEXT_PATH: must be relative to the tenant home, without "..": %s\n' "$TPL_CONTEXT_PATH" >&2; return 1 ;;
esac
[[ "$TPL_CLI_NAME" =~ ^[a-z0-9][a-z0-9._-]*$ ]] \
|| { printf 'template.env: CLI_NAME: not a sane command name: %s\n' "$TPL_CLI_NAME" >&2; return 1; }
# A literal '~/' on purpose (SC2088): the value is DATA — the mechanism
# expands it to the tenant home by string substitution, never the shell.
# shellcheck disable=SC2088
case "$TPL_CLI_SRC" in
*..*) printf 'template.env: CLI_SRC: must not contain "..": %s\n' "$TPL_CLI_SRC" >&2; return 1 ;;
''|'~/'*|/*) ;;
*) printf 'template.env: CLI_SRC: must be absolute or ~/-relative: %s\n' "$TPL_CLI_SRC" >&2; return 1 ;;
esac
case "$TPL_NEEDS_NODE" in
yes|no) ;;
*) printf 'template.env: NEEDS_NODE: want yes or no, got: %s\n' "$TPL_NEEDS_NODE" >&2; return 1 ;;
esac
[ -n "$TPL_PATH_LINE" ] \
|| { printf 'template.env: PATH_LINE: must not be empty\n' >&2; return 1; }
# Every word must be a sane package name — the list is handed to apt-get
# unquoted by design, and this is what keeps an option ('-o …') or a path
# from riding in through the data file.
local pkg
for pkg in $TPL_APT_EXTRAS; do
[[ "$pkg" =~ ^[a-z0-9][a-z0-9.+-]*$ ]] \
|| { printf 'template.env: APT_EXTRAS: not a sane package name: %s\n' "$pkg" >&2; return 1; }
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
# content, on stdout: the one file every agent reads before touching
# anything. The skeleton is MECHANISM and lives here once — the box#80 guard
# note ("never run box setup-host or the drill inside a box; the box you are
# in is not a host you own") must never be copy-pasted per template again —
# and only the creds paragraph is per-vendor DATA, spliced in from the
# definition's creds.md.
render_tenant_context() {
local role="$1" creds_file="$2"
cat <<EOF
# You are running inside a box (tenant: ${role})
A box is a trust-less, network-isolated, ephemeral VM created by the
\`box\` CLI. Keep this context in mind:
$(cat "$creds_file")
- **Isolated.** The box reaches the public internet but nothing on the host or
local network. There is no inbound path.
- **Disposable.** Nothing here is backed up. State is discarded when the box is
removed; the operator persists work via git push and via \`box snapshot\`.
- **Not a host you own.** Never run \`box setup-host\`, \`box teardown-host\`,
or the drill inside a box. The box you are in is not a host you own: a
nested box stack claims the guest's own uplink subnet and gateway, and
silently breaks this box's networking with intermittent egress blackouts
(heavy-duty/box#80). Working ON the box repo from in here is fine — editing
and testing never needs the host stack; host setup belongs to the operator's
machine, never this one.
- **Bootstrap runbook.** If the repository you are working in contains a
\`.box/\` folder (older repos may use \`.claudebox/\`), read it as your setup
runbook — how to install dependencies, start services, template environment
files, seed data, and smoke-test — and follow it. It is documentation for
you, not a script the host runs.
EOF
}
# template_lint <role-dir> — the whole-definition check the registry repo's
# CI runs on every PR (rig defines what a valid template is; rig-templates
# CI enforces it, so a broken definition is refused before it can reach a
# mint). Same parser the mint runs — the two gates are not redundant: CI
# protects the registry, the mint-time parse protects a mint served through
# RIG_TEMPLATES_REPO/_DIR that CI never saw.
template_lint() {
local dir="${1%/}" role family
role="$(basename "$dir")"
[ -d "$dir" ] || { printf '%s: not a directory\n' "$dir" >&2; return 1; }
family="$(template_family "$role" 2>/dev/null || true)"
[ -n "$family" ] || {
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
return 1
}
if [ "$family" = "tenant" ]; then
template_parse_env "$dir/template.env" || return 1
[ -s "$dir/install.sh" ] \
|| { printf '%s: install.sh missing or 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; }
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; }
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
}

View file

@ -1,88 +0,0 @@
#!/usr/bin/env bash
# Shared parameters for the box TENANT roles (claude, codex, grok, staging) —
# sourced by bootstrap-tenant.sh and by the test harness. Pure text→text, no
# side effects: the per-tenant differences live HERE, in one table, so the
# mechanism stays one script parameterized per tenant instead of four
# hand-maintained copies (repo precedent: parse_users_file, runner-config).
# tenant_user <role> — the user the box seed creates (box.env BOX_USER). The
# agent tenants are named after their agent; staging keeps box#69's `ops`.
tenant_user() {
case "$1" in
claude) printf 'claude' ;;
codex) printf 'codex' ;;
grok) printf 'grok' ;;
staging) printf 'ops' ;;
*) return 1 ;;
esac
}
# tenant_context_path <role> <home> — where the agent-context file lands. Each
# agent CLI reads its own instructions file from its own dotdir; staging has no
# agent and no context file (return 1).
tenant_context_path() {
case "$1" in
claude) printf '%s/.claude/CLAUDE.md' "$2" ;;
codex) printf '%s/.codex/AGENTS.md' "$2" ;;
grok) printf '%s/.grok/AGENTS.md' "$2" ;;
*) return 1 ;;
esac
}
# render_tenant_context <role> — the agent-context file's content, on stdout.
# One renderer for all three agents: only the creds paragraph is per-vendor,
# and the box#80 guard note lives HERE once — never copy-pasted per template.
# staging renders nothing (return 1): no agent lives there.
render_tenant_context() {
local role="$1" creds
# The single-quoted markdown below carries literal `$`-free backtick prose;
# single quotes are deliberate — nothing in it may expand here.
# shellcheck disable=SC2016
case "$role" in
claude)
creds='- **Creds-free by default.** The box starts with no Claude and no git
credentials. If you need to authenticate Claude, the operator runs `/login`
interactively. For git, the operator adds their own credentials (a PAT or
`gh auth login`). Never assume credentials are present; never ask for or
store secrets on disk beyond what the operator sets up.' ;;
codex)
creds='- **Creds-free by default.** The box starts with no OpenAI and no git
credentials. If you need to authenticate Codex, the operator runs the
login flow (`codex`) interactively. For git, the operator adds their own
credentials (a PAT or `gh auth login`). Never assume credentials are
present; never ask for or store secrets on disk beyond what the operator
sets up.' ;;
grok)
creds='- **Creds-free by default.** The box starts with no xAI and no git
credentials. If you need to authenticate, the operator runs
`grok login` interactively (SuperGrok / X Premium+). For git, the
operator adds their own credentials (a PAT or `gh auth login`). Never
assume credentials are present; never ask for or store secrets on disk
beyond what the operator sets up.' ;;
*) return 1 ;;
esac
cat <<EOF
# You are running inside a box (tenant: ${role})
A box is a trust-less, network-isolated, ephemeral VM created by the
\`box\` CLI. Keep this context in mind:
${creds}
- **Isolated.** The box reaches the public internet but nothing on the host or
local network. There is no inbound path.
- **Disposable.** Nothing here is backed up. State is discarded when the box is
removed; the operator persists work via git push and via \`box snapshot\`.
- **Not a host you own.** Never run \`box setup-host\`, \`box teardown-host\`,
or the drill inside a box. The box you are in is not a host you own: a
nested box stack claims the guest's own uplink subnet and gateway, and
silently breaks this box's networking with intermittent egress blackouts
(heavy-duty/box#80). Working ON the box repo from in here is fine — editing
and testing never needs the host stack; host setup belongs to the operator's
machine, never this one.
- **Bootstrap runbook.** If the repository you are working in contains a
\`.box/\` folder (older repos may use \`.claudebox/\`), read it as your setup
runbook — how to install dependencies, start services, template environment
files, seed data, and smoke-test — and follow it. It is documentation for
you, not a script the host runs.
EOF
}

View file

@ -32,8 +32,8 @@
# fix cycle, not one round-trip per line. # fix cycle, not one round-trip per line.
# #
# Refusals: unknown role (the valid set is named), differing roles across one # Refusals: unknown role (the valid set is named), differing roles across one
# user's lines, root as username (root's keys are class policy's business, not # user's lines, root as username (root's keys are root-door policy's business,
# this file's), malformed line (fewer than 3 fields, or a key field that does # not this file's), malformed line (fewer than 3 fields, or a key field that does
# not start with an SSH key type and is not exactly '@root'), '@root' with # not start with an SSH key type and is not exactly '@root'), '@root' with
# trailing material (the token IS the whole field), invalid username (the # trailing material (the token IS the whole field), invalid username (the
# charset below — '|' would corrupt this parser's own delimited stream, a # charset below — '|' would corrupt this parser's own delimited stream, a
@ -71,7 +71,7 @@ parse_users_file() {
continue continue
fi fi
if [ "$u" = "root" ]; then if [ "$u" = "root" ]; then
errs+=("line $n: 'root' is not a rig-managed user — this file names operators; root SSH's fate is class policy") errs+=("line $n: 'root' is not a rig-managed user — this file names operators; root SSH's fate is root-door policy")
continue continue
fi fi
ok=1 ok=1
@ -103,21 +103,99 @@ parse_users_file() {
} }
# read_role_marker <path> — the marker line bootstrap wrote # read_role_marker <path> — the marker line bootstrap wrote
# (`role=... class=... host=... join=...`), or nothing when absent. NO policy # (`role=... root-door=... host=... join=...`), or nothing when absent. NO
# here: what an absent marker or a given class MEANS is each caller's call # policy here: what an absent marker or a given trait MEANS is each caller's
# (apply notes it, close-root refuses on it) — this reader only reads. # call (apply notes it, close-root refuses on it) — this reader only reads.
read_role_marker() { read_role_marker() {
[ -r "$1" ] || return 0 [ -r "$1" ] || return 0
head -n1 "$1" head -n1 "$1"
} }
# assert_marker_human <marker_path> — close-root's marker gate: return 0, # root_door_of <marker line> — resolve the root-door trait from a marker LINE,
# silently, only when the marker says class=human; otherwise print the refusal # reading both the current `root-door=` vocabulary and the `class=` one it
# reason on stdout and return 1 (the caller wraps it in its own die). The # replaced (#77). Prints exactly one of:
# policy is a pure lib function on purpose: the CLI path sits behind the root #
# check, so the harness proves every refusal HERE, against fixture markers, # closed the root SSH door is meant to shut once named operators exist
# non-root (repo precedent: parse_users_file, assert_runner_repo). # (`rig users close-root`) — was class=human
assert_marker_human() { # open root SSH stays as the control plane's automation door
# — was class=server
# conflict the marker names BOTH vocabularies and they DISAGREE
# (empty) the marker names neither, or names one with a value that is
# not in its value set
#
# Text->text and total, so the harness proves every arm off a literal string
# (repo precedent: deny_verdict, group_allow_verdict). Callers turn a verdict
# into policy; this function has none.
#
# WHY THE COMPAT READ IS NOT OPTIONAL, and why it is here rather than at each
# call site. #77 renamed the trait because `class=human|server` was named for
# who lives on the box while what it decides is whether root SSH stays open as
# the control plane's automation door — the axis that made `dev-server` a
# `class=human` box, a suffix and a trait that read as a contradiction. But
# unlike #76's role rename, this field is not informational: it is written into
# /etc/rig/role and read back on live machines, where it gates `rig users
# close-root`. Every box bootstrapped before this change carries `class=` and
# carries it FOREVER, until someone re-bootstraps it — there is no migration
# step that reaches a fleet. So dropping the old read breaks in both directions
# at once, and both are incidents: a machine whose door should close stops
# being able to close it (close-root refuses on a marker it no longer
# understands), and — through bootstrap-tenant's machine-marker guard, which
# used `class=` as its "this is a machine" detector — a real fleet box stops
# looking like a machine at all and a tenant converge will happily clobber it.
# One resolver, consulted everywhere the trait is read, is what keeps those
# two readings from drifting apart.
#
# BOTH FIELDS PRESENT is a state bootstrap never writes — it writes one line,
# fresh, in the new vocabulary only — so a marker carrying both was
# hand-edited, and the two answers are a question about intent that rig cannot
# settle. Agreement is taken (it says one thing twice); disagreement resolves
# to `conflict` and every caller fails CLOSED on it, because the alternative is
# picking a winner between two equally-authored claims about a root door. The
# repair is the same one the rest of the marker family names: re-run bootstrap,
# which rewrites the line whole.
#
# NEITHER FIELD PRESENT resolves empty and is likewise fail-closed everywhere,
# unchanged from before: a marker that names no door policy cannot authorize
# shutting a door.
root_door_of() {
local marker="$1" new="" old="" padded
# FIELD-ANCHORED, not substring. The marker is one line of space-separated
# `key=value` fields (bootstrap writes it with a single printf), so padding
# both ends and matching whole fields is exact. Unanchored patterns matched
# any value that EXTENDS a real one: `root-door=closedish` resolved as
# `closed` and passed close-root's gate — the one arm that authorizes an
# irreversible act — and `class=humanoid` did the same through the compat
# arm. That contradicted this function's own promise above, that a value
# outside the set resolves empty and fails closed. Only reachable by hand
# editing, but this is the function every consumer trusts, so it owes them
# exactness rather than "close enough" (found in review on #77).
# Whitespace is normalised first so a hand-edit using tabs or double spaces
# is read the same way rather than silently failing to match.
padded=" ${marker//[[:space:]]/ } "
case "$padded" in
*" root-door=closed "*) new=closed ;;
*" root-door=open "*) new=open ;;
esac
case "$padded" in
*" class=human "*) old=closed ;;
*" class=server "*) old=open ;;
esac
if [ -n "$new" ] && [ -n "$old" ] && [ "$new" != "$old" ]; then
printf 'conflict'; return 0
fi
# New wins where both are readable and agree; the old field answers alone on
# every marker written before #77, which is the whole point.
printf '%s' "${new:-$old}"
}
# assert_marker_closes_root <marker_path> — close-root's marker gate: return 0,
# silently, only when the marker's root-door trait resolves to `closed`;
# otherwise print the refusal reason on stdout and return 1 (the caller wraps
# it in its own die). The policy is a pure lib function on purpose: the CLI
# path sits behind the root check, so the harness proves every refusal HERE,
# against fixture markers, non-root (repo precedent: parse_users_file,
# assert_runner_repo).
assert_marker_closes_root() {
local marker local marker
marker="$(read_role_marker "$1")" marker="$(read_role_marker "$1")"
if [ -z "$marker" ]; then if [ -z "$marker" ]; then
@ -126,22 +204,27 @@ assert_marker_human() {
printf '%s\n' "no /etc/rig/role marker: re-run rig bootstrap so this box knows what it is; refusing to shut the root door blind" printf '%s\n' "no /etc/rig/role marker: re-run rig bootstrap so this box knows what it is; refusing to shut the root door blind"
return 1 return 1
fi fi
case "$marker" in case "$(root_door_of "$marker")" in
*class=human*) return 0 ;; closed) return 0 ;;
*class=server*) open)
# Root SSH on a server IS the control plane's (Coolify's) automation # Root SSH on such a box IS the control plane's (Coolify's) automation
# identity — closing it severs fleet management. No --force exists. # identity — closing it severs fleet management. No --force exists.
# Deliberately per-CLASS, not per-role: #17's original table let the # Deliberately keyed on the DOOR, not on the role: #17's original table
# runner role close root ("no Coolify involved"), but the class model # let the runner role close root ("no Coolify involved"), but the trait
# (#26) supersedes that — every server-class box, runner included, is # model (#26) supersedes that — every root-door=open box, runner
# an automation identity whose management plane is root SSH, and rig # included, is an automation identity whose management plane is root
# itself converges through that door. A CI box someone administers # SSH, and rig itself converges through that door. A CI box someone
# like a human machine is class=human at bootstrap, not an exception # administers like a human machine is --root-door closed at bootstrap,
# carved out here. # not an exception carved out here.
printf '%s\n' "class=server: root here is the control plane's automation identity — closing it severs fleet management. Every server-class box (runner included) keeps root deliberately: it is an automation identity, and root SSH is its management plane; a box meant to be administered like a human machine is --class human at bootstrap, not an exception here" printf '%s\n' "root-door=open: root here is the control plane's automation identity — closing it severs fleet management. Every root-door=open box (runner included) keeps root deliberately: it is an automation identity, and root SSH is its management plane; a box meant to be administered like a human machine is --root-door closed at bootstrap, not an exception here"
return 1 ;;
conflict)
# Hand-edited into naming both vocabularies, disagreeing. Fail closed:
# see root_door_of's header for why rig refuses to pick a winner.
printf '%s\n' "marker names both root-door= and the pre-#77 class= and they disagree (${marker}): rig will not pick a winner between two claims about a root door — re-run rig bootstrap to rewrite the marker, and refusing to shut the root door meanwhile"
return 1 ;; return 1 ;;
*) *)
printf '%s\n' "marker names no class (${marker}): re-run rig bootstrap; refusing to shut the root door blind" printf '%s\n' "marker names no root-door policy (${marker}): re-run rig bootstrap; refusing to shut the root door blind"
return 1 ;; return 1 ;;
esac esac
} }
@ -149,7 +232,7 @@ assert_marker_human() {
# assert_marker_hosts_vms <marker_path> — the box role's gate: return 0, # assert_marker_hosts_vms <marker_path> — the box role's gate: return 0,
# silently, only when the marker says host=yes; otherwise print the reason on # silently, only when the marker says host=yes; otherwise print the reason on
# stdout and return 1 (the caller decides whether that is a warn or a die). # stdout and return 1 (the caller decides whether that is a warn or a die).
# Same shape and same reason as assert_marker_human above: the policy is a # Same shape and same reason as assert_marker_closes_root above: the policy is a
# pure marker->verdict function so the harness can prove every arm against # pure marker->verdict function so the harness can prove every arm against
# fixture markers, non-root, while the CLI path sits behind the root check. # fixture markers, non-root, while the CLI path sits behind the root check.
# #

88
commands/manifest.sh Executable file
View file

@ -0,0 +1,88 @@
#!/usr/bin/env bash
# rig manifest — print this machine's provenance record, /etc/rig/manifest
# (#61). Reads only: this command NEVER writes the file. `rig bootstrap` is its
# single writer, and it stamps it as its own last durable act.
set -euo pipefail
HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
# shellcheck source=SCRIPTDIR/lib/manifest.sh
. "$HERE/lib/manifest.sh" # manifest_path / manifest_value
die() { printf 'rig-manifest: ERROR: %s\n' "$1" >&2; exit "${2:-1}"; }
usage() {
cat <<'EOF'
usage: rig manifest [<key>]
Print /etc/rig/manifest — which rig converged this machine, and when. With a
key, print that key's value alone, unquoted and newline-terminated, so a shell
caller does not re-parse the file:
rig manifest schema=1
bootstrapped_by=0.2.0
bootstrapped_at=2026-07-19T14:24:51Z
converged_by=0.4.0
converged_at=2026-08-02T09:11:03Z
rig manifest converged_by 0.4.0
Both pairs are provenance, both immutable in the sense that matters: BIRTH —
the rig that first converged this machine, pinned forever — and LATEST — the
newest rig to have converged it. On a fresh machine the two are equal. Ask the
second pair "is this machine converged by something ancient?"; ask the first
"what built it".
The version recorded is the one that RAN. `rig --version` reports the tree
installed NOW, which after an upgrade is a different question — a machine
outlives the rig that built it.
Reads only. Needs no root (the file is 0644), no network, and works on a
machine whose rig has since been upgraded or removed. Specs — cores, RAM,
disk, kernel — are NOT here: they are observed rather than decided, so they go
stale on their own and belong to `rig platform`, which computes them fresh and
stores nothing.
Exit 1 when there is no manifest — a machine converged before rig wrote one,
or never converged at all.
RIG_MANIFEST override the path (default /etc/rig/manifest)
EOF
}
KEY=""
for a in "$@"; do
case "$a" in
-h|--help) usage; exit 0 ;;
-*)
printf 'rig-manifest: unknown option: %s\n' "$a" >&2
usage >&2
exit 2
;;
*)
if [ -n "$KEY" ]; then
printf 'rig-manifest: manifest takes at most one key\n' >&2
usage >&2
exit 2
fi
KEY="$a"
;;
esac
done
MPATH="$(manifest_path)"
[ -r "$MPATH" ] || die "no manifest at $MPATH — this machine has not been converged by a rig that writes one (rig bootstrap writes it)"
if [ -z "$KEY" ]; then
cat "$MPATH"
exit 0
fi
# A key that is absent and a key whose value is empty are different answers to
# a shell caller, and $(...) collapses both to "". So the ABSENCE is the exit
# code, and only a present key ever prints — a caller reading `rig manifest
# converged_by` into a variable can trust that an empty result it accepted was
# a real empty value, not a missing key.
manifest_has "$MPATH" "$KEY" \
|| die "no such key: $KEY (keys present: $(cut -d= -f1 "$MPATH" | tr '\n' ' '))"
manifest_value "$MPATH" "$KEY"
echo

268
commands/platform.sh Executable file
View file

@ -0,0 +1,268 @@
#!/usr/bin/env bash
# rig platform — what is this machine? Calculated at run time, stored nowhere.
#
# Read-only in the strongest sense rig has: it reads /proc, uname,
# /etc/os-release, /etc/machine-id, df and systemd-detect-virt, and writes
# NOTHING, ever. That
# is the design, not an implementation detail — specs change without rig doing
# anything (RAM added, root disk resized, unattended-upgrades patching the
# kernel), so a stored spec is stale the moment the machine changes, and
# refreshing one on every run would collide with bootstrap's convergence
# contract ("safe to re-run; a second run changes nothing").
#
# The corollary is worth having deliberately: this runs on a machine rig has
# never converged, and needs no root. It answers "what should I converge this
# into?", not only "what did I converge this into?".
set -euo pipefail
HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
# shellcheck source=SCRIPTDIR/lib/users-config.sh
. "$HERE/lib/users-config.sh" # read_role_marker — one reader of /etc/rig/role
die() { printf 'rig-platform: ERROR: %s\n' "$1" >&2; exit "${2:-1}"; }
usage() {
cat <<'EOF'
usage: rig platform
Describes the machine you are on: hostname, a stable machine ID, OS, kernel,
CPU, memory, disk and virtualization, then rig's own provenance (which rig,
when, and the role marker bootstrap wrote).
ID names the machine where HOSTNAME names the slot: it is derived from
/etc/machine-id (a namespaced sha256, never the raw value, which machine-id(5)
asks tools not to expose). Two machines reporting the same ID were cloned
from one image — actionable information, not a coincidence: no identity that
lives in the filesystem survives the filesystem being copied.
Computed at run time from /proc, uname, /etc/os-release, /etc/machine-id, df
and systemd-detect-virt. Writes nothing, needs no root, makes no network call
— so it also works on a pristine Debian box rig has never bootstrapped, where
the provenance block reads 'not bootstrapped'.
EOF
}
# --- args ------------------------------------------------------------------
while [ $# -gt 0 ]; do
case "$1" in
-h|--help) usage; exit 0 ;;
*) die "unknown flag: $1" 2 ;;
esac
done
# One aligned column for every line, so the output diffs cleanly across a
# fleet and reads as one table rather than a log.
field() { printf '%-10s %s\n' "$1" "$2"; }
# --- hostname ---------------------------------------------------------------
# uname -n is the coreutils fallback: `hostname` lives in its own package and a
# minimal image may not carry it, and this command's whole point is running
# before anything has been installed.
HOSTNAME_V="$(hostname 2>/dev/null || uname -n)"
# --- identity (#95) -----------------------------------------------------------
# HOSTNAME names the slot; ID names the machine. rig itself sets the hostname
# during bootstrap and reuses it across rebuilds ('hetzner-cp-1' is a role, not
# hardware), so nothing above answers "is this the same machine I converged in
# June, or its replacement?". /etc/machine-id does — but machine-id(5) asks
# that the raw value not be exposed (it is a stable correlator across every
# tool that leaks it), and its documented remedy is an application-specific
# derivation. So: THE PINNED DERIVATION, fixed by #95 so two implementations
# can never disagree —
#
# printf 'rig-machine-id:%s' "$(cat /etc/machine-id)" | sha256sum
# → first 32 hex chars, rendered 8-4-4-4-12
#
# The 'rig-machine-id:' prefix is the contract, not decoration: it is what
# keeps this id uncorrelatable with any other tool's derivation of the same
# machine-id. sha256sum is coreutils, which this command is restricted to.
# Derived, computed here, stored nowhere — #64's thesis — so it exists before
# bootstrap and needs no write path.
#
# What this deliberately does NOT fix: a host cloned from a golden image
# carries the clone's /etc/machine-id, so two machines reporting the same ID
# means a cloned image. That is surfaced (help text, README) rather than
# defended against — no identity that lives in the filesystem survives the
# filesystem being copied.
#
# RIG_MACHINE_ID overrides the path so the harness can drive the present,
# absent, empty and uninitialized cases against fixtures (repo precedent:
# RIG_MANIFEST / RIG_ROLE_MARKER below).
MID_FILE="${RIG_MACHINE_ID:-/etc/machine-id}"
ID_V=""
if [ ! -r "$MID_FILE" ]; then
# Never an empty string: an ID field that renders blank looks like a bug,
# and a missing file is a fact worth naming.
ID_V="unavailable (no $MID_FILE)"
else
# $(...) strips the trailing newline — that is part of the pinned derivation
# above, not an accident of shell.
MID="$(cat "$MID_FILE")"
if [ -z "$MID" ]; then
# NEVER a hash of nothing: hashing the empty string would hand every such
# machine the SAME id — the worst possible failure for an identity field.
# Images do ship the file empty (that is first-boot semantics per
# machine-id(5)), so this path is real, not defensive.
ID_V="unavailable ($MID_FILE is empty)"
elif [ "$MID" = "uninitialized" ]; then
# machine-id(5)'s other not-yet-set sentinel — same collision failure as
# empty if hashed, so same loud degradation.
ID_V="unavailable ($MID_FILE is uninitialized)"
else
MID_HASH="$(printf 'rig-machine-id:%s' "$MID" | sha256sum)"
MID_HASH="${MID_HASH%% *}"
ID_V="${MID_HASH:0:8}-${MID_HASH:8:4}-${MID_HASH:12:4}-${MID_HASH:16:4}-${MID_HASH:20:12}"
fi
fi
# --- OS ---------------------------------------------------------------------
# THE os-release TRAP: /etc/os-release defines VERSION, NAME and ID, so
# sourcing it in the MAIN shell silently clobbers same-named script variables.
# Every site in this tree sources it in a SUBSHELL instead (bootstrap.sh:305,
# bootstrap-tenant.sh:126, runner-install.sh:88, db.sh:52,
# coolify-backup-install.sh:88), and test/cli.sh greps commands/ to keep it
# that way. Follow the form verbatim.
if [ -r /etc/os-release ]; then
OS="$(. /etc/os-release && printf '%s %s' "${NAME:-}" "${VERSION:-${VERSION_ID:-}}")"
else
OS=""
fi
# --- kernel -----------------------------------------------------------------
KERNEL="$(uname -r) ($(uname -m))"
# --- CPU --------------------------------------------------------------------
# 'model name' is x86's spelling; arm64 /proc/cpuinfo has no such field, so an
# unnamed CPU still reports its core count rather than nothing at all.
CPU_MODEL="$(awk -F': ' '/^model name/ {print $2; exit}' /proc/cpuinfo 2>/dev/null || true)"
CORES="$(nproc 2>/dev/null || true)"
# --- memory -----------------------------------------------------------------
# /proc/meminfo is in kB. MemAvailable is the kernel's own estimate of what a
# new workload could claim (MemFree undercounts badly, reclaimable cache being
# most of a busy box's RAM); it predates every kernel rig targets, but degrade
# rather than print a wrong number if it is missing.
mem_kb() { awk -v k="$1" '$1 == k":" {print $2; exit}' /proc/meminfo 2>/dev/null || true; }
MEM_TOTAL_KB="$(mem_kb MemTotal)"
MEM_AVAIL_KB="$(mem_kb MemAvailable)"
human_kb() { # kB -> IEC, matching df's units below
[ -n "${1:-}" ] || { printf 'unknown'; return 0; }
numfmt --to=iec-i "$(( $1 * 1024 ))" 2>/dev/null || printf '%s kB' "$1"
}
# --- disk -------------------------------------------------------------------
# -P is the one-line-per-filesystem guarantee (a long device name otherwise
# wraps and breaks field positions); -B1 gives bytes, so numfmt renders the
# same IEC units as memory above instead of df's own bare 'G'.
DISK_TOTAL="" DISK_FREE=""
if DF="$(df -PB1 / 2>/dev/null)"; then
DISK_TOTAL="$(printf '%s\n' "$DF" | awk 'NR==2 {print $2}')"
DISK_FREE="$(printf '%s\n' "$DF" | awk 'NR==2 {print $4}')"
fi
human_b() { # bytes -> IEC; falls back like human_kb rather than to 'unknown'
[ -n "${1:-}" ] || { printf 'unknown'; return 0; }
numfmt --to=iec-i "$1" 2>/dev/null || printf '%s B' "$1"
}
# --- virtualization ---------------------------------------------------------
# THE set -e TRAP: systemd-detect-virt exits NON-ZERO on bare metal while
# printing 'none'. That is a normal, correct answer — without the `|| true` a
# bare-metal machine would turn this whole command into a failed run. The
# substitution also swallows the binary being absent entirely (a non-systemd
# box), which lands as 'unknown'.
VIRT="$(systemd-detect-virt 2>/dev/null || true)"
printf '%s\n' "PLATFORM"
field HOSTNAME "$HOSTNAME_V"
field ID "$ID_V"
field OS "${OS:-unknown}"
field KERNEL "$KERNEL"
field CPU "${CPU_MODEL:-unknown}${CORES:+ ($CORES cores)}"
field MEMORY "$(human_kb "$MEM_TOTAL_KB") total, $(human_kb "$MEM_AVAIL_KB") available"
field DISK "$(human_b "$DISK_TOTAL") total, $(human_b "$DISK_FREE") free on /"
field VIRT "${VIRT:-unknown}"
echo
# --- provenance: READ, never written ----------------------------------------
# The complementary half of the answer — which rig, and when — is decided
# rather than observed, so unlike everything above it IS stored. rig writes it
# during bootstrap; this command only ever reads it.
#
# /etc/rig/manifest is #61 and is NOT implemented yet, so on every machine in
# existence today this block reads 'not bootstrapped'. That degradation is the
# point: the two features are independent and neither blocks the other. The
# parse is the flat key=value shape the manifest is specified to use — the
# same jq-free shape /etc/rig/users and /etc/rig/role already use, parseable
# with `read` on a box that has no YAML parser.
#
# RIG_MANIFEST / RIG_ROLE_MARKER override the paths so the harness can drive
# both the present and the absent case against fixtures, non-root, without a
# real marker on the machine running the tests (repo precedent: the
# RIG_ROLE_MARKER gate in bin/rig, install.sh and users-close-root.sh).
MANIFEST="${RIG_MANIFEST:-/etc/rig/manifest}"
MARKER="${RIG_ROLE_MARKER:-/etc/rig/role}"
manifest_field() { # $1 = key — empty when absent, unreadable or unset
local k v
[ -r "$MANIFEST" ] || return 0
# `|| [ -n "$k" ]` so a manifest whose last line lacks a trailing newline
# still yields that line: read returns 1 at EOF even having filled k/v.
# Same guard parse_users_file uses (lib/users-config.sh:47) — #61's writer
# should not have to know whether this reader tolerates a missing \n.
while IFS='=' read -r k v || [ -n "$k" ]; do
[ "$k" = "$1" ] || continue
printf '%s\n' "$v"
return 0
done < "$MANIFEST"
return 0
}
printf '%s\n' "PROVENANCE"
# Keys are #61's documented schema verbatim — schema/bootstrapped_by/
# bootstrapped_at/converged_by/converged_at — NOT invented ones. #61 keeps
# birth and latest deliberately separate ("is this machine converged by a rig
# that predates the fix?"), so both are reported and neither is inferred from
# the other: CONVERGED answers currency, BOOTSTRAPPED answers provenance.
# Every field degrades independently, so a partial manifest from a future
# schema still renders what it does carry.
if [ -r "$MANIFEST" ]; then
M_SCHEMA="$(manifest_field schema)"
B_BY="$(manifest_field bootstrapped_by)"; B_AT="$(manifest_field bootstrapped_at)"
C_BY="$(manifest_field converged_by)"; C_AT="$(manifest_field converged_at)"
# A manifest with no schema= line is pre-#61; say so rather than render blanks.
if [ -z "$M_SCHEMA$B_BY$B_AT$C_BY$C_AT" ]; then
field RIG "manifest present but carries no recognised fields ($MANIFEST)"
else
# 'not recorded' rather than 'unknown', and it does NOT describe a fresh
# box: #61's writer records both pairs equally at bootstrap, so no writer
# produces a manifest missing converged_* — its absence means the file is
# partial or hand-edited. Deliberately not backfilled from bootstrapped_*,
# because inferring a convergence that never happened is worse than saying
# the record is not there. README and the fixtures pin exactly this.
field CONVERGED "${C_BY:-not recorded}${C_AT:+, $C_AT}"
field BOOTSTRAP "${B_BY:-not recorded}${B_AT:+, $B_AT}"
[ -n "$M_SCHEMA" ] && [ "$M_SCHEMA" != "1" ] && \
field NOTE "manifest schema=$M_SCHEMA is newer than this rig reads (expects 1)"
fi
else
field RIG "not bootstrapped (no $MANIFEST)"
fi
# The role marker is bootstrap's own line — 'role=dev-server
# root-door=closed host=yes join=authkey' — printed as the role plus its
# traits. The example tracks the CURRENT vocabulary (#76's -server/-box role
# suffixes, #77's root-door trait); this command renders whatever fields the
# marker carries, so a pre-rename box still prints its own class= line as-is.
MARKER_LINE="$(read_role_marker "$MARKER")"
if [ -n "$MARKER_LINE" ]; then
ROLE_NAME="" ROLE_TRAITS=""
for kv in $MARKER_LINE; do
case "$kv" in
role=*) ROLE_NAME="${kv#role=}" ;;
*) ROLE_TRAITS="${ROLE_TRAITS:+$ROLE_TRAITS }$kv" ;;
esac
done
field ROLE "${ROLE_NAME:-unknown}${ROLE_TRAITS:+ ($ROLE_TRAITS)}"
else
field ROLE "not bootstrapped (no $MARKER)"
fi

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.

55
commands/template-lint.sh Executable file
View file

@ -0,0 +1,55 @@
#!/usr/bin/env bash
# rig template-lint <role-dir>... — is this a valid role definition?
#
# rig defines what a valid template is (the schema lives in
# lib/templates.sh, beside the mint-time parser that enforces it); the
# heavy-duty/rig-templates repo's CI runs this on every definition on every
# PR, so a broken definition is refused before it can ever reach a mint
# (#110). The two gates are deliberate: CI protects the registry, the
# mint-time parse protects a mint served through RIG_TEMPLATES_REPO/_DIR
# that CI never saw.
#
# Pure read: no root, no network, no writes — lintable anywhere, including
# the registry repo's checkout, where rig's tree is only a fetched tool.
set -euo pipefail
HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
# shellcheck source=SCRIPTDIR/lib/templates.sh
. "$HERE/lib/templates.sh" # template_lint (and the schema it enforces)
die() { printf 'rig-template-lint: ERROR: %s\n' "$1" >&2; exit "${2:-1}"; }
usage() {
cat <<'EOF'
usage: rig template-lint <role-dir>...
Validate role definitions (the heavy-duty/rig-templates shape).
Tenant roles use a *-box directory, tenant template.env schema, a shebang
install.sh, and non-blank creds.md. Machine roles use a *-server directory
(or exact name workstation), the ROOT_DOOR/HOST/JOIN schema, no creds.md,
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
}
case "${1:-}" in
-h|--help) usage; exit 0 ;;
"") usage >&2; die "at least one role directory required" 2 ;;
esac
fail=0
for dir in "$@"; do
case "$dir" in
-*) usage >&2; die "unknown flag: $dir" 2 ;;
esac
if template_lint "$dir"; then
printf 'rig-template-lint: OK: %s\n' "$dir"
else
printf 'rig-template-lint: FAIL: %s\n' "$dir" >&2
fail=1
fi
done
exit "$fail"

View file

@ -1,16 +1,18 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# rig users apply — converge named operator accounts from a declarative users # rig users apply — converge named operator accounts from a declarative users
# file, on every class. Humans always enter as themselves and elevate via # file, on every box. Humans always enter as themselves and elevate via
# sudo: a shared root login is unattributable, so operators belong on servers # sudo: a shared root login is unattributable, so operators belong on servers
# too — class never gates this command, it only decides root SSH's fate AFTER # too — the root-door trait never gates this command, it only decides root
# users exist (close-root on human, kept as the control plane's automation # SSH's fate AFTER users exist (close-root on root-door=closed, kept as the
# door on server). Convergent: a second identical run changes nothing and # control plane's automation door on root-door=open). Convergent: a second
# says so. # identical run changes nothing and says so.
set -euo pipefail 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; }
@ -18,9 +20,15 @@ die() { printf 'rig-users: ERROR: %s\n' "$1" >&2; exit "${2:-1}"; }
usage() { usage() {
cat <<'EOF' cat <<'EOF'
usage: rig users apply --file <path> usage: rig users apply --file <path> [--yes]
--file <path> users file (required; '-' reads it from stdin) --file <path> users file (required; '-' reads it from stdin)
--yes consent, up front, to the one prompt this command can ask:
a file naming ZERO users against a box that still has
managed operators revokes all of them. RIG_YES=1 says the
same thing (the installer-family convention). Without
either, that case asks on a TTY and REFUSES (exit 2)
without one — it never assumes consent it cannot get.
The file is line-based and bash-parseable on purpose — a rig box has no YAML The file is line-based and bash-parseable on purpose — a rig box has no YAML
parser and no jq, and gets neither for this. Whitespace-separated: user, parser and no jq, and gets neither for this. Whitespace-separated: user,
@ -72,11 +80,17 @@ EOF
# --- args (validated before the root check, so errors are testable) --------- # --- args (validated before the root check, so errors are testable) ---------
FILE="" FILE=""
# RIG_YES is the installer-family consent contract (bin/rig's uninstall_confirm
# reads the same variable): how automation says yes where there is no terminal
# to say it on. Set here so --yes and the env var are one flag with two doors.
ASSUME_YES=0
[ -n "${RIG_YES:-}" ] && ASSUME_YES=1
while [ $# -gt 0 ]; do while [ $# -gt 0 ]; do
case "$1" in case "$1" in
--file) --file)
[ $# -ge 2 ] || die "--file needs a value" 2 [ $# -ge 2 ] || die "--file needs a value" 2
FILE="$2"; shift 2 ;; FILE="$2"; shift 2 ;;
--yes) ASSUME_YES=1; shift ;;
-h|--help) usage; exit 0 ;; -h|--help) usage; exit 0 ;;
*) die "unknown flag: $1" 2 ;; *) die "unknown flag: $1" 2 ;;
esac esac
@ -124,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
@ -153,13 +171,27 @@ if [ "$NEED_SEED" -eq 1 ]; then
|| die "a user's keys seed from @root but root has no authorized_keys (/root/.ssh/authorized_keys missing or without key lines) — @root's whole point is copying a key you provably hold; list a literal key instead" || die "a user's keys seed from @root but root has no authorized_keys (/root/.ssh/authorized_keys missing or without key lines) — @root's whole point is copying a key you provably hold; list a literal key instead"
fi fi
# Class is a note, never a refusal: #26's call is that operators belong on # The root-door trait is a note here, never a refusal: #26's call is that
# EVERY class — what differs is root SSH's fate once they exist. # operators belong on EVERY box — what differs is root SSH's fate once they
case "$(read_role_marker "${RIG_ROLE_MARKER:-/etc/rig/role}")" in # exist. Resolved through root_door_of so this note and close-root's gate read
*class=server*) log "class=server: root SSH stays — it is the control plane's automation door" ;; # one marker the same way, pre-#77 class= spellings included (#77): a box whose
*class=human*) log "class=human: once your admin key works, 'rig users close-root' shuts the root door" ;; # note says the door will shut must be a box where close-root agrees it shuts.
"") warn "no /etc/rig/role marker — re-run rig bootstrap so this box knows what it is" ;; APPLY_MARKER="$(read_role_marker "${RIG_ROLE_MARKER:-/etc/rig/role}")"
if [ -z "$APPLY_MARKER" ]; then
warn "no /etc/rig/role marker — re-run rig bootstrap so this box knows what it is"
else
case "$(root_door_of "$APPLY_MARKER")" in
open) log "root-door=open: root SSH stays — it is the control plane's automation door" ;;
closed) log "root-door=closed: once your admin key works, 'rig users close-root' shuts the root door" ;;
# A marker naming both vocabularies in disagreement, or naming no door
# policy at all, is exactly where close-root will refuse. Apply still
# converges operators — that is the point of #26 — but it must not stay
# quiet about the refusal waiting at the end of the sequence it just
# pointed the operator at.
conflict) warn "marker names both root-door= and the pre-#77 class= and they disagree ($APPLY_MARKER) — 'rig users close-root' will refuse until you re-run rig bootstrap" ;;
*) warn "marker names no root-door policy ($APPLY_MARKER) — 'rig users close-root' will refuse until you re-run rig bootstrap" ;;
esac esac
fi
CHANGED=0 CHANGED=0
@ -169,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
@ -487,6 +539,62 @@ done
# revoked, data kept, convergence never destroys. # revoked, data kept, convergence never destroys.
LEDGER=/etc/rig/users LEDGER=/etc/rig/users
REVOKED=() REVOKED=()
# --- the empty-file gate (#65) -----------------------------------------------
# "Revoke everyone" and "I truncated the file" are the same instruction in this
# file format, and apply cannot read intent. What it CAN read is the ledger, and
# that draws the only line worth drawing: a file naming zero users against an
# empty ledger is an unambiguous no-op, while the same file against a populated
# one closes every named door on the box. Only the second is dangerous.
#
# So this is a CONFIRMATION, not a refusal — deliberately unlike bootstrap's
# flat die on the same file (#57/#59). bootstrap ASSERTS who lives on a box, so
# an empty answer there is a self-contradiction; apply CONVERGES, and converging
# to zero is a complete, legitimate de-provisioning that must keep working. The
# difference between the two commands is the whole point, and it survives here.
#
# The per-user warnings below are not this gate and cannot replace it: they
# arrive after the decision, one line per operator, so the signal is loudest
# exactly where it reads as scrollback rather than as a question.
#
# Count FIRST, then speak: the message states a real number, and counting is
# what makes the already-converged case silent. An entry the ledger already
# marks `revoked`, or one whose account no longer exists, is not at risk — this
# run would not change it — so a second identical run of an emptied file stays
# the clean no-op convergence promises, with no prompt to answer twice.
#
# SCOPE: the bright line only. Whether a file that drops 19 of 20 operators
# deserves the same gate is the issue's open question — it needs a threshold
# someone has to justify, where "the file is empty" needs nothing. Left open.
if [ "${#USERS[@]}" -eq 0 ] && [ -r "$LEDGER" ] && [ "$ASSUME_YES" -eq 0 ]; then
AT_RISK=0
while read -r prev pstate _; do
[ -n "$prev" ] || continue
[ "${pstate:-active}" != "revoked" ] || continue
id -u "$prev" >/dev/null 2>&1 || continue
AT_RISK=$((AT_RISK + 1))
done < "$LEDGER"
if [ "$AT_RISK" -gt 0 ]; then
warn "this users file names ZERO users, and this box still manages $AT_RISK operator(s): applying it revokes every one of them — accounts expired, authorized_keys renamed, rig groups stripped. If the file was meant to be empty this is de-provisioning; if it was truncated by accident, stop here."
# No terminal means no consent, and a question nobody can answer must not
# be assumed into a yes or left to hang. Same shape and same words as
# bin/rig's uninstall_confirm, on purpose — one refusal in this codebase.
if [ ! -t 0 ]; then
printf 'rig-users: refusing to revoke every managed operator without --yes (no terminal to confirm on; RIG_YES=1 also means yes)\n' >&2
exit 2
fi
# `|| reply=""` is load-bearing: under `set -e` a read that hits EOF is a
# non-zero command, and an unguarded read would abort the script rather
# than take the safe default (#68, the same bug class).
printf 'rig-users: revoke all %s managed operator(s) on this box? [y/N] ' "$AT_RISK"
read -r reply || reply=""
case "$reply" in
y|Y|yes|YES|Yes) ;;
*) die "aborted — no operator was revoked" ;;
esac
fi
fi
if [ -r "$LEDGER" ]; then if [ -r "$LEDGER" ]; then
while read -r prev pstate _; do while read -r prev pstate _; do
[ -n "$prev" ] || continue [ -n "$prev" ] || continue

View file

@ -1,8 +1,9 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# rig users close-root — shut the human-class root SSH door, once and only # rig users close-root — shut the root SSH door on the boxes whose door is
# once a named admin can already get in. class decides root SSH's fate (#26): # meant to shut, once and only once a named admin can already get in. The
# on class=human a root login is unattributable noise, so it goes; on # root-door trait decides root SSH's fate (#26, renamed by #77): on
# class=server root IS the control plane's automation identity, so closing it # root-door=closed a root login is unattributable noise, so it goes; on
# root-door=open root IS the control plane's automation identity, so closing it
# would sever fleet management — this command refuses there, and no --force # would sever fleet management — this command refuses there, and no --force
# exists. Convergent: a second run is a no-op and says so. # exists. Convergent: a second run is a no-op and says so.
set -euo pipefail set -euo pipefail
@ -10,6 +11,12 @@ 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"
# The sshd validator is shared with bootstrap's hardening on purpose: both
# commands ask sshd the same question before bouncing the daemon, and two
# copies of that judgement is drift by construction (#31's law, #92's bug —
# the flaw this fixes was present in both copies).
# shellcheck source=SCRIPTDIR/lib/sshd.sh
. "$HERE/lib/sshd.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; }
@ -23,9 +30,12 @@ Shuts the root SSH door: installs /etc/ssh/sshd_config.d/00-rig-users.conf
carrying exactly `PermitRootLogin no`, which beats bootstrap's drop-in by carrying exactly `PermitRootLogin no`, which beats bootstrap's drop-in by
first-wins include order. first-wins include order.
Human class ONLY. On class=server, root SSH is the control plane's (Coolify's) root-door=closed boxes ONLY. On root-door=open, root SSH is the control plane's
automation identity — closing it severs fleet management — so close-root (Coolify's) automation identity — closing it severs fleet management — so
refuses there, with no --force. It also refuses without a role marker (re-run close-root refuses there, with no --force. Markers written before #77 name this
trait as class=human|server and are read as closed|open respectively, so a box
bootstrapped before the rename gates exactly as it always did.
It also refuses without a role marker (re-run
rig bootstrap; never shut the root door blind) and refuses while no rig-admin rig bootstrap; never shut the root door blind) and refuses while no rig-admin
member holds a login this box would actually honor. Per candidate, in order: member holds a login this box would actually honor. Per candidate, in order:
the StrictModes shape (authorized_keys present and non-empty, home/.ssh/keys the StrictModes shape (authorized_keys present and non-empty, home/.ssh/keys
@ -72,11 +82,13 @@ if [ -n "${SUDO_USER:-}" ] && [ "$SUDO_USER" != "root" ] \
die "the users family changes who holds root — only rig-admin members (or root itself) may run it; role rig grants operational rig use, not identity management (invoker: $SUDO_USER)" die "the users family changes who holds root — only rig-admin members (or root itself) may run it; role rig grants operational rig use, not identity management (invoker: $SUDO_USER)"
fi fi
# Marker gate — the policy lives in assert_marker_human (lib) so the harness # Marker gate — the policy lives in assert_marker_closes_root (lib) so the
# can prove its refusals against fixture markers as non-root; RIG_ROLE_MARKER # harness can prove its refusals against fixture markers as non-root;
# exists for the same reason: it keeps the command's own gate pointable at # RIG_ROLE_MARKER exists for the same reason: it keeps the command's own gate
# fixtures instead of only at the real /etc/rig/role. # pointable at fixtures instead of only at the real /etc/rig/role. The gate
if ! WHY="$(assert_marker_human "${RIG_ROLE_MARKER:-/etc/rig/role}")"; then # reads the pre-#77 class= spelling too — see root_door_of for why that compat
# read is load-bearing rather than courteous.
if ! WHY="$(assert_marker_closes_root "${RIG_ROLE_MARKER:-/etc/rig/role}")"; then
die "$WHY" die "$WHY"
fi fi
@ -264,13 +276,13 @@ if [ "$RESTART" -eq 1 ]; then
# to become — restarting into a config the daemon refuses to parse leaves # to become — restarting into a config the daemon refuses to parse leaves
# no listener and no way back in. Roll back (when we installed anything) # no listener and no way back in. Roll back (when we installed anything)
# and stop rather than shut the door on a maybe. # and stop rather than shut the door on a maybe.
if ! sshd -t 2>/dev/null; then if ! sshd_config_ok; then
if [ "$INSTALLED" -eq 1 ]; then if [ "$INSTALLED" -eq 1 ]; then
if [ -n "$BACKUP" ]; then cp -a "$BACKUP" "$DROPIN"; else rm -f "$DROPIN"; fi if [ -n "$BACKUP" ]; then cp -a "$BACKUP" "$DROPIN"; else rm -f "$DROPIN"; fi
rm -f "$BACKUP" rm -f "$BACKUP"
die "sshd rejects the merged config; drop-in rolled back, daemon untouched. Run 'sshd -t' to see which file is bad." die "sshd rejects the merged config; drop-in rolled back, daemon untouched: $sshd_err"
fi fi
die "sshd rejects the current config; daemon untouched. Run 'sshd -t' to see which file is bad." die "sshd rejects the current config; daemon untouched: $sshd_err"
fi fi
rm -f "$BACKUP" rm -f "$BACKUP"
systemctl restart ssh systemctl restart ssh

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"

114
drill/README.md Normal file
View file

@ -0,0 +1,114 @@
# The drill — running it
`drill/drill.sh` is the instrument; `drills/` is the record it feeds
(see [drills/README.md](../drills/README.md) for what a record means and
how the three repos' drills relate). rig's drill asserts **convergence**:
a machine reaches its role, idempotently. This file is the procedure —
written down so a run is repeatable, not reconstructed from memory each
release (#105, and #107's debt).
## What you need
- **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
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
for one.
- **The pinned candidate refs, both of them.** `--rig-ref` and
`--box-ref` are required; the harness refuses to run without them and
refuses to continue if what installed disagrees with what was asked
(`INSTALLED_FROM`, both trees). Since heavy-duty/rig#103 landed, both
installers have sane defaults when unpinned — box installs the
`BOX_RELEASE` pin (currently `0.9.0`), rig's `install.sh` resolves the
latest release — and a sane default is exactly why the drill will not
let a ref go unstated: an unpinned run silently drills a shipping pair
that is not the candidate, and the record it leaves looks clean.
- **A single-use, tagged tailscale pre-auth key** in `TS_AUTHKEY`
(`tag:local` for the default `staging-server` role — bootstrap refuses
`tag:server` outside the control-plane shapes).
- **A users file** (`--users`) naming at least one operator — leg 1
asserts the accounts and keys actually converged.
- **For leg 3** (runner lifecycle): a fork to register against
(`--runner-repo you/rig`) carrying a `workflow_dispatch` workflow —
default name `drill.yml` — whose job has `runs-on: [self-hosted, drill]`
and does something trivial (`echo drilled`). Tokens come from an
authenticated `gh`, or from `RUNNER_TOKEN` / `RUNNER_REMOVE_TOKEN`.
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`.
No pin, no leg — rig's own `coolify install` refuses to default a
version and so does its drill. The skip is recorded.
- **A run ID** (`--run-id`) when this drill shares a substrate with
box's or cast's — the shared ID is what lets the per-repo records be
joined afterwards. Defaults to `drill-<date>`.
## Running it
From a checkout of this repo on the throwaway machine (the record lands
in the checkout's `drills/`):
```sh
TS_AUTHKEY=tskey-... bash drill/drill.sh \
--rig-ref release/0.4.0 --box-ref 0.9.0 \
--users ./drill-users --run-id drill-2026-07-24-a \
--coolify-version 4.1.2 --runner-repo you/rig --yes
```
`--box-ref` is a tag on purpose: since #103 the box that ships is the
`BOX_RELEASE` tag, so a `release/…` branch is the wrong thing to pin for
box — while a release branch stays exactly right for rig's own candidate.
It runs unattended from there. Legs execute as 1, 4, 2, 3 — Coolify's
installer is what puts Docker on the box and the db leg needs a daemon —
and the record lists them as they ran. A failing check never aborts the
run (`set -u`, no `-e`: a failing check is data), and the summary counts
passes, failures and skips separately.
## What it asserts
1. **Convergence, and idempotence.** `rig bootstrap <role> --users …`
reaches the declared role, asserted on *effective* state — the marker,
`sshd -T`, the granted tailnet tag, the operators' accounts and keys.
Then bootstrap runs **again**, and the state captured before and after
the re-run must diff **empty**. The diff is mechanical; "watched it
not obviously break" is exactly what this leg exists to replace.
Riding along, the `--host yes` assertions: the **pinned** box
installed (`INSTALLED_FROM` matches `--box-ref`, fatal if not),
`box doctor` passes. It stops there and says so in the output — the
isolation boundary is **box's** drill's assertion, never rig's.
2. **db**`test/db-integration.sh` from the *installed* tree: a real
dump/restore round-trip. Its clean-skip contract (no Docker → loud
skip, exit 0) survives into the record as a SKIP, never a pass.
3. **Runner lifecycle** — register against the fork, dispatch the drill
workflow and watch the runner take it, deregister, and assert the
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
effective `.env`, container running.
## The record
The run always ends by writing `drills/<version>.md` (the version is the
installed tree's own `VERSION`) — on failures too: **a failed drill is a
valid record**; the gate wants evidence, not success. Skipped legs are
named as not-run so the record can never read as a clean sweep. Commit
the file on the release branch; the `drill-recorded` guard reads that
file and nothing else.
The instrument's own honesty — the refusals, the skip accounting, the
capture-and-diff, the emitter — is `test/drill.sh`'s job, and CI runs it
on every PR. The live four-leg run is a release's job, once per cycle.

983
drill/drill.sh Normal file
View file

@ -0,0 +1,983 @@
#!/usr/bin/env bash
# drill/drill.sh — rig's release drill: the instrument behind drills/README.md.
#
# ⚠ 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
# ref, hardens sshd, sets the hostname, joins the tailnet, installs box
# 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.
#
# TS_AUTHKEY=tskey-... bash drill/drill.sh \
# --rig-ref release/0.4.0 --box-ref 0.9.0 \
# --users ./drill-users --run-id drill-2026-07-24-a \
# --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.)
# rig's drill asserts CONVERGENCE — a machine reaches its role, idempotently.
# The legs (drills/README.md, issue #105):
#
# 1. convergence + idempotence — `rig bootstrap <role> --users <path>`
# reaches the declared role; a re-run produces an EMPTY state diff,
# mechanically, never by eye. Rides along: the --host yes assertions
# (the pinned box installed, its host stack stands — and it STOPS there;
# 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.
# 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.
#
# Execution order is 1, 4, 2, 3 — coolify's installer is what puts Docker on
# the box, and leg 2 needs a daemon; running db before coolify would skip a
# leg this same run makes runnable. The record lists legs as they ran.
#
# Exit 0 = no check failed. A FAILED drill still emits a complete record —
# the gate wants evidence, not success — and skipped legs are counted and
# named, never folded into the passes (heavy-duty/box#153's defect class).
#
# The file is one long 'probe && ok "…" || no "…"'. ok/no always return 0, so
# the C-may-run-when-A-is-true trap SC2015 warns about cannot fire here.
# shellcheck disable=SC2015
#
# NOT -e: a failing check is data, not a crash — a drill that aborts on its
# first failure reports one problem per afternoon. NOT pipefail: checks of the
# 'refusal 2>&1 | grep -q text' shape have a left side that exits non-zero BY
# DESIGN, and 'grep -q' SIGPIPEs the left side on early match — box's first
# live run turned both into false FAILs. The pipeline verdict must be grep's
# alone. (box drill/drill.sh's header, the discipline #105 prescribes.)
set -u
SELF="$(readlink -f "$0")"
ROOT="$(cd "$(dirname "$SELF")/.." && pwd)"
REPO="${RIG_REPO:-heavy-duty/rig}"
REF="${RIG_REF:-}"
BOXREPO="${BOX_REPO:-heavy-duty/box}"
BOXREF="${BOX_REF:-}"
# The template registry the converge will read (#110). No explicitness
# demand here, unlike the two refs above: the DEFAULT is already a pin — the
# candidate tree's RIG_TEMPLATES_PIN, read after install from what actually
# landed — so an unset override means "the ref the release will really use",
# not "whatever main was that afternoon".
TPLREPO="${RIG_TEMPLATES_REPO:-heavy-duty/rig-templates}"
TPLREF="${RIG_TEMPLATES_REF:-}"
TPL_SHA=""
TPL_SOURCE="fetched"
ROLE=staging-server
USERS_FILE="${DRILL_USERS_FILE:-}"
RUN_ID="${DRILL_RUN_ID:-drill-$(date -u +%F)}"
RECORD="${DRILL_RECORD:-}"
COOLIFY_VERSION="${DRILL_COOLIFY_VERSION:-}"
RUNNER_REPO="${DRILL_RUNNER_REPO:-}"
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
while [ $# -gt 0 ]; do
case "$1" in
--yes|-y) YES=1; shift ;;
--rig-repo) REPO="$2"; shift 2 ;;
--rig-ref) REF="$2"; shift 2 ;;
--box-repo) BOXREPO="$2"; shift 2 ;;
--box-ref) BOXREF="$2"; shift 2 ;;
--role) ROLE="$2"; shift 2 ;;
--users) USERS_FILE="$2"; shift 2 ;;
--run-id) RUN_ID="$2"; shift 2 ;;
--record) RECORD="$2"; shift 2 ;;
--coolify-version) COOLIFY_VERSION="$2"; shift 2 ;;
--runner-repo) RUNNER_REPO="$2"; shift 2 ;;
--runner-workflow) RUNNER_WORKFLOW="$2"; shift 2 ;;
--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 ;;
esac
done
# --- the reporting verbs (box drill/drill.sh:52-58, the parts worth copying) --
# ok/no/skip/note always return 0: the body stays one long sequence of
# 'probe && ok || no' without fighting the shell. SKIP is its own verb and its
# own counter — a leg that did not run must be visually and arithmetically
# distinct from one that passed (box#153's defect class: a silent skip reads
# as a pass in the record, months later).
pass=0; fail=0; skipped=0; findings=()
ok() { printf ' \033[32mPASS\033[0m %s\n' "$*"; pass=$((pass + 1)); }
no() { printf ' \033[31mFAIL\033[0m %s\n' "$*"; fail=$((fail + 1)); findings+=("FAIL: $*"); }
skip() { printf ' \033[35mSKIP\033[0m %s\n' "$*"; skipped=$((skipped + 1)); findings+=("SKIP: $*"); }
note() { printf ' \033[33mNOTE\033[0m %s\n' "$*"; findings+=("NOTE: $*"); }
inf() { printf ' %s\n' "$*"; }
phase(){ printf '\n\033[1m══ %s\033[0m\n' "$*"; }
# The record's leg table, appended as legs run. One row per leg, result text
# written at the moment the leg's verdict is known — never reconstructed from
# memory at the end (an invented number is worse than no number).
LEG_NAMES=(); LEG_RESULTS=()
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
# 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
# evenings. Returns the command's exit code.
run_logged() {
local log="$1"; shift
inf "watch it live in another terminal: tail -f $log"
"$@" >"$log" 2>&1 </dev/null &
local pid=$!
while kill -0 "$pid" 2>/dev/null; do printf '.'; sleep 5; done
printf '\n'
wait "$pid"
}
# tree_of <cli-path> — the versioned install tree a CLI's symlink chain lands
# in. Both rig and box install as <root>/versions/<v>/bin/<cli> behind a
# 'current' link, so the tree is two dirnames above the resolved binary —
# derived from the chain itself, never from a hardcoded install root (root vs
# user installs put the root in different places).
tree_of() {
local real
real="$(readlink -f "$1" 2>/dev/null)"
# -e as well as -n: GNU readlink -f resolves a path whose LAST component
# does not exist (exit 0), so a dangling link would hand back a tree that
# is not there.
{ [ -n "$real" ] && [ -e "$real" ]; } || return 1
dirname "$(dirname "$real")"
}
# assert_installed_from <what> <tree> <want> — ASSERT WHAT LANDED, never trust
# that the install obeyed. An installer invoked with stale env vars silently
# falls back to its defaults — sane ones since rig#103 landed (box: the
# BOX_RELEASE pin, rig: the latest release), which is what makes the fallback
# invisible — and a drill that thinks it exercised release/X but actually got
# whatever the defaults resolve to has proven nothing about the combination
# that ships — worse than one that fails, because the record it leaves LOOKS
# like evidence. Refusal names both refs, per #105's acceptance criteria.
assert_installed_from() {
local what="$1" tree="$2" want="$3" got
got="$(cat "$tree/INSTALLED_FROM" 2>/dev/null || echo '<unreadable>')"
if [ "$got" != "$want" ]; then
printf 'drill: FATAL — asked to install %s from %s, but the installed tree says %s.\n' "$what" "$want" "$got" >&2
printf ' (tree: %s)\n' "$tree" >&2
printf ' A drill that silently drills the wrong code is worse than one that fails:\n' >&2
printf ' every result below would describe a tree that is not the candidate. Check\n' >&2
printf ' the env this drill inherited (a stale RIG_REF/BOX_REF export), fix the\n' >&2
printf ' pin, and re-run.\n' >&2
return 1
fi
return 0
}
# classify_leg <rc> <outfile> — pass | skip | fail. The skip contract is
# test/db-integration.sh's, copied carefully: it skips CLEANLY (exit 0) with a
# 'skip: <reason>' line when it cannot run, so exit code alone reads a
# not-run leg as a pass. The reason line is the verdict's tiebreaker; a
# non-zero exit is a fail whatever the output says (a die after a skip line
# would be a broken harness, not a skip).
classify_leg() {
local rc="$1" out="$2"
if [ "$rc" -eq 0 ] && grep -q '^skip:' "$out" 2>/dev/null; then
printf 'skip'
elif [ "$rc" -eq 0 ]; then
printf 'pass'
else
printf 'fail'
fi
}
# capture_state <outfile> — the convergent surface bootstrap owns, as one
# diffable text file. Leg 1's idempotence claim is decided by capturing this
# BEFORE and AFTER the re-run and diffing — mechanically, because idempotence
# is the single easiest property to convince yourself of by eye (#105).
#
# What is captured is what bootstrap CONVERGES, nothing that legitimately
# moves between two back-to-back runs: no package lists (unattended-upgrades
# may act between captures), no clocks. The manifest is included WHOLE on
# purpose — lib/manifest.sh's contract is that a same-version re-run renders
# byte-identical content (converged_at tracks the version, not the run), so
# the diff ENFORCES that contract instead of exempting it.
#
# Every path is overridable so test/drill.sh proves the capture-and-diff
# machinery against fixtures, without root (repo precedent: RIG_ROLE_MARKER,
# RIG_MANIFEST). Absent files and commands degrade to a deterministic
# '(absent)' — a capture must never fail, only describe.
capture_state() {
local out="$1" marker manifest ledger autoup hosts u state home keys
marker="${RIG_ROLE_MARKER:-/etc/rig/role}"
manifest="${RIG_MANIFEST:-/etc/rig/manifest}"
ledger="${DRILL_LEDGER:-/etc/rig/users}"
autoup="${DRILL_AUTOUPGRADES:-/etc/apt/apt.conf.d/20auto-upgrades}"
hosts="${DRILL_ETC_HOSTS:-/etc/hosts}"
{
printf 'hostname: %s\n' "$(hostname 2>/dev/null || echo '(absent)')"
printf 'hosts.127.0.1.1: %s\n' "$(grep -E '^127\.0\.1\.1[[:space:]]' "$hosts" 2>/dev/null || echo '(absent)')"
printf 'role-marker: %s\n' "$(cat "$marker" 2>/dev/null || echo '(absent)')"
printf 'manifest:\n'
sed 's/^/ /' "$manifest" 2>/dev/null || printf ' (absent)\n'
printf 'auto-upgrades:\n'
sed 's/^/ /' "$autoup" 2>/dev/null || printf ' (absent)\n'
printf 'sshd-effective:\n'
if command -v sshd >/dev/null 2>&1; then
sshd -T 2>/dev/null | sort | sed 's/^/ /' || printf ' (sshd -T failed)\n'
else
printf ' (sshd absent)\n'
fi
# Self's Tags is the FIRST occurrence in the status JSON (Self serializes
# before Peer). Tags only, nothing livelier: peers joining, IPs renewing
# or a backend-state flap between two captures is not a convergence diff
# on this box, and a capture that can move on its own poisons the
# idempotence verdict with noise.
printf 'tailscale.self.tags: %s\n' "$(tailscale status --json 2>/dev/null | tr -d '\n ' | grep -o '"Tags":\[[^]]*\]' | head -n1 || true)"
printf 'users-ledger:\n'
sed 's/^/ /' "$ledger" 2>/dev/null || printf ' (absent)\n'
# Per-operator effective state: the account, its groups, its lock state,
# its keys. sha256 of authorized_keys, not the keys themselves — the
# capture may end up quoted in a record and keys are long, not secret.
while read -r u state; do
[ -n "${u:-}" ] || continue
if ! id -u "$u" >/dev/null 2>&1; then
printf 'user.%s: (no account)\n' "$u"
continue
fi
printf 'user.%s: state=%s groups=%s lock=%s\n' "$u" "${state:-active}" \
"$(id -Gn "$u" 2>/dev/null | tr ' ' ',')" \
"$(passwd -S "$u" 2>/dev/null | awk '{print $2}' || echo '?')"
home="$(getent passwd "$u" | cut -d: -f6)"
keys="$home/.ssh/authorized_keys"
printf 'user.%s.authorized_keys: %s\n' "$u" \
"$(sha256sum "$keys" 2>/dev/null | cut -d' ' -f1 || echo '(none)')"
done < <(cat "$ledger" 2>/dev/null)
printf 'sudoers.d:\n'
find "${DRILL_SUDOERS_DIR:-/etc/sudoers.d}" -maxdepth 1 -type f 2>/dev/null | sort \
| while read -r u; do printf ' %s %s\n' "$(sha256sum "$u" | cut -d' ' -f1)" "$u"; done
printf 'box: %s\n' "$(command -v box 2>/dev/null || echo '(absent)')"
} > "$out"
}
# ref_sha <owner/repo> <ref> — the commit the record cites. Tags outrank
# branches (the installer's own precedence, install.sh:117-120). Resolved once
# up front and reused, so the record and the install describe the same instant
# even if the branch moves mid-drill. Empty on failure; the record then says
# 'unresolved' rather than inventing one.
ref_sha() {
local sha
sha="$(git ls-remote "https://github.com/$1" "refs/tags/$2" 2>/dev/null | head -n1 | cut -f1)"
[ -n "$sha" ] || sha="$(git ls-remote "https://github.com/$1" "refs/heads/$2" 2>/dev/null | head -n1 | cut -f1)"
printf '%s' "${sha:0:7}"
}
# emit_record <path> — drills/<version>.md, in the shape drills/README.md
# defines: what ran, on what host, the pinned refs and SHAs, the numbers, and
# what failed. Emitted on EVERY completed run — a failed drill is still a
# valid record; the gate wants evidence, not success. Skipped legs are listed
# by name: a record with no failures listed reads as "nothing broke", so a leg
# that was not run says so instead of being omitted.
emit_record() {
local out="$1" i os cpus ram virt line
os="$(. /etc/os-release 2>/dev/null && printf '%s' "${PRETTY_NAME:-unknown}")"
cpus="$(nproc 2>/dev/null || echo '?')"
ram="$(awk '/MemTotal/{printf "%.0f", $2/1024/1024}' /proc/meminfo 2>/dev/null || echo '?')"
virt="$(systemd-detect-virt 2>/dev/null || echo unknown)"
{
printf '# Release drill — %s — %s\n\n' "$DRILL_VERSION" "$(date -u +%F)"
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' \
"${RIG_SHA:-unresolved}" "$REF" "${BOX_SHA:-unresolved}" "$BOXREF"
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}" "${TPL_SOURCE:-fetched}"
printf 'Instrument: drill/drill.sh, legs in execution order.\n\n'
printf '| Leg | Result |\n'
printf '| --- | --- |\n'
for i in "${!LEG_NAMES[@]}"; do
printf '| %s | %s |\n' "${LEG_NAMES[$i]}" "${LEG_RESULTS[$i]}"
done
printf '\nChecks: %s passed, %s failed, %s skipped.\n' "$pass" "$fail" "$skipped"
if [ "$fail" -eq 0 ] && [ "$skipped" -eq 0 ]; then
printf '\nFailed: nothing. Every leg ran and every check passed.\n'
else
# printf --: a format opening with '- ' reads as an option to bash's
# printf and emits NOTHING — a record whose Failed section silently
# vanished is exactly the lie this file exists to make impossible.
[ "$fail" -gt 0 ] && printf '\nFailed:\n'
for line in "${findings[@]:-}"; do
case "$line" in FAIL:*) printf -- '- %s\n' "$line" ;; esac
done
[ "$skipped" -gt 0 ] && printf '\nSkipped — these did NOT run, and this record is not evidence for them:\n'
for line in "${findings[@]:-}"; do
case "$line" in SKIP:*) printf -- '- %s\n' "$line" ;; esac
done
fi
printf '\nThe isolation boundary was NOT asserted here: it is box'\''s drill'\''s\n'
printf 'assertion (heavy-duty/box drill/drill.sh), joined to this record by the run ID.\n'
} > "$out"
}
# =============================================================================
# Pre-flight — every refusal this run can see coming fires here, before
# anything is installed or any credential is spent. Args are validated BEFORE
# the root check (repo doctrine, bootstrap.sh:114 — so the refusals are
# testable without root, and a typo costs a re-type, never a re-ssh).
# =============================================================================
# Both refs EXPLICIT, or nothing runs. Defaulting either to main is exactly
# the #103 hazard this harness exists to refuse: "I drilled the release" must
# not quietly mean "I drilled whatever main was that afternoon".
if [ -z "$REF" ] || [ -z "$BOXREF" ]; then
echo "drill: both refs must be pinned explicitly — a drill against an unstated ref is not evidence (#103):" >&2
echo " --rig-ref <ref> (or RIG_REF) the rig candidate, e.g. release/0.4.0 [got: ${REF:-<unset>}]" >&2
echo " --box-ref <ref> (or BOX_REF) the box that will ship with it [got: ${BOXREF:-<unset>}]" >&2
exit 2
fi
case "$ROLE" in
staging-server|dev-server|control-plane-server|workload-server|runner-server) ;;
*) echo "drill: --role $ROLE is not a machine role this drill can converge unattended" >&2; exit 2 ;;
esac
if [ -z "$USERS_FILE" ]; then
echo "drill: --users <path> is required — leg 1 asserts operators converged, and bootstrap requires the file (its --no-users opt-out would leave leg 1 asserting nothing)" >&2
exit 2
fi
[ -r "$USERS_FILE" ] || { echo "drill: cannot read users file: $USERS_FILE" >&2; exit 2; }
[ "$(id -u)" -eq 0 ] || { echo "drill: must run as root (bootstrap, runner, coolify and db all require it) — ssh in as root on the throwaway machine" >&2; exit 1; }
# The tailnet join needs a key unless this machine already joined (a re-drill
# on the same throwaway). Caught here, not 10 apt-minutes into bootstrap.
if [ -z "${TS_AUTHKEY:-}" ]; then
if ! { command -v tailscale >/dev/null 2>&1 && tailscale status >/dev/null 2>&1; }; then
echo "drill: TS_AUTHKEY is unset and this machine has not joined a tailnet — leg 1's bootstrap will refuse. Mint a single-use TAGGED pre-auth key and export TS_AUTHKEY." >&2
exit 2
fi
fi
command -v curl >/dev/null 2>&1 || { echo "drill: curl is required (the pinned installs download over it)" >&2; exit 1; }
if [ "$YES" -ne 1 ]; then
cat <<EOF
This will, ON THIS HOST ($(hostname)):
· wipe any installed rig and reinstall $REPO@$REF from scratch
· run 'rig bootstrap $ROLE --users $USERS_FILE' — sshd hardening, hostname
change, tailnet join, box ($BOXREPO@$BOXREF) + its Incus stack — TWICE
(the second run is the idempotence assertion)
· 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.
EOF
[ -t 0 ] || { echo "drill: no TTY to confirm on — pass --yes if you mean it." >&2; exit 2; }
printf 'Continue? [y/N] '
read -r reply
case "$reply" in y|Y|yes) ;; *) echo "stopped."; exit 1 ;; esac
fi
phase "Pinned candidates"
RIG_SHA="$(ref_sha "$REPO" "$REF")"
BOX_SHA="$(ref_sha "$BOXREPO" "$BOXREF")"
inf "rig: $REPO@$REF (${RIG_SHA:-unresolved})"
inf "box: $BOXREPO@$BOXREF (${BOX_SHA:-unresolved})"
inf "run ID: $RUN_ID — drills sharing this substrate share it (drills/README.md)"
# =============================================================================
phase "Installing rig ($REPO@$REF) from scratch"
# =============================================================================
# The drill proves a tree from SCRATCH every run — a fresh machine, not a
# converged install — so any prior rig goes first (root's install lands at
# \$HOME/.local/share/rig with the /usr/local/bin symlink).
rm -rf "$HOME/.local/share/rig" /usr/local/bin/rig
if ! run_logged /tmp/drill-rig-install.log \
env RIG_REPO="$REPO" RIG_REF="$REF" \
bash -c "bash <(curl -fsSL \"https://raw.githubusercontent.com/$REPO/$REF/install.sh\")"; then
echo "drill: rig's installer failed — tail of /tmp/drill-rig-install.log:" >&2
tail -5 /tmp/drill-rig-install.log >&2
exit 1
fi
command -v rig >/dev/null 2>&1 || { echo "drill: installer reported success but no 'rig' on PATH" >&2; exit 1; }
# ASSERT WHAT LANDED — the up-front ref assertion, fatal on mismatch.
RIG_TREE="$(tree_of "$(command -v rig)")"
assert_installed_from rig "$RIG_TREE" "$REPO@$REF" || exit 1
DRILL_VERSION="$(head -n1 "$RIG_TREE/VERSION" 2>/dev/null || echo unknown)"
ok "installed tree confirms: $REPO@$REF (version $DRILL_VERSION)"
# The rig-templates ref this candidate converges (#110), for the record: the
# env override when the drill was pointed somewhere, else the pin read from
# the INSTALLED tree — what actually landed, never this checkout's copy. A
# 40-hex ref IS its own SHA (the pin's normal shape); anything else resolves
# through ref_sha like the two candidates above.
if [ -z "$TPLREF" ]; then
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
if [[ "$TPLREF" =~ ^[0-9a-f]{40}$ ]]; then
TPL_SHA="${TPLREF:0:7}"
elif [ -n "$TPLREF" ]; then
TPL_SHA="$(ref_sha "$TPLREPO" "$TPLREF")"
fi
inf "templates: $TPLREPO@${TPLREF:-unresolved} (${TPL_SHA:-unresolved}, $TPL_SOURCE)"
[ -n "$RECORD" ] || RECORD="$ROOT/drills/$DRILL_VERSION.md"
# =============================================================================
phase "Leg 1 — convergence: rig bootstrap $ROLE"
# =============================================================================
# BOX_REPO/BOX_REF ride the environment into bootstrap's host=yes box install,
# so the box that lands is the pinned candidate, not what bootstrap falls back
# to unexported (the BOX_RELEASE pin, since rig#103 landed).
export BOX_REPO="$BOXREPO" BOX_REF="$BOXREF"
t0=$SECONDS
if run_logged /tmp/drill-bootstrap-1.log rig bootstrap "$ROLE" --users "$USERS_FILE"; then
ok "rig bootstrap $ROLE --users … exited 0 ($((SECONDS - t0))s)"
BOOTSTRAP_OK=1
else
no "rig bootstrap $ROLE FAILED — tail: $(tail -3 /tmp/drill-bootstrap-1.log | tr '\n' ' ')"
BOOTSTRAP_OK=0
fi
MARKER_LINE="$(cat "${RIG_ROLE_MARKER:-/etc/rig/role}" 2>/dev/null || true)"
if [ "$BOOTSTRAP_OK" -eq 1 ]; then
# The role, asserted on EFFECTIVE state — the marker, the daemon's resolved
# config, the netmap's granted tags — never on what was requested (the
# sshd-first-wins lesson, lib/sshd.sh:63-70).
case "$MARKER_LINE" in
"role=$ROLE "*) ok "role marker: $MARKER_LINE" ;;
*) no "role marker is '$MARKER_LINE' — expected role=$ROLE" ;;
esac
sshd -T 2>/dev/null | grep -qx 'passwordauthentication no' \
&& ok "sshd -T resolves passwordauthentication no (the hardening took)" \
|| no "sshd still resolves password auth — the 00-rig.conf drop-in is not winning"
ts_tags="$(tailscale status --json 2>/dev/null | tr -d '\n ' | grep -o '"Tags":\[[^]]*\]' | head -n1)"
if [ -n "$ts_tags" ] && [ "$ts_tags" != '"Tags":[]' ]; then
ok "tailnet joined, tagged: $ts_tags"
else
no "tailnet join did not leave a tagged node (got: ${ts_tags:-nothing}) — bootstrap's verify should have refused this"
fi
grep -q 'Unattended-Upgrade "1"' /etc/apt/apt.conf.d/20auto-upgrades 2>/dev/null \
&& ok "unattended-upgrades enabled" || no "20auto-upgrades missing or wrong"
grep -q "converged_by=$DRILL_VERSION" "${RIG_MANIFEST:-/etc/rig/manifest}" 2>/dev/null \
&& ok "manifest: converged_by=$DRILL_VERSION" || no "manifest does not name $DRILL_VERSION as the converging rig"
users_bad=""
while read -r u state; do
[ "$state" = active ] || continue
id -u "$u" >/dev/null 2>&1 || { users_bad="$users_bad $u(no-account)"; continue; }
uhome="$(getent passwd "$u" | cut -d: -f6)"
[ -s "$uhome/.ssh/authorized_keys" ] || users_bad="$users_bad $u(no-keys)"
done < <(cat "${DRILL_LEDGER:-/etc/rig/users}" 2>/dev/null)
# NOT 'grep -c … || echo 0': grep -c already prints 0 on no match (and then
# exits 1), so the fallback would emit a second line into the substitution.
n_users="$(grep -c ' active$' "${DRILL_LEDGER:-/etc/rig/users}" 2>/dev/null)" || true
n_users="${n_users:-0}"
[ -z "$users_bad" ] && [ "$n_users" -gt 0 ] \
&& ok "operators converged: $n_users active, accounts and keys present" \
|| no "operators NOT converged:${users_bad:- ledger empty}"
leg "convergence — bootstrap $ROLE reaches its role" \
"$([ "$fail" -eq 0 ] && echo "PASS ($((SECONDS - t0))s)" || echo "FAIL — see Failed below")"
# --- idempotence: the claim this drill exists to make ----------------------
# Capture, re-run, capture, diff. Mechanically — never "watched it not
# obviously break". An empty diff IS the definition of converged.
phase "Leg 1 — idempotence: the re-run must change nothing"
pre="$(mktemp)"; post="$(mktemp)"
capture_state "$pre"
t0=$SECONDS
if run_logged /tmp/drill-bootstrap-2.log rig bootstrap "$ROLE" --users "$USERS_FILE"; then
ok "second bootstrap exited 0 ($((SECONDS - t0))s)"
else
no "second bootstrap FAILED — tail: $(tail -3 /tmp/drill-bootstrap-2.log | tr '\n' ' ')"
fi
capture_state "$post"
if statediff="$(diff -u "$pre" "$post")"; then
ok "re-converge is a no-op: the state diff is empty"
leg "re-converge (idempotence)" "clean, no changes"
else
dlines="$(printf '%s\n' "$statediff" | grep -c '^[+-][^+-]')"
no "re-converge CHANGED the box — $dlines state line(s) differ:"
printf '%s\n' "$statediff" | sed 's/^/ /'
leg "re-converge (idempotence)" "DIRTY — $dlines state line(s) changed on the re-run"
fi
rm -f "$pre" "$post"
else
leg "convergence — bootstrap $ROLE reaches its role" "FAIL — bootstrap exited non-zero"
skip "idempotence not asserted — the first converge already failed, a re-run diff would measure noise"
leg "re-converge (idempotence)" "SKIPPED — first converge failed"
fi
# =============================================================================
phase "--host yes — the box that will ship"
# =============================================================================
# The assertions #105 settles this leg at: the installer ran, INSTALLED_FROM
# matches the requested BOX_REF, setup-host exited clean, the stack it claims
# stands. Then it STOPS. Not one isolation probe: two records that both claim
# the trust boundary will eventually disagree with no tiebreaker, and a
# partial isolation check reads — months later, in a record — as though the
# boundary was drilled (box#153's shape through a different door). Resist
# adding "just one" probe here; that is box's drill's whole job.
case "$MARKER_LINE" in
*"host=yes"*)
if command -v box >/dev/null 2>&1; then
ok "box CLI on PATH"
BOX_TREE="$(tree_of "$(command -v box)")"
# Fatal, like rig's own: a wrong box under --host yes poisons the pair.
assert_installed_from box "$BOX_TREE" "$BOXREPO@$BOXREF" || exit 1
ok "installed box confirms: $BOXREPO@$BOXREF"
if box doctor >/dev/null 2>&1; then
ok "box doctor passes — setup-host converged; the host stack stands (box's own effective-state verdict)"
leg "--host yes: pinned box installed, host stack up" "PASS — $BOXREPO@$BOXREF, box doctor clean"
else
no "box is installed but 'box doctor' does not pass — the host stack is unproven (run 'box doctor' for box's verdict)"
leg "--host yes: pinned box installed, host stack up" "FAIL — box doctor does not pass"
fi
else
no "no 'box' on PATH after a host=yes bootstrap — the box install did not take (bootstrap warns rather than dies there; the drill does not)"
leg "--host yes: pinned box installed, host stack up" "FAIL — box CLI never landed"
fi
inf "isolation NOT asserted here — deliberately. The VM trust boundary is box's"
inf "assertion, made by box's own drill (~85 probes); this leg stops at 'the pinned"
inf "box installed and its host stack stands'. The records join on the run ID."
;;
*)
skip "--host yes assertions: role $ROLE left host=no (marker: ${MARKER_LINE:-absent})"
leg "--host yes: pinned box installed, host stack up" "SKIPPED — this role does not host VMs"
;;
esac
# =============================================================================
phase "Leg 4 — coolify install (pinned, AUTOUPDATE=false)"
# =============================================================================
# Runs BEFORE leg 2 on purpose: Coolify's installer is what puts Docker on the
# box, and the db leg needs a daemon — ordering them the other way around
# would manufacture a skip this same run could have avoided.
if [ -z "$COOLIFY_VERSION" ]; then
skip "coolify install: no --coolify-version pin given — the leg did not run (rig's own install refuses to default a version, and so does its drill)"
leg "coolify install" "SKIPPED — no version pin provided"
else
t0=$SECONDS
if run_logged /tmp/drill-coolify.log rig coolify install --version "$COOLIFY_VERSION"; then
ok "rig coolify install --version $COOLIFY_VERSION exited 0 ($((SECONDS - t0))s)"
grep -qx 'AUTOUPDATE=false' /data/coolify/source/.env 2>/dev/null \
&& ok "AUTOUPDATE=false landed in /data/coolify/source/.env — the platform will not move under its operators" \
|| no "AUTOUPDATE=false is NOT in coolify's .env — the pin is not holding"
cstate="$(docker inspect -f '{{.State.Status}}' coolify 2>/dev/null || echo absent)"
[ "$cstate" = running ] && ok "the coolify container is running" \
|| no "coolify container state: $cstate (expected running)"
leg "coolify install ($COOLIFY_VERSION)" \
"$([ "$cstate" = running ] && echo "PASS ($(((SECONDS - t0) / 60)) min)" || echo "FAIL — container $cstate")"
else
no "coolify install FAILED — tail: $(tail -3 /tmp/drill-coolify.log | tr '\n' ' ')"
leg "coolify install ($COOLIFY_VERSION)" "FAIL — installer exited non-zero"
fi
fi
# =============================================================================
phase "Leg 2 — db dump/restore round-trip (test/db-integration.sh)"
# =============================================================================
# Driven from the INSTALLED tree — the drill exercises what shipped, not the
# checkout this script happens to sit in. The leg's skip contract is the
# script's own (loud, reasoned, exit 0) and classify_leg keeps it a SKIP:
# counted, rendered distinctly, named in the record — never a pass.
db_out="$(mktemp)"
bash "$RIG_TREE/test/db-integration.sh" >"$db_out" 2>&1
db_rc=$?
case "$(classify_leg "$db_rc" "$db_out")" in
pass)
db_numbers="$(tail -1 "$db_out")"
ok "db round-trip: $db_numbers"
leg "test/db-integration.sh" "PASS — $db_numbers"
;;
skip)
db_reason="$(grep -m1 '^skip:' "$db_out")"
skip "db round-trip did not run — $db_reason"
leg "test/db-integration.sh" "SKIPPED — ${db_reason#skip: }"
;;
fail)
no "db round-trip FAILED (exit $db_rc) — tail: $(tail -3 "$db_out" | tr '\n' ' ')"
leg "test/db-integration.sh" "FAIL — exit $db_rc"
;;
esac
rm -f "$db_out"
# =============================================================================
phase "Leg 3 — runner lifecycle against a fork"
# =============================================================================
# Register, take a job, deregister. The fork must carry a workflow_dispatch
# workflow (default drill.yml) whose job runs-on the 'drill' label — see
# drill/README.md. Tokens: RUNNER_TOKEN / RUNNER_REMOVE_TOKEN env, or minted
# via an authenticated gh. Without a fork or a token source the leg SKIPS,
# loudly, and the record says it did not run.
if [ -z "$RUNNER_REPO" ]; then
skip "runner lifecycle: no --runner-repo fork given — the leg did not run"
leg "runner lifecycle" "SKIPPED — no fork provided"
else
GH_OK=0
command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1 && GH_OK=1
reg_token="${RUNNER_TOKEN:-}"
if [ -z "$reg_token" ] && [ "$GH_OK" -eq 1 ]; then
reg_token="$(gh api -X POST "repos/$RUNNER_REPO/actions/runners/registration-token" --jq .token 2>/dev/null)"
fi
if [ -z "$reg_token" ]; then
skip "runner lifecycle: no RUNNER_TOKEN and no authenticated gh to mint one — the leg did not run"
leg "runner lifecycle ($RUNNER_REPO)" "SKIPPED — no registration token source"
else
RUNNER_NAME="drill-$(hostname)-$$"
if RUNNER_TOKEN="$reg_token" run_logged /tmp/drill-runner-install.log \
rig runner install --repo "$RUNNER_REPO" --name "$RUNNER_NAME" --labels drill; then
ok "rig runner install --repo $RUNNER_REPO exited 0 (registered as $RUNNER_NAME)"
else
no "runner install FAILED — tail: $(tail -3 /tmp/drill-runner-install.log | tr '\n' ' ')"
fi
rig runner status 2>/dev/null | grep -q "$RUNNER_REPO" \
&& ok "runner status names the fork: $RUNNER_REPO" \
|| no "runner status does not name $RUNNER_REPO"
took_job=none
if [ "$GH_OK" -eq 1 ]; then
# Dispatch, then poll the newest run of that workflow to completion.
# The newest run's ID is read BEFORE dispatching, so an old completed
# run can never be mistaken for the one just dispatched (the poll's
# verdict must be about OUR run, and workflow_dispatch takes a few
# seconds to materialize a run at all). ~5 min bound: a queued-forever
# run means the runner never picked the job up, which is exactly what
# this check exists to catch.
pre_id="$(gh run list -R "$RUNNER_REPO" --workflow "$RUNNER_WORKFLOW" --limit 1 --json databaseId --jq '.[0].databaseId' 2>/dev/null)"
if gh workflow run "$RUNNER_WORKFLOW" -R "$RUNNER_REPO" >/dev/null 2>&1; then
inf "dispatched $RUNNER_WORKFLOW on $RUNNER_REPO — waiting for the runner to take it (≤5 min)…"
took_job=timeout
for _i in $(seq 1 30); do
sleep 10
run_line="$(gh run list -R "$RUNNER_REPO" --workflow "$RUNNER_WORKFLOW" --limit 1 \
--json databaseId,status,conclusion --jq '.[0] | "\(.databaseId) \(.status) \(.conclusion)"' 2>/dev/null)"
read -r rid rstatus rconc <<< "$run_line"
[ -n "${rid:-}" ] || continue
[ "$rid" != "${pre_id:-}" ] || continue
if [ "${rstatus:-}" = completed ]; then
case "${rconc:-}" in
success) took_job=success ;;
*) took_job=failed ;;
esac
break
fi
done
else
took_job=nodispatch
fi
case "$took_job" in
success) ok "the 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 $RUNNER_REPO" ;;
timeout) no "the dispatched job never completed within 5 min — the runner did not take it (is the workflow's runs-on label 'drill'?)" ;;
nodispatch) no "could not dispatch $RUNNER_WORKFLOW on $RUNNER_REPO — does the fork carry it, with workflow_dispatch? (see drill/README.md)" ;;
esac
else
skip "took a job: not attempted — no authenticated gh to dispatch $RUNNER_WORKFLOW with"
fi
rem_token="${RUNNER_REMOVE_TOKEN:-}"
if [ -z "$rem_token" ] && [ "$GH_OK" -eq 1 ]; then
rem_token="$(gh api -X POST "repos/$RUNNER_REPO/actions/runners/remove-token" --jq .token 2>/dev/null)"
fi
if [ -n "$rem_token" ]; then
RUNNER_REMOVE_TOKEN="$rem_token" rig runner remove >/dev/null 2>&1 \
&& ok "rig runner remove deregistered cleanly" \
|| no "runner remove FAILED"
else
rig runner remove --local >/dev/null 2>&1 \
&& note "deregistered --local only (no removal token source) — delete the stale runner from $RUNNER_REPO's settings by hand" \
|| no "runner remove --local FAILED"
fi
rig runner status >/dev/null 2>&1 \
&& no "runner status still answers after remove — the deregistration did not take" \
|| ok "runner status confirms: nothing registered"
leg "runner lifecycle ($RUNNER_REPO)" \
"$(case "$took_job" in
success) echo "PASS — registered, took a job, deregistered clean" ;;
none) echo "PARTIAL — registered and deregistered; took a job: not attempted (no gh)" ;;
*) echo "FAIL — see Failed below" ;;
esac)"
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"
# =============================================================================
printf ' %s passed, %s failed, %s skipped\n' "$pass" "$fail" "$skipped"
if [ "${#findings[@]}" -gt 0 ]; then
echo
printf ' %s\n' "${findings[@]}"
fi
mkdir -p "$(dirname "$RECORD")"
emit_record "$RECORD"
echo
inf "record written: $RECORD"
inf "commit it on the release branch as drills/$DRILL_VERSION.md — the"
inf "drill-recorded gate reads that file and nothing else (drills/README.md)."
[ "$fail" -eq 0 ]

59
drills/0.3.0.md Normal file
View file

@ -0,0 +1,59 @@
# rig 0.3.0 — release drill
**WAIVED. No drill was run for this release.**
Waived by the maintainer (@danmt) on 2026-07-21. This file exists because the
gate requires a *record*, not a passing result — so a skip is a line in a diff
somebody reviewed, rather than a silence nobody notices.
## Why
The drill harness is not in a state to produce a trustworthy run, and rig is
the sharpest case: **rig has no drill harness of its own at all.** Its legs —
tenant guests minted via box and converged, the GitHub runner lifecycle against
a fork, `rig coolify install` and the backup install failing to its credential
boundary — have only ever been run by hand, reconstructed each time. box has a
harness; rig and cast do not.
A run improvised under those conditions produces numbers nobody should rely on,
and `drills/` is worth nothing if it fills with records like that.
Deferred deliberately, and tracked: #107.
## What this release therefore does NOT prove
Stated plainly so it is not rediscovered as a surprise:
- **Convergence on real hardware** — that a machine reaches its role, and
reaches it *idempotently*, on a host that is not a container.
- **The `--host yes` path end to end** — rig installing box and running its
`setup-host` on a bare machine, which is the bottom of the whole stack.
- **The three BREAKING renames in this release under real use**`--class`
`--root-door` (#77), and the `-box` / `-server` role suffixes (#76). These are
hard cuts with no aliases; CI proves the parsing, not the lived upgrade.
- **The combination users receive.** rig still resolves `BOX_REF` to `main`
(#103), so even a passing drill would have proven a pair that drifts the
moment box's main moves.
## What CI did prove on this tree
Not nothing, and worth recording so the gap is precise rather than total:
| Job | Result |
|---|---|
| `db-integration` — a real PostgreSQL dump/restore round-trip | pass |
| `scope` | pass |
| `reconcile` | pass |
| `test/cli.sh` | 566 passed, 0 failed |
| `test/release.sh` | 134 passed, 0 failed |
| `test/labels-reconcile.sh` | 72 passed, 0 failed |
| CI shellcheck sweep (globstar + dotglob) | clean |
The command surface and the DB round-trip are covered. Convergence on real
hardware is not.
## The commitment
The next release carries a real drill record at `drills/<version>.md` — what
ran, on what host, the pinned refs and SHAs, the numbers, and what failed. A
failed drill is a valid record; another waiver is not. Tracked in #107.

12
drills/0.3.1.md Normal file
View file

@ -0,0 +1,12 @@
# rig 0.3.1 — release drill
**WAIVED. No real-hardware drill was run for this release.**
The maintainer (@danmt) directed “Add a waiver as the drill” on
2026-07-25 at 09:02Z:
https://github.com/heavy-duty/rig/pull/145#issuecomment-5077845754
The real-hardware run requested in discussion #132 was not performed. At the
time of the ruling, the harness and procedure (`drill/drill.sh` and
`drill/README.md`) were available on `main`, unlike for the 0.3.0 release.
The ruling gave no reason for the waiver.

124
drills/README.md Normal file
View file

@ -0,0 +1,124 @@
# Drill records
Per-release evidence for rig's real-hardware drill. **One file per version**,
named for the version exactly as `VERSION` carries it:
drills/<version>.md
So `0.3.0` is recorded in `drills/0.3.0.md`, and `0.3.0-rc1` in
`drills/0.3.0-rc1.md`. They are different files, which is the whole point: the
filesystem does the whole-version comparison that an earlier single-file
version of this had to do with an awk extractor and a heading grammar. A
record for the candidate cannot be mistaken for evidence for the final.
The directory is `drills/`, not `.drills/` — a dot-directory is invisible to
any glob without `dotglob`, which is how #70 here and box#116 / box#118 all
happened.
**This directory is the record, not the instrument.** The instrument is
[`drill/drill.sh`](../drill/README.md) (#105): it runs the legs, asserts the
pinned refs actually landed, decides idempotence by a mechanical state diff,
and emits the record file this directory holds. rig does not reach into
another repo's harness to decide whether rig may ship: a cross-repo lookup
that fails silently degrades to "pass", which is the UNREADABLE-vs-NONE shape
#90 fixed. The gate reads a file in this repo, and nothing else.
## What the gate requires
The `drill-recorded` guard (heavy-duty/ceremony's action, pinned in
`ci.yml`) runs on every PR. On a `-dev` tree it
asserts nothing — a development tree has no release to evidence. On a bare
`VERSION` — a release ceremony tree — it requires `drills/<version>.md` to
exist and to hold at least one non-whitespace character. An empty file, or one
of only spaces and tabs, is not a record (box#149 and cast#138 both shipped an
extractor where a single tab satisfied the gate).
The guard requires a **record**, not a passing result. A maintainer waiver is
a legitimate outcome of a release — but it is written in that version's file,
so that skipping the drill is a deliberate, reviewable commit rather than a
silence. **A failed drill is still a valid record**: the gate wants evidence,
not success.
## The drill
rig's legs (#105; `drill/drill.sh` runs them):
- `rig bootstrap <role>` converges the machine to its role — then runs
**again**, and the captured state must diff **empty** (idempotence,
decided mechanically). On a host=yes role this is also what installs the
pinned box and asserts its host stack stands.
- `bash test/db-integration.sh` against a real Postgres on the machine
- the GitHub runner lifecycle — register, take a job, deregister — against a
fork
- a coolify install, pinned, `AUTOUPDATE=false`
box and rig are **mutually recursive**: `rig bootstrap --host yes` installs box
and runs box's `setup-host`, while box's guests converge back through rig's
installer. Within a single drill you naturally bring the substrate up before
probing it — a host before a guest — but that is how you run a drill, not an
ordering rule between repos.
**The three repos' drills are independent.** Run them in any order, on any
schedule, in separate sittings. What makes that safe is that every drill
**pins the same fixed set of candidate refs**: rig's drill runs `--host yes`
with `BOX_REF=release/<box-version>`, so it exercises the box that will
actually ship; box's drill mints with `RIG_REF=release/<rig-version>`, so it
exercises the rig that will actually ship. Both measure the same pair. The
record also cites the **rig-templates SHA** the converge read (#110) — the
candidate tree's `RIG_TEMPLATES_PIN` unless the drill was pointed elsewhere
via `RIG_TEMPLATES_REF` — so the mechanism+registry pair a release freezes
is the pair the drill proved.
That — not sequencing — is what dissolves the box↔rig recursion. The refs are
static identifiers that exist as soon as the release branches do, long before
any drill runs, so a cycle at runtime becomes two independent tests against
one fixed pair. It also means **candidate refs, not released artifacts**:
`RIG_REPO`/`RIG_REF` are mint-time environment variables, so no repo has to be
released before another can be drilled. Drilling the candidate *is* drilling
the release, because a release PR's diff is `VERSION` + `CHANGELOG.md` and
nothing executable differs.
Each repo drills in a **different way** and asserts a different thing: rig
asserts **convergence** (a machine reaches its role, idempotently), box asserts
the **isolation contract** (the VM trust boundary), cast asserts **promotion**
(A→B reproduces, the diff is idempotent). Three different exercises sharing a
substrate, not three phases of one script — which is exactly why the records
are per-repo.
Drills that share a substrate share **one run ID**; each repo records its own
legs in its own file, citing that run ID and the other repos' commit SHAs, so
the records can be joined after the fact. If a defect shows up only in the
combination: patch, re-drill, re-record. The three releases converge on a set
that holds together; they are not required to be right in one pass. Releases
do **not** have to be published in a fixed order.
## What a record should contain
What ran, on what host, the pinned candidate refs, the numbers, and what
failed. Below is the *shape*, in a file named `drills/9.9.9.md` — a version
that can never collide with a real release. **No drill has been recorded here
yet**; this log starts empty rather than reconstructing runs from memory, since
an invented number is worse than no number.
```markdown
# Release drill — 9.9.9 — 2026-01-01
Run ID: drill-2026-01-01-a. Host: bare Debian 13 cloud image, 4 vCPU / 8 GB.
Candidate refs: box@1a2b3c4 (BOX_REF=release/0.4.0), rig@5d6e7f8, cast@9a0b1c2.
| Leg | Result |
| --- | --- |
| convergence — bootstrap staging-server reaches its role | PASS (312s) |
| re-converge (idempotence) | clean, no changes |
| --host yes: pinned box installed, host stack up | PASS — box doctor clean |
| `test/db-integration.sh` | PASS — 14 passed, 0 failed |
| 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) |
Failed: `rig users apply` left one revoked key in `authorized_keys`
(filed #NNN). Everything else clean.
```
State what failed. A record with no failures listed reads as "nothing broke",
so if a leg was not run, say that instead of omitting it.

View file

@ -29,12 +29,19 @@ set -euo pipefail
# protect, and the operator flipping versions on purpose is the common case. # protect, and the operator flipping versions on purpose is the common case.
# A pre-versioning flat tree is migrated in place, so upgrading is seamless. # A pre-versioning flat tree is migrated in place, so upgrading is seamless.
# #
# RIG_INSTALL_SOURCE=<dir-or-tarball> installs from a local tree instead of # RIG_INSTALL_SOURCE=<dir-or-tarball> is the LOCAL channel, a supported input
# downloading — for CI and the test suite, so what lands is the code under # like RIG_REF (#106): installs from that tree instead of downloading — CI's
# review. # install-lifecycle job and the test suites use it, so what lands is the code
# under review. A path that is neither refuses by name, never falls back to
# a download.
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 —
@ -99,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 ;;
@ -111,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 -----------------------------------------------------------
@ -198,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
@ -219,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" \
@ -247,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
@ -257,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
@ -274,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

402
test/drill.sh Normal file
View file

@ -0,0 +1,402 @@
#!/usr/bin/env bash
# test/drill.sh — the drill harness's HONESTY, proven without hardware.
#
# drill/drill.sh is the instrument (#105), so what this suite tests is the
# instrument itself: the refusals, the classifications, the capture-and-diff
# that decides idempotence, and the record emitter — the parts whose lies
# would be believed, months later, by a reader of drills/<version>.md. The
# four-leg live run on a real Debian machine is #107's exercise, not this
# file's: nothing here needs root, Docker, a tailnet or the network.
#
# Extraction pattern is test/release.sh's: the functions under test are
# awk-extracted from drill/drill.sh and driven against fixtures, so the tests
# exercise the shipped bytes, and the extraction check itself guards the awk
# against a drifted function boundary.
# Deliberately no `set -e` — the harness asserts on failing commands.
set -u
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT" || exit 1
PASS=0 FAIL=0
# check <desc> <want_exit> <want_substr> <cmd...>
check() {
local desc="$1" want="$2" substr="$3"; shift 3
local out rc
out="$("$@" 2>&1)"; rc=$?
if [ "$rc" -ne "$want" ]; then
echo "FAIL: $desc — exit $rc, wanted $want"
printf '%s\n' "$out" | sed 's/^/ /'
FAIL=$((FAIL + 1)); return
fi
if [ -n "$substr" ] && ! printf '%s' "$out" | grep -qF -e "$substr"; then
echo "FAIL: $desc — output missing '$substr'"
printf '%s\n' "$out" | sed 's/^/ /'
FAIL=$((FAIL + 1)); return
fi
echo "ok: $desc"; PASS=$((PASS + 1))
}
# refute <desc> <substr> <file> — the file must NOT contain the substring.
refute() {
if grep -qF -e "$2" "$3"; then
echo "FAIL: $1 — found forbidden '$2'"
FAIL=$((FAIL + 1)); return
fi
echo "ok: $1"; PASS=$((PASS + 1))
}
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
# --- the functions under test, extracted -------------------------------------
FNS="$WORK/drill-fns.sh"
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"
done
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"
done
# shellcheck source=/dev/null
. "$FNS"
# =============================================================================
# tree_of — the versioned tree behind a CLI's symlink chain
# =============================================================================
IR="$WORK/install"; mkdir -p "$IR/versions/1.2.3/bin"
: > "$IR/versions/1.2.3/bin/rig"
ln -s "versions/1.2.3" "$IR/current"
mkdir -p "$WORK/bin"
ln -s "$IR/current/bin/rig" "$WORK/bin/rig"
check "tree_of resolves a current-symlink chain to versions/<v>" 0 "$IR/versions/1.2.3" \
tree_of "$WORK/bin/rig"
ln -s "$IR/gone/bin/rig" "$WORK/bin/dangling"
check "tree_of refuses a dangling chain — a tree that is not there is not a tree" 1 "" \
tree_of "$WORK/bin/dangling"
# =============================================================================
# assert_installed_from — the up-front ref refusal, naming both refs
# =============================================================================
TREE="$WORK/tree-main"; mkdir -p "$TREE"
printf 'heavy-duty/rig@main\n' > "$TREE/INSTALLED_FROM"
check "matching INSTALLED_FROM passes silently" 0 "" \
assert_installed_from rig "$TREE" "heavy-duty/rig@main"
check "a mismatch refuses (the #103 hazard: asked release, got main)" 1 "FATAL" \
assert_installed_from rig "$TREE" "heavy-duty/rig@release/9.9.9"
check "…the refusal names the ref that was ASKED for" 1 "heavy-duty/rig@release/9.9.9" \
assert_installed_from rig "$TREE" "heavy-duty/rig@release/9.9.9"
check "…and the ref that actually LANDED" 1 "heavy-duty/rig@main" \
assert_installed_from rig "$TREE" "heavy-duty/rig@release/9.9.9"
check "an unreadable INSTALLED_FROM refuses too — absence is not a match" 1 "<unreadable>" \
assert_installed_from rig "$WORK/no-such-tree" "heavy-duty/rig@main"
# =============================================================================
# classify_leg — a loud skip is a SKIP, never a pass (box#153's defect class)
# =============================================================================
printf 'skip: docker not installed — nothing to exercise\n' > "$WORK/out-skip"
printf 'ok: seeded\nok: restored\n---\n14 passed, 0 failed\n' > "$WORK/out-pass"
printf 'FAIL: restore blew up\n' > "$WORK/out-fail"
check "exit 0 + 'skip:' line classifies as skip" 0 "skip" classify_leg 0 "$WORK/out-skip"
check "exit 0, no skip line, classifies as pass" 0 "pass" classify_leg 0 "$WORK/out-pass"
check "non-zero exit classifies as fail" 0 "fail" classify_leg 1 "$WORK/out-fail"
check "a skip line cannot rescue a non-zero exit (fail wins)" 0 "fail" \
classify_leg 1 "$WORK/out-skip"
# =============================================================================
# capture_state + diff — the idempotence verdict's machinery. The claim in
# #105's acceptance criteria: the assertion is a REAL diff of captured state,
# and it FAILS when convergence is broken — demonstrated here, mechanically,
# on every CI run, by breaking the state between two captures.
# =============================================================================
FIX="$WORK/fix"; mkdir -p "$FIX/sudoers.d"
printf 'role=staging-server root-door=open host=yes join=authkey\n' > "$FIX/role"
printf 'schema=1\nbootstrapped_by=9.9.9\nbootstrapped_at=T\nconverged_by=9.9.9\nconverged_at=T\n' > "$FIX/manifest"
printf 'dan active\nghost revoked\n' > "$FIX/ledger"
printf 'APT::Periodic::Update-Package-Lists "1";\n' > "$FIX/autoup"
printf '127.0.0.1 localhost\n127.0.1.1\tstaging-server\n' > "$FIX/hosts"
printf 'nosuchdrilluser ALL=(ALL) NOPASSWD:ALL\n' > "$FIX/sudoers.d/00-rig-nosuch"
# A stubbed sshd, so the effective-config section is exercised rather than
# skipped on a box with no daemon (repo precedent: test/release.sh's curl).
STUB="$WORK/stub"; mkdir -p "$STUB"
# The single-quoted $SSHD_FIXTURE is the STUB's expansion, not this shell's.
# shellcheck disable=SC2016
printf '#!/usr/bin/env bash\ncat "$SSHD_FIXTURE"\n' > "$STUB/sshd"; chmod +x "$STUB/sshd"
printf 'passwordauthentication no\npermitrootlogin prohibit-password\n' > "$FIX/sshd-T"
cap() { # cap <outfile> — capture_state against the fixture set
RIG_ROLE_MARKER="$FIX/role" RIG_MANIFEST="$FIX/manifest" \
DRILL_LEDGER="$FIX/ledger" DRILL_AUTOUPGRADES="$FIX/autoup" \
DRILL_ETC_HOSTS="$FIX/hosts" DRILL_SUDOERS_DIR="$FIX/sudoers.d" \
SSHD_FIXTURE="$FIX/sshd-T" PATH="$STUB:$PATH" \
bash -c '. "$1"; capture_state "$2"' _ "$FNS" "$2" 2>/dev/null
:
}
# cap runs capture_state in a child bash so the PATH stub cannot leak into
# this harness; $2 arrives as the capture's outfile.
cap out "$WORK/cap1"
cap out "$WORK/cap2"
check "two captures over untouched state diff EMPTY (the converged verdict)" 0 "" \
diff -u "$WORK/cap1" "$WORK/cap2"
check "the capture reads the fixtures, not the machine (marker line present)" 0 "role=staging-server" \
grep -o 'role=staging-server[^"]*' "$WORK/cap1"
check "…the sshd section captured the effective config" 0 "passwordauthentication no" \
cat "$WORK/cap1"
check "…a ledger user with no account reads as one, deterministically" 0 "(no account)" \
cat "$WORK/cap1"
# Break convergence: the re-run "changed" the role marker and root's door.
printf 'role=staging-server root-door=closed host=yes join=authkey\n' > "$FIX/role"
printf 'passwordauthentication yes\npermitrootlogin prohibit-password\n' > "$FIX/sshd-T"
cap out "$WORK/cap3"
check "a broken convergence makes the diff NON-empty — the assertion can fail" 1 "root-door=closed" \
diff -u "$WORK/cap1" "$WORK/cap3"
check "…and the diff names the drifted sshd keyword, not just 'differs'" 1 "passwordauthentication yes" \
diff -u "$WORK/cap1" "$WORK/cap3"
# =============================================================================
# emit_record — the record is drills/README.md's shape, and it cannot lie:
# a failed run still emits, a skipped leg is named, no clean-sweep reading.
# =============================================================================
emit() { # emit <outfile> — emit_record with the harness globals staged
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" \
TPLREPO="heavy-duty/rig-templates" TPLREF="9f8e7d6c5b4a39281706f5e4d3c2b1a098765432" TPL_SHA="9f8e7d6" TPL_SOURCE="snapshot" \
bash -c '
. "$1"
pass=12 fail=1 skipped=1
findings=("FAIL: coolify container state: absent" "SKIP: runner lifecycle: no --runner-repo fork given — the leg did not run" "NOTE: something worth a line")
LEG_NAMES=("convergence — bootstrap staging-server reaches its role" "re-converge (idempotence)" "coolify install (4.1.2)" "runner lifecycle")
LEG_RESULTS=("PASS (312s)" "clean, no changes" "FAIL — container absent" "SKIPPED — no fork provided")
emit_record "$2"
' _ "$FNS" "$2"
}
emit out "$WORK/record.md"
check "record: the version-and-date heading" 0 "# Release drill — 9.9.9 — " head -1 "$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: …box's too" 0 "box@1a2b3c4 (BOX_REF=release/0.4.0)" 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: 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 skipped leg is stated as NOT run, by name" 0 "SKIP: runner lifecycle" cat "$WORK/record.md"
check "record: the skip section says the record is not evidence for it" 0 "not evidence" cat "$WORK/record.md"
check "record: the isolation boundary is named as box's, in words" 0 "NOT asserted here" cat "$WORK/record.md"
refute "record with a skip cannot read as a clean sweep" "Failed: nothing" "$WORK/record.md"
refute "notes are findings for the log, not failures for the record" "NOTE: something" "$WORK/record.md"
# The all-green shape: says so plainly, and only then.
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" \
bash -c '
. "$1"
pass=20 fail=0 skipped=0
findings=()
LEG_NAMES=("convergence" "re-converge (idempotence)")
LEG_RESULTS=("PASS" "clean, no changes")
emit_record "$2"
' _ "$FNS" "$WORK/record-green.md"
check "an all-green record says every leg ran and passed" 0 "Every leg ran and every check passed" \
cat "$WORK/record-green.md"
# =============================================================================
# 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),
# 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" \
env -u RIG_REF -u BOX_REF bash "$ROOT/drill/drill.sh" --rig-ref release/9.9.9 --yes
check "…and the refusal shows which ref is missing" 2 "<unset>" \
env -u RIG_REF -u BOX_REF bash "$ROOT/drill/drill.sh" --rig-ref release/9.9.9 --yes
check "a tenant role is refused — the drill converges machines, not guests" 2 "not a machine role" \
bash "$ROOT/drill/drill.sh" --rig-ref r --box-ref b --role claude-box --yes
check "no --users is a refusal, naming why the drill will not default it" 2 "--users <path> is required" \
bash "$ROOT/drill/drill.sh" --rig-ref r --box-ref b --yes
check "an unreadable users file dies before anything is spent" 2 "cannot read users file" \
bash "$ROOT/drill/drill.sh" --rig-ref r --box-ref b --users "$WORK/no-such-users" --yes
check "an unknown flag dies loudly, exit 2" 2 "unknown option" \
bash "$ROOT/drill/drill.sh" --frobnicate
check "--help prints the header and exits 0" 0 "THROWAWAY" \
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 "$PASS passed, $FAIL failed"
[ "$FAIL" -eq 0 ]

189
test/install-lifecycle.sh Executable file
View file

@ -0,0 +1,189 @@
#!/usr/bin/env bash
# The install LIFECYCLE, driven end to end against a tree install.sh itself
# produced (#106) — the four beats box and cast already run in CI, which rig,
# the repo whose headline claim is convergence, ran nowhere:
#
# 1. install from THIS checkout (RIG_INSTALL_SOURCE — the local channel)
# 2. assert what landed (layout, current, the PATH chain)
# 3. a converging re-run (an EMPTY DIFF, never an exit code)
# 4. uninstall --all (ending in the absence assert)
#
# test/cli.sh drives the same verbs against throwaway roots; this suite runs
# them in the environment cli.sh deliberately fakes — the real default paths
# under the runner's own $HOME. Run: bash test/install-lifecycle.sh (CI's
# `install:` job). RIG_HOME/RIG_BIN redirect the roots for a local run; the
# refusal below explains when you need them.
#
# Deliberately no `set -e` — a failing beat is data, and the summary is the
# verdict (the test/release.sh harness shape).
set -u
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT" || exit 1
PASS=0 FAIL=0
# check <desc> <want_exit> <want_substr> <cmd...>
# Runs cmd, asserts exit code and (if non-empty) that combined output
# contains want_substr.
check() {
local desc="$1" want="$2" substr="$3"; shift 3
local out rc
out="$("$@" 2>&1)"; rc=$?
if [ "$rc" -ne "$want" ]; then
echo "FAIL: $desc — exit $rc, wanted $want"
printf '%s\n' "$out" | sed 's/^/ /'
FAIL=$((FAIL + 1)); return
fi
if [ -n "$substr" ] && ! printf '%s' "$out" | grep -qF -e "$substr"; then
echo "FAIL: $desc — output missing '$substr'"
printf '%s\n' "$out" | sed 's/^/ /'
FAIL=$((FAIL + 1)); return
fi
echo "ok: $desc"; PASS=$((PASS + 1))
}
# The roots install.sh will use, computed by ITS rules (install.sh:55-60), so
# every assert below points at what the installer actually touched.
DEST="${RIG_HOME:-$HOME/.local/share/rig}"
if [ "$(id -u)" -eq 0 ]; then
BINDIR="${RIG_BIN:-/usr/local/bin}"
else
BINDIR="${RIG_BIN:-$HOME/.local/bin}"
fi
# Beat 4 REMOVES the install at those roots, so a rig that already lives there
# is a refusal, not a fixture — this suite must never eat an operator's
# install. CI runners are clean; a workstation run points the roots at
# something disposable.
if [ -e "$DEST" ] || [ -L "$DEST" ] || [ -e "$BINDIR/rig" ] || [ -L "$BINDIR/rig" ]; then
echo "install-lifecycle: a rig install already exists ($DEST or $BINDIR/rig)" >&2
echo "install-lifecycle: refusing to drive the lifecycle over it — re-run against scratch roots:" >&2
echo " W=\$(mktemp -d); RIG_HOME=\$W/rig RIG_BIN=\$W/bin bash test/install-lifecycle.sh" >&2
exit 2
fi
VER="$(cat "$ROOT/VERSION")"
WORK="$(mktemp -d)"
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,
# every path's type and mode, every symlink's target. Beat 3 captures this
# before and after the re-run and diffs the two.
tree_state() {
(cd "$1" || return 1
find . -type f -exec sha256sum {} + | LC_ALL=C sort
find . -type l -printf '%p -> %l\n' | LC_ALL=C sort
find . -printf '%y %m %p\n' | LC_ALL=C sort)
}
diff_state() { # diff_state <capture> <root> — how the tree drifted, by name
tree_state "$2" | diff "$1" -
}
no_residue() { # no_residue <path>... — 0 iff every path is GONE: file, dir OR
local p bad=0 # symlink. `! -e` alone follows the link and cannot see it
for p in "$@"; do # dangling — the residue a broken uninstall actually leaves.
if [ -e "$p" ] || [ -L "$p" ]; then echo "still present: $p"; bad=1; fi
done
return "$bad"
}
# --- instrument honesty ------------------------------------------------------
# The diff and the absence assert must be able to FAIL, or beats 3 and 4 prove
# nothing — so break each one against a scratch tree first, on every run
# (the test/drill.sh doctrine: mechanical, not a one-off claim in a PR).
SCR="$WORK/scr"; mkdir -p "$SCR/tree/bin"
echo content > "$SCR/tree/bin/rig"
ln -s bin/rig "$SCR/tree/link"
tree_state "$SCR/tree" > "$SCR/cap"
check "honesty: an untouched tree reads as zero drift" 0 "" \
diff_state "$SCR/cap" "$SCR/tree"
echo drift >> "$SCR/tree/bin/rig"
check "honesty: a mutated file is drift, named" 1 "bin/rig" \
diff_state "$SCR/cap" "$SCR/tree"
tree_state "$SCR/tree" > "$SCR/cap"
ln -sfn ../elsewhere "$SCR/tree/link"
check "honesty: a retargeted symlink is drift" 1 "elsewhere" \
diff_state "$SCR/cap" "$SCR/tree"
tree_state "$SCR/tree" > "$SCR/cap"
touch "$SCR/tree/leftover"
check "honesty: an ADDED file is drift (what a non-convergent installer leaves)" 1 "leftover" \
diff_state "$SCR/cap" "$SCR/tree"
# The beat-4 distinction, demonstrated: `test ! -e` PASSES on a dangling
# symlink (it follows the link), so on its own it would certify a broken
# uninstall clean — only `! -L` sees the corpse.
ln -s "$SCR/nowhere" "$SCR/dangling-rig"
check "honesty: test ! -e cannot see a dangling symlink (the lie)" 0 "" \
test ! -e "$SCR/dangling-rig"
check "honesty: the absence assert can (! -L is the catch)" 1 "still present" \
no_residue "$SCR/dangling-rig"
rm "$SCR/dangling-rig"
check "honesty: a really-gone path passes the absence assert" 0 "" \
no_residue "$SCR/dangling-rig"
# --- beat 1: install from THIS checkout --------------------------------------
# RIG_INSTALL_SOURCE is the supported local channel (its contract — dir,
# 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.
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
# --- beat 2: assert what landed ----------------------------------------------
check "beat 2: the tree landed in versions/$VER" 0 "" \
test -x "$DEST/versions/$VER/bin/rig"
check "beat 2: current points at versions/$VER" 0 "versions/$VER" \
readlink "$DEST/current"
check "beat 2: the PATH symlink rides the chain" 0 "$DEST/current/bin/rig" \
readlink "$BINDIR/rig"
check "beat 2: ...and resolves into versions/ (cast's assert)" 0 "/versions/$VER/bin/rig" \
readlink -f "$BINDIR/rig"
check "beat 2: rig --version answers through the whole chain" 0 "rig $VER" \
"$BINDIR/rig" --version
check "beat 2: INSTALLED_FROM names the local source" 0 "local:$ROOT" \
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 -------------------------------------------
# "Ran twice without crashing" is the self-deception this beat exists to
# refuse (#106): the assert is an empty diff of captured state, plus current
# still pointing where it did.
tree_state "$DEST" > "$WORK/before"
CUR_BEFORE="$(readlink "$DEST/current")"
check "beat 3: the re-run is a no-op that says so" 0 "already installed" b1
check "beat 3: ...and changed NOTHING — the diff is the verdict" 0 "" \
diff_state "$WORK/before" "$DEST"
check "beat 3: current did not move" 0 "" \
test "$(readlink "$DEST/current")" = "$CUR_BEFORE"
# --- beat 4: uninstall --all, ending in the absence assert -------------------
check "beat 4: uninstall --all removes the whole install" 0 "uninstalled" \
"$BINDIR/rig" uninstall --all --force
check "beat 4: zero residue at the install root" 0 "" no_residue "$DEST"
check "beat 4: zero residue on PATH — not even a dangling symlink" 0 "" \
no_residue "$BINDIR/rig"
# The doctrine spelled out as its two distinct asserts (#106): -e for
# presence, -L for the dangling link -e cannot see.
check "beat 4: test ! -e on the PATH entry" 0 "" test ! -e "$BINDIR/rig"
check "beat 4: test ! -L on the PATH entry" 0 "" test ! -L "$BINDIR/rig"
check "beat 4: test ! -e on the install root" 0 "" test ! -e "$DEST"
check "beat 4: test ! -L on the install root" 0 "" test ! -L "$DEST"
echo "---"
echo "$PASS passed, $FAIL failed"
[ "$FAIL" -eq 0 ]

View file

@ -1,150 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# Fixture tests for the labels-reconcile state machine: a comment is a
# non-verdict whatever its body says (the AUTHOR escalates by requesting the
# human), a stale approval does not promote unreviewed code, and an explicit
# human request outranks everything.
# Dependency-free beyond jq; no network, no daemon — pure decide_state.
cd "$(dirname "$0")/.."
# shellcheck source=.github/scripts/labels-reconcile.sh
. .github/scripts/labels-reconcile.sh
# The DRAFT/HEAD_SHA/REQUESTED/REVIEWS_JSON assignments below are the state
# machine's inputs, consumed inside the sourced decide_state — not unused.
# shellcheck disable=SC2034
BOT1="${BOTS[0]}" BOT2="${BOTS[1]}" BOT3="${BOTS[2]}"
pass=0 fail=0
expect() { # $1 = description, $2 = want, $3 = got
if [ "$2" = "$3" ]; then
pass=$((pass + 1))
else
fail=$((fail + 1))
printf 'FAIL: %s — want %s, got %s\n' "$1" "$2" "$3"
fi
}
rev() { # $1=login $2=state $3=commit $4=body $5=submitted_at → one review object
jq -n --arg u "$1" --arg s "$2" --arg c "$3" --arg b "$4" --arg t "$5" \
'{user: {login: $u}, state: $s, commit_id: $c, body: $b, submitted_at: $t}'
}
reviews() { jq -s '.' <<<"$*"; } # collect review objects into an array
# -- drafts are building, whoever is requested --------------------------------
DRAFT=true HEAD_SHA=head1 REQUESTED="" REVIEWS_JSON='[]'
expect "draft PR is building" state:building "$(decide_state)"
# -- fresh ready PR with bots requested ---------------------------------------
DRAFT=false REQUESTED="$BOT1
$BOT2
$BOT3" REVIEWS_JSON='[]'
expect "requested bots mean bots-reviewing" state:bots-reviewing "$(decide_state)"
# -- a bot that never reviewed keeps the round open ---------------------------
REQUESTED="" REVIEWS_JSON="$(reviews \
"$(rev "$BOT1" APPROVED head1 "" t1)" \
"$(rev "$BOT2" APPROVED head1 "" t2)")"
expect "missing bot review means bots-reviewing" state:bots-reviewing "$(decide_state)"
# -- a comment is a non-verdict, agreement body or not: the author escalates --
REVIEWS_JSON="$(reviews \
"$(rev "$BOT1" COMMENTED head1 "✅ **Reviewed — I agree with everything.**" t1)" \
"$(rev "$BOT2" APPROVED head1 "" t2)" \
"$(rev "$BOT3" APPROVED head1 "" t3)")"
expect "comment-only agreement still parks on the author" state:addressing "$(decide_state)"
# ...and the author's escalation — requesting the human — flips it
REQUESTED="$HUMAN"
expect "author escalation flips to needs-human" state:needs-human "$(decide_state)"
REQUESTED=""
# -- three formal approvals need no author judgment ---------------------------
REVIEWS_JSON="$(reviews \
"$(rev "$BOT1" APPROVED head1 "" t1)" \
"$(rev "$BOT2" APPROVED head1 "" t2)" \
"$(rev "$BOT3" APPROVED head1 "" t3)")"
expect "three formal approvals reach needs-human" state:needs-human "$(decide_state)"
# -- a comment WITHOUT a verdict parks the PR on the agent --------------------
REVIEWS_JSON="$(reviews \
"$(rev "$BOT1" COMMENTED head1 "🔧 Reviewed — I agree with most; feedback below." t1)" \
"$(rev "$BOT2" APPROVED head1 "" t2)" \
"$(rev "$BOT3" APPROVED head1 "" t3)")"
expect "comment without verdict is addressing" state:addressing "$(decide_state)"
# -- changes requested blocks, at any head ------------------------------------
REVIEWS_JSON="$(reviews \
"$(rev "$BOT1" CHANGES_REQUESTED old1 "blockers below" t1)" \
"$(rev "$BOT2" APPROVED head1 "" t2)" \
"$(rev "$BOT3" APPROVED head1 "" t3)")"
expect "changes-requested blocks even from an old head" state:addressing "$(decide_state)"
# -- a stale approval must not promote unreviewed code ------------------------
REVIEWS_JSON="$(reviews \
"$(rev "$BOT1" APPROVED old1 "" t1)" \
"$(rev "$BOT2" APPROVED head1 "" t2)" \
"$(rev "$BOT3" APPROVED head1 "" t3)")"
expect "stale approval is addressing (agent owes re-request)" state:addressing "$(decide_state)"
# -- a re-requested bot reopens the round even with an old approval on file ---
REQUESTED="$BOT1"
expect "re-requested bot means bots-reviewing" state:bots-reviewing "$(decide_state)"
REQUESTED=""
# -- only the LATEST review per bot counts ------------------------------------
REVIEWS_JSON="$(reviews \
"$(rev "$BOT1" CHANGES_REQUESTED head1 "blockers" t1)" \
"$(rev "$BOT1" APPROVED head1 "" t2)" \
"$(rev "$BOT2" APPROVED head1 "" t3)" \
"$(rev "$BOT3" APPROVED head1 "" t4)")"
expect "later approval supersedes earlier block" state:needs-human "$(decide_state)"
# -- an explicit human request outranks the bot rounds ------------------------
REQUESTED="$HUMAN" REVIEWS_JSON="$(reviews \
"$(rev "$BOT1" COMMENTED head1 "feedback, no verdict" t1)")"
expect "human requested outranks bots" state:needs-human "$(decide_state)"
REQUESTED=""
# -- human CHANGES_REQUESTED puts the ball back on the agent ------------------
REVIEWS_JSON="$(reviews \
"$(rev "$BOT1" APPROVED head1 "" t1)" \
"$(rev "$BOT2" APPROVED head1 "" t2)" \
"$(rev "$BOT3" APPROVED head1 "" t3)" \
"$(rev "$HUMAN" CHANGES_REQUESTED head1 "not yet" t4)")"
expect "human block with bots approving is addressing" state:addressing "$(decide_state)"
# ...and re-requesting the human hands it back to them
REQUESTED="$HUMAN"
expect "re-requested human is needs-human again" state:needs-human "$(decide_state)"
REQUESTED=""
# -- an old human comment must not wedge the handoff (codex, #85 round 3) -----
REVIEWS_JSON="$(reviews \
"$(rev "$HUMAN" COMMENTED old1 "early thoughts" t0)" \
"$(rev "$BOT1" APPROVED head1 "" t1)" \
"$(rev "$BOT2" APPROVED head1 "" t2)" \
"$(rev "$BOT3" APPROVED head1 "" t3)")"
expect "old human comment + three approvals is needs-human" state:needs-human "$(decide_state)"
expect "old human comment still needs a fresh request" needed "$(human_request_needed && echo needed || echo not-needed)"
# ...a stale human APPROVAL likewise needs a re-request for the new head
REVIEWS_JSON="$(reviews \
"$(rev "$HUMAN" APPROVED old1 "" t0)" \
"$(rev "$BOT1" APPROVED head1 "" t1)" \
"$(rev "$BOT2" APPROVED head1 "" t2)" \
"$(rev "$BOT3" APPROVED head1 "" t3)")"
expect "stale human approval needs a fresh request" needed "$(human_request_needed && echo needed || echo not-needed)"
# ...a HEAD-CURRENT human approval needs nothing more
REVIEWS_JSON="$(reviews \
"$(rev "$HUMAN" APPROVED head1 "" t0)" \
"$(rev "$BOT1" APPROVED head1 "" t1)" \
"$(rev "$BOT2" APPROVED head1 "" t2)" \
"$(rev "$BOT3" APPROVED head1 "" t3)")"
expect "head-current human approval needs no request" not-needed "$(human_request_needed && echo needed || echo not-needed)"
# ...and a live request suppresses re-requesting
REQUESTED="$HUMAN"
expect "live human request suppresses re-request" not-needed "$(human_request_needed && echo needed || echo not-needed)"
REQUESTED=""
printf 'labels-reconcile tests: %d passed, %d failed\n' "$pass" "$fail"
[ "$fail" -eq 0 ]

View file

@ -1,17 +1,16 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# The release flow's testable half (#32): changelog extraction, latest-tag # Rig's own half of the release surface (#32; trimmed in ceremony#13's
# resolution, and the installer's three channels. Dependency-free and # conversion): latest-tag resolution and the installer's three channels.
# The machinery halves — changelog extraction, the arming rule,
# monotonicity, the drill gate, the workflow-shape pins — moved to
# heavy-duty/ceremony, which tests them in its own test/; what stays is
# everything that drives rig's install.sh and bin/. Dependency-free and
# NETWORK-FREE — wherever the code under test would call curl, the curl on # NETWORK-FREE — wherever the code under test would call curl, the curl on
# PATH is a stub this harness wrote. Run: bash test/release.sh # PATH is a stub this harness wrote. Run: bash test/release.sh
# Deliberately no `set -e` — the harness asserts on failing commands. # Deliberately no `set -e` — the harness asserts on failing commands.
set -u set -u
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT" || exit 1 cd "$ROOT" || exit 1
# The extraction the workflow runs is the extraction under test — one
# function, sourced by release.yml and by this harness (repo precedent:
# test/labels-reconcile.sh sourcing the reconciler's decide_state).
# shellcheck source=.github/scripts/release-lib.sh
. "$ROOT/.github/scripts/release-lib.sh"
PASS=0 FAIL=0 PASS=0 FAIL=0
# check <desc> <want_exit> <want_substr> <cmd...> # check <desc> <want_exit> <want_substr> <cmd...>
@ -37,265 +36,20 @@ check() {
WORK="$(mktemp -d)" WORK="$(mktemp -d)"
FAKEHOME="$WORK/home"; mkdir -p "$FAKEHOME" FAKEHOME="$WORK/home"; mkdir -p "$FAKEHOME"
# --- changelog_section: the release body, extracted --------------------------
# A fixture changelog with the three heading shapes the flow produces: the
# bare '## Unreleased', stamped '## X.Y.Z — date' releases, and a last
# section that runs to EOF.
FIXCH="$WORK/CHANGELOG.fixture.md"
cat > "$FIXCH" <<'MD'
# Changelog
History before 0.1.0 lives in git.
## Unreleased
- an unreleased entry
## 0.2.0 — 2026-07-18
### Added
- **the newer entry** (#42) — prose.
### Fixed
- a fix in 0.2.0
## 0.1.0 — 2026-07-01
- the first entry
MD
sect_has() { changelog_section "$1" "$2" | grep -qF -e "$3"; }
check "changelog: extracts the asked-for section" 0 "the newer entry" \
changelog_section "$FIXCH" 0.2.0
check "changelog: the whole section, subheadings included" 0 "a fix in 0.2.0" \
changelog_section "$FIXCH" 0.2.0
check "changelog: stops at the next release heading" 1 "" \
sect_has "$FIXCH" 0.2.0 "the first entry"
check "changelog: never leaks the preceding section" 1 "" \
sect_has "$FIXCH" 0.2.0 "an unreleased entry"
check "changelog: the heading itself is not the body" 1 "" \
sect_has "$FIXCH" 0.2.0 "## 0.2.0"
first_line() { changelog_section "$1" "$2" | head -n1; }
check "changelog: leading blank lines are dropped" 0 "### Added" \
first_line "$FIXCH" 0.2.0
check "changelog: the bare Unreleased heading matches too" 0 "an unreleased entry" \
changelog_section "$FIXCH" Unreleased
check "changelog: the last section runs to EOF" 0 "the first entry" \
changelog_section "$FIXCH" 0.1.0
absent() { [ -z "$(changelog_section "$1" "$2")" ]; }
check "changelog: an unknown version yields NOTHING (the refusal signal)" 0 "" \
absent "$FIXCH" 3.3.3
check "changelog: a date-stamped heading never matches by date" 0 "" \
absent "$FIXCH" 2026-07-18
# ...and the SHIPPED changelog fits the extractor. The real file has two
# legitimate states, and the old check knew only one (#44, found the day the
# first release PR turned CI red): BETWEEN releases there is an `## Unreleased`
# section feature PRs append to; on a `release: X.Y.Z` tree — and on main
# right after it, until the next feature PR — that section IS the stamped
# `## X.Y.Z — date`. Demanding the literal heading (or, worse, an issue
# number inside it) made the release PR of the ceremony unshippable by
# construction. What the guard is FOR is format drift: whatever the top
# section is called, the exact function release.yml runs must extract it
# non-empty.
# shellcheck disable=SC2016 # the $-refs are the inner bash -c's, deliberately
check "CHANGELOG.md: has a top section (Unreleased or a stamped release)" 0 "" \
bash -c '[ -n "$(grep -m1 "^## " "$1")" ]' _ "$ROOT/CHANGELOG.md"
# --- the arming rule: is main's changelog ready for a late merge? ------------
# #66: stamping the Unreleased heading DISARMS the file. A PR authored before
# a release and merged after it wrote its entry under `## Unreleased`; once
# that heading has become `## X.Y.Z — date`, git lands the entry under the
# release that already shipped — cleanly, no conflict, nothing for the author
# to notice. It happened here: #60's #58 entry landed inside `## 0.1.0` at
# 67386b4, repaired two minutes later by 0ff520c.
#
# The check above cannot see this, and #44 is why: demanding a literal
# `## Unreleased` is FALSE BY CONSTRUCTION on the tree the ceremony's own PR
# produces, which made the release PR unshippable. That relaxation must not
# be undone.
#
# What distinguishes the two states the old guard collapsed is VERSION.
# A stamped top section is legal exactly when VERSION is bare — the ceremony
# PR, and main until the -dev bump lands. The moment VERSION carries -dev,
# main is a place feature PRs merge into, and the top section MUST be
# `## Unreleased` or the next late merge is misfiled.
#
# Note the asymmetry, which is deliberate: on a BARE version the top heading
# is not constrained at all. The ceremony re-arms in the same PR
# (CONTRIBUTING step 1), so its tree legitimately carries an EMPTY
# `## Unreleased` above the section it just stamped — and an empty top
# section is exactly what the old non-empty assert would have rejected.
# What must extract non-empty on a bare VERSION is the section that SHIPS,
# which is the same assert release.yml makes before it publishes.
#
# changelog_armed <version> <changelog-file> — 0 armed, 1 disarmed.
changelog_armed() {
local ver="$1" file="$2" top
top="$(grep -m1 '^## ' "$file")"
[ -n "$top" ] || return 1
case "$ver" in
*-dev) [ "$top" = "## Unreleased" ] ;;
*) [ -n "$(changelog_section "$file" "$ver")" ] ;;
esac
}
# The guard itself, against the real tree.
check "CHANGELOG.md: armed for the VERSION it carries (#66)" 0 "" \
changelog_armed "$(cat "$ROOT/VERSION")" "$ROOT/CHANGELOG.md"
# ...and the rule proven against trees built for the purpose, because a guard
# that is only ever run against a passing tree has not been shown to fail.
# Each is a real VERSION + CHANGELOG.md pair the flow actually produces.
armtree() { # armtree <name> <version> <changelog-body...> -> prints the dir
local d="$WORK/arm-$1"; mkdir -p "$d"; printf '%s\n' "$2" > "$d/VERSION"
shift 2; printf '%s\n' "$@" > "$d/CHANGELOG.md"; printf '%s' "$d"
}
armed() { changelog_armed "$(cat "$1/VERSION")" "$1/CHANGELOG.md"; }
# The ceremony PR's own tree, re-armed per CONTRIBUTING step 1: VERSION bare,
# an empty Unreleased sitting above the section it just stamped. GREEN — this
# is the case #44 was about, and the empty section must not break it.
T="$(armtree ceremony 0.2.0 '# Changelog' '' '## Unreleased' '' '## 0.2.0 — 2026-07-19' '' '- **A shipped thing** (#1) — prose.')"
check "arming: the re-armed ceremony tree passes (#44 stays fixed)" 0 "" armed "$T"
# The same ceremony WITHOUT the re-arm — old-style, stamped straight over the
# heading. Also GREEN: VERSION is bare, so a stamped top is legal. The guard
# refuses to make the ceremony unshippable, which is the whole #44 lesson.
T="$(armtree ceremony-old 0.2.0 '# Changelog' '' '## 0.2.0 — 2026-07-19' '' '- **A shipped thing** (#1) — prose.')"
check "arming: an un-re-armed ceremony tree still passes (bare VERSION)" 0 "" armed "$T"
# main AFTER release.yml's -dev bump, with the changelog left disarmed. This
# is #66 exactly, and the state cast sat in at the time of writing. RED.
T="$(armtree disarmed 0.2.1-dev '# Changelog' '' '## 0.2.0 — 2026-07-19' '' '- **A shipped thing** (#1) — prose.')"
check "arming: a -dev main with a stamped top section FAILS (#66)" 1 "" armed "$T"
# The same main, re-armed. The Unreleased section is EMPTY — no feature PR has
# merged since the release — and that is a correct, expected state. GREEN.
T="$(armtree rearmed 0.2.1-dev '# Changelog' '' '## Unreleased' '' '## 0.2.0 — 2026-07-19' '' '- **A shipped thing** (#1) — prose.')"
check "arming: a -dev main with an EMPTY Unreleased passes (no entries yet)" 0 "" armed "$T"
# Steady state between releases: entries accumulating under Unreleased.
T="$(armtree steady 0.2.1-dev '# Changelog' '' '## Unreleased' '' '### Fixed' '' '- **A pending thing** (#2) — prose.' '' '## 0.2.0 — 2026-07-19' '' '- **A shipped thing** (#1) — prose.')"
check "arming: the normal between-releases tree passes" 0 "" armed "$T"
# A release PR that bumped VERSION but forgot to stamp: the version it claims
# to ship has no section, so release.yml would publish empty notes. RED here,
# one round earlier than the workflow's own refusal.
T="$(armtree unstamped 0.3.0 '# Changelog' '' '## Unreleased' '' '- **A pending thing** (#2) — prose.')"
check "arming: a bare VERSION whose section was never stamped FAILS" 1 "" armed "$T"
# And a file with no '## ' heading at all is disarmed, not silently fine.
T="$(armtree headless 0.2.1-dev '# Changelog' '' 'no sections here')"
check "arming: a changelog with no sections FAILS" 1 "" armed "$T"
# --- release.yml: the pins ---------------------------------------------------
# The workflow itself runs only on a tag push upstream, so pin its
# load-bearing pieces the way the harness pins root-only paths (repo
# precedent: the tag-refusal greps in test/cli.sh).
RY="$ROOT/.github/workflows/release.yml"
check "release.yml: exists" 0 "" test -f "$RY"
check "release.yml: triggers on tag pushes" 0 "" grep -q "tags:" "$RY"
check "release.yml: sources the shared lib (one extractor, not a copy)" 0 "" \
grep -q "release-lib.sh" "$RY"
check "release.yml: the body comes from changelog_section" 0 "" \
grep -q "changelog_section CHANGELOG.md" "$RY"
check "release.yml: a tag/VERSION mismatch refuses to create" 0 "" \
grep -q "refusing to create a release" "$RY"
check "release.yml: an empty changelog section refuses too" 0 "" \
grep -q "has no '## " "$RY"
check "release.yml: gh release create verifies the tag" 0 "" \
grep -q -- "--verify-tag" "$RY"
# Ordering: the mismatch assert must precede the create (line compare, the
# repo's marker-then-box idiom; defaults fail closed).
assert_at="$(grep -n "refusing to create a release" "$RY" | head -n1 | cut -d: -f1)"
create_at="$(grep -n "gh release create" "$RY" | head -n1 | cut -d: -f1)"
check "release.yml: the assert precedes the create" \
0 "" test "${assert_at:-999999}" -lt "${create_at:-0}"
# --- release.yml, the merge path: the pins (#47; box#96's design) ------------
# Merging the release-labeled ceremony PR IS the release. Same grep-pin
# treatment for the merge path's load-bearing pieces: the gate, the four
# fail-loud asserts, the same-job tag+publish, and the surviving tag-push
# fallback.
# The merge door rides pushes to MAIN, not pull_request events: a fork PR's
# pull_request run gets a read-only GITHUB_TOKEN (permissions: cannot raise
# it), and every ceremony PR this org merges is cross-repo from the bot
# fork — the tag create would 403 after green asserts (#48 round 1). The
# label — the operator's intent — is read via the API off the merge commit.
check "release.yml: the merge door rides pushes to main (fork-token-proof)" 0 "" \
grep -qF "branches: [main]" "$RY"
# YAML maps are last-key-wins: a second sibling push: key silently replaces
# the first and kills a door (grok's round-2 catch — the tag fallback had
# stopped triggering). Exactly ONE push key may exist.
check "release.yml: exactly one on.push key (duplicate keys drop a door)" 0 "1" \
grep -cE '^ push:' "$RY"
check "release.yml: ...and the doors split on the ref (tag door takes tags)" 0 "" \
grep -qF "startsWith(github.ref, 'refs/tags/')" "$RY"
# shellcheck disable=SC2016 # the $-string is a literal in the target file
check "release.yml: the release label is read via the API off the merge commit" 0 "" \
grep -qF 'commits/$MERGE_SHA/pulls' "$RY"
check "release.yml: a transition without a labeled PR refuses" 0 "" \
grep -qF "no merged, release-labeled PR is behind this commit" "$RY"
# The decide step tells the label's two meanings apart (LABELS.md gives
# `release` to release-flow WORK as well as to the ceremony PR): work under
# the label is a green NOTICE no-op — in the -dev steady state and in the
# post-release window (bare, unchanged, already released) — while every
# half-ceremony refuses. Pin each verdict's message and the gating output.
check "release.yml: decide — dev-tree work no-ops green (not a red run per infra PR)" 0 "" \
grep -qF "release-flow work under the release label, not a ceremony" "$RY"
check "release.yml: decide — a -dev endstate is always work (the bump PR no-ops green)" 0 "" \
grep -qF "a dev tree is by definition not a release" "$RY"
check "release.yml: decide — post-release-window work no-ops green" 0 "" \
grep -qF "release-flow work merged in the post-release window" "$RY"
check "release.yml: decide — bare, unchanged, never released refuses to guess" 0 "" \
grep -qF "Refusing to guess" "$RY"
# shellcheck disable=SC2016 # the $-refs are the inner bash -c's, deliberately
check "release.yml: decide gates every later step on ceremony=yes" 0 "" \
bash -c '[ "$(grep -cF "if: steps.decide.outputs.ceremony == '\''yes'\''" "$1")" -ge 3 ]' _ "$RY"
check "release.yml: assert 3 — an empty section refuses to publish" 0 "" \
grep -qF "refusing to publish an empty release" "$RY"
check "release.yml: assert 4 — an existing tag or release refuses (idempotent)" 0 "" \
grep -qF "refusing to re-release" "$RY"
# Same-job matters: a GITHUB_TOKEN-created tag fires no tag-push workflow,
# so the publish must live NEXT TO the tag creation. The workflow keeps
# release-on-merge as its last job (pinned by comment there) so the awk
# range runs to EOF; both acts must land inside it.
MJOB="$(awk '/^ release-on-merge:/,0' "$RY")"
mjob_has() { printf '%s' "$MJOB" | grep -qF -e "$1"; }
check "release.yml: the merge job API-creates the tag itself" 0 "" \
mjob_has "git/refs"
# shellcheck disable=SC2016 # the $-string is a literal in the target file
check "release.yml: ...at the pushed main head (github.sha = the merge commit)" 0 "" mjob_has 'sha="$MERGE_SHA"'
# The release re-arms main itself: the post-release -dev bump is arithmetic,
# not judgment, so it rides the same job — direct push, PR fallback.
check "release.yml: the release bumps main to the next -dev itself" 0 "" \
grep -qF "bump main to the next -dev" "$RY"
check "release.yml: ...with a PR fallback when the direct push is refused" 0 "" \
grep -qF "opening the bump PR instead" "$RY"
check "release.yml: ...and publishes in the SAME job" 0 "" \
mjob_has "gh release create"
# Ordering, the marker-then-box idiom again: the last assert's refusal must
# precede the tag creation (asserts first, acts last; defaults fail closed).
massert_at="$(grep -n "refusing to re-release" "$RY" | head -n1 | cut -d: -f1)"
mtag_at="$(grep -n "git/refs" "$RY" | head -n1 | cut -d: -f1)"
check "release.yml: the merge-path asserts precede the tag" \
0 "" test "${massert_at:-999999}" -lt "${mtag_at:-0}"
# ...and the manual path SURVIVES: tag-push trigger plus a push-gated job,
# the documented fallback and backfill.
check "release.yml: the tag-push trigger survives (manual fallback intact)" 0 "" \
grep -qF "tags: ['**']" "$RY"
check "release.yml: the fallback job is gated to push events" 0 "" \
grep -qF "github.event_name == 'push'" "$RY"
# --- the installer's ref logic, extracted ------------------------------------ # --- the installer's ref logic, extracted ------------------------------------
# install.sh must stay a single curl|bash file, so its channel functions live # install.sh must stay a single curl|bash file, so its channel functions live
# 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"
@ -345,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
@ -396,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"
@ -419,8 +237,65 @@ 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) ----------------------------
# A supported input, not test scaffolding — CI's `install:` job and test/cli.sh
# both install THIS checkout through it. What release.sh owes is the channel's
# contract: a directory installs, a tarball installs, neither touches the
# network, and a bad path refuses BY NAME — never a silent fallback to
# downloading a release, which would leave a green CI job testing the wrong
# tree. The stub curl's log is the network witness: any download, even an
# attempted one, would land a URL in it.
H6="$WORK/h6"; B6="$WORK/b6"; LOG6="$WORK/log6"
check "channel local: a directory installs" 0 "done" \
rinst "$H6" "$B6" RIG_INSTALL_SOURCE="$TBDIR/rig-7.7.7-relflow" CURL_STUB_LOG="$LOG6"
check "channel local: the tree landed under its VERSION" 0 "" \
test -x "$H6/versions/7.7.7-relflow/bin/rig"
check "channel local: INSTALLED_FROM records local:<path>" 0 \
"local:$TBDIR/rig-7.7.7-relflow" cat "$H6/versions/7.7.7-relflow/INSTALLED_FROM"
check "channel local: curl was never consulted" 1 "" test -s "$LOG6"
H7="$WORK/h7"; B7="$WORK/b7"; LOG7="$WORK/log7"
check "channel local: a tarball installs too" 0 "done" \
rinst "$H7" "$B7" RIG_INSTALL_SOURCE="$WORK/release.tgz" CURL_STUB_LOG="$LOG7"
check "channel local: the tarball's tree landed" 0 "" \
test -x "$H7/versions/7.7.7-relflow/bin/rig"
check "channel local: ...also without a download" 1 "" test -s "$LOG7"
H8="$WORK/h8"; B8="$WORK/b8"; LOG8="$WORK/log8"
check "channel local: a missing path refuses BY NAME" 1 "$WORK/no-such-source" \
rinst "$H8" "$B8" RIG_INSTALL_SOURCE="$WORK/no-such-source" CURL_STUB_LOG="$LOG8"
check "channel local: the refusal installed NOTHING" 1 "" test -e "$H8"
check "channel local: ...and downloaded nothing (no silent fallback)" 1 "" \
test -s "$LOG8"
rm -rf "$WORK" rm -rf "$WORK"