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
29 changes: 28 additions & 1 deletion scripts/check-changelog-parity.sh
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,12 @@ changelog_versions() {
grep -oE '[0-9]+\.[0-9]+(\.[0-9]+)?([+-][0-9A-Za-z][0-9A-Za-z.-]*)?'
}

# Bracketed Keep-a-Changelog release headings, one per line, in file order.
# Only `## [x.y.z]` — the form --check-bump requires for release entries.
changelog_bracket_headings() {
rendered_lines "$1" | grep -E '^## \[[0-9]+\.[0-9]+(\.[0-9]+)?([+-][0-9A-Za-z][0-9A-Za-z.-]*)?\]'
}

if [[ "$mode" == "--check-order" ]]; then
changelogs=(plugins/*/CHANGELOG.md docs/conventions/*/CHANGELOG.md)
misordered=0
Expand Down Expand Up @@ -372,6 +378,7 @@ undocumented=0
malformed=0
preexisting=0
nonmonotonic=0
absorbed=0
for manifest in "${manifests[@]}"; do
plugin_dir="${manifest%/.claude-plugin/plugin.json}"
name="${plugin_dir##*/}"
Expand Down Expand Up @@ -421,6 +428,25 @@ for manifest in "${manifests[@]}"; do
continue
fi

# Every released `## [x]` heading at the fork point must still exist at head. A
# merge-forward that absorbs the predecessor section into the new release
# deletes headings without touching the manifest monotonicity checks. Compare
# against $merge_base, not $base: main may have landed release headings after
# the fork that this branch has not merged yet.
if [[ -f "$changelog" ]]; then
missing_headings="$(
comm -23 \
<(git show "$merge_base:$changelog" 2>/dev/null | changelog_bracket_headings - | sort) \
<(changelog_bracket_headings "$changelog" | sort)
)"
if [[ -n "$missing_headings" ]]; then
echo "ABSORBED CHANGELOG HEADING: $name lost release section heading(s) vs the fork point (a merge-forward may have fused two releases into one section):" >&2
printf '%s\n' "$missing_headings" >&2
absorbed=$((absorbed + 1))
continue
fi
fi

# Require the bumped version's own entry at head, not merely that the file
# changed: an unrelated edit (whitespace, title, an old release) must not
# satisfy the gate. The match is a FIXED-STRING heading anchored to line start
Expand Down Expand Up @@ -474,11 +500,12 @@ for manifest in "${manifests[@]}"; do
fi
done

if ((undocumented > 0 || malformed > 0 || preexisting > 0 || nonmonotonic > 0)); then
if ((undocumented > 0 || malformed > 0 || preexisting > 0 || nonmonotonic > 0 || absorbed > 0)); then
((undocumented > 0)) && echo "Add a '## [<version>]' entry for every plugin whose version changed." >&2
((malformed > 0)) && echo "Convert unbracketed changelog headings to the '## [<version>]' Keep-a-Changelog form." >&2
((preexisting > 0)) && echo "Add the bumped version's '## [<version>]' entry in this change set; it must be absent from the base changelog, not merely present at head." >&2
((nonmonotonic > 0)) && echo "Renumber every bumped version strictly above the base ref's CURRENT version, not the version the branch was cut from." >&2
((absorbed > 0)) && echo "Restore every '## [<version>]' heading that existed at the fork point; release notes must not be relabelled or absorbed into a newer section." >&2
exit 1
fi
echo "Every plugin whose version changed vs $base has a '## [<version>]' CHANGELOG.md entry."
16 changes: 16 additions & 0 deletions scripts/check-changelog-parity.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,22 @@ git -C "$repo" add -A >/dev/null && git -C "$repo" commit -qm bump
if (cd "$repo" && bash scripts/check-changelog-parity.sh --check-bump "$base" >/dev/null 2>&1); then ok "bump + '## [x.y.z]' entry passes --check-bump"; else fail "bump+entry wrongly failed"; fi
rm -rf "$repo"

# ABSORBED HEADING: a merge-forward folds the predecessor release into the new
# section and deletes its `## [x]` heading — every other mode passes today.
repo="$(mk_repo)"
git_init "$repo"
mk_plugin "$repo" alpha 0.51.8 yes
printf '# Changelog\n\n## [0.51.8]\n\n### Fixed\n\n- predecessor note\n\n## [0.51.7]\n' >"$repo/plugins/alpha/CHANGELOG.md"
git -C "$repo" add -A >/dev/null && git -C "$repo" commit -qm base
base="$(git -C "$repo" rev-parse HEAD)"
printf '{ "name": "alpha", "version": "0.51.9" }\n' >"$repo/plugins/alpha/.claude-plugin/plugin.json"
printf '# Changelog\n\n## [0.51.9]\n\n### Fixed\n\n- new note\n- predecessor note\n\n## [0.51.7]\n' >"$repo/plugins/alpha/CHANGELOG.md"
git -C "$repo" add -A >/dev/null && git -C "$repo" commit -qm bump
out="$(cd "$repo" && bash scripts/check-changelog-parity.sh --check-bump "$base" 2>&1)"
rc=$?
if [[ $rc -ne 0 && "$out" == *"ABSORBED CHANGELOG HEADING"*"alpha"* && "$out" == *"## [0.51.8]"* ]]; then ok "absorbed predecessor release heading fails --check-bump"; else fail "absorbed heading not caught: rc=$rc out='$out'"; fi
rm -rf "$repo"

# LARGE CHANGELOG (SIGPIPE regression, #2130): the new entry sits near the top
# of a changelog far larger than the pipe buffer — the shape every mature
# changelog has. A has_heading reader that exits on first match kills
Expand Down
Loading