From 00f0a64d86b7464597a6ac895c3638ab665054c3 Mon Sep 17 00:00:00 2001 From: Roberto Cano <3525807+robercano@users.noreply.github.com> Date: Thu, 6 Aug 2026 19:00:54 +0200 Subject: [PATCH] fix(release): list squash-merged PRs in the generated changelog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit release.sh derived its CHANGELOG section from `git log --merges` whenever any merge commit existed in the range, falling back to non-merge subjects only when there were none. That silently dropped every squash-merged PR as soon as ONE merge commit was present. This repo's history mixes both styles: #215/#216 landed as merge commits, #217-#221 as squashes. The v0.3.0..HEAD range therefore generated 2 bullets instead of 7 — omitting five shipped fixes and naming the v0.3.0 release PR itself as a change in the next release. Walk the base branch's first-parent chain instead. One bullet per landed change, whatever shape it landed in: - a merge-committed PR contributes its merge subject (rewritten to "#N: branch") - a squash-merged PR contributes its squash subject - a merged PR's internal commits stay excluded — they hang off the second parent, which is the double-listing the --merges-only path was really guarding against A direct-to-base commit is on the first-parent chain and is a real shipped change, so it is now listed too. Tests: scenario A's "non-merge commit is excluded once merge commits exist" assertion encoded the old behaviour and is inverted, paired with a new assertion that merged PRs' internal commits are still never double-listed. New scenario H covers mixed merge+squash history — the real v0.3.0..v0.3.1 shape — asserting exactly one bullet per landed change. release.test.sh: PASS (51 checks) Co-Authored-By: Claude Opus 5 (1M context) --- .claude/scripts/release.sh | 37 ++++++++++------ .claude/scripts/release.test.sh | 75 ++++++++++++++++++++++++++++++--- 2 files changed, 94 insertions(+), 18 deletions(-) diff --git a/.claude/scripts/release.sh b/.claude/scripts/release.sh index 1012893..d012423 100755 --- a/.claude/scripts/release.sh +++ b/.claude/scripts/release.sh @@ -25,12 +25,15 @@ # checks.sh's own JSON-editing style). # 2. Generate a dated Keep-a-Changelog section in # .claude/.claude-plugin/CHANGELOG.md from commits since the last git -# tag — or, if there is no prior tag yet (this repo's current state: -# zero tags), from the full history (first-release path). Prefers merge -# commit subjects ("Merge pull request #N from owner/branch" -> one -# bullet per merged PR) when any exist in range; falls back to every -# non-merge commit subject in range otherwise (the no-merges case a -# fresh/test repo hits). +# tag — or, if there is no prior tag yet, from the full history +# (first-release path). Walks the base branch's FIRST-PARENT chain, so +# exactly one bullet is emitted per landed change no matter how it +# landed: a merge-committed PR contributes its "Merge pull request #N +# from owner/branch" subject (rewritten to "#N: branch"), a +# squash-merged PR contributes its squash subject, and a merged PR's +# internal commits are excluded (they are off the first-parent chain). +# Repos that mix merge and squash merges — this one does — are handled +# correctly; see the comment at the generator itself. # 3. Tag vX.Y.Z, commit the bumps + changelog, push both. # 4. "Publish" per the existing "Updating the plugin" runbook # (docs/USAGE.md): this script CANNOT run the interactive @@ -157,12 +160,22 @@ else echo "release.sh: no prior git tag found — first-release path, using full history" fi -merge_subjects="$(git -C "$root" log $range --merges --pretty=%s 2>/dev/null || true)" -if [ -n "$merge_subjects" ]; then - changelog_items="$(printf '%s\n' "$merge_subjects" | sed -E 's/^Merge pull request (#[0-9]+) from [^\/]+\/(.+)$/- \1: \2/')" -else - changelog_items="$(git -C "$root" log $range --no-merges --pretty=%s 2>/dev/null | sed 's/^/- /')" -fi +# One bullet per LANDED change, whatever shape it landed in. Walking the base +# branch's FIRST-PARENT chain is what makes both merge styles work at once: +# - a merge-committed PR appears as its "Merge pull request #N from …" commit, +# - a squash-merged PR appears as its single squash commit, +# - a merged PR's INTERNAL commits never appear at all — they hang off the +# second parent, off the first-parent chain. +# That last property is the double-listing the old --merges-only branch was +# really guarding against; --first-parent gets it without having to discard +# anything. Preferring --merges *wholesale* (the previous behaviour) silently +# dropped every squash-merged PR as soon as ONE merge commit existed in range — +# and this repo's history mixes both styles, so the v0.3.0..v0.3.1 range +# generated 2 bullets instead of 7, omitting five shipped fixes and naming the +# v0.3.0 release PR itself. A direct-to-base commit is on the first-parent +# chain too, and is a real shipped change, so it is now listed as well. +changelog_items="$(git -C "$root" log $range --first-parent --pretty=%s 2>/dev/null \ + | sed -E -e 's/^Merge pull request (#[0-9]+) from [^\/]+\/(.+)$/\1: \2/' -e 's/^/- /')" [ -n "$changelog_items" ] || changelog_items="- (no changes recorded)" release_date="$(date -u +%Y-%m-%d)" diff --git a/.claude/scripts/release.test.sh b/.claude/scripts/release.test.sh index 6f3ea90..f03b242 100755 --- a/.claude/scripts/release.test.sh +++ b/.claude/scripts/release.test.sh @@ -44,6 +44,11 @@ # 9. the no-`--issue` real-path variant: milestone-close warns and skips # (never crashes, never guesses), while the rollout companion issue is # still filed with a graceful "titles unavailable" test-focus fallback. +# 10. MIXED merge-commit + squash-merge history in a single range (this +# repo's real v0.3.0..v0.3.1 shape): every landed change is listed +# exactly once regardless of how it landed, and a merge-committed PR's +# internal commits are never double-listed. Guards the regression where +# one merge commit in range caused every squash-merged PR to be dropped. # # Exit 0 on success, non-zero if any assertion fails. Runnable bare: # bash .claude/scripts/release.test.sh @@ -209,13 +214,15 @@ seed_fixture "$repo_a" # is "since the last tag" and must be the ONLY thing the changelog picks up. git -C "$repo_a" -c tag.gpgSign=false tag v1.0.0 -# A commit landing AFTER the tag but BEFORE the merge commits below — it is -# in-range but not a merge commit, so with merge commits present it must be -# excluded from the changelog (merge subjects win over plain commits when any -# exist in range — see release.sh's changelog-generation comment). +# A commit landing AFTER the tag but BEFORE the merge commits below — a direct +# commit to the base branch. It is in-range and ON the first-parent chain, so +# it is a real shipped change and MUST appear in the changelog even though +# merge commits also exist in range. (This inverts the pre-fix behaviour, which +# discarded every non-merge commit as soon as one merge commit was present — +# the bug that dropped five squash-merged PRs from the v0.3.1 range.) echo "pre-merge" > "$repo_a/premerge.txt" git -C "$repo_a" add premerge.txt -git -C "$repo_a" commit -q -m "premerge: should not show up once merge commits exist in range" +git -C "$repo_a" commit -q -m "premerge: a direct-to-main commit alongside merge commits" # Two fake merged-PR commits, real GitHub merge-commit shape. git -C "$repo_a" checkout -q -b feat/thing-one @@ -250,7 +257,15 @@ check "scenario A: new 1.1.0 section is ABOVE the prior 1.0.0 section" bash -c ' ' _ "$changelog_a" "$work" check "scenario A: changelog references merged PR #42" bash -c 'printf "%s" "$1" | grep -q "#42: feat/thing-one"' _ "$changelog_a" check "scenario A: changelog references merged PR #43" bash -c 'printf "%s" "$1" | grep -q "#43: feat/thing-two"' _ "$changelog_a" -check "scenario A: in-range non-merge commit is excluded once merge commits exist" bash -c '! printf "%s" "$1" | grep -q "premerge:"' _ "$changelog_a" +check "scenario A: in-range direct-to-main commit is INCLUDED alongside merge commits" bash -c 'printf "%s" "$1" | grep -q -- "- premerge: a direct-to-main commit alongside merge commits"' _ "$changelog_a" +# The real anti-double-listing guarantee: a merged PR contributes its merge +# subject ONCE, never also its internal commits (which are off the first-parent +# chain). Without this, #42 would be listed as both "#42: feat/thing-one" and +# "feat: thing one". +check "scenario A: merged PRs' internal commits are NOT double-listed" bash -c ' + ! printf "%s" "$1" | grep -q -- "- feat: thing one" && + ! printf "%s" "$1" | grep -q -- "- feat: thing two" +' _ "$changelog_a" check "scenario A: no real tag v1.1.0 was created" bash -c '! git -C "$1" tag --list | grep -qx v1.1.0' _ "$repo_a" check "scenario A: original tag v1.0.0 untouched" bash -c 'git -C "$1" tag --list | grep -qx v1.0.0' _ "$repo_a" check "scenario A: HEAD has no new commit (dry-run never commits)" bash -c ' @@ -460,6 +475,54 @@ check "scenario G: version was still bumped and pushed for real (only milestone- git -C "$1" tag --list | grep -qx v1.3.0 ' _ "$origin_bare_g" +# ============================================================================= +# Scenario H: MIXED merge-commit and squash-merge history in one range — the +# exact shape of this repo's real v0.3.0..v0.3.1 range, and the regression that +# motivated the --first-parent generator. Before the fix, the presence of ANY +# merge commit made release.sh discard every squash-merged PR: the real range +# generated 2 bullets instead of 7. Both styles must now be represented, and +# the merge-committed PR must still contribute exactly one bullet. +# ============================================================================= +repo_h="$work/repo-h" +seed_fixture "$repo_h" +git -C "$repo_h" -c tag.gpgSign=false tag v1.0.0 + +# (a) a merge-committed PR, with an internal commit that must NOT be listed +git -C "$repo_h" checkout -q -b feat/merged-style +echo "m" > "$repo_h/m.txt" +git -C "$repo_h" add m.txt +git -C "$repo_h" commit -q -m "internal: implementation detail of the merged PR" +git -C "$repo_h" checkout -q main +git -C "$repo_h" merge -q --no-ff -m "Merge pull request #90 from robercano/feat/merged-style" feat/merged-style + +# (b) two squash-merged PRs — single commits on main, GitHub's "(#N)" subject +echo "s1" > "$repo_h/s1.txt" +git -C "$repo_h" add s1.txt +git -C "$repo_h" commit -q -m "fix(notify): use https:// in the ntfy notify command (#91)" +echo "s2" > "$repo_h/s2.txt" +git -C "$repo_h" add s2.txt +git -C "$repo_h" commit -q -m "feat(server): support multiple agent users on one box (#92)" + +out_h="$(run_release "$repo_h" v1.4.0 --dry-run)" +rc_h=$? + +check "scenario H: exits 0" [ "$rc_h" -eq 0 ] +changelog_h="$(cat "$repo_h/.claude/.claude-plugin/CHANGELOG.md")" +check "scenario H: merge-committed PR #90 is listed" bash -c 'printf "%s" "$1" | grep -q -- "- #90: feat/merged-style"' _ "$changelog_h" +check "scenario H: squash-merged PR #91 is listed despite a merge commit in range" bash -c ' + printf "%s" "$1" | grep -q -- "- fix(notify): use https:// in the ntfy notify command (#91)" +' _ "$changelog_h" +check "scenario H: squash-merged PR #92 is listed despite a merge commit in range" bash -c ' + printf "%s" "$1" | grep -q -- "- feat(server): support multiple agent users on one box (#92)" +' _ "$changelog_h" +check "scenario H: the merged PR's internal commit is not double-listed" bash -c ' + ! printf "%s" "$1" | grep -q -- "- internal: implementation detail" +' _ "$changelog_h" +check "scenario H: the 1.4.0 section holds exactly 3 bullets (one per landed change)" bash -c ' + n=$(printf "%s\n" "$1" | awk "/^## \[1\.4\.0\]/{s=1;next} s&&/^## \[/{s=0} s&&/^- /{c++} END{print c+0}") + [ "$n" -eq 3 ] +' _ "$changelog_h" + echo "" if [ "$fail" -eq 0 ]; then echo "release.test.sh: PASS ($ok checks)"