Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 57 additions & 1 deletion scripts/release.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -186,13 +205,46 @@ 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
local prev_tag=$(git describe --tags --abbrev=0 2>/dev/null || echo "")

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"
Expand Down Expand Up @@ -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
169 changes: 169 additions & 0 deletions scripts/test_release_changelog.sh
Original file line number Diff line number Diff line change
@@ -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"
Loading