docs(changelog): one line per entry, and a pass over the whole file

This commit is contained in:
dan-claude-bot 2026-07-21 13:50:43 +00:00
parent 9c82d56230
commit d69ecd1104
2 changed files with 70 additions and 815 deletions

View file

@ -8,843 +8,66 @@ on the way to cutting its first release, and this file starts there.
### Fixed
- **Deleting a shipped release heading from `CHANGELOG.md` is now caught, on
every PR** (#98, heavy-duty/box#122) — the arming rule (#66) asks one
question about **one** heading: does the top section agree with `VERSION`?
It says nothing about the rest of the file, and the failure that lives there
is an author adding an entry under `## Unreleased` who *replaces* the line
`## 0.2.0 — 2026-07-19` below it instead of inserting above it. git merges
that cleanly — a one-line edit in a file nobody touched concurrently, so no
conflict and no signal — and `changelog_armed()` stays green on that exact
tree, correctly: `## Unreleased` is still on top and still right for
`VERSION`. Verified here rather than assumed: the arming rule was run
against a deliberately mangled copy of this repo's real tree and passed,
while `changelog_section()` extracted `0.2.0` to zero lines. The damage
would have surfaced a whole release later, when `release.yml` refused to
publish an empty section it could no longer find by heading.
`.github/scripts/changelog-monotonic.sh` asserts the complementary
invariant: release headings are **append-only**, so the set of `## X.Y.Z`
headings on a branch must be a **superset** of the set at its merge base.
Its own script, not a clause in the arming check, because "a heading
disappeared" is a property of a DIFF, not of a tree — and because the arming
rule is driven from `test/release.sh` against constructed `VERSION` +
`CHANGELOG.md` pairs that are not git repos and could not express it. The
ceremony's stamp passes by construction (it *adds* `X.Y.Z` and removes none;
`Unreleased` fails the version shape and is deliberately outside the guarded
set), and no reachable base ref is a loud SKIP locally but a hard failure in
CI, which sets `CHANGELOG_MONOTONIC_STRICT=1` and now checks out with
`fetch-depth: 0` so the guard can never quietly stop guarding.
It carries box's second half too: containment catches a **deleted** heading
but cannot catch a **duplicated** one, since the duplicate is head-side
surplus and `comm -23` (base minus head) is blind to extras on the head side
— with or without `sort -u`, and multiset comparison does not close it for
the same reason. So version headings are also asserted **unique on HEAD**.
rig's symptom there is not box's, and the guard says so in its own words:
box's extractor re-arms on every matching `## ` line and *absorbs* whatever
sits between the copies, while rig's `changelog_section()` has
`if (found) exit` and stops dead at the second copy — a duplicate
**truncates**, publishing only what sits between the two headings and
silently dropping the release's real body underneath. Different symptom,
same class: no conflict, no red run, found only by a human reading the
published notes.
- **...and the uniqueness half no longer sits behind conditions it does not
depend on** (#98, heavy-duty/box#143) — containment is a property of a DIFF
and genuinely needs the base ref, the merge base and the base blob.
Uniqueness is a property of **HEAD alone** and needs none of the three. It
sat downstream of all of them anyway, so every degradation path returned
success on a tree carrying a duplicate in plain sight.
The base-blob case was the worst of the three, because it was not a skip at
all: a branch that *introduces* `CHANGELOG.md` hit a bare `exit 0` on a
message that was true about deletion and silent about the duplicate in front
of it. `STRICT=1` could not reach it — STRICT guards the two `skip()` calls,
and that path is not one of them. Off CI the two skips had the same shape, so
a shallow clone or an unpacked tarball would not look at a duplicate the
author was about to push.
That inverted the value of the two halves. Deletion is the failure that needs
a diff to see; duplication is the one `changelog_section()` actually
mis-renders, stopping dead at the second copy and truncating the release's
real body. The half with the live extraction bug behind it was the half with
the most ways to silently not run.
Fixed by moving, not rewriting: uniqueness now runs directly after the file
exists, before any git access, under a boundary comment saying so. The skip
messages say *containment* skipped and that uniqueness already passed, so a
skip no longer claims nothing was checked — and the **success** line got the
same treatment, because dropping the gate below made `merge_base == HEAD` a
routine path rather than a degradation. On a push to main containment
compares the file against itself and asserts nothing, so the line now reports
containment *vacuous* and names uniqueness as the half that ran, instead of
claiming N headings were verified present by a comparison that could not have
detected their absence. Both forms are pinned, including a negative that the
two do not collapse.
The CI step is also no longer gated to `pull_request` — deletion is vacuous
on a push to main, but duplication is vacuous on no tree, so a duplicate
reaching main by any other route went unasserted. The pin that holds that
open is scoped to the step's own block: as a file-wide grep it forbade any
FUTURE step in `ci.yml` from being `pull_request`-gated, and a companion
check keeps the block extractor from silently matching nothing and turning
the negative into a tautology. That gate could not simply be dropped:
`github.base_ref` is empty on a push, and a bare `origin/` under `STRICT=1`
is a hard failure on *every* push to main, so the base ref falls back to
`github.ref_name`. The regression cases pin the ORDER rather than the exit
code, since the clean base-absent tree is green either way — asserting only
the code is what let the original ship. `changelog-monotonic.sh` is also now
`100755`, matching cast's copy of the same file.
- **An unreadable check rollup no longer reads as "nothing is failing"** (#90)
— when `gh pr view` failed, the fallback left the `statusCheckRollup` key
absent entirely, and `(.statusCheckRollup // [])` collapsed that into the
same `NONE` as a PR that genuinely has no checks. `NONE` blocks nothing, so
a transient API failure presented the PR as mergeable by a human: an unknown
certified as green, which is the exact shape of the bug #87 was opened to
stop, surviving in the one place that fix never looked.
`checks_state` now separates the two — `UNREADABLE` for an absent key (a
read that failed), `NONE` for a present-but-empty array (a PR that really
has no checks) — and the sweep leaves an `UNREADABLE` PR exactly as it
found it rather than recomputing labels from facts it did not read.
Deliberately *not* a blocker: blocking would flap the whole board on one bad
API call, and the next tick is fifteen minutes away. Caught by the author
after opening the PR, not by review.
- **CI runs `test/labels-reconcile.sh`, which it had never run** (#90) — the
file arrived with #87 and `ci.yml` was not extended to call it, so the label
state machine that gates every PR in this repo went covered only by whoever
remembered to run its fixtures by hand. #88 merged reporting 51 passing
fixtures: true on the author's machine, never once verified here. Box and
cast both ran the suite already; only rig did not, so this closes a rig-local
gap rather than a family-wide one. It was found by asking, while adding
fixtures to the suite, where the suite actually ran.
- **`state:needs-human` no longer appears on PRs a human cannot merge**
(#87, heavy-duty/box#136) — `decide_state()` derived state from three inputs
(draft flag, requested reviewers, submitted reviews) and read *nothing* about
mergeability or checks. Combined with the `if requested "$HUMAN"`
short-circuit at the top of its precedence, the label was **sticky**: once
the maintainer was requested, the PR read `state:needs-human` through
conflicts, through red CI, through a force-push that staled every approval.
Nothing demoted it.
This repo paid for it directly. During the ten-PR batch merged on 2026-07-20,
every merge re-conflicted the PRs below it through `CHANGELOG.md` — and each
one kept its `state:needs-human` label the whole time, inviting a merge that
could not happen. It was noticed only by opening them one at a time, which is
the exact work the label exists to save.
The rule the label now keeps is that **`state:needs-human` means a human
could merge this right now**, so anything making that false outranks the
request that put it there. A `CONFLICTING` branch or a failing check is the
agent's to fix: new `state:needs-rebase`. Approvals staled by a push mean
nobody reviewed this tree: `state:addressing`, because the agent owes a
re-request. An *unfinished* round still yields to an explicit human request —
a maintainer pulling a PR to themselves early is deliberate, and `MISSING`
(nobody has reviewed yet) is a different fact from `STALE` (everyone reviewed
something else). Precedence is applied to the round as a whole, after every
verdict is collected: deciding inside the loop let the order of `BOTS` pick
the answer, so a round that was *both* unfinished and staled returned on the
`MISSING` before any later bot's `STALE` was read — and came out
`needs-human` over a head nobody had reviewed, the original bug wearing a
different hat.
Whether a check blocks is judged by listing the outcomes that *don't*
`SUCCESS`, `NEUTRAL`, `SKIPPED`, and the pending set — rather than the
outcomes that do. The rollup mixes two closed enums (`CheckRun.conclusion`
and `StatusContext.state`), and an outcome the list forgets is one the label
cannot certify as mergeable: `ERROR`, `CANCELLED` and `STALE` all read as
green under an allow-list of failures. The costs are not symmetric — a false
failure parks the PR on the agent, who looks; a false success invites a human
to merge a tree that will not merge. Superseded runs are dropped first, each
context collapsing to its newest entry: a re-run does not evict the run it
replaced, so box#137's own tip carried a `CANCELLED` `scope` beside the
`SUCCESS` `scope` that superseded it, and judging every entry would have
stranded every re-run PR in `needs-rebase`.
Which entry is newest is dated by **when the run began**`startedAt`,
falling back only if it never recorded one — discarding *both* spellings of
absent. A run still in flight has no completion, but `gh` does not omit the
field: its Go struct marshals the zero time as the string
`0001-01-01T00:00:00Z`, and `//` falls through `null` and `false` only.
Dating by completion therefore sorted the *live* re-run to the bottom and
let `last` pick the very run it superseded — a green context with a
replacement mid-flight read `SUCCESS`, inviting a merge the button had
already disabled, which is #136 restored by the fix for it.
Taking the *newest* stamp each run carries is not a fix either, and this is
the subtle part: it compares a finished predecessor by when it **ended**
against a live successor by when it **began**, which is not an ordering on
runs at all. A run cancelled by the concurrency group does not stop the
instant its replacement starts — the runner has to receive the signal and
wind down — so the predecessor completing *after* the successor started is
the ordinary case, 13s wide on the box#137 tip that motivated the supersede
rule. For that whole drain window the dying predecessor out-dated its own
replacement. One consistent quantity, start time, is the only ordering that
holds. An entry carrying no usable timestamp at all sorts last rather than
first, so something that cannot be dated is never discarded in favour of a
stale success. Every ambiguity here resolves toward "not settled".
`UNKNOWN` mergeability is deliberately not treated as unmergeable: GitHub
reports it for about a minute after every merge while it recomputes, and
flapping every open PR through `needs-rebase` on each merge would be worse
than the bug. A failed read of either fact degrades to the same "do not know"
value, for the same reason.
Also adds `merge-next`, because a correct `needs-human` still does not say
*which* PR to merge first, and order matters when they conflict. Queue order
is intent, so the reconciler never sets it — it only **clears** it once the
PR stops being mergeable-by-a-human, which is precisely the staleness that
made `needs-human` untrustworthy. Both live shapes, the mixed round, the
whole check-outcome enum, and the re-run in every temporal arrangement it
occurs in — superseded, still in flight in both spellings of an absent
completion, undateable, and still draining past its replacement's start —
are pinned in `test/labels-reconcile.sh`. Ported from heavy-duty/box#137 so
the three repos' reconcilers stay byte-identical; fixtures 19 → 51.
- **A missing `/run/sshd` no longer reads as a broken sshd config** (#92) —
`sshd -t` folds two questions into one exit code: is the merged config
parseable, and is the privilege-separation directory there. Both call sites
that validate before bouncing the daemon ran it as `sshd -t 2>/dev/null` and
read *any* non-zero exit as the first question's answer, discarding the one
line that named the second. Bootstrap aborted with `sshd rejects the merged
config` — a verdict sshd never reached — and the follow-up hint, `Run 'sshd
-t' to see which file is bad`, sent the operator to audit `/etc/ssh` files
that were never broken, on a box whose SSH door was serving their own session
at that moment.
The absent directory is not an exotic state. `/run` is a tmpfs and
`/run/sshd` is `ssh.service`'s `RuntimeDirectory`, which systemd creates with
that unit and removes when it stops — so it is legitimately gone under socket
activation (`ssh.socket`, the default on current Debian/Ubuntu), where
per-connection `ssh@.service` instances serve the door and no long-lived unit
holds the directory open. Reported from a box mid-bootstrap right after an
`openssh-server` upgrade.
The classification is now a pure, sourceable `sshd_privsep_gap` — the STATUS
is the verdict and the text only classifies a *failure*, so a passing `sshd
-t` is never diverted whatever its output says — and `sshd_config_ok` repairs
the gap with an idempotent `install -d` and retests once. The repair creates
exactly what systemd would and is not meant to outlive a reboot; by then the
ssh unit has recreated it. **A genuine parse refusal still refuses, and the
rollback is untouched**: the daemon is never bounced into a config it rejects.
The refusals now carry sshd's own stderr instead of asserting a cause and
withholding the evidence for it. `rig users close-root` had the identical
three lines — the more dangerous copy, since it is the command that shuts the
root door — and now reaches the shared judgement through `lib/sshd.sh` rather
than keeping a second copy of it, which is what #31 extracted that lib for
and what let this bug ship twice.
- 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)
### Added
- **`rig platform` — what is this machine, calculated at run time, stored
nowhere** (#64) — rig read no hardware at all; the single exception was
`uname -m` in `runner-install.sh`, used to pick a tarball and then
discarded. So "is this the 32GB one, or the M900?" was 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**: 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 per run would collide with bootstrap's
"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 it needs no root,
makes no network call, and **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 instead of grepping: the tests assert the
actual answer describes the actual test machine. Provenance is read, never
written, and degrades per-file — `/etc/rig/manifest` is #61 and does not
exist yet, so those lines read `not bootstrapped` on every machine today and
nothing else depends on it. The reader is keyed to #61's documented schema
(`schema`, `bootstrapped_by`/`_at`, `converged_by`/`_at`) and fixtures pin
that exact spelling, so the integration cannot land silently broken; birth
and latest stay separate rather than one being inferred from the other. A
fresh machine writes both pairs equal, so two identical lines read as
"never re-converged"; a manifest missing the pair is partial rather than
fresh, and says so instead of backfilling from birth. Named `platform` and not `status` on purpose:
`users status` and `runner status` cross-check recorded against live state
and print `DRIFT`, and a command that records nothing cannot drift — which
also leaves `rig status` free for the machine-wide roll-up. Known
limitation, stated rather than guessed at: `CPU`/`MEMORY` are read from
`/proc` with no cgroup awareness, and whether an `lxc` guest sees its own
limits or the host's totals depends on whether `lxcfs` is in play — it is
unverified, so those two lines are unreliable there.
- **`/etc/rig/manifest` records which rig converged a machine, and when**
(#61) — the entire durable output of a bootstrap run was one line in
`/etc/rig/role`, and that line is about what the box *is*, never about what
built it. `VERSION` was read in exactly one place (`bin/rig:9`, for
`--version`) and that reports the *currently installed* tree, not the one
that ran; there was no timestamp anywhere in the codebase. SSH into a
control plane six months on and a machine converged by `0.1.0-dev` was
indistinguishable from one converged by `0.4.0`. Bootstrap — both the
machine roles and the box tenant roles — now stamps a second file beside
the marker: `schema=1`, `bootstrapped_by`/`bootstrapped_at` (the rig that
*first* converged this machine, pinned forever) and
`converged_by`/`converged_at` (the newest rig to have converged it), read
back with a new `rig manifest [<key>]`. `key=value`, one per line, `0644`
never JSON or YAML, because this is the one file that must stay readable on
the most broken machine in the fleet and a rig-bootstrapped box has no YAML
parser and no `jq`.
Only **decided** facts go in, which is what keeps `bootstrap.sh:3`'s
contract ("a second run changes nothing", enforced by cmp-guards at nine
sites) 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. A naive
timestamp would have made every re-run a diff and had rig report a change it
did not make. **Observed** facts — cores, RAM, disk, kernel — are
deliberately absent: they go stale without rig doing anything, so they
belong to `rig platform` (#64), which computes them fresh and stores
nothing. `/etc/rig/role` is untouched — the marker holds traits and has six
readers; the manifest holds provenance. Readers must ignore keys they do not
know, and the writer preserves lines it does not own, so a manifest written
by a newer rig stays readable to (and survives a rewrite by) an older one.
- `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)
### Changed
- **`state:needs-human` no longer waits on the cron to become true** (#96)
— the labels workflow now also wakes on `pull_request_target: labeled` and
`unlabeled`, and the author sets `state:needs-human` themselves when handing
a PR to the maintainer.
A review landing was never a trigger. There is no
`pull_request_review_target`, and on fork PRs — which is all of them here —
`pull_request_review` runs with a read-only token and cannot label anything.
So the exact moment the label became true, the third bot approving, fired
nothing at all: the label waited for the `*/15` cron, or for somebody to
touch an unrelated PR. And that cron does not run at its declared rate —
GitHub deprioritises short intervals hard enough that, measured across the
three repos over a two-hour window on 2026-07-20, each got **one** scheduled
run against the eight `*/15` implies. heavy-duty/rig#94 took its third
approval and sat on `state:bots-reviewing` for hours; box and cast happened
to catch a tick and flipped correctly, on byte-identical workflow files. The
lag was worst on the quietest repo, which depends on the cron most and
receives it least.
The two halves fix each other: the author's own label write is what fires
the sweep that validates it. That makes it an optimistic write rather than a
transfer of ownership — seconds later the reconciler either confirms the
label or corrects it, and the cron falls back to being a genuine last
resort, for the round an agent forgets to hand off. It cannot loop, because
the reconciler's own writes use `GITHUB_TOKEN` and GitHub does not create
workflow runs from `GITHUB_TOKEN`-triggered events, while agent writes use a
PAT and do. `labels-reconcile.sh` is unchanged: it already recomputes every
open PR from scratch on every run, which is exactly what makes the
optimistic write safe. The `scope` job is skipped on the two new actions,
where no path can have changed and labeler has nothing to derive.
Landed in all three repos together (heavy-duty/box#142,
heavy-duty/cast#132) — `labels.yml` and the label taxonomy are shared.
- **PR labels split into two axes: `state:*` (whose ball) and `blocker:*`
(what is in the way)** (heavy-duty/box#137) — `state:needs-rebase`, added
here only days ago by #87, is retired in the same breath. In its place:
`blocker:conflict`, `blocker:ci-red` and `blocker:unrequested`, applied
additively. A single rule joins the axes — `state:needs-human` requires zero
blockers — which is the invariant #87 was reaching for, stated once instead
of defended at every branch of a precedence chain.
The single-label design projected independent facts onto one totally-ordered
value. Mergeability, check status and the review round move on their own
clocks; a PR can be conflicted *and* red *and* stalled at the same instant.
A total order has to pick one of those to say, so the rest vanish. Every
precedence bug this machine has had lived on that ordering, #87's included:
the fix there was to reorder the chain and collect the round before deciding,
which bought correctness for one more configuration without removing the
reason the next one would break. `state:needs-rebase` was the design's
clearest tell — a single label fired by both a conflict and a failing check,
two problems needing opposite work, telling an agent to rebase when what it
owed was a bug fix. Box's board has the case in the open: #120 was conflicted
**and** red, and could only ever say one of them.
Blockers are a set. There is no precedence between them to get wrong, and
adding a fourth one later cannot reshuffle the meaning of the other three.
What stays on the ordered axis is purely the review round, which is the one
place here where an ordering is genuinely meaningful — a round really does
have a sequence.
`state:bots-reviewing` tightens with it, to mean strictly *a request is live
and an answer is coming*. A ready PR nobody was asked to review used to read
as "waiting on the reviewers" until the stale sweep caught up; it now reads
`state:addressing` + `blocker:unrequested`, because the agent owes the ask
and the board should say so. `blocker:unrequested` covers both shapes of
"this head has no verdict from somebody": nobody reviewed it, or everybody
reviewed an older tree and the approvals staled behind a push. The second is
the worse of the two, since it leaves approvals on the page that no longer
describe the code. Drafts stay exempt — the bots ignore drafts by design —
as does an explicit human request, since a maintainer claiming a PR early is
deliberate.
The reconciler strips `state:needs-rebase` on sight via a `RETIRED` list, so
the retirement heals the existing board instead of stranding a label that
nothing recomputes. It also filters every label it is about to *add* against
the repo's actual label set, read once per sweep: `gh issue edit` rejects the
whole call on one unknown name, so on a repo that has not yet bootstrapped
the new `blocker:*` labels a single missing one would have taken the state
convergence down with it — on exactly the PRs this change exists to heal.
Now the state still converges and the missing labels are named in the log.
Fixtures 51 → 72.
- **BREAKING: `--class human|server` is now `--root-door closed|open`** (#77) —
the trait was named for who *lives on* a box; what it decides is one thing,
and it is not occupancy: whether root SSH stays open as the control plane's
automation door, or `rig users close-root` shuts it once named operators can
get in. The roles had been saying so for a while. `dev-server` is an
unattended VM-host appliance — nobody lives there, operators visit it to mint
boxes and leave — and by occupancy it is plainly a server. It was
`class=human` anyway, and correctly so, because operators enter it *as
themselves* and its root door must close. The trait was right; its name
described the wrong axis.
That stayed cheap until a second thing wanted the word "server". After #76
the `-server` suffix names the machine *family*, so `dev-server` carried a
suffix saying server and a trait saying human, and nothing in the name told a
reader that the two words were answering unrelated questions. `dev-server
--root-door closed` says exactly what is true, and `-server` means one thing
everywhere. The values moved with the name: `human``closed`, `server`
`open`, and the marker field follows as `root-door=`.
Other names were considered. `--root-door open|closed` describes a
*destination* rather than the state at bootstrap time — bootstrap leaves root
SSH open on every box, and the door only shuts later, when `close-root` runs
— so `--root-door closes|stays` was on the table for naming the fate as a
verb, as was `--automation-door yes|no` for naming the thing itself. Both were
rejected in favour of the plainer pair: the marker is already a declaration of
*intent* rather than a report of observed state everywhere else in this repo
(`host=yes` claims a box hosts VMs; #58 settled that the marker's claim wins
over probing the machine), so a trait that states the door's designed end
state is consistent with how every other field is read. Every string that
prints the trait says "once operators exist" or names `close-root` explicitly,
so the tense never has to be inferred.
**Old markers still resolve, permanently, and that is the substance of this
change.** Unlike #76's role rename — role names are informational, nothing
reads them back — this field is written into `/etc/rig/role` and read *from*
there on live machines, where it gates `rig users close-root`. Every box
bootstrapped before this carries `class=human` or `class=server` and carries
it until someone re-bootstraps it, which for a fleet is never. Dropping the
old read would have broken in both directions at once and both are incidents:
a machine whose door is supposed to close loses the ability to close it, and
— through `bootstrap-tenant`'s machine-marker guard, which used the presence
of `class=` as its "is this a real fleet machine?" test — a live box stops
looking like a machine at all, so a tenant converge sails past the refusal
that exists to protect it and clobbers its marker. That second one is the
fail-*open* direction and was the least obvious part of the change.
So one resolver, `root_door_of`, reads both vocabularies, and every consumer
goes through it — close-root's gate, apply's root-SSH note, and the tenant
guard — because a compat read that lives at three call sites is three chances
to drift. `root-door=` wins where both fields are present and agree;
`class=` answers alone on every pre-#77 marker. A marker carrying **both and
disagreeing** resolves to a refusal rather than a winner: bootstrap writes one
line fresh and never produces that state, so a marker in it was hand-edited,
and rig declines to arbitrate between two equally-authored claims about a root
door. A marker naming **neither** refuses too, unchanged from before. Both
refusals fail closed, which here means the door stays open and the operator is
told to re-run bootstrap — never a door welded shut on a machine whose only
entrance it was.
The resolver matches **whole fields, not substrings** — the marker is one
line of space-separated `key=value` pairs, so it pads both ends and matches
on field boundaries. Review caught the first cut doing unanchored matching,
which resolved any value that *extended* a real one: `root-door=closedish`
read as `closed` and passed close-root's gate — the single arm that
authorizes an irreversible act — and `class=humanoid` did the same through
the compat arm, both contradicting the resolver's own promise that a value
outside the set resolves empty and fails closed. Only reachable by hand-
editing a marker, so never a live incident, but this is the one function
every consumer trusts and it owes them exactness rather than nearly. Both
vocabularies are anchored: fixing only the current spelling would have left
every pre-#77 box carrying the hole. Whitespace is normalised first, so a
hand-edit using tabs reads the same rather than trading one silent misread
for another.
**New markers are written in the new vocabulary only.** Writing both would
keep an old rig reading a new marker, but it would entrench the retired
spelling on every box rig ever converges and make the disagreement case
reachable from rig's own hand instead of only from a text editor. The compat
obligation runs the other way and only the other way: new rig reads old
markers. The bounded consequence to know about is downgrade — flipping a
box back to a pre-#77 rig with `rig use` leaves that older code unable to
recognize the new marker; the flip already WARNS on a bootstrapped host (#35),
and re-running bootstrap under whichever rig you settle on rewrites the line.
The suite proves the compat read rather than asserting it. Fixture markers are
kept **deliberately** at the retired spelling — byte for byte as a real
pre-#77 box reads, the same convention #76's `pre-rename-cp` fixture
established — and pinned at both consumers: `close-root` still passes on
`class=human` and still *refuses* on `class=server`, with today's refusal text
naming today's flag, and the tenant guard still recognizes a pre-#77 machine
marker as a machine. Deleting the compat arm turns ten of them red.
- **BREAKING: the box tenant roles carry a `-box` suffix** (#76) — the other
half of the rename below. `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.
**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` was `class=human` when this landed, which read like a
contradiction and was not: the suffix names the family, the class named the
root-SSH door policy, and operators enter a dev box as themselves so
`close-root` shuts its door. The two axes genuinely shared the word "server",
which was a wart — #77, above, renames the trait to what it actually controls
and retires it. It stayed a separate change because it reaches markers on live
machines that guard root SSH, and so needed a compat read this rename did not.
### Fixed
- **CI's shellcheck sweep now reaches `.github/scripts/`** (#70) — the step
ran `shopt -s globstar` and globbed `bin/* **/*.sh`, but globs skip
dot-prefixed names without `dotglob`, so `**/` never descended into
`.github/` and two tracked scripts were linted by nothing:
`labels-reconcile.sh` and `release-lib.sh`. The second is the one that
stings — it holds `changelog_section`, the extraction `release.yml` sources
to build the published release body and the same function `test/release.sh`'s
`changelog_armed` guard (#66) calls to decide whether main is armed. The
script deciding both what ships and whether the changelog is safe was the
script CI never read. Adding `dotglob` pulls in exactly those two files and
nothing else; both already pass, so this closes a hole in the net rather
than fixing a defect behind it. Paired with a class check that fails the
step when any tracked `.sh` falls outside the globbed set, so the gap
cannot reopen quietly — including via a symlinked directory, which
`globstar` declines to traverse.
- **Ctrl-D at the `rig uninstall` confirm no longer aborts in silence**
(#68) — `uninstall_confirm`'s `read -r reply` was unguarded. Under
`set -euo pipefail`, and called as a plain statement, EOF made `read`
return non-zero and killed the shell *at the read* — the `case` on the
next line never ran, so `die "aborted."` never fired. The operator saw
the question, pressed Ctrl-D, and got nothing back: no message, just
exit 1, at the exact moment the tool had asked whether to delete their
install. It failed closed (nothing was ever removed), but nothing said
so. Now `read -r reply || reply=""`, so EOF falls through to the `*)`
arm and aborts out loud — the spelling `commands/db.sh` already used for
the same `[y/N]` shape, one file away. `test/cli.sh` gains the first
drills of the interactive path, driving `y` and Ctrl-D through a real
pty (util-linux `script`, skipped where it is absent) and asserting the
MESSAGE rather than the exit code, which the bug also produced.
- **`users apply` now tells "revoke everyone" apart from "I truncated the
file"** (#65) — a users file naming zero users is a valid instruction to
revoke every operator on the box, and it is indistinguishable from a file a
stray `>` produced. 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 — so
only the second case now stops, states how many operators are about to be
revoked, and requires explicit consent: `--yes`, `RIG_YES=1` (the
installer-family variable `rig uninstall` already reads), or a `y` on a TTY.
Without a terminal and without consent it exits 2, in `uninstall_confirm`'s
words, rather than assume a yes it cannot ask for or hang on a prompt nothing
can answer. A **confirmation**, not `rig 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` don't count toward the number, so a second identical run
stays the silent no-op. Mass revocation below the empty-file bright line (a
file dropping 19 of 20) is deliberately still ungated — that needs a threshold
someone has to justify, and #65 stays open for it.
- `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)
## 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.
- `users apply` grants the box *tier*, not just its socket (#49)
### 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.
- BREAKING: `rig bootstrap` takes the users file, and requires it (#51)
### 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](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.
- 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)
## 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.
- 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)
### 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.
- Merging a release-labeled PR IS the release, and the release re-arms main
(#47)
- Tagged releases, and an installer that installs them (#32)

View file

@ -57,6 +57,38 @@ labels tell you where everything is without opening anything.
convention): add it under `CHANGELOG.md`'s `## Unreleased` heading —
that section becomes the release notes verbatim when a release is cut.
## Changelog entries
Every PR that changes behaviour adds one line to `## Unreleased`. One line is
the whole rule — if it wraps more than twice in your editor, cut it down.
- **Say what changed, and stop.** Why it was wrong, how it was found, what it
cost, what it implies — that belongs in the PR body and the commit message,
which is where anyone chasing the reasoning already goes. This file answers
one question: what is different in this version.
- **Any word that can be removed, is removed.**
- **Lead with the surface, not the mechanism.** "`state:needs-human` is set at
handoff" beats "the labels workflow now also wakes on `labeled`".
- **Cite the issue or PR**`(#96)` — and let the reader follow it for the
rest.
- **Mark a breaking change** with a leading `BREAKING:`.
- Group under `### Added` / `### Changed` / `### Fixed` / `### Removed`.
- No bold run-in headings, no sub-paragraphs, no code blocks, no prose essays.
Good:
- `state:needs-human` is set at handoff, not by the cron (#96)
- An unreadable check rollup no longer reads as "nothing is failing" (#90)
- BREAKING: `--class human|server` is now `--root-door closed|open` (#77)
Not an entry — that is a PR body:
- **`state:needs-human` no longer waits on the cron to become true** (#96) —
the labels workflow now also wakes on `pull_request_target: labeled` and
`unlabeled`, and the author sets it themselves when handing a PR over. A
review landing was never a trigger. There is no `pull_request_review_target`,
and on fork PRs — which is all of them here — ...
## Releasing
A release is a PR, and merging it is the release (#47; box#96's design, on