From 9fe429ea0c3be3a732023b1c071ca03ca4ecae79 Mon Sep 17 00:00:00 2001 From: Adnaan Badr Date: Mon, 20 Jul 2026 04:09:05 +0000 Subject: [PATCH 1/2] fix(release): promote the curated [Unreleased] instead of regenerating MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports the core library's fix (livetemplate/livetemplate#511) to the client, which has the same defect. release.sh rebuilt CHANGELOG.md from a raw git log dump on every release, so a hand-written [Unreleased] section never became the release notes. It survived only as a stranded section below the new version, still titled [Unreleased], while the notes that shipped were bare commit subjects — the release-script chore billed equally with the change users see. Hit on both v0.19.1 and v0.20.0, each needing a manual curation commit plus a gh release edit, since publish_github extracts its notes from the clobbered file. Promote instead: retitle [Unreleased] as the release heading and leave its content alone. The commit-subject dump stays as the fallback for releases nobody curated, so nothing regresses when the section is absent or empty. Also refuse to start when the file has more than one [Unreleased] heading, since release-note extraction stops at the first and a duplicate means one section can never ship. scripts/test_release_changelog.sh is shared with core, asserting on which branch was taken rather than on the resulting file — this repo's fallback writes an "Initial release" block when no tag exists, so the end state differs while the promote-vs-regenerate decision does not. Refs livetemplate/livetemplate#511 Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Ui2cwpeGkrUfRt8rh2FgGG --- scripts/release.sh | 54 +++++++++- scripts/test_release_changelog.sh | 161 ++++++++++++++++++++++++++++++ 2 files changed, 214 insertions(+), 1 deletion(-) create mode 100755 scripts/test_release_changelog.sh diff --git a/scripts/release.sh b/scripts/release.sh index f3c85df..b314c25 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -36,6 +36,21 @@ 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." + exit 1 + fi } # Get core library version @@ -186,6 +201,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 +228,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 +730,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..ba7a46f --- /dev/null +++ b/scripts/test_release_changelog.sh @@ -0,0 +1,161 @@ +#!/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. +took_fallback() { generate_changelog "9.9.9" 2>&1 | grep -q "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" From 293f3c73ab030609c90193a8329706024828bc29 Mon Sep 17 00:00:00 2001 From: Adnaan Badr Date: Mon, 20 Jul 2026 04:20:41 +0000 Subject: [PATCH 2/2] fix(release): de-flake the fallback test, explain the duplicate guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports the same two follow-ups made in the core library's PR. The took_fallback helper piped generate_changelog into `grep -q`. Sourcing release.sh brings `set -o pipefail` with it, and grep -q exits on its first match, so the closed pipe could make generate_changelog exit non-zero and fail the whole pipeline — intermittently, depending on which finished first. Measured at 9 failures in 15 runs in the core repo; capturing to a variable instead is 0 in 30 here and there. The duplicate-[Unreleased] guard now also names the likeliest cause: a release that takes the commit-subject fallback leaves an empty [Unreleased] behind, so a later curated one added above it trips the guard, and the empty section is the one to drop. Refs livetemplate/livetemplate#511 Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Ui2cwpeGkrUfRt8rh2FgGG --- scripts/release.sh | 4 ++++ scripts/test_release_changelog.sh | 10 +++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/scripts/release.sh b/scripts/release.sh index b314c25..0b2c91b 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -49,6 +49,10 @@ check_prerequisites() { 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 } diff --git a/scripts/test_release_changelog.sh b/scripts/test_release_changelog.sh index ba7a46f..293c35b 100755 --- a/scripts/test_release_changelog.sh +++ b/scripts/test_release_changelog.sh @@ -84,7 +84,15 @@ 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. -took_fallback() { generate_changelog "9.9.9" 2>&1 | grep -q "falling back"; } +# 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"