rig/CHANGELOG.md
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

20 KiB

Changelog

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) on the way to cutting its first release, and this file starts there.

Unreleased

Changed

  • BREAKING: the box tenant roles carry a -box suffix (#76) — the other half of the rename below. claudeclaude-box, codexcodex-box, grokgrok-box, stagingstaging-box, so a role name always says which family it belongs to: -server builds a fleet machine, -box converges a guest a box minted.

    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. The suffix is rig's word for "this is a guest", not a rename of anything the guest contains — no path, no account, and no CLI binary moved.

    Migration: hard cut, no aliases, same as the machine roles. The old names are refused as unknown tenant roles at both entrypoints — rig bootstrap <name> and the tenant script directly — and the suite asserts each one at both, because an alias left in for a single tenant is exactly the shape that survives review: the taxonomy reads complete while one old name still quietly converges. The practical consequence is cross-repo: a box seed carrying BOX_BOOTSTRAP_ROLE="claude" now fails its own mint-time bootstrap, so heavy-duty/box#125 (closing heavy-duty/box#123) updates the seeds and must land after this.

  • BREAKING: machine roles carry a -server suffix, and the VM host gets its name back (#76) — rig builds two kinds of thing that sit on opposite sides of a trust boundary: tailnet machines it converges, and guests a box mints. Both families lived in one flat namespace, and no role name said which one you were asking for. staging is where that stopped being cosmetic — the word names the metal that hosts guests and the guests on it, only one of them could have the name, and #31 gave it to the guests. The VM-host shape was left with no name at all, spelled custom --class server --host yes --join authkey, which is what every refusal in the tree recited at an operator who had confused the two.

    So the suffix names the family: control-plane-server, workload-server, runner-server, dev-server, and the restored staging-server (class=server host=yes join=authkey — the preset #31 retired, back under a name that cannot be mistaken for its own guests). host=yes already installs the box CLI and runs box's setup-host, so staging-server is a table row rather than new machinery, and it stays out of the tag:server allow-list on purpose: a host is never managed by the control plane, its guests are, so mint its key with tag:local.

    custom and workstation keep bare names, and that is the rule rather than an exception to it. custom presets nothing and can be any shape — a guest included — so a family claim is one it cannot make. workstation is somebody's own device rather than fleet infrastructure: it joins by interactive login, comes up user-owned and untagged, and the tailnet never manages it.

    Migration — this is a hard cut, with no aliases. The old names are refused as unknown roles; a box bootstrapped under one is re-bootstrapped rather than migrated, which at this fleet size costs less than four deprecation paths each quietly keeping an old name alive. Two consequences worth knowing before you re-run anything. TS_HOSTNAME defaults to the role name, so a box that took the default now comes up as control-plane-server rather than control-plane — pass --hostname to hold a name steady, and check anything pinning one (ACL entries, a cast environments.yaml server name, host keys). And rig coolify install / rig coolify backup install match the role name in /etc/rig/role, so they now look for role=control-plane-server; a pre-rename control plane takes their warning branch until it is re-bootstrapped. That check has always been advisory and never a gate, so the run still proceeds and the warning names the repair.

    The rename also reaches every string that tells an operator to run a role, not just the code that accepts one — bootstrap-tenant.sh emits the staging guest's tailnet-join next step (sudo rig bootstrap workload-server), and two of its refusals recite the machine-role list. A stale next-step is worse than a stale flag: it fails when someone copy-pastes it, on a different box, minutes after the run that printed it reported success. test/cli.sh sweeps every shipped script for pre-rename role names rather than pinning the known sites, because the next instance of this will be somewhere else.

    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, and operators enter a dev box as themselves so close-root shuts its door. The two axes genuinely share the word "server", which is a wart — #77 renames the class trait to what it actually controls, and is kept separate because it reaches markers on live machines that guard root SSH.

0.2.0 — 2026-07-19

Added

  • users apply grants the box tier, not just its socket (#49) — role 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

  • 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

  • A release no longer disarms the changelog under the PRs still in flight (#67) — the ceremony stamps ## Unreleased to ## X.Y.Z — YYYY-MM-DD and stops. Every PR authored before that merge wrote its entry under ## Unreleased; with the heading gone, git files the entry under whatever now occupies the position — the release that 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, 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

Fixed

  • The release suite accepts the ceremony's own tree (#44) — test/release.sh demanded a literal ## Unreleased heading in the real CHANGELOG.md, extracting non-empty and containing #32. All three are 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

  • Merging a release-labeled PR IS the release — and the release re-arms main itself (#47) — the rig twin of heavy-duty/box#96, born of the ceremony retro: the tag was a separate, manual, silent-when-forgotten 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.