From 971f33096d8b9900e084c79a821f568b80edc1cf Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Sun, 19 Jul 2026 19:40:27 +0000 Subject: [PATCH 1/2] fix(release): re-arm the changelog heading, and guard it against VERSION --- CONTRIBUTING.md | 14 +++++++- test/release.sh | 90 +++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 100 insertions(+), 4 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ecccb28..b088f5e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -51,7 +51,19 @@ top of #32/box#83's tag flow): 1. A small PR — `release: X.Y.Z`, carrying the `release` label — bumps `VERSION` from `X.Y.Z-dev` and stamps `CHANGELOG.md`'s Unreleased - section as `## X.Y.Z — YYYY-MM-DD`. CI green on it, same loop as any PR. + section as `## X.Y.Z — YYYY-MM-DD`. **Then re-arm the file in the same + PR**: add a fresh, empty `## Unreleased` immediately above the section + you just stamped (#66). Stamping alone *disarms* main — a PR authored + before the release and merged after it wrote its entry under + `## Unreleased`, and with that heading gone git files the entry under + whatever now occupies the position, which is the release that already + shipped. It lands cleanly, with no conflict and nothing for the author + to notice, so the empty section is the only thing standing between a + late merge and a changelog that misattributes a shipped release. No + workflow does this for you: `release.yml` re-arms `VERSION`, never the + changelog. `test/release.sh` enforces the pairing — whenever `VERSION` + ends in `-dev` the top section must be `## Unreleased`. CI green on it, + same loop as any PR. 2. Merge it — that IS the ship decision. `release.yml`'s `release-on-merge` job asserts, in order, fail-loud, creating nothing: the merged tree's `VERSION` is non-`-dev`; this PR is the one that diff --git a/test/release.sh b/test/release.sh index 6a59278..b9b1268 100644 --- a/test/release.sh +++ b/test/release.sh @@ -102,9 +102,93 @@ check "changelog: a date-stamped heading never matches by date" 0 "" \ # shellcheck disable=SC2016 # the $-refs are the inner bash -c's, deliberately check "CHANGELOG.md: has a top section (Unreleased or a stamped release)" 0 "" \ bash -c '[ -n "$(grep -m1 "^## " "$1")" ]' _ "$ROOT/CHANGELOG.md" -# shellcheck disable=SC2016 # same: positional args resolve inside the inner shell -check "CHANGELOG.md: the top section extracts non-empty (the format fits the tool)" 0 "" \ - bash -c '. "$2/.github/scripts/release-lib.sh"; [ -n "$(changelog_section "$1" "$(grep -m1 "^## " "$1" | awk "{print \$2}")")" ]' _ "$ROOT/CHANGELOG.md" "$ROOT" + +# --- the arming rule: is main's changelog ready for a late merge? ------------ +# #66: stamping the Unreleased heading DISARMS the file. A PR authored before +# a release and merged after it wrote its entry under `## Unreleased`; once +# that heading has become `## X.Y.Z — date`, git lands the entry under the +# release that already shipped — cleanly, no conflict, nothing for the author +# to notice. It happened here: #60's #58 entry landed inside `## 0.1.0` at +# 67386b4, repaired two minutes later by 0ff520c. +# +# The check above cannot see this, and #44 is why: demanding a literal +# `## Unreleased` is FALSE BY CONSTRUCTION on the tree the ceremony's own PR +# produces, which made the release PR unshippable. That relaxation must not +# be undone. +# +# What distinguishes the two states the old guard collapsed is VERSION. +# A stamped top section is legal exactly when VERSION is bare — the ceremony +# PR, and main until the -dev bump lands. The moment VERSION carries -dev, +# main is a place feature PRs merge into, and the top section MUST be +# `## Unreleased` or the next late merge is misfiled. +# +# Note the asymmetry, which is deliberate: on a BARE version the top heading +# is not constrained at all. The ceremony re-arms in the same PR +# (CONTRIBUTING step 1), so its tree legitimately carries an EMPTY +# `## Unreleased` above the section it just stamped — and an empty top +# section is exactly what the old non-empty assert would have rejected. +# What must extract non-empty on a bare VERSION is the section that SHIPS, +# which is the same assert release.yml makes before it publishes. +# +# changelog_armed — 0 armed, 1 disarmed. +changelog_armed() { + local ver="$1" file="$2" top + top="$(grep -m1 '^## ' "$file")" + [ -n "$top" ] || return 1 + case "$ver" in + *-dev) [ "$top" = "## Unreleased" ] ;; + *) [ -n "$(changelog_section "$file" "$ver")" ] ;; + esac +} + +# The guard itself, against the real tree. +check "CHANGELOG.md: armed for the VERSION it carries (#66)" 0 "" \ + changelog_armed "$(cat "$ROOT/VERSION")" "$ROOT/CHANGELOG.md" + +# ...and the rule proven against trees built for the purpose, because a guard +# that is only ever run against a passing tree has not been shown to fail. +# Each is a real VERSION + CHANGELOG.md pair the flow actually produces. +armtree() { # armtree -> prints the dir + local d="$WORK/arm-$1"; mkdir -p "$d"; printf '%s\n' "$2" > "$d/VERSION" + shift 2; printf '%s\n' "$@" > "$d/CHANGELOG.md"; printf '%s' "$d" +} +armed() { changelog_armed "$(cat "$1/VERSION")" "$1/CHANGELOG.md"; } + +# The ceremony PR's own tree, re-armed per CONTRIBUTING step 1: VERSION bare, +# an empty Unreleased sitting above the section it just stamped. GREEN — this +# is the case #44 was about, and the empty section must not break it. +T="$(armtree ceremony 0.2.0 '# Changelog' '' '## Unreleased' '' '## 0.2.0 — 2026-07-19' '' '- **A shipped thing** (#1) — prose.')" +check "arming: the re-armed ceremony tree passes (#44 stays fixed)" 0 "" armed "$T" + +# The same ceremony WITHOUT the re-arm — old-style, stamped straight over the +# heading. Also GREEN: VERSION is bare, so a stamped top is legal. The guard +# refuses to make the ceremony unshippable, which is the whole #44 lesson. +T="$(armtree ceremony-old 0.2.0 '# Changelog' '' '## 0.2.0 — 2026-07-19' '' '- **A shipped thing** (#1) — prose.')" +check "arming: an un-re-armed ceremony tree still passes (bare VERSION)" 0 "" armed "$T" + +# main AFTER release.yml's -dev bump, with the changelog left disarmed. This +# is #66 exactly, and the state cast sat in at the time of writing. RED. +T="$(armtree disarmed 0.2.1-dev '# Changelog' '' '## 0.2.0 — 2026-07-19' '' '- **A shipped thing** (#1) — prose.')" +check "arming: a -dev main with a stamped top section FAILS (#66)" 1 "" armed "$T" + +# The same main, re-armed. The Unreleased section is EMPTY — no feature PR has +# merged since the release — and that is a correct, expected state. GREEN. +T="$(armtree rearmed 0.2.1-dev '# Changelog' '' '## Unreleased' '' '## 0.2.0 — 2026-07-19' '' '- **A shipped thing** (#1) — prose.')" +check "arming: a -dev main with an EMPTY Unreleased passes (no entries yet)" 0 "" armed "$T" + +# Steady state between releases: entries accumulating under Unreleased. +T="$(armtree steady 0.2.1-dev '# Changelog' '' '## Unreleased' '' '### Fixed' '' '- **A pending thing** (#2) — prose.' '' '## 0.2.0 — 2026-07-19' '' '- **A shipped thing** (#1) — prose.')" +check "arming: the normal between-releases tree passes" 0 "" armed "$T" + +# A release PR that bumped VERSION but forgot to stamp: the version it claims +# to ship has no section, so release.yml would publish empty notes. RED here, +# one round earlier than the workflow's own refusal. +T="$(armtree unstamped 0.3.0 '# Changelog' '' '## Unreleased' '' '- **A pending thing** (#2) — prose.')" +check "arming: a bare VERSION whose section was never stamped FAILS" 1 "" armed "$T" + +# And a file with no '## ' heading at all is disarmed, not silently fine. +T="$(armtree headless 0.2.1-dev '# Changelog' '' 'no sections here')" +check "arming: a changelog with no sections FAILS" 1 "" armed "$T" # --- release.yml: the pins --------------------------------------------------- # The workflow itself runs only on a tag push upstream, so pin its -- 2.45.2 From a43d91e7f9b497875228545335780e9ca70b3f0c Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Sun, 19 Jul 2026 19:41:49 +0000 Subject: [PATCH 2/2] docs(changelog): the changelog re-arm entry (#67) --- CHANGELOG.md | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e2e4c8e..972beaa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,6 +66,40 @@ on the way to cutting its first release, and this file starts there. ### 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 -- 2.45.2