chore(release): promote changelog into [4.0.4] (+ fix promote tooling) - #3219
Conversation
…rmat The changelog.d fragment system (#2994) was never exercised by a real cut. Its promote step wrote "## [version] - date" (two-hash, no tag link), but every released section — and release.yml's notes extraction (awk '/^# \[VERSION\]/,/^---$/') — uses a single-hash "# [version](tag) => date". With the two-hash header the one-hash awk extracts zero lines, so the GitHub Release would publish with EMPTY notes. Also: boundary detection searched for the next "## " heading, but version sections here are single-hash, so the first "## " match landed on a subheading inside the 3.0.0 section — pulling every release since into the promoted body. And a stray "----"/"---" rule in [Unreleased] could leak in. - Match the next version heading at "#" OR "##" + " [" (correct tail boundary). - Drop horizontal-rule lines from the promoted body. - Emit "# [version](tag-url) => date" + explicit "---" separators around it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Peter Amiri <petera@pai.com>
Promote the 83 pending changelog.d fragments (plus prior [Unreleased] content) into the [4.0.4] release section via tools/changelog-promote.sh, and clear the fragment folder. Done on develop first (per the release playbook) so develop's [Unreleased] doesn't go stale and force a back-port after the cut. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Peter Amiri <petera@pai.com>
The first promote pass exposed a second defect: when [Unreleased] carries the same heading twice (develop had two '### Performance' blocks), the merge appended the fragment bullets to EACH same-named section — double-counting every performance fragment (e.g. the $getStatusCodes entry appeared twice). It also emitted sections in [Unreleased] file order with the duplicate intact and fragment-only sections tacked on last. merged_sections now combines all bullets for a heading into ONE section and emits sections in canonical order (Added, Changed, Deprecated, Removed, Performance, Fixed, Security), existing bullets before fragment bullets. Regenerated the [4.0.4] section: 116 unique entries (was 124 with 8 dupes), canonical order, no duplicate headings. Verified zero unique entries lost. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Peter Amiri <petera@pai.com>
There was a problem hiding this comment.
Wheels Bot — Reviewer
TL;DR — Release-prep for 4.0.4: a fix(build) to tools/changelog-promote.sh (emit the single-hash, tag-linked, =>-dated section format release.yml's awk extraction expects) plus the chore(release) that promotes the 83 changelog.d/ fragments into the [4.0.4] section. The tooling fix is correct and well-reasoned, and the section boundaries are clean — release.yml's awk '/^# \[4.0.4\]/,/^---$/' extracts the section with zero bleed (verified below). Verdict: comment. No correctness / cross-engine / security / commitlint blockers. But the promoted [4.0.4] section ships duplicate changelog bullets and a duplicated ### Performance heading in the user-facing release notes, which should be cleaned up before the tag is cut.
Docs
1. The promoted [4.0.4] section has two ### Performance headings with duplicated bullets. CHANGELOG.md lines 44 and 154 are both ### Performance inside the same [4.0.4] section (lines 29–172), and several bullets appear verbatim in both:
- line 47 and line 157 — both
Request-lifecycle mixin injection ... $pluginObj() ... (#2897, Stage 3) - line 53 and line 163 — both
Mixin-free apps no longer pay a throwaway wheels.Plugins ... (Stage 3 quick win) - line 54 and line 164 — both
Database column metadata is now memoized per datasource+table ... schemaColumnCache ... (#3210)
The section order also ends up Added → Performance → Fixed → Security → Performance → Changed, not the canonical Added, Changed, Deprecated, Removed, Performance, Fixed, Security from tools/changelog-promote.sh:38.
Root cause: the pre-existing ## [Unreleased] body had a second, hand-added ### Performance block whose bullets overlapped the *.performance.md fragments; merged_sections() (tools/changelog-promote.sh:127) preserves existing-section file order and never coalesces two same-named existing sections, so both copies survive. The script faithfully promoted malformed input — but since this PR produces the final 4.0.4 release notes (and the GitHub Release is built from exactly this range), the fix belongs here: collapse the two ### Performance blocks into one (dropping the duplicate #2897 / #3210 bullets) and move ### Changed into canonical position before tagging. Worth a follow-up to make merged_sections() coalesce duplicate headings so a malformed [Unreleased] can't reintroduce this.
2. Stale usage header in tools/changelog-promote.sh. The fix changed the emitted format, but the top-of-file usage comment still advertises the old shape:
tools/changelog-promote.sh:8: # # "## [<version>] - <date>" section,
The script now writes # [<version>](tag-url) => <date> (line 199). The summary print() at line 213 (f"[{version}] - {date} now carries ...") likewise still uses the old - form. Cosmetic, but the header comment is the contract a future maintainer reads — update it to match the single-hash, tag-linked, =>-dated format.
Correctness
No issues. The regex fix is sound: re.search(r"(?m)^#{1,2} \[", rest) (tools/changelog-promote.sh:100) correctly stops the [Unreleased] body at the next version heading — ### Added / ### Performance (three hashes) and ## Detailed Changes (no [) are both excluded, so the tail begins at # [4.0.3] rather than a ## subheading buried in the 3.0.0 section. The horizontal-rule strip (re.fullmatch(r"-{3,}", line.strip()), line 114) correctly drops the original four-dash ---- separator that sat below [Unreleased] and replaces it with the explicit --- (line 204) — I confirmed line 173 of the result is exactly ---, so the release.yml four-dash-bleed guard (.github/workflows/release.yml:121-136) passes. The awk '/^# \[4.0.4\]/,/^---$/' extraction yields exactly one # [ heading, i.e. zero bleed into 4.0.3.
Commits
Both conform to commitlint.config.js: fix(build): ... and chore(release): ... — valid types, scopes, subjects ≤ 100 chars, DCO sign-off present. Messages explain the "why" (the empty-GitHub-Release failure mode) well.
Address the Reviewer-A nit: the top-of-file usage comment still advertised the old "## [version] - date" shape and the summary print() used " - " — both now reflect the single-hash, tag-linked, "=>"-dated section the script emits. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Peter Amiri <petera@pai.com>
There was a problem hiding this comment.
Wheels Bot — Reviewer
TL;DR — Release-prep for 4.0.4: a fix(build) pair on tools/changelog-promote.sh (single-hash tag-linked => date section format + per-heading section consolidation in canonical order) plus the chore(release) promotion that assembles the 83 changelog.d/ fragments into the [4.0.4] section. The generated CHANGELOG.md output is correct and the consolidation logic is sound — but commit b86e75f29 changed the emitted section format while leaving the companion dev test asserting the old format, so tools/test-changelog-promote.sh now fails, and the new consolidation behavior (the headline of commit f6646784a) ships untested. Verdict: request changes — update the test in this PR.
Tests
tools/test-changelog-promote.sh:90 asserts the pre-change format and now fails. Commit b86e75f29 changed the emitted version header from ## [{version}] - {date} to single-hash/tag-linked/=>:
# tools/changelog-promote.sh:226
version_section = f"# [{version}]({tag_url}) => {date}\n\n{render(merged)}\n"producing e.g. # [1.2.3](https://github.com/wheels-dev/wheels/releases/tag/v1.2.3) => 2026-01-01. But Test 3 still greps for the two-hash/- form:
# tools/test-changelog-promote.sh:90
if grep -q '## \[1.2.3\] - 2026-01-01' "$tmp/CHANGELOG.md"; then
ok "promote wrote the '## [1.2.3] - 2026-01-01' section"
else
bad "CHANGELOG.md is missing the promoted version section"That pattern cannot match the new output (# not ##, (url) => not -), so Test 3 reports FAIL: CHANGELOG.md is missing the promoted version section and exit $fail is non-zero. The prior format on develop (## [{version}] - {date}, changelog-promote.sh:181 there) is what the test was written against, so this PR's format change owns the breakage. The file is not in the diff — it was not updated.
Concrete fix — anchor the assertion to the new format the script (and release.yml's awk '/^# \[VERSION\]/,/^---$/') actually expects:
if grep -qE '^# \[1\.2\.3\]\(https://github.com/wheels-dev/wheels/releases/tag/v1\.2\.3\) => 2026-01-01$' "$tmp/CHANGELOG.md"; then
ok "promote wrote the '# [1.2.3](tag) => 2026-01-01' section"
else
bad "CHANGELOG.md is missing the promoted version section"
fiNo coverage for the new consolidation behavior. merged_sections (commit f6646784a) is the substantive change — it consolidates a heading that appears twice in [Unreleased] into one section and emits canonical order, existing-before-fragment bullets. None of the three existing tests exercise a duplicate-heading [Unreleased] fixture, so the exact regression this commit fixes (double-appended fragment bullets across two ### Performance blocks) would not be caught by the suite. Please add a fixture with two same-named ### blocks plus a matching fragment and assert the promoted section has a single heading with no duplicated bullets.
Correctness (verified clean)
For the record, the produced artifact checks out — these are confirmations, not findings:
CHANGELOG.md:29—# [4.0.4](…/tag/v4.0.4) => 2026-06-18: single hash, tag link,=>date. ✔- Exactly one
---separator on each side of the section (CHANGELOG.md:27and:162), sorelease.yml'sawk '/^# \[VERSION\]/,/^---$/'extracts the full section with no bleed into 4.0.3. ✔ - 4.0.4 headings are
Added → Changed → Performance → Fixed → Security— canonical order, no duplicates (merged_sectionsemitsCANONICALorder, non-canonical headings preserved after). ✔ - The defensive
for heading in fragments: if heading not in CANONICALloop (changelog-promote.sh:160) is unreachable givenread_fragmentsonly yieldsTYPES-mapped names — correctly noted as defensive in the comment, not a bug.
Commits
fix(build): consolidate changelog sections by heading in canonical order, chore(release): assemble changelog.d fragments into [4.0.4], and fix(build): changelog-promote emits release.yml-compatible section format all conform to commitlint (valid types/scopes, headers ≤ 100 chars, not ALL-CAPS) and carry Signed-off-by:. No issues.
Nothing else fires — the direct CHANGELOG.md edit here is the legitimate promote operation, not an [Unreleased]-anchor edit, and there is no CFML/cross-engine or security surface in this bash/python tooling.
There was a problem hiding this comment.
Wheels Bot — Reviewer
TL;DR — Release-prep for 4.0.4: a fix(build) trio on tools/changelog-promote.sh (single-hash tag-linked => date section format + per-heading consolidation in canonical order), a docs(build) sync of the usage header/summary text, and the chore(release) promotion assembling the 83 changelog.d/ fragments into the [4.0.4] section. The new head commit 0a3cd2352 correctly addressed the doc-comment/summary nit from the prior round — but the load-bearing finding is still open: the format change made tools/test-changelog-promote.sh assert output the script no longer produces, so that test now fails and the new consolidation path ships untested. Verdict: request changes — fix the test in this PR.
Tests
tools/test-changelog-promote.sh:90 still asserts the pre-change format and now fails. The script emits the single-hash/tag-linked/=> header:
# tools/changelog-promote.sh:228
version_section = f"# [{version}]({tag_url}) => {date}\n\n{render(merged)}\n"producing # [1.2.3](https://github.com/wheels-dev/wheels/releases/tag/v1.2.3) => 2026-01-01. But Test 3 still greps for the two-hash/- form:
# tools/test-changelog-promote.sh:90
if grep -q '## \[1.2.3\] - 2026-01-01' "$tmp/CHANGELOG.md"; then
ok "promote wrote the '## [1.2.3] - 2026-01-01' section"
else
bad "CHANGELOG.md is missing the promoted version section"# is not ## and (url) => is not -, so the grep can never match → bad → fail is incremented → exit $fail (:106) is non-zero. git diff origin/develop..0a3cd2352 -- tools/test-changelog-promote.sh is empty — the file was not touched in this PR, even though commit b86e75f29 here owns the format change that broke it. The head commit 0a3cd2352 ("Address the Reviewer-A nit") fixed only the usage comment (:7-11) and the summary print() (:242), not this assertion.
Concrete fix — anchor the assertion to the format the script (and release.yml's awk '/^# \[VERSION\]/,/^---$/') actually expects:
if grep -qE '^# \[1\.2\.3\]\(https://github.com/wheels-dev/wheels/releases/tag/v1\.2\.3\) => 2026-01-01$' "$tmp/CHANGELOG.md"; then
ok "promote wrote the '# [1.2.3](tag) => 2026-01-01' section"
else
bad "CHANGELOG.md is missing the promoted version section"
fiNo coverage for the new consolidation behavior. merged_sections (commit f6646784a) — consolidating a heading that appears twice in [Unreleased] into one section in canonical order — is the substantive change, but none of the three fixtures in tools/test-changelog-promote.sh use a duplicate-heading [Unreleased], so the exact regression it fixes (double-appended bullets across two same-named ### blocks) would not be caught. Please add a fixture with two same-named ### blocks plus a matching fragment and assert the promoted section has a single heading with no duplicated bullets.
Correctness (verified clean)
The generated artifact still checks out — confirmations, not findings:
CHANGELOG.md:29—# [4.0.4](…/tag/v4.0.4) => 2026-06-18: single hash, tag link,=>date. ✔- The summary
print()change in0a3cd2352(changelog-promote.sh:242) is cosmetic stdout text; Test 4'sgrep -q 'Removed 1 fragment'keys off:243, which is unchanged, so that assertion still passes. ✔ - Usage comment (
:7-11) now matches the emitted shape. ✔
Commits
The new docs(build): sync changelog-promote usage header + summary to new format conforms to commitlint (valid type/scope, header ≤ 100 chars, not ALL-CAPS) and carries Signed-off-by:. No issues.
Nothing else fires — the direct CHANGELOG.md edit is the legitimate promote operation, not an [Unreleased]-anchor edit, and there is no CFML/cross-engine or security surface in this bash/python tooling.
Address Reviewer-A (CHANGES_REQUESTED): the format change broke Test 3, which still asserted the old "## [ver] - date" header — re-anchor it to the single-hash, tag-linked, "=>"-dated section the script now emits. Add Test 5 covering the merged_sections consolidation: a [Unreleased] body with two "### Performance" blocks plus a matching *.performance.md fragment must collapse to one heading in canonical order with each bullet appearing exactly once (the double-count regression the 4.0.4 cut surfaced). Full suite green (11/11). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Peter Amiri <petera@pai.com>
There was a problem hiding this comment.
Wheels Bot — Reviewer
TL;DR — Release-prep for 4.0.4. The new head commit 6e0b12e55 (test(build): cover new changelog-promote format + section consolidation) resolves both still-open blocking findings from the prior CHANGES_REQUESTED rounds: Test 3 is re-anchored to the single-hash/tag-linked/=> header the script now emits, and a new Test 5 exercises the merged_sections consolidation path. I traced both assertions against the actual script behavior and they hold. Verdict: approve — submitting --approve to supersede the earlier wheels-bot CHANGES_REQUESTED reviews (issue #3048; otherwise the stale block keeps the PR merge-wedged).
Resolved blocking findings (audit trail)
1. tools/test-changelog-promote.sh:90 asserted the pre-change format → fixed. The assertion now reads:
# tools/test-changelog-promote.sh:90
if grep -qE '^# \[1\.2\.3\]\(https://github.com/wheels-dev/wheels/releases/tag/v1\.2\.3\) => 2026-01-01$' "$tmp/CHANGELOG.md"; thenwhich matches the script's emitted header verbatim:
# tools/changelog-promote.sh:228
version_section = f"# [{version}]({tag_url}) => {date}\n\n{render(merged)}\n"with tag_url = https://github.com/wheels-dev/wheels/releases/tag/v{version} (:227). Single hash, tag link, => date — Test 3 now passes.
2. No coverage for the new consolidation behavior → added (Test 5, :106-157). The fixture (:114-136) seeds a [Unreleased] body with two ### Performance blocks plus an ### Added block, and adds a matching speed.performance.md fragment (:137). I traced it through merged_sections (changelog-promote.sh:129-174): the two ### Performance blocks plus the fragment bullet collapse into one Performance section, emitted after Added in CANONICAL order (:169-171). The three assertions match that result:
nperf -eq 1(:142) — one### Performanceheading ✔- each bullet (
perf block one/two,perf from fragment,an added entry) appears exactly once (:148-152) ✔ — this is the precise double-append regression the commit fixes - canonical order, Added before Performance (
:153) ✔
Correctness (verified clean)
- Head commit
6e0b12e55touches onlytools/test-changelog-promote.sh(git diff 0a3cd2352..6e0b12e55 --stat);CHANGELOG.mdandchangelog-promote.share byte-identical to the prior round, where the promoted[4.0.4]artifact was already confirmed clean (single-hash tag-linked header atCHANGELOG.md:29, exactly one---on each side forrelease.yml'sawk '/^# \[VERSION\]/,/^---$/'extraction, canonical heading order). ✔ - Test 5 runs the script in an isolated
mktemp -droot (:111-138) —PROJECT_ROOTderives from the copied script's dirname, so it never touches the real changelog. ✔
Commits
test(build): cover new changelog-promote format + section consolidation conforms to commitlint (valid type/scope, header ≤ 100 chars, not ALL-CAPS) and carries Signed-off-by:. No issues.
Tests (one non-blocking nit)
The new test is labeled "Test 5" but the suite has no "Test 4" — the Removed 1 fragment check (:100-104) lives inside the Test 3 block rather than a standalone Test 4. Harmless comment-numbering gap; rename to "Test 4" at convenience. Not a merge blocker.
Nothing else fires — the direct CHANGELOG.md edit is the legitimate promote operation (not an [Unreleased]-anchor edit), and there is no CFML / cross-engine / security surface in this bash+python tooling.
Release prep for 4.0.4. Two commits:
fix(build)—tools/changelog-promote.sh(the#2994fragment system, never used in a real cut) wrote## [version] - date(two-hash, no tag link). But every released section, andrelease.yml's notes extraction (awk '/^# \[VERSION\]/,/^---$/'), uses a single-hash# [version](tag) => date. The mismatch meant the one-hash awk would extract zero lines → an empty GitHub Release. Boundary detection also matched the next##heading, which on this changelog lands on a subheading inside the 3.0.0 section, pulling every release since into the promoted body. Fixed: match#/##version headers, drop stray---/----rules from the body, emit# [ver](tag) => datewith explicit---separators.chore(release)— assemble the 83 pendingchangelog.d/fragments (+ prior[Unreleased]content) into the[4.0.4]section and clear the folder. Done on develop first per the playbook.Verified locally: the
[4.0.4]header is single-hash with a tag link, the separator is exactly---,release.yml's awk extracts the section with no bleed into 4.0.3, and every prior version heading still appears exactly once.🤖 Generated with Claude Code