From d69ecd11046dbf3e98e640270edda9913559f5a9 Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Tue, 21 Jul 2026 13:50:43 +0000 Subject: [PATCH] docs(changelog): one line per entry, and a pass over the whole file --- CHANGELOG.md | 853 +++--------------------------------------------- CONTRIBUTING.md | 32 ++ 2 files changed, 70 insertions(+), 815 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 714b92b..c14f577 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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=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 - ` 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 ` 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 ` 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 - ` 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-`, 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 ` as the remedy. rig logged - `removed 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/.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) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7e1c4a5..e61ea76 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 -- 2.45.2