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
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
- **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.
|
|
|
|
|
|
|
|
|
|
`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.
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
- **`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.
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
- **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.
|
|
|
|
|
|
2026-07-19 12:15:08 +00:00
|
|
|
### Fixed
|
|
|
|
|
|
2026-07-19 19:41:49 +00:00
|
|
|
- **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](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.
|
|
|
|
|
|
2026-07-19 17:29:20 +00:00
|
|
|
- **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.
|
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
|
|
|
- **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.
|
fix(bootstrap): refuse a users file that names no users
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>
2026-07-19 17:28:39 +00:00
|
|
|
- **`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.
|
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-19 13:48:41 +00:00
|
|
|
- **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.
|
|
|
|
|
|
2026-07-19 14:41:43 +00:00
|
|
|
- **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.)
|
2026-07-19 12:15:08 +00:00
|
|
|
- **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.
|
|
|
|
|
|
2026-07-18 20:57:13 +00:00
|
|
|
### Added
|
|
|
|
|
|
2026-07-19 16:34:29 +00:00
|
|
|
- **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.
|
feat: merging a release-labeled PR is the release (#47)
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>
2026-07-19 15:19:47 +00:00
|
|
|
|
2026-07-18 20:57:13 +00:00
|
|
|
- **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.
|