Skip to content

chore(release): add a script for accruing an Unreleased changelog note - #1523

Merged
Martin Torp (mtorp) merged 2 commits into
v1.xfrom
martin/add-unreleased-changelog-helper
Sep 1, 2026
Merged

chore(release): add a script for accruing an Unreleased changelog note#1523
Martin Torp (mtorp) merged 2 commits into
v1.xfrom
martin/add-unreleased-changelog-helper

Conversation

@mtorp

@mtorp Martin Torp (mtorp) commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Summary

#1515 wrote down the rule that the release owns package.json's version and every ## [X.Y.Z] heading. A rule was all it was — every caller still hand-built the ## [Unreleased] block from prose, and the caller that got it wrong is the one nobody here reviews: Coana's release automation, which opens the upgrading coana to version … PRs.

The cost so far:

  • v1.1.161 shipped with the wrong notes. The bot's hand-written ## [1.1.161] heading consumed the [Unreleased] block, so promoteChangelog found nothing to promote and fell back to commit-derived notes. The actual user-facing change — the Coana 15.10.25 bump — was stranded below the release heading.
  • Every bump PR since has needed a human fixup commit reverting the version and rewriting the heading (upgrading coana to version 15.10.25 #1516 "Fix", upgrading coana to version 15.10.26 #1518 "Update package.json").

Nothing in CI catches it on a PR, so it fails silently each time.

Changes

addUnreleasedEntry() in changelog.mts — the counterpart to promoteChangelog. It finds or creates the ## [Unreleased] block and the ### Changed section (in SECTION_ORDER position), and with --replace rewrites a matching bullet in place, so a bump that reruns on every upstream release updates its own line instead of stacking one per run.

It reuses unreleasedRange() and the same parsed-not-matched structure rule as promoteChangelog, so the writer and the reader can't drift: markdown inside a fence stays content, and the block this writes is by construction one the release can see. There's a test asserting exactly that round trip.

It also repairs a decorated ## [Unreleased] - 2026-08-27 heading. That form is invisible to unreleasedRange(), which matches for equality — a human wrote one on #1516 while fixing up a bump PR, and the entry under it was stranded until a later bump happened to overwrite the line.

scripts/release/add-unreleased.mts exposes it to non-JS callers, in the house isMainModule + runMain(main, SCRIPT_META) shape so it inherits --describe / --help and the bare--- refusal:

node scripts/release/add-unreleased.mts \
  --replace '^Updated the Coana CLI to v ' \
  'Updated the Coana CLI to v `15.10.28`.'

/bump-coana now runs that instead of describing the shape in prose, and coana-tech/coana-package-manager#2378 switches the bot to the same call — so the human path and the bot path become one implementation, tested here.

Why here and not in the caller

That Coana PR originally carried a ~60-line changelog editor embedded in a YAML run: heredoc. Oskar Haarklou Veileborg (@BarrensZeppelin)'s review asked for this instead, and he's right: the convention belongs to this repo, the parse half is already installed here, and a helper can be unit-tested where a heredoc can't. Keeping it there also meant re-implementing h2 detection with regex and losing the code-fence correctness this repo explicitly tests for.

Verification

  • pnpm exec vitest run test/release-add-unreleased.test.mts test/release-version.test.mts test/release-bump.test.mts — 53 pass (19 new, 34 existing unaffected by the h2LineIndexesheadingLineIndexes refactor).
  • New cases cover: existing section, recreated block, created section, SECTION_ORDER placement, in-place replace, replace scoped to its own section, decorated heading repaired, lower-cased heading, heading lookalike inside a fence, released blocks left byte-for-byte, idempotent rerun, and the addUnreleasedEntrypromoteChangelog round trip. Plus parseArgs flags and refusals.
  • Ran the CLI against the real CHANGELOG.md on v1.x: creates the block, updates in place on a version bump, no-ops on a rerun.
  • node scripts/check.mts passes; tsc --noEmit clean.

Note

Medium Risk
Changes release changelog write path used by automation; mistakes could still strand notes, but the change replaces error-prone hand edits with tested logic aligned to promoteChangelog.

Overview
Adds addUnreleasedEntry in changelog.mts and a add-unreleased.mts CLI so Coana bumps and other automation write CHANGELOG.md under ## [Unreleased] instead of hand-editing. The writer mirrors promoteChangelog (same parsing rules, fenced-code safety) and only touches the unreleased block—not package.json version or ## [X.Y.Z] headings.

Behavior includes recreating [Unreleased] after a release consumes it, inserting ### Changed (and other sections) in SECTION_ORDER, optional --replace to update a matching bullet in place (for repeated Coana bumps), and normalizing decorated ## [Unreleased] - date headings so releases can still promote the block.

The /bump-coana skill now runs the CLI instead of prose instructions for manual changelog edits. test/release-add-unreleased.test.mts covers edge cases and an addUnreleasedEntrypromoteChangelog round trip.

Reviewed by Cursor Bugbot for commit 15fd47a. Configure here.

The release owns package.json's version and every `## [X.Y.Z]` heading, and
#1515 wrote that rule down in the bump-coana skill. A rule is all it was: every
caller still had to hand-build the `## [Unreleased]` block, and the one that got
it wrong — Coana's release automation, which opens the bump PRs — kept costing
us. v1.1.161 shipped with commit-derived notes because a hand-written heading
had consumed the block the release meant to promote, and every bump PR since has
needed a human fixup commit.

Give callers something to call instead. `addUnreleasedEntry` finds or creates the
block and the section, and rewrites a matching bullet in place so a bump that
reruns updates its own line rather than stacking one per run. It reuses
`unreleasedRange` and the same parsed-not-matched structure rule as
`promoteChangelog`, so the writer and the reader cannot drift: markdown inside a
fence stays content, and the block this writes is by construction one the
release can see.

It also repairs a decorated `## [Unreleased] - 2026-08-27` heading. That form is
invisible to `unreleasedRange`, which matches for equality — a human wrote one on
#1516 while fixing up a bump PR, and the entry under it was stranded.

`scripts/release/add-unreleased.mts` exposes it to non-JS callers; the skill now
runs that rather than describing the shape in prose.
Comment thread scripts/release/add-unreleased.mts Outdated
Comment thread scripts/release/add-unreleased.mts Outdated
Comment thread scripts/release/changelog.mts Outdated
Review: the v1.1.161 story belongs in the PR, not in three doc comments.
Apply @BarrensZeppelin's suggestions verbatim.
@mtorp Martin Torp (mtorp) changed the title feat(release): add a script for accruing an Unreleased changelog note chore(release): add a script for accruing an Unreleased changelog note Sep 1, 2026
@mtorp
Martin Torp (mtorp) merged commit 381f38f into v1.x Sep 1, 2026
8 checks passed
@mtorp
Martin Torp (mtorp) deleted the martin/add-unreleased-changelog-helper branch September 1, 2026 07:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants