From 2b66ecb1bafa1a0a9976e93177cde55fb3d01c18 Mon Sep 17 00:00:00 2001 From: codex-bot-andresmgsl <304681515+codex-bot-andresmgsl@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:38:35 +0000 Subject: [PATCH] feat: add canonical changelog section extractor --- bin/changelog-section | 24 +++++++++ lib/changelog.sh | 22 +++++++++ test/changelog.test.sh | 74 ++++++++++++++++++++++++++++ test/fixtures/CHANGELOG.realistic.md | 26 ++++++++++ 4 files changed, 146 insertions(+) create mode 100755 bin/changelog-section create mode 100644 lib/changelog.sh create mode 100755 test/changelog.test.sh create mode 100644 test/fixtures/CHANGELOG.realistic.md diff --git a/bin/changelog-section b/bin/changelog-section new file mode 100755 index 0000000..1c0f61b --- /dev/null +++ b/bin/changelog-section @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# shellcheck source=lib/changelog.sh +source "$ROOT/lib/changelog.sh" + +changelog="${1:-}" +ver="${2:-}" +if [ -z "$changelog" ] || [ -z "$ver" ]; then + echo "usage: changelog-section " >&2 + exit 2 +fi +[ -f "$changelog" ] || { + echo "changelog-section: no such file: $changelog" >&2 + exit 1 +} + +notes="$(changelog_section "$changelog" "$ver")" +[ -n "$notes" ] || { + echo "changelog-section: $changelog has no section for '$ver' — the release PR stamps the Unreleased section with version + date BEFORE the tag" >&2 + exit 1 +} +printf '%s\n' "$notes" diff --git a/lib/changelog.sh b/lib/changelog.sh new file mode 100644 index 0000000..c9c052c --- /dev/null +++ b/lib/changelog.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# This is the convergence point for box/cast's executable release-notes.sh +# and rig's sourced release-lib.sh. Both histories matter: the publisher and +# guards must use exactly one definition of a changelog section. +# +# box/cast: https://github.com/heavy-duty/box/blob/a17903f07c83aa18c0f009565e1a5442da6d0827/.github/scripts/release-notes.sh +# rig: https://github.com/heavy-duty/rig/blob/7f8a0e08852837475505f404985a1251a2c3a8a1/.github/scripts/release-lib.sh + +# changelog_section +# +# Print the body of exactly one changelog section. The whole second field is +# compared as a string so dots are not regex metacharacters and 0.7.0 can +# never select 0.7.0-rc1. Leading blank padding is omitted; blank lines after +# the body starts are content. Empty output represents either an absent or an +# empty section, which callers deliberately treat as the same refusal. +changelog_section() { + awk -v ver="$2" ' + /^## / { if (found) exit; found = ($2 == ver); next } + found && !body && /^[[:space:]]*$/ { next } + found { body = 1; print } + ' "$1" +} diff --git a/test/changelog.test.sh b/test/changelog.test.sh new file mode 100755 index 0000000..fbbb698 --- /dev/null +++ b/test/changelog.test.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +set -u + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# shellcheck source=test/harness.sh +source "$ROOT/test/harness.sh" +# shellcheck source=lib/changelog.sh +source "$ROOT/lib/changelog.sh" + +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +FIXTURE="$TMP/CHANGELOG.md" +cat >"$FIXTURE" <<'EOF' +# Changelog + +Preamble prose belongs to no section. + +## Unreleased + +- Not yet released and never part of a version. + +## 0.7.0 — 2026-07-20 + +### Added + +- The seven-oh entry. + +## 0.7.0-rc1 — 2026-07-19 + +- The release-candidate entry. + +## 0.6.0 — 2026-07-18 + +- The six-oh entry. + +## 0.5.0 — 2026-07-15 + +## 0.4.0 +- A date-less version heading also parses. +EOF + +assert_section() { + local version="$1" expected="$2" actual + actual="$(changelog_section "$FIXTURE" "$version")" + [ "$actual" = "$expected" ] || { + printf 'wanted:\n%s\ngot:\n%s\n' "$expected" "$actual" + return 1 + } +} + +check "extracts one section body exactly" 0 "" assert_section 0.7.0 $'### Added\n\n- The seven-oh entry.' +check "adjacent older section does not bleed" 0 "" assert_section 0.6.0 '- The six-oh entry.' +check "whole match selects the rc section" 0 "" assert_section 0.7.0-rc1 '- The release-candidate entry.' +check "Unreleased parses as a date-less heading" 0 "" assert_section Unreleased '- Not yet released and never part of a version.' +check "date-less version heading parses" 0 "" assert_section 0.4.0 '- A date-less version heading also parses.' +check "empty stamped section returns empty output" 0 "" assert_section 0.5.0 "" +check "missing section returns empty output" 0 "" assert_section 9.9.9 "" + +WRAPPER="$ROOT/bin/changelog-section" +check "wrapper publishes the requested body" 0 "The seven-oh entry" "$WRAPPER" "$FIXTURE" 0.7.0 +check "wrapper refuses an empty section" 1 "no section for '0.5.0'" "$WRAPPER" "$FIXTURE" 0.5.0 +check "wrapper refuses an absent section" 1 "no section for '9.9.9'" "$WRAPPER" "$FIXTURE" 9.9.9 +check "wrapper explains how the release PR fixes refusal" 1 "stamps the Unreleased section" "$WRAPPER" "$FIXTURE" 9.9.9 +check "wrapper requires both arguments" 2 "usage:" "$WRAPPER" +check "wrapper refuses a missing file" 1 "no such file" "$WRAPPER" "$TMP/missing.md" 1.0.0 + +REALISTIC="$ROOT/test/fixtures/CHANGELOG.realistic.md" +check "realistic changelog keeps its production heading shape" 0 "A mint records its provenance" \ + "$WRAPPER" "$REALISTIC" 0.9.0 +check "realistic adjacent release remains independently extractable" 0 "Merging the release PR" \ + "$WRAPPER" "$REALISTIC" 0.8.0 + +summary diff --git a/test/fixtures/CHANGELOG.realistic.md b/test/fixtures/CHANGELOG.realistic.md new file mode 100644 index 0000000..7e56223 --- /dev/null +++ b/test/fixtures/CHANGELOG.realistic.md @@ -0,0 +1,26 @@ +# Changelog + +History before 0.5.0 lives in git and in the drill records. + +## Unreleased + +## 0.9.0 — 2026-07-21 + +### Added + +- A mint records its provenance and reports it back. +- CI refuses a release PR with no drill record. + +### Fixed + +- A release section cannot be deleted or duplicated unnoticed. + +## 0.8.0 — 2026-07-19 + +### Added + +- Merging the release PR is the release. + +### Fixed + +- The release ceremony re-arms the changelog after publishing.