diff --git a/scripts/check-changelog-parity.sh b/scripts/check-changelog-parity.sh index 53bc77c655..864dd315bc 100755 --- a/scripts/check-changelog-parity.sh +++ b/scripts/check-changelog-parity.sh @@ -231,6 +231,42 @@ changelog_versions() { grep -oE '[0-9]+\.[0-9]+(\.[0-9]+)?([+-][0-9A-Za-z][0-9A-Za-z.-]*)?' } +# Version headings present at the fork point but absent at head. Uses +# changelog_versions on both sides so plugin, convention, and two-component +# heading forms share one reader. Reads the base blob with command substitution +# (never process substitution) so a git failure fails closed. A changelog that +# is new at the fork point is a legitimate pass — probe with git cat-file -e +# first so "absent" is not conflated with "git failed". +missing_preserved_headings() { + local merge_base="$1" changelog="$2" + local base_body missing v + declare -A head_seen + + if ! git cat-file -e "$merge_base:$changelog" 2>/dev/null; then + return 0 + fi + if ! base_body="$(git show "$merge_base:$changelog")"; then + echo "check-changelog-parity: 'git show $merge_base:$changelog' failed; refusing to pass without checking." >&2 + return 2 + fi + + mapfile -t base_versions < <(printf '%s\n' "$base_body" | changelog_versions -) + ((${#base_versions[@]} > 0)) || return 0 + + mapfile -t head_versions < <(changelog_versions "$changelog") + for v in "${head_versions[@]}"; do head_seen["$v"]=1; done + + missing="" + for v in "${base_versions[@]}"; do + [[ -n "${head_seen[$v]:-}" ]] && continue + missing+="${missing:+$'\n'}$v" + done + if [[ -n "$missing" ]]; then + printf '%s\n' "$missing" + fi + return 0 +} + if [[ "$mode" == "--check-order" ]]; then changelogs=(plugins/*/CHANGELOG.md docs/conventions/*/CHANGELOG.md) misordered=0 @@ -518,6 +554,25 @@ undocumented=0 malformed=0 preexisting=0 nonmonotonic=0 +absorbed=0 +declare -A absorbed_reported + +for changelog in "${touched_changelogs[@]}"; do + [[ -n "${absorbed_reported[$changelog]:-}" ]] && continue + absorbed_reported["$changelog"]=1 + [[ -f "$changelog" ]] || continue + missing_headings="$(missing_preserved_headings "$merge_base" "$changelog")" || + exit 2 + if [[ -n "$missing_headings" ]]; then + echo "ABSORBED CHANGELOG HEADING: $changelog lost release section heading(s) vs the fork point (a merge-forward may have fused two releases into one section):" >&2 + while IFS= read -r v; do + [[ -z "$v" ]] && continue + echo " ## [$v]" >&2 + done <<<"$missing_headings" + absorbed=$((absorbed + 1)) + fi +done + for manifest in "${manifests[@]}"; do plugin_dir="${manifest%/.claude-plugin/plugin.json}" name="${plugin_dir##*/}" @@ -620,11 +675,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 '## []' entry for every plugin whose version changed." >&2 ((malformed > 0)) && echo "Convert unbracketed changelog headings to the '## []' Keep-a-Changelog form." >&2 ((preexisting > 0)) && echo "Add the bumped version's '## []' 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 '## []' 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 '## []' CHANGELOG.md entry." diff --git a/scripts/check-changelog-parity.test.sh b/scripts/check-changelog-parity.test.sh index f129ce3f11..6b42124084 100755 --- a/scripts/check-changelog-parity.test.sh +++ b/scripts/check-changelog-parity.test.sh @@ -217,6 +217,35 @@ 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 (#2324): merge-forward deletes a predecessor heading. +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" + +# ABSORBED without manifest bump (#2324 gap 2): only the changelog changed. +repo="$(mk_repo)" +git_init "$repo" +mk_plugin "$repo" alpha 0.51.9 yes +printf '# Changelog\n\n## [0.51.9]\n\n- nine\n\n## [0.51.8]\n\n- eight\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 '# Changelog\n\n## [0.51.9]\n\n- nine\n- eight\n\n## [0.51.7]\n\n- seven\n' >"$repo/plugins/alpha/CHANGELOG.md" +git -C "$repo" add -A >/dev/null && git -C "$repo" commit -qm 'absorb without bump' +out="$(cd "$repo" && bash scripts/check-changelog-parity.sh --check-bump "$base" 2>&1)" +rc=$? +if [[ $rc -ne 0 && "$out" == *"ABSORBED CHANGELOG HEADING"* && "$out" == *"0.51.8"* ]]; then ok "absorbed heading with no manifest bump fails --check-bump"; else fail "absorbed-no-bump 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 @@ -843,10 +872,13 @@ git -C "$repo" add -A >/dev/null && git -C "$repo" commit -qm 'absorb 0.51.8 int out="$(cd "$repo" && bash scripts/check-changelog-parity.sh --check-preserved "$base" 2>&1)" rc=$? if [[ $rc -eq 1 && "$out" == *"DELETED CHANGELOG ENTRY"*"plugins/alpha/CHANGELOG.md"* && "$out" == *"0.51.8"* ]]; then ok "an absorbed predecessor section fails --check-preserved, naming the vanished version"; else fail "absorbed section not caught: rc=$rc out='$out'"; fi -for other in "--check-bump $base" "--check-order" "--check"; do +for other in "--check-order" "--check"; do # shellcheck disable=SC2086 # deliberate split: the mode and its optional base ref - if (cd "$repo" && bash scripts/check-changelog-parity.sh $other >/dev/null 2>&1); then ok "the absorbed-section tree still passes '$other' (the gap --check-preserved exists to close)"; else fail "'$other' unexpectedly fired on the absorbed-section tree — the gap assertion is stale"; fi + if (cd "$repo" && bash scripts/check-changelog-parity.sh $other >/dev/null 2>&1); then ok "the absorbed-section tree still passes '$other'"; else fail "'$other' unexpectedly fired on the absorbed-section tree"; fi done +out="$(cd "$repo" && bash scripts/check-changelog-parity.sh --check-bump "$base" 2>&1)" +rc=$? +if [[ $rc -ne 0 && "$out" == *"ABSORBED CHANGELOG HEADING"* && "$out" == *"0.51.8"* ]]; then ok "--check-bump now catches absorbed headings too (#2324)"; else fail "--check-bump should catch absorbed headings: rc=$rc out='$out'"; fi rm -rf "$repo" # RELABELLED PREDECESSOR: the same bad resolution renames 0.51.8's heading to