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>
9.3 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
Added
users applygrants the box tier, not just its socket (#49) — roleboxresolved to exactly one action,usermod -aG incus. That is the socket; it is step 1 of the fivebox grantperforms, so every box-role user still needed an admin to runbox grant <user>by hand before their firstbox newwould do anything but refuse ("your project has no box-net profile"), and until that admin arrived they held anincusmembership with no converged project — incus-user would lazily hand them a stock unhardened NAT bridge, which is worse than no grant at all. Onhost=yesapply now callsbox grantper box-role user, afteruseradd(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 thehost=guard beside them already splits: a missingboxCLI onhost=yesdies (a broken VM host), a per-user grant failure warns and continues (one box-role user must not stop apply for the fleet).host=noand marker-less boxes keep their existing skip-with-warning. Anincus-adminmember is warned, not fatal —box grantrefuses them today, which heavy-duty/box#99 fixes box-side with no rig change needed.
Fixed
- A
host=nobox with anincusgroup no longer hands out the bare socket (#58) —users applyconsulted thehost=trait only when groupincuswas ABSENT (die onhost=yes, skip onhost=no). When the group was PRESENT the trait was never asked, so ahost=noor marker-less box that nonetheless carried the group —box setup-hostran, then the box was re-bootstrapped with other traits — gave every box-role user a bareusermod -aG incus: the socket with no tier behind it, whichincus-useranswers by lazily building an UNHARDENED project under whoever opens it (incusbr-<uid>, NAT on v4 and v6, no ACL, nodns.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 likeassert_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 andrig bootstrapas the repair. On such a box exact-membership convergence now strips box-role users out ofincus, 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 applyconverged groupincuswith a baregpasswd -d, the same move it makes forrig-adminandrig. Those two are rig's;incusis box's, andbox revokedoes 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 overloginctl terminate-user <user>as the remedy. rig loggedremoved <user> from incusand 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 callbox 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.
0.1.0 — 2026-07-19
Fixed
-
The release suite accepts the ceremony's own tree (#44) —
test/release.shdemanded a literal## Unreleasedheading in the realCHANGELOG.md, extracting non-empty and containing#32. All three are false by construction on therelease: X.Y.Ztree 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.ymlruns;ci.ymlnever does). The guard now asserts what it was for: whatever the TOP##section is —Unreleasedbetween releases, the stamped version on and right after one — the exactchangelog_sectionthe workflow runs extracts it non-empty. The rotting issue-number grep is gone. -
The installer survives an environment with no
$HOME(#39) — cloud-init'sruncmdrunsinstall.shwith no$HOMEset, and underset -uthe first expansion died with a bash unbound-variable stack instead of an install — found live by box#88's template seed, which pinsHOME=/rootas its own scar. The installer now derives the home fromgetentfor 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_AUTHKEYinbootstrap,RUNNER_TOKENinrunner install,RUNNER_REMOVE_TOKENinrunner remove, and both tokens inrunner repoint— a site the new no-bare-read test caught after the issue counted three) were bareread -rsp: with stdin not a tty (CI,box exec, any script),readfails,set -eends 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 removealso names--local), and everyreadis|| die-guarded so EOF at a real prompt gets the same courtesy.db.shalready 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.ymlnow fires on pushes to main (fork-sourced ceremony PRs get a read-only token onpull_requestevents), reading the transition from the push itself:event.beforeto the pushed head. A decide step answers four states — release-flow work merged under thereleaselabel (-devendstates, 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 toX.Y.(Z+1)-devitself, direct push with a loud open-a-PR fallback, so no follow-up bump PR exists on the paved road. AGITHUB_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.ZPR bumpsVERSIONand stamps this file's Unreleased section with version + date; the merge commit is tagged bareX.Y.Z(box's tag scheme — novprefix).release.ymlturns 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 samechangelog_sectionthe test harness drives. No assets: for a pure-bash tree, GitHub's source tarball for the tag IS the package.install.shnow defaults to the latest release: the tag is resolved by following thereleases/latestredirect and reading theLocationheader — no API, no token — and the download isarchive/refs/tags/<tag>.tar.gz.RIG_REFpicks the other two channels: a tag pins (refs/tagsoutranks 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, namingRIG_REF=mainas 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 — pinningBOX_REFin the host-installs-box path — stays open until box cuts its next tagged release.