diff --git a/scripts/release.sh b/scripts/release.sh index f3c85df..0b2c91b 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -36,6 +36,25 @@ check_prerequisites() { log_error "GitHub CLI not authenticated. Run 'gh auth login' first" exit 1 fi + + # Refuse to guess which [Unreleased] section is the real one. Release-note + # extraction stops at the first heading, so a duplicate means one section can + # never ship — and it stays invisible until someone reads the whole file. + local unreleased_headings + unreleased_headings=$(grep -c '^## \[Unreleased\]' CHANGELOG.md 2>/dev/null || true) + if [ "${unreleased_headings:-0}" -gt 1 ]; then + log_error "CHANGELOG.md has $unreleased_headings '## [Unreleased]' headings; there must be at most one" + echo "" + grep -n '^## \[Unreleased\]' CHANGELOG.md + echo "" + echo "Merge them into the topmost one, or retitle the stale section with the" + echo "version it actually shipped in, then re-run." + echo "" + echo "Most likely cause: a release took the commit-subject fallback, which" + echo "leaves an empty [Unreleased] in place, and a later curated one was added" + echo "above it. The empty section is the one to drop." + exit 1 + fi } # Get core library version @@ -186,6 +205,26 @@ update_versions() { log_info "All version files updated to $new_version" } +# Print the body of the "## [Unreleased]" section — everything between that +# heading and the next "## [" heading. +unreleased_body() { + awk '/^## \[Unreleased\]/ { inside = 1; next } + inside && /^## \[/ { inside = 0 } + inside { print }' CHANGELOG.md 2>/dev/null || true +} + +# Retitle "## [Unreleased]" as the release heading, leaving its content alone. +# Only the first match is rewritten; main() has already refused to proceed if +# there is more than one. +promote_unreleased() { + local heading=$1 + + awk -v heading="$heading" ' + /^## \[Unreleased\]/ && !promoted { print heading; promoted = 1; next } + { print }' CHANGELOG.md > CHANGELOG.md.tmp + mv CHANGELOG.md.tmp CHANGELOG.md +} + # Generate changelog generate_changelog() { local new_version=$1 @@ -193,6 +232,19 @@ generate_changelog() { log_step "Generating changelog for v$new_version" + # Prefer what a human wrote. The file declares Keep a Changelog, whose whole + # workflow is to maintain [Unreleased] and promote it on release — so a + # curated section is the release notes, and regenerating over it throws away + # the only description of the change anyone will read + # (livetemplate/livetemplate#511). + if [ -n "$(unreleased_body | tr -d '[:space:]')" ]; then + log_info "Promoting the curated [Unreleased] section to v$new_version" + promote_unreleased "## [v$new_version] - $(date +%Y-%m-%d)" + return + fi + + log_warn "No curated [Unreleased] content; falling back to a commit-subject list" + if [ -n "$prev_tag" ]; then { echo "# Changelog" @@ -682,4 +734,8 @@ main() { echo " • Update examples to use new version" } -main "$@" +# Sourced with RELEASE_SH_LIB=1 by scripts/test_release_changelog.sh, which +# exercises the changelog functions without running a release. +if [ -z "${RELEASE_SH_LIB:-}" ]; then + main "$@" +fi diff --git a/scripts/test_release_changelog.sh b/scripts/test_release_changelog.sh new file mode 100755 index 0000000..293c35b --- /dev/null +++ b/scripts/test_release_changelog.sh @@ -0,0 +1,169 @@ +#!/usr/bin/env bash +# Tests the changelog handling in release.sh: a curated [Unreleased] section is +# promoted to the release heading, and the commit-subject fallback is used only +# when there is nothing curated to promote. +# +# Sources release.sh with RELEASE_SH_LIB=1 so the functions are defined without +# main() running. + +set -e + +PROJECT_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +TMPDIR=$(mktemp -d) + +failures=0 + +pass() { echo " ✓ $1"; } +fail() { echo " ✗ $1"; echo "$2" | sed 's/^/ /'; failures=$((failures + 1)); } + +# Source before installing the cleanup trap: release.sh registers its own EXIT +# trap at top level, which would otherwise replace ours and leak TMPDIR. +# shellcheck source=/dev/null +RELEASE_SH_LIB=1 source "$PROJECT_ROOT/scripts/release.sh" +trap 'rm -rf "$TMPDIR"' EXIT + +header() { + cat <<'EOF' +# Changelog + +All notable changes to @livetemplate/client will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +EOF +} + +echo "🔥 Testing release.sh changelog handling..." +cd "$TMPDIR" + +echo "" +echo "1️⃣ Curated [Unreleased] is promoted, content preserved verbatim" +{ + header + echo "## [Unreleased]" + echo "" + echo "### Fixed" + echo "" + echo "- **Something a human wrote.** With detail worth keeping." + echo "" + echo "## [v0.1.0] - 2026-01-01" + echo "" + echo "- older" +} > CHANGELOG.md +generate_changelog "9.9.9" >/dev/null 2>&1 + +if grep -q '^## \[v9.9.9\] - ' CHANGELOG.md; then + pass "release heading written" +else + fail "release heading missing" "$(head -12 CHANGELOG.md)" +fi +if ! grep -q '^## \[Unreleased\]' CHANGELOG.md; then + pass "[Unreleased] heading consumed, not left stranded" +else + fail "[Unreleased] survived the promotion" "$(grep -n 'Unreleased' CHANGELOG.md)" +fi +if grep -q 'Something a human wrote' CHANGELOG.md; then + pass "curated prose preserved" +else + fail "curated prose lost" "$(cat CHANGELOG.md)" +fi +# The regression that motivated this: a commit-subject dump appearing instead of, +# or above, the curated text. +if ! grep -qE '^- .* \([0-9a-f]{7,}\)$' CHANGELOG.md; then + pass "no raw commit-subject dump" +else + fail "raw commit dump present despite curated content" "$(grep -nE '^- .* \([0-9a-f]{7,}\)$' CHANGELOG.md)" +fi +if [ "$(grep -c '^## \[v9.9.9\]' CHANGELOG.md)" = "1" ] && grep -q '^## \[v0.1.0\]' CHANGELOG.md; then + pass "prior releases still present, exactly one new heading" +else + fail "history damaged" "$(grep -n '^## \[' CHANGELOG.md)" +fi + +# Which branch generate_changelog took. Asserted on the decision rather than the +# resulting file: the fallback's output differs per repo (and with whether any +# tag exists), but the choice between promoting and regenerating does not. +# Captured into a variable rather than piped into `grep -q`: release.sh brings +# `set -o pipefail` in with it, and grep -q exits on first match, so the closed +# pipe can make generate_changelog exit non-zero and fail the whole pipeline — +# intermittently, depending on which finishes first. +took_fallback() { + local out + out=$(generate_changelog "9.9.9" 2>&1) + [[ "$out" == *"falling back"* ]] +} + +echo "" +echo "2️⃣ Empty [Unreleased] falls through to the commit-subject fallback" +{ + header + echo "## [Unreleased]" + echo "" + echo "## [v0.1.0] - 2026-01-01" + echo "" + echo "- older" +} > CHANGELOG.md +if took_fallback; then + pass "empty section not promoted to a contentless release heading" +else + fail "promoted an empty [Unreleased]" "$(head -12 CHANGELOG.md)" +fi + +echo "" +echo "3️⃣ Whitespace-only [Unreleased] counts as empty" +{ + header + echo "## [Unreleased]" + echo "" + echo " " + echo "" + echo "## [v0.1.0] - 2026-01-01" +} > CHANGELOG.md +if took_fallback; then + pass "whitespace-only section treated as empty" +else + fail "whitespace-only section was promoted" "$(head -12 CHANGELOG.md)" +fi + +echo "" +echo "3️⃣ b A curated section does NOT take the fallback" +{ + header + echo "## [Unreleased]" + echo "" + echo "- real content" + echo "" + echo "## [v0.1.0] - 2026-01-01" +} > CHANGELOG.md +if ! took_fallback; then + pass "promotion chosen over regeneration" +else + fail "regenerated over curated content" "$(head -12 CHANGELOG.md)" +fi + +echo "" +echo "4️⃣ unreleased_body reads only its own section" +{ + header + echo "## [Unreleased]" + echo "" + echo "MINE" + echo "" + echo "## [v0.1.0] - 2026-01-01" + echo "" + echo "NOT-MINE" +} > CHANGELOG.md +body=$(unreleased_body) +if echo "$body" | grep -q 'MINE' && ! echo "$body" | grep -q 'NOT-MINE'; then + pass "stops at the next version heading" +else + fail "section boundary wrong" "$body" +fi + +echo "" +if [ "$failures" -ne 0 ]; then + echo "❌ $failures check(s) failed" + exit 1 +fi +echo "✅ All changelog checks passed"