# Changelog History before 0.5.0 lives in git and in [drill/RUNS.md](drill/RUNS.md), which records not just what changed but what each drill run proved. ## Unreleased ### Added - **Merging the release PR IS the release — and the release re-arms main itself** (#96) — the 0.7.0 ceremony ended in an absence: the release PR merged with four approvals and nothing happened, correctly, because publishing hung off a separate, manual, silent-when-forgotten tag push — a failure shape with no error and no red X. The ship decision already lives in the release PR (the one PR whose whole diff is "the version leaves `-dev`"), so `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) before anything is created. Then, in the same job, it tags the merge commit via the API, publishes — 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. Same-job on purpose: a `GITHUB_TOKEN`-created tag triggers no workflows, which is also what makes double-publish impossible. The tag-push path stays unchanged as the documented manual fallback and backfill (it shipped 0.7.0 itself). `test/release.sh` grep-pins the gate, every decide verdict, the single `on.push` key, and the same-job tag+publish+re-arm in the same daemon-free, fail-closed style. ### Fixed - **The release ceremony re-arms `CHANGELOG.md`, and CI refuses to let `main` sit disarmed** (#108) — the ceremony stamps `## Unreleased` into `## X.Y.Z — DATE` by hand, and nothing put the heading back, so `main` sat with no `## Unreleased` from the release until the next PR that happened to re-create one. A PR authored *before* the release wrote its entry under `## Unreleased`; with that heading gone, git lands the entry under whatever now occupies the position — **the section that just shipped** — and it merges **cleanly**. No conflict, no error, no red X: the one signal an author would trust is absent exactly when the outcome is wrong, and the changelog credits a released version with a change it does not contain until a human reads the file. Confirmed in the sibling repo (heavy-duty/rig#66); box has not drifted yet, and the reason is luck rather than design — 0.6.0's ceremony (`77599ab`) added its heading *without* removing `## Unreleased`, so main was never disarmed, while 0.7.0 did disarm it and left a window that nothing happened to cross. Two halves land together. The ceremony step in `CONTRIBUTING.md` is now explicitly **two edits**: stamp, then put an empty `## Unreleased` back above the section just stamped — it belongs there and not in `release.yml`, which only ever touches `VERSION`. And `.github/scripts/changelog-armed.sh` enforces it in CI, keyed on `VERSION` because the two states are genuinely different: a `-dev` tree must carry `## Unreleased` on top, a bare-`VERSION` tree (the ceremony PR, and the merge that publishes it) may carry either that or its own stamped section. The keying is the whole design and not an over-complication — box previously had **no** top-section guard at all, and the obvious one, an unconditional `## Unreleased` requirement, is false by construction on the ceremony PR's own tree, which is why rig#44 and heavy-duty/cast#108 both had to revert it. So a forgotten re-arm does not block the release; it turns `main` red on the very next push, the automatic `-dev` bump the release itself makes. Leaving the bare branch's top heading unconstrained is what keeps both ceremony shapes legal, and a review round on the sibling fix (heavy-duty/cast#114) found the gap that asymmetry leaves: a **half-ceremony** tree — `VERSION` bumped, `## Unreleased` still populated on top, and the section for that version never stamped — makes the wrong-number test false on its first clause, short-circuits, and passes. Nothing then refuses until `release.yml` extracts the notes, which is *after* the merge, on `main`, with the release already half-shipped. So the bare branch now also requires that the section it is about to publish exists and is non-empty, and it asserts that by running `release-notes.sh` — the very script `release.yml` runs — so the guard and the publisher cannot drift apart over what a section is. The message is its own: a missing stamp is not a misnumbered one, and an operator sent to correct a version number that is already right will not find the real problem. Matches heavy-duty/rig#67, so the three repos agree. - **Ctrl-D at a confirmation prompt aborts out loud, instead of exiting in silence** (#111) — `confirm()` and `uninstall_confirm()` both took the operator's answer with a bare `read -r reply`. Every answer a human can type routes through the `case` below it and ends at a `return` or at `die "aborted."` — every answer except EOF. Ctrl-D makes `read` return non-zero, `set -euo pipefail` ends the run on that line, and the `case` is never reached: box exits 1 having printed nothing at all after the question it just asked. It fails closed, which is why this is a small fix and not an incident — nothing is destroyed, the abort is real. The damage is that the tool goes mute at the one moment it had the operator's full attention, and someone who Ctrl-Ds out of `box rm work` cannot tell from the output whether the box is still there. The cure is one token in each function, `read -r reply || die "aborted."`, the same one heavy-duty/rig#43 applied to rig's credential prompts so the two repos read alike. The bug predates everything it touches — `rm` has carried a confirm gate for as long as the verb has existed — but #105 took the number of verbs reaching that line from one to two, and both are irreversible, which is the argument for closing it now rather than the next time someone notices. The three answers a human can actually give (`y`, `n`, and Ctrl-D) are now driven for real on a pty via util-linux `script`: they were structurally untested before, because `[ -t 0 ]` sends a terminal-less suite to the refusal branch and every existing check stopped there — which is exactly how this survived four releases. - **`box restore` asks before it destroys — and the confirmation prompt is now the row's, not rm's** (#105) — `restore` and `rm` both irreversibly discard user state, and only one of them asked. The table gave `restore` the preconditions `box,arg2`: the instance is ours, a snapshot name is present, go. So `box restore work stale-label` silently threw away everything done in the box since that snapshot, with no prompt, no `--force`, and no way to take it back — a warning in `--help` is not a gate. It has been that way since the verb shipped, and it is about to become routine rather than rare (heavy-duty/rig#62's pristine snapshot), which is the wrong time to still be relying on the operator typing the right label. The reason it stayed ungated is worth recording, because it is the actual bug: `confirm` was already a precondition token, but the dispatch line hardcoded the *words* — `confirm "delete $inst and all its snapshots"` — so the one-token fix would have gated restore behind a prompt offering to DELETE the box the operator was trying to rescue. A gate that names the wrong act is worse than no gate; it is how people learn to answer `y` without reading. So the prompt moved into the table as a seventh field, each row saying what it is about to do in its own words, and `restore` now asks to "roll `` back to snapshot `