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>
Bump VERSION 0.1.1-dev -> 0.2.0 and stamp CHANGELOG.md's Unreleased
section as `## 0.2.0 — 2026-07-19`. Minor, not patch: the section carries
an explicit `### Changed` / BREAKING entry (#51 — `rig bootstrap` takes
the users file, and requires it) alongside an `### Added` (#49), and a
patch release would misrepresent a documented breaking change.
Re-armed in the same PR per CONTRIBUTING step 1 (#66): a fresh, empty
`## Unreleased` sits immediately above the stamped section, so a PR
authored before this release and merged after it files its entry under
Unreleased rather than silently inside the release that already shipped.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The inserted sentences left the pre-existing "And on `host=yes` with
`RIG_SKIP_BOX_INSTALL=1`" sentence appended to a line that ran to 124
columns, against the 75-78 band the rest of the file wraps in. Reflowed
the paragraph at 76. Prose only — no behavior, no code, no test change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
An empty, comments-only or whitespace-only users file is not a parse error,
so it walked straight through the requirement #51 built: pre-flight passed,
apply converged nothing, and the box came up root-only — the exact outcome
--no-users exists to make explicit, reached by the flag added to guarantee
the opposite. `--users ./empty` and `--no-users` produced the identical box
and only one of them said so.
Catch the zero-user parse in bootstrap's pre-flight, where the file is
already parsed for validation and before apt, the hostname change, or a
spent pre-auth key. The refusal names --no-users: the root-only box is
reachable, it just has to be asked for out loud.
Deliberately narrow. This is bootstrap's contract, not the parser's and not
apply's: zero users is a legal file, and a standalone `rig users apply`
against an emptied file is a real de-provisioning operation that must stay
possible. Negative-grep tests pin both.
Closes#57
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`rig 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 one 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.
BREAKING: --users is required on every machine role, with --no-users as the
explicit opt-out. Omitting both is a usage error naming both flags; passing
both is a usage error too. class=server is required as well: a machine
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.
The file is never persisted — passed per invocation, read once through
apply, copied nowhere. `--users -` is refused: bootstrap's stdin belongs to
the pre-auth key prompt. The box TENANT roles take neither flag; a guest is
minted non-interactively, never joins the tailnet, and has no SSH door of
its own.
rig still never installs Incus and never calls `box setup-host` itself. The
host=yes box-role precondition refuses early only where the outcome is
already proven (RIG_SKIP_BOX_INSTALL=1); every other way that step can fail
lands in `users apply`'s existing refusal, unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Role `box` resolved to exactly one action, `usermod -aG incus`. That is
the socket — step 1 of the five `box grant` performs. Without the other
four (the user-<uid> project, its narrowing to boxnet and only boxnet,
the snapshot and backup allowances clone and `box export` ride, and the
shipped box-net profile installed into that project) the user's first
`box new` refuses for want of a box-net profile, so apply's promise —
the users file is the fleet's source of truth — was not kept for this
role. Worse, until an admin arrived by hand the user held an `incus`
membership with no converged project, and incus-user would lazily hand
them a stock unhardened NAT bridge: a state box's own contract forbids.
On host=yes apply now calls `box grant <user>` per box-role user. rig
calls box's grant rather than reimplementing four fifths of it — the
"rig never installs Incus" boundary is about installation, not
invocation, and grant is already script-callable: idempotent,
root-or-sudo, stdin-pinned, with its own run-as-the-user touch.
Three decisions the code carries in comment form:
- Ordering. The call sits after `useradd` (grant opens with a getent
passwd and refuses an unknown account) and after the other groups, so
a user whose grant fails still lands with everything rig owns outright.
- Failure granularity, split the way the host= guard beside it already
splits. A missing box CLI on host=yes dies, like the missing incus
group: a broken VM host, not a per-user accident. A per-user grant
failure warns and continues — one box-role user somewhere in the fleet
must not stop apply everywhere VMs don't live. host=no and marker-less
boxes keep their existing skip-with-warning untouched.
- The group ADD is deferred to grant, while `incus` stays in the wanted
set so the exact-convergence loop never strips a box-role user's
socket. Grant's rollback only reaches a membership that run added, so
rig opening the socket first would leave a failed grant unable to
close it. And grant is the authority on whether the group belongs at
all: for an incus-admin member it deliberately does not add `incus`.
An incus-admin member is warned, never fatal: box grant refuses them
today, which heavy-duty/box#99 fixes box-side with no rig change needed.
Closes#49
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`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 that session dies, and it hands
over `loginctl terminate-user <user>` as the remedy.
rig logged "removed <user> from incus" and moved on. 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 loop and the dropped-user
sweep — now route the incus group through one `drop_incus` helper that
calls `box revoke`, keeping a single 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 (the #12 lesson bootstrap already applies to
box's installer): a revoke that returns 0 with the membership still
standing has not closed the socket, so the effective state is checked and
rig falls back to removing the group itself — as it also does on a host
where box is not installed. Every fallback path carries the session warning
in rig's own voice, because the silence was the bug. The absent-group case
needs no new guard: `id -nG` cannot report a group that does not exist, so
the existing `in_group` test at both call sites is already false on a
host=no box or one where `box setup-host` never ran.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
#58 inverted the section's central claim — the host= trait now decides in
both directions and the incus group never overrides it — but README still
read "when the `incus` group is absent, the `host=` trait decides". That
qualifier is precisely the bypass the change removes, so the operator
reference asserted the bug as the contract.
It also omitted the behavior an operator most needs to know before running
apply on a repurposed box: on a host=no box carrying a stray incus group,
apply warns about the marker/reality mismatch and STRIPS box-role users out
of the group. Discovering that from a diff of your own fleet is the wrong
way to learn it.
Rewritten so the trait gates the role, the group only distinguishes
ready-vs-die once the trait already said yes, and the mismatch names both
its hazard and `rig bootstrap --host yes` as the repair. Pinned in both
directions — current sentences present, superseded one absent — following
the same grep-the-prose-stays-honest discipline the file already uses.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Both reviewers caught it: #41 merged after the changelog convention
landed (#40) and skipped its entry; the release PR is the last gate
before the section becomes the permanent release body.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
rig's first release: VERSION leaves 0.1.0-dev and the Unreleased section
is stamped with today's date. The surface was drilled twice on 2026-07-19
(offline suites, tenant guests on real incus via box, db round-trip, a
real GitHub runner lifecycle, coolify install) and the ceremony itself
rehearsed end to end on a scratch fork — including the guard that made
this very PR possible (#44).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
users apply consulted the host= trait only when group incus was ABSENT,
so a host=no or marker-less box that nonetheless carried the group handed
box-role users a bare `usermod -aG incus` — the socket with no tier, which
incus-user answers by lazily building an unhardened project under whoever
opens it.
The marker now decides in both directions through one pure gate,
assert_marker_hosts_vms, so the verdict is identical whether or not the
group exists. The marker wins over the machine deliberately — it is the
box's declared identity and every other host= decision already treats it
as authoritative — but not silently: when the group exists and the trait
disagrees, the skip names the contradiction and rig bootstrap as the fix.
Closes#58
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The cast-twin blocker (claude-bot): a declared permissions: block zeroes
unspecified scopes, so the label read and the bump fallback's gh pr
create could only 403 — every genuine ceremony would end red at the
label check. pull-requests: write added, consumers named. CONTRIBUTING
step 3 and the changelog entry now tell the shipped story: push-to-main
door, event.before interlock, self-re-arm, manual-path bump stays the
operator's.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
grok's round-2 catch: two sibling push: maps under on: leave only the
second (branches: [main]) alive — the tag-push fallback stopped
triggering entirely. Both filters now live under one push key, the jobs
still split on the pushed ref, and a pin counts exactly one on.push.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Operator decision: the post-release bump PR is ceremony debris — a
derivable one-liner with no judgment for a review to add. After tag +
publish, the same job computes X.Y.(Z+1)-dev and pushes it to main
directly (a GITHUB_TOKEN push fires no workflows, so no recursion and no
red run); if branch protection refuses, the step opens the bump PR
itself, loudly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Round-1 blocker (grok, claude on box#97's twin): a pull_request run from
a public fork gets a read-only GITHUB_TOKEN — permissions: cannot raise
it — and every ceremony PR this org merges is cross-repo from the bot
fork, so the tag create would 403 after green asserts, red on main per
release. The door now triggers on push to main (in-repo event, full
token): the decide step reads the version transition from event.before
(first-parent fallback for the all-zeros edge), and the release label —
still the operator's declared intent — is read via the API off the merge
commit's PR. A transition with no labeled PR behind it refuses. The two
doors now split on the pushed ref: tags to the tag door, main to this one.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The four-state table called '-dev but changed' half a ceremony and
refused — but that state IS the mandatory post-release bump PR
(bare -> X.Y.(Z+1)-dev after every release), a red run on main once per
release, forever. A tree that ends -dev is by definition not a release:
every such merge is work, green NOTICE no-op. Red now guards only bare
endstates.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
LABELS.md gives 'release' to release-flow WORK as well as to the ceremony
PR — including every PR that improves this very workflow. The old assert
pair turned each of those merges into a red run on main. The fused decide
step reads the version against the PR base and answers all four states:
-dev unchanged = work, green NOTICE no-op; bare unchanged but already
released = work in the post-release window, same no-op; -dev-but-changed
and bare-unchanged-never-released = half-ceremonies, refused loudly;
bare-and-changed = the ceremony. Later steps gate on its output.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The rig twin of heavy-duty/box#96, from the release-ceremony retro: the
tag was a separate, manual, silent-when-forgotten step, and a forgotten
tag produces no red X — the worst failure shape. The ship decision
already lives in the release PR; merging it is "ship". After that,
tagging is transcription, and transcription belongs to machines.
release.yml now also fires on pull_request closed into main, gated on
merged AND the `release` label. The job asserts in order, each fail-loud
and creating nothing: VERSION at the merge commit is non--dev; VERSION
changed in THIS PR (base vs merge — the interlock that fails a
mislabeled ordinary PR); the changelog section for that version extracts
non-empty via the existing changelog_section from release-lib.sh; and no
tag or release exists yet. Then, in the same job, it API-creates the tag
at the merge commit and publishes the release with the extracted notes.
Same-job is load-bearing: a GITHUB_TOKEN-created tag does not fire the
tag-push trigger, so the publish must live next to the tag and the
fallback job cannot double-publish; the nothing-exists assert covers a
manual race. The tag-push path survives verbatim as the documented
manual fallback and backfill, and CONTRIBUTING's Releasing section now
reads merge-is-ship with the manual tag as fallback.
test/release.sh pins the merge path in the house grep-pin style: the
merged+labeled gate, the four asserts, the same-job tag+publish (awk
from release-on-merge: to EOF), the asserts-precede-the-tag ordering,
and the surviving tag-push trigger.
Fixes#47
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
test/release.sh demanded a literal '## Unreleased' heading extracting
non-empty with '#32' in it — all false by construction on the very tree
the release PR produces, so the first real 'release: 0.1.0' PR turned CI
red and the ceremony blocked itself. Both fork rehearsals missed it: they
tag a branch, which runs release.yml and never ci.yml. The guard now
asserts its actual purpose — the TOP section, whatever its name, extracts
non-empty via the exact function release.yml runs — and passes on both
legitimate tree states (verified on main's shape and on a stamped copy).
Fixes#44
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A bare 'read -rsp' with no tty exits non-zero and set -e ends the script
with no output at all: the release drill watched 'rig runner remove' exit 1
in complete silence, and a guest bootstrap stop mid-log the same way. Every
prompt now checks for a tty first and dies naming the variable that
unblocks an unattended run; every read is || die-guarded so EOF at a real
prompt also gets a last word. The no-bare-read test swept up runner
repoint's two prompts, which the issue had not counted.
Fixes#42
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
cloud-init's runcmd runs the installer with no $HOME, and under set -u
the first expansion died with an unbound-variable stack instead of an
install — found live by box#88's seed, which pins HOME=/root as its own
scar. Derive the home from getent for the effective user (root included)
before any path comes from $HOME; when getent has no answer either,
refuse by name instead of a bash stack. Driven with a shim getent: the
derived-home install lands, and the no-answer refusal is pinned.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
RIG_REF unset now means 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.
An explicit RIG_REF tries refs/tags first — a pin must outrank a branch
that shares its name — and falls back to refs/heads, which keeps
RIG_REF=main the dev channel. "Re-run any time to upgrade" now means
upgrade to the latest release.
Transitional, and loud about it: rig has no release until 0.1.0 is cut,
so the default channel dies naming RIG_REF=main as the way to install
today — never a silent fallback to main, because "I installed the latest
release" must not quietly mean "I installed whatever main was that
second". test/release.sh drives all three channels through the real
install.sh with a stubbed curl (no network in CI), and the resolve/
candidate functions are extracted and exercised the valid_version way.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The PR-then-tag half of box#83's flow, rig's side. CHANGELOG.md starts
with an Unreleased section (feature PRs land their entry as part of the
PR — box's convention, now written into CONTRIBUTING alongside the
release ritual). On a tag push, release.yml asserts the bare tag equals
the tree's own VERSION — a mismatch fails loudly and creates nothing —
then creates the GitHub release with that version's changelog section as
the body, extracted by changelog_section in .github/scripts/release-lib.sh:
one function, sourced by the workflow and driven by test/release.sh
against fixtures and the shipped CHANGELOG.md itself. No assets — for a
pure-bash tree, the tag's source tarball IS the package.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- The staging marker tolerance now says what the docs meant: class=server
with host=no only. A non-server machine (class=human via custom) refuses
with its own message instead of dying later inside harden_sshd with
server-specific advice. Fixture pins the refusal.
- The docker converge asserts the DAEMON answers (docker info, bounded
30s settle), not just the client binary — grep-pinned.
- The agent-CLI version check is one capture serving assert and log;
emptiness is the failure signal (head exits 0, a pipeline status can't be).
- Harness gains the codex login-flow context grep alongside claude/grok.
Verified: test/cli.sh 244/0, shellcheck -x clean, live container e2e
(staging round 1 + convergence round 2, dockerd answering).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
codex's late #85/#98 round-3 finding, valid post-merge: the needs-human
auto-request fired only when the human had NEVER reviewed, so any earlier
human comment or stale approval left a fully-approved PR labeled
needs-human with nobody actually requested — a wedged handoff.
human_request_needed() now asks whether a fresh head-current human review
is missing (live request or head-current approval → nothing to ask;
anything else → request). Five new fixtures cover the wedge, the stale
approval, the satisfied handoff, and request suppression (19 total).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Maintainer direction: body-parsing agreement was a guess, and the machine
must not guess. COMMENTED is now unconditionally a non-verdict; the judgment
that a comment-only reviewer's round passed belongs to the PR AUTHOR, who
escalates by requesting the human's review — an explicit request is a fact,
and it is the machine's top-precedence input. Auto-request survives only for
the no-judgment case: three formal head-current approvals. CONTRIBUTING and
LABELS.md state the handoff; fixtures updated (14 transitions, including
author-escalation and the three-formal-approvals path).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>