2026-07-18 20:57:13 +00:00
|
|
|
# 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.
|
|
|
|
|
|
fix: dropping the box role revokes through box, not behind its back
`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>
2026-07-19 16:18:21 +00:00
|
|
|
## Unreleased
|
2026-07-18 20:57:13 +00:00
|
|
|
|
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
|
|
|
### Fixed
|
|
|
|
|
|
2026-07-21 13:50:43 +00:00
|
|
|
- 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)
|
2026-07-20 17:58:10 +00:00
|
|
|
|
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-19 23:35:58 +00:00
|
|
|
### Added
|
|
|
|
|
|
2026-07-21 13:50:43 +00:00
|
|
|
- `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)
|
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 10:05:13 +00:00
|
|
|
|
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:01:16 +00:00
|
|
|
### Changed
|
|
|
|
|
|
2026-07-21 13:50:43 +00:00
|
|
|
- `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)
|
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-19 23:35:42 +00:00
|
|
|
|
2026-07-19 21:31:55 +00:00
|
|
|
## 0.2.0 — 2026-07-19
|
|
|
|
|
|
feat: users apply grants the box tier, not just the socket
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>
2026-07-19 16:15:55 +00:00
|
|
|
### Added
|
|
|
|
|
|
2026-07-21 13:50:43 +00:00
|
|
|
- `users apply` grants the box *tier*, not just its socket (#49)
|
feat: users apply grants the box tier, not just the socket
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>
2026-07-19 16:15:55 +00:00
|
|
|
|
feat!: bootstrap takes the users file
`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>
2026-07-19 16:17:49 +00:00
|
|
|
### Changed
|
|
|
|
|
|
2026-07-21 13:50:43 +00:00
|
|
|
- BREAKING: `rig bootstrap` takes the users file, and requires it (#51)
|
feat!: bootstrap takes the users file
`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>
2026-07-19 16:17:49 +00:00
|
|
|
|
2026-07-19 12:15:08 +00:00
|
|
|
### Fixed
|
|
|
|
|
|
2026-07-21 13:50:43 +00:00
|
|
|
- A release no longer disarms the changelog under the PRs still in flight (#67)
|
|
|
|
|
- A `host=no` box with an `incus` group no longer hands out the bare socket
|
|
|
|
|
(#58)
|
|
|
|
|
- Dropping the box role revokes through `box`, not behind its back (#50)
|
|
|
|
|
- `rig bootstrap` refuses a users file that names no users (#57)
|
fix: dropping the box role revokes through box, not behind its back
`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>
2026-07-19 16:18:21 +00:00
|
|
|
|
|
|
|
|
## 0.1.0 — 2026-07-19
|
|
|
|
|
|
|
|
|
|
### Fixed
|
2026-07-19 17:29:20 +00:00
|
|
|
|
2026-07-21 13:50:43 +00:00
|
|
|
- The release suite accepts the ceremony's own tree (#44)
|
|
|
|
|
- The installer survives an environment with no `$HOME` (#39, #41)
|
|
|
|
|
- Headless credential prompts refuse loudly instead of dying silently (#42)
|
2026-07-19 12:15:08 +00:00
|
|
|
|
2026-07-18 20:57:13 +00:00
|
|
|
### Added
|
|
|
|
|
|
2026-07-21 13:50:43 +00:00
|
|
|
- Merging a release-labeled PR IS the release, and the release re-arms main
|
|
|
|
|
(#47)
|
|
|
|
|
- Tagged releases, and an installer that installs them (#32)
|