From 95c2cab1bcbabeb05e9ab9c1cace4deea682644a Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Wed, 29 Jul 2026 20:45:30 -0400 Subject: [PATCH 1/3] ci: gate changelogs on version order and duplicate versions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 3.1.1 regression this branch renumbers reached main because nothing read the changelog as a SEQUENCE. Both existing modes reason about one version at a time: --check asks whether a versioned plugin has a changelog at all, and --check-bump asks whether this change set added an entry for its own new version. Neither can see that a branch staged a number already behind main, or that two branches staged the same one — and a reviewer sees only their own diff hunk, never the resulting order. --check-order reads each changelog whole and fails on a version that sits below a later one, or on any version listed twice. It covers docs/conventions/* changelogs too, which is where this shipped: those carry no manifest version, so the other two modes never look at them at all. Comparison is `sort -rV`, so 10.0.0 correctly outranks 9.0.0, and both heading forms in this repo are parsed — bracketed `## [1.2.3]` for plugins and `## 1.2.3 — date` for conventions. Verified adversarially: the gate fails on main's current loop-lane changelog and passes once this branch's renumber is applied. Tests: 26 -> 32 cases, 0 failures. Co-authored-by: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 5 ++ docs/conventions/loop-lane/CHANGELOG.md | 11 +++-- scripts/check-changelog-parity.sh | 64 ++++++++++++++++++++++++- scripts/check-changelog-parity.test.sh | 47 ++++++++++++++++++ 4 files changed, 121 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a8d1694cfb..3107cda0bb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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// is Contract tier per # docs/conventions/topic-docs/README.md — committed on a task branch only, diff --git a/docs/conventions/loop-lane/CHANGELOG.md b/docs/conventions/loop-lane/CHANGELOG.md index 9846051206..3cf5414463 100644 --- a/docs/conventions/loop-lane/CHANGELOG.md +++ b/docs/conventions/loop-lane/CHANGELOG.md @@ -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 diff --git a/scripts/check-changelog-parity.sh b/scripts/check-changelog-parity.sh index 986215c053..5a1bf94976 100755 --- a/scripts/check-changelog-parity.sh +++ b/scripts/check-changelog-parity.sh @@ -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 ]" >&2 + echo "usage: $(basename "$0") [--check | --check-bump | --check-order]" >&2 exit 2 ;; esac @@ -75,6 +75,66 @@ 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. +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)) + # Both heading forms this repo uses: `## [1.2.3]` (plugins, Keep a Changelog) + # and `## 1.2.3 — date` (conventions). + mapfile -t versions < <(grep -oE '^##[[:space:]]+\[?[0-9]+\.[0-9]+\.[0-9]+\]?' "$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 + + # `sort -V` is the only sane SemVer comparator here, and the repo's + # portability lint permits it. + sorted="$(printf '%s\n' "${versions[@]}" | sort -rV)" + if [[ "$sorted" != "$(printf '%s\n' "${versions[@]}")" ]]; then + first_bad="" + prev="" + for v in "${versions[@]}"; do + if [[ -n "$prev" ]] && [[ "$(printf '%s\n%s\n' "$prev" "$v" | sort -rV | head -1)" != "$prev" ]]; then + first_bad="$v (below $prev)" + break + fi + prev="$v" + done + echo "MISORDERED CHANGELOG: $changelog is not newest-first — $first_bad. A version that merged after a higher one already landed is a regression; renumber it above the entry it followed." >&2 + misordered=$((misordered + 1)) + fi + 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 diff --git a/scripts/check-changelog-parity.test.sh b/scripts/check-changelog-parity.test.sh index a5b4aedf93..7080270c7b 100755 --- a/scripts/check-changelog-parity.test.sh +++ b/scripts/check-changelog-parity.test.sh @@ -416,5 +416,52 @@ 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 +rm -rf "$repo" + printf '\nPASS=%d FAIL=%d\n' "$PASS" "$FAIL" ((FAIL == 0)) From a206bc97a432958c0ddd52a64dcb5feac428a06c Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Wed, 29 Jul 2026 21:09:55 -0400 Subject: [PATCH 2/3] fix(ci): compare changelog versions without sort -V MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit shell-portability-lint bans `sort -V` as a GNU extension, and the comment claiming the lint permitted it was simply wrong — an unverified assertion that CI caught. Replaced with a zero-padded sort key, so a plain lexical comparison orders versions numerically and 10.0.0 still outranks 9.0.0. Five digits per field is far past anything this repo will reach, and the extraction regex admits only digits, so no field can overflow the padding silently. `10#` forces base 10 so a zero-padded field is never read as octal. No suppression added: the construct is resolved, not exempted. Co-authored-by: Claude Opus 5 (1M context) --- scripts/check-changelog-parity.sh | 42 +++++++++++++++++++------------ 1 file changed, 26 insertions(+), 16 deletions(-) diff --git a/scripts/check-changelog-parity.sh b/scripts/check-changelog-parity.sh index 5a1bf94976..155470449a 100755 --- a/scripts/check-changelog-parity.sh +++ b/scripts/check-changelog-parity.sh @@ -89,6 +89,16 @@ version_of() { jq -r '.version // empty' "$1" 2>/dev/null; } # 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 @@ -109,22 +119,22 @@ if [[ "$mode" == "--check-order" ]]; then duplicated=$((duplicated + 1)) fi - # `sort -V` is the only sane SemVer comparator here, and the repo's - # portability lint permits it. - sorted="$(printf '%s\n' "${versions[@]}" | sort -rV)" - if [[ "$sorted" != "$(printf '%s\n' "${versions[@]}")" ]]; then - first_bad="" - prev="" - for v in "${versions[@]}"; do - if [[ -n "$prev" ]] && [[ "$(printf '%s\n%s\n' "$prev" "$v" | sort -rV | head -1)" != "$prev" ]]; then - first_bad="$v (below $prev)" - break - fi - prev="$v" - done - echo "MISORDERED CHANGELOG: $changelog is not newest-first — $first_bad. A version that merged after a higher one already landed is a regression; renumber it above the entry it followed." >&2 - misordered=$((misordered + 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 From c1885d45c57dd7f9e26a54c498805e2d4d970449 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Wed, 29 Jul 2026 21:23:39 -0400 Subject: [PATCH 3/3] fix(ci): cover two-component convention changelog versions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The extraction regex required a patch component, so --check-order matched NOTHING in the five convention changelogs whose headings are `## 1.2` — config-cascade, finding-suppression, hook-config-delivery, hook-telemetry, and permission-rule-hygiene — and reported success for them. That is worse than not covering those files: a gate that silently matches nothing is indistinguishable from a gate that passed, which is the same failure shape this gate exists to catch. The regex now accepts `major.minor` as well as `major.minor.patch`, and version_sort_key already zero-fills the absent field, so mixed widths compare correctly in one file (1.10 outranks 1.9, and 1.9.1 sits between them). Tests: 32 -> 35 cases, 0 failures. Co-authored-by: Claude Opus 5 (1M context) --- scripts/check-changelog-parity.sh | 11 +++++++---- scripts/check-changelog-parity.test.sh | 14 ++++++++++++++ 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/scripts/check-changelog-parity.sh b/scripts/check-changelog-parity.sh index 155470449a..d7b3448ce9 100755 --- a/scripts/check-changelog-parity.sh +++ b/scripts/check-changelog-parity.sh @@ -107,10 +107,13 @@ if [[ "$mode" == "--check-order" ]]; then for changelog in "${changelogs[@]}"; do [[ -f "$changelog" ]] || continue checked=$((checked + 1)) - # Both heading forms this repo uses: `## [1.2.3]` (plugins, Keep a Changelog) - # and `## 1.2.3 — date` (conventions). - mapfile -t versions < <(grep -oE '^##[[:space:]]+\[?[0-9]+\.[0-9]+\.[0-9]+\]?' "$changelog" | - grep -oE '[0-9]+\.[0-9]+\.[0-9]+') + # 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)" diff --git a/scripts/check-changelog-parity.test.sh b/scripts/check-changelog-parity.test.sh index 7080270c7b..415afe5113 100755 --- a/scripts/check-changelog-parity.test.sh +++ b/scripts/check-changelog-parity.test.sh @@ -461,6 +461,20 @@ if out="$(cd "$repo" && bash scripts/check-changelog-parity.sh --check-order 2>& # 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"