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
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -468,6 +468,11 @@ jobs:
env:
BASE_REF: ${{ github.base_ref }}
run: scripts/check-changelog-parity.sh --check-bump "origin/$BASE_REF"
# Whole-file, not per-diff: the other two modes reason about one version at
# a time, so neither can see that two branches staged the same number or
# that a stale-based entry landed below one already on main.
- name: Verify every changelog reads newest-first with no duplicate versions
run: scripts/check-changelog-parity.sh --check-order

# Contract-slice prune: docs/topics/<slug>/ is Contract tier per
# docs/conventions/topic-docs/README.md — committed on a task branch only,
Expand Down
11 changes: 7 additions & 4 deletions docs/conventions/loop-lane/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,13 @@ clarified, not altered.
satisfy both clauses. The permission is now scoped by **purpose** rather than by operation:
deriving `five_hour_delta_pct`, covering the subtraction and the rollover comparison together. The
measure-only guarantee is unchanged — the value still reaches no decision at any threshold.
- **Changelog version order corrected.** The `#1638` entry was authored against 3.1.0 and merged as
`3.1.1` after 4.0.0 had already landed, leaving a version regression in a descending-order file.
It is renumbered `4.0.1` and repositioned below 5.0.0, preserving both version order and the order
entries actually shipped in. No wording in that entry changed.
- **Changelog version order corrected, and gated.** The `#1638` entry was authored against 3.1.0 and
merged as `3.1.1` after 4.0.0 had already landed, leaving a version regression in a
descending-order file. It is renumbered `4.0.1` and repositioned below 5.0.0, preserving both
version order and the order entries actually shipped in. No wording in that entry changed. Nothing
caught it because no gate read the *sequence* — `check-changelog-parity.sh` now has a
`--check-order` mode, wired as a required check, covering convention changelogs as well as plugin
ones.

## 6.0.0 — 2026-07-29

Expand Down
77 changes: 75 additions & 2 deletions scripts/check-changelog-parity.sh
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,9 @@ BASELINE="${CHANGELOG_PARITY_BASELINE:-scripts/changelog-parity-baseline.txt}"

mode="${1:-}"
case "$mode" in
--check | --check-bump) ;;
--check | --check-bump | --check-order) ;;
*)
echo "usage: $(basename "$0") [--check | --check-bump <base-ref>]" >&2
echo "usage: $(basename "$0") [--check | --check-bump <base-ref> | --check-order]" >&2
exit 2
;;
esac
Expand All @@ -75,6 +75,79 @@ fi

version_of() { jq -r '.version // empty' "$1" 2>/dev/null; }

# --check-order: every changelog reads newest-first, with no version repeated.
#
# The gap this closes shipped: docs/conventions/loop-lane/CHANGELOG.md reached
# main reading 6.0.0 -> 3.1.1 -> 5.0.0 -> 4.0.0. The 3.1.1 entry was authored
# against 3.1.0 and merged after 4.0.0 had already landed, so its number was a
# regression the moment it merged. Nothing caught it: --check and --check-bump
# both reason about ONE version at a time, and neither reads the sequence. Any
# two branches that stage the same next version, or one that sits on a stale
# base, produce this — and the reviewer sees only their own diff hunk, never the
# resulting order.
#
# Convention changelogs are in scope precisely because that is where it shipped:
# they are unversioned by any manifest, so --check and --check-bump never look
# at them at all.
# A fixed-width key so a lexical comparison orders versions NUMERICALLY. Five
# digits per field is far beyond any version this repo will reach, and the
# regex above admits only digits, so no field can overflow it silently.
version_sort_key() {
local IFS='.'
# shellcheck disable=SC2086 # deliberate word-split of a digits-only version on IFS
set -- $1
printf '%05d.%05d.%05d' "$((10#${1:-0}))" "$((10#${2:-0}))" "$((10#${3:-0}))"
}

