fix(release): re-arm the changelog heading, and guard it against VERSION (#66) #67
3 changed files with 134 additions and 4 deletions
34
CHANGELOG.md
34
CHANGELOG.md
|
|
@ -66,6 +66,40 @@ on the way to cutting its first release, and this file starts there.
|
||||||
|
|
||||||
### Fixed
|
### 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
|
- **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
|
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
|
`incus` was ABSENT (die on `host=yes`, skip on `host=no`). When the group
|
||||||
|
|
|
||||||
|
|
@ -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
|
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
|
`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
|
2. Merge it — that IS the ship decision. `release.yml`'s
|
||||||
`release-on-merge` job asserts, in order, fail-loud, creating nothing:
|
`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
|
the merged tree's `VERSION` is non-`-dev`; this PR is the one that
|
||||||
|
|
|
||||||
|
|
@ -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
|
# 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 "" \
|
check "CHANGELOG.md: has a top section (Unreleased or a stamped release)" 0 "" \
|
||||||
bash -c '[ -n "$(grep -m1 "^## " "$1")" ]' _ "$ROOT/CHANGELOG.md"
|
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 "" \
|
# --- the arming rule: is main's changelog ready for a late merge? ------------
|
||||||
bash -c '. "$2/.github/scripts/release-lib.sh"; [ -n "$(changelog_section "$1" "$(grep -m1 "^## " "$1" | awk "{print \$2}")")" ]' _ "$ROOT/CHANGELOG.md" "$ROOT"
|
# #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 <version> <changelog-file> — 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 <name> <version> <changelog-body...> -> 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 ---------------------------------------------------
|
# --- release.yml: the pins ---------------------------------------------------
|
||||||
# The workflow itself runs only on a tag push upstream, so pin its
|
# The workflow itself runs only on a tag push upstream, so pin its
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue