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