if [[ "$mode" == "--check-order" ]]; then
changelogs=(plugins/*/CHANGELOG.md docs/conventions/*/CHANGELOG.md)
misordered=0
duplicated=0
checked=0
for changelog in "${changelogs[@]}"; do
[[ -f "$changelog" ]] || continue
checked=$((checked + 1))
# Every heading form this repo actually uses: `## [1.2.3]` (plugins, Keep a
# Changelog), `## 1.2.3 — date` (conventions), and the two-component
# `## 1.2` several convention changelogs use. Requiring a patch component
# would make this gate silently check NOTHING in those files — worse than
# not covering them, because the pass would be indistinguishable.
mapfile -t versions < <(grep -oE '^##[[:space:]]+\[?[0-9]+\.[0-9]+(\.[0-9]+)?\]?([[:space:]]|$)' "$changelog" |
grep -oE '[0-9]+\.[0-9]+(\.[0-9]+)?')
((${#versions[@]} > 1)) || continue

dupes="$(printf '%s\n' "${versions[@]}" | sort | uniq -d)"
if [[ -n "$dupes" ]]; then
echo "DUPLICATE CHANGELOG VERSION: $changelog lists $(printf '%s' "$dupes" | tr '\n' ' ')more than once. Two branches almost certainly staged the same version; renumber one." >&2
duplicated=$((duplicated + 1))
fi

# Compare on a zero-padded key rather than `sort -V`, which this repo's
# shell-portability lint bans (it is a GNU extension). Padding each field to
# a fixed width makes a plain lexical `>` numerically correct, so 10.0.0
# still outranks 9.0.0.
prev=""
prev_key=""
for v in "${versions[@]}"; do
key="$(version_sort_key "$v")"
if [[ -n "$prev_key" && "$key" > "$prev_key" ]]; then
echo "MISORDERED CHANGELOG: $changelog is not newest-first — $v (below $prev). A version that merged after a higher one already landed is a regression; renumber it above the entry it followed." >&2
misordered=$((misordered + 1))
break
fi
prev="$v"
prev_key="$key"
done
done

if ((misordered > 0 || duplicated > 0)); then
echo "Changelogs must read newest-first with no repeated version. Renumber against what is on the DEFAULT BRANCH, not against the base the branch was cut from." >&2
exit 1
fi
echo "All $checked changelog(s) read newest-first with no duplicate versions."
exit 0
fi

if [[ "$mode" == "--check" ]]; then
declare -A saw_debt
missing=0
Expand Down
61 changes: 61 additions & 0 deletions scripts/check-changelog-parity.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -416,5 +416,66 @@ repo="$(mk_repo)"
if [[ $? -eq 2 ]]; then ok "bad mode -> exit 2"; else fail "bad mode did not exit 2"; fi
rm -rf "$repo"

# --- --check-order -----------------------------------------------------------
# Regression cover for the defect that shipped: a stale-based entry whose number
# was already behind by the time it merged, in a file no other mode reads.
write_changelog() { # $1 path, $2... headings
local path="$1"
shift
mkdir -p "$(dirname "$path")"
printf '# Changelog\n\n' >"$path"
local h
for h in "$@"; do printf '%s\n\nsome note\n\n' "$h" >>"$path"; done
}

repo="$(mk_repo)"
mk_plugin "$repo" alpha 1.0.0 no
write_changelog "$repo/plugins/alpha/CHANGELOG.md" '## [3.0.0]' '## [2.0.0]' '## [1.0.0]'
if out="$(cd "$repo" && bash scripts/check-changelog-parity.sh --check-order 2>&1)"; then ok "descending plugin changelog passes"; else fail "descending changelog rejected: $out"; fi

write_changelog "$repo/plugins/alpha/CHANGELOG.md" '## [3.0.0]' '## [1.5.0]' '## [2.0.0]'
out="$(cd "$repo" && bash scripts/check-changelog-parity.sh --check-order 2>&1)"
rc=$?
if [[ $rc -eq 1 && "$out" == *"MISORDERED CHANGELOG"* ]]; then ok "a version below a later one is caught"; else fail "misordering not caught: rc=$rc $out"; fi
if [[ "$out" == *"2.0.0 (below 1.5.0)"* ]]; then ok "the offending pair is named"; else fail "offender not named: $out"; fi

write_changelog "$repo/plugins/alpha/CHANGELOG.md" '## [2.0.0]' '## [2.0.0]' '## [1.0.0]'
out="$(cd "$repo" && bash scripts/check-changelog-parity.sh --check-order 2>&1)"
rc=$?
if [[ $rc -eq 1 && "$out" == *"DUPLICATE CHANGELOG VERSION"* ]]; then ok "two branches staging one version is caught"; else fail "duplicate not caught: rc=$rc $out"; fi

# The exact shape that shipped, in the exact file class that shipped it: a
# CONVENTION changelog, which is unversioned by any manifest and therefore
# invisible to --check and --check-bump.
write_changelog "$repo/plugins/alpha/CHANGELOG.md" '## [1.0.0]'
write_changelog "$repo/docs/conventions/demo/CHANGELOG.md" \
'## 6.0.0 — 2026-07-29' '## 3.1.1 — 2026-07-29' '## 5.0.0 — 2026-07-29' '## 4.0.0 — 2026-07-27'
out="$(cd "$repo" && bash scripts/check-changelog-parity.sh --check-order 2>&1)"
rc=$?
if [[ $rc -eq 1 && "$out" == *"docs/conventions/demo/CHANGELOG.md"* ]]; then ok "unbracketed CONVENTION changelogs are in scope"; else fail "convention changelog not checked: rc=$rc $out"; fi

# A single-entry changelog has no order to violate.
write_changelog "$repo/docs/conventions/demo/CHANGELOG.md" '## 1.0.0 — 2026-01-01'
if out="$(cd "$repo" && bash scripts/check-changelog-parity.sh --check-order 2>&1)"; then ok "a single-entry changelog passes"; else fail "single entry rejected: $out"; fi

# Numeric, not lexical: 10.0.0 sorts ABOVE 9.0.0.
write_changelog "$repo/docs/conventions/demo/CHANGELOG.md" '## 10.0.0 — 2026-02-01' '## 9.0.0 — 2026-01-01'
if out="$(cd "$repo" && bash scripts/check-changelog-parity.sh --check-order 2>&1)"; then ok "version order is numeric, not lexical (10.0.0 > 9.0.0)"; else fail "lexical comparison leaked in: $out"; fi

# Two-component `## 1.2` headings: several convention changelogs use them, and
# requiring a patch component made the gate silently check NOTHING there.
write_changelog "$repo/docs/conventions/demo/CHANGELOG.md" '## 1.2 — 2026-02-01' '## 1.1 — 2026-01-15' '## 1.0 — 2026-01-01'
if out="$(cd "$repo" && bash scripts/check-changelog-parity.sh --check-order 2>&1)"; then ok "descending two-component versions pass"; else fail "two-component descending rejected: $out"; fi

write_changelog "$repo/docs/conventions/demo/CHANGELOG.md" '## 1.2 — 2026-02-01' '## 1.0 — 2026-01-01' '## 1.1 — 2026-01-15'
out="$(cd "$repo" && bash scripts/check-changelog-parity.sh --check-order 2>&1)"
rc=$?
if [[ $rc -eq 1 && "$out" == *"MISORDERED CHANGELOG"* ]]; then ok "misordered two-component versions are caught"; else fail "two-component misordering missed: rc=$rc $out"; fi

# Mixed widths in one file must compare correctly: 1.10 outranks 1.9.
write_changelog "$repo/docs/conventions/demo/CHANGELOG.md" '## 1.10 — 2026-02-01' '## 1.9.1 — 2026-01-15' '## 1.9 — 2026-01-01'
if out="$(cd "$repo" && bash scripts/check-changelog-parity.sh --check-order 2>&1)"; then ok "two- and three-component versions compare correctly together"; else fail "mixed-width comparison wrong: $out"; fi
rm -rf "$repo"

printf '\nPASS=%d FAIL=%d\n' "$PASS" "$FAIL"
((FAIL == 0))