فهرست منبع

feat(release): auto-promote [Unreleased] into [<version>] on release workflow run

Fixes the silent-sparse-release-notes failure mode that surfaced on
v0.9.5: the Release workflow used to do a literal
`extract-release-notes.mjs <version>` lookup with an `[Unreleased]`
fallback. The fallback only triggered when the `[<version>]` block
DIDN'T exist at all — and in practice maintainers sometimes had a
sparse `[<version>]` block pre-populated (e.g. one early fix
documented before the rest of the work landed). The workflow then
extracted that sparse block, ignoring the much-larger `[Unreleased]`
section above it. Result: the published v0.9.5 release notes were
missing the shared MCP daemon, the per-file staleness banner, the
Objective-C indexing, AND the Mixed iOS/RN/Expo bridging.

The fix is a new `scripts/prepare-release.mjs` step that runs at the
start of the workflow:

  Case A — `[<version>]` does not yet exist:
    Rename `[Unreleased]` → `[<version>] - <today>`. Add a fresh
    empty `[Unreleased]` above. The common path.

  Case B — `[<version>]` exists AND `[Unreleased]` has content:
    Merge `[Unreleased]`'s sub-sections (### Added / ### Fixed /
    ### Changed / ### Removed / ### Deprecated / ### Security) into
    the corresponding sub-sections of `[<version>]`. Unmatched
    sub-sections are appended. Then empty `[Unreleased]`.

  Case C — `[Unreleased]` is empty:
    No-op. Re-runs of the workflow are safe.

After the script runs, the workflow auto-commits + pushes the
CHANGELOG.md change back to main (with a `[skip ci]` tag in the
commit body) so future runs and human eyes both see the same
on-disk truth.

9 unit tests (`__tests__/prepare-release.test.ts`) cover all three
cases, idempotency, version-source precedence, and an
extract-release-notes.mjs integration check.

Workflow comment header rewritten to reflect the new flow.

Trigger reminder going forward: bump package.json. CHANGELOG entries
can live under `[Unreleased]` — the workflow takes care of moving
them.

937/939 existing tests pass (2 pre-existing skips); +9 new tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Colby McHenry 4 هفته پیش
والد
کامیت
db141eff75
3فایلهای تغییر یافته به همراه476 افزوده شده و 10 حذف شده
  1. 43 10
      .github/workflows/release.yml
  2. 187 0
      __tests__/prepare-release.test.ts
  3. 246 0
      scripts/prepare-release.mjs

+ 43 - 10
.github/workflows/release.yml

@@ -2,25 +2,33 @@ name: Release
 
 # Manually triggered ("Run workflow"). On trigger it:
 #   1. reads the version from package.json,
-#   2. builds a self-contained bundle for every platform (one runner — there's no
+#   2. promotes `## [Unreleased]` content into `## [<version>]` in
+#      CHANGELOG.md (and commits + pushes that change back to main), so
+#      the published release notes are never sparse just because the
+#      maintainer didn't pre-stage the [<version>] block by hand,
+#   3. builds a self-contained bundle for every platform (one runner — there's no
 #      native compilation, so cross-packaging is fine),
-#   3. creates the GitHub Release (tag v<version>) with all archives, using the
+#   4. creates the GitHub Release (tag v<version>) with all archives, using the
 #      release notes from CHANGELOG.md,
-#   4. publishes the npm thin-installer (shim + per-platform packages).
+#   5. publishes the npm thin-installer (shim + per-platform packages).
 #
-# Before triggering: bump package.json and make sure CHANGELOG.md has the matching
-# section (## [<version>], or ## [Unreleased]). Set the NPM_TOKEN repo secret.
+# Before triggering: bump package.json. CHANGELOG.md entries can live under
+# `## [Unreleased]` — step 2 takes care of moving them. Set the NPM_TOKEN secret.
 on:
   workflow_dispatch: {}
 
 permissions:
-  contents: write   # create the GitHub Release + tag
+  contents: write   # create the GitHub Release + tag, push the CHANGELOG promote
 
 jobs:
   release:
     runs-on: ubuntu-latest
     steps:
       - uses: actions/checkout@v6
+        with:
+          # Default checkout is detached at a SHA; we need an actual branch
+          # so the CHANGELOG-promote commit knows where to push.
+          ref: ${{ github.ref }}
       - uses: actions/setup-node@v6
         with:
           node-version: 22
@@ -29,6 +37,32 @@ jobs:
       - name: Ensure zip/unzip
         run: sudo apt-get update -qq && sudo apt-get install -y -qq zip unzip
 
+      - name: Resolve version
+        id: ver
+        run: echo "version=$(node -p "require('./package.json').version")" >> "$GITHUB_OUTPUT"
+
+      - name: Promote [Unreleased] → [<version>] in CHANGELOG.md
+        # Idempotent: a no-op if [Unreleased] is empty OR if the previous
+        # run already moved everything. Auto-commit + push the change back
+        # so the version block on main is the source of truth going
+        # forward (and so subsequent extract-release-notes.mjs calls
+        # surface the full content even if this run is re-triggered).
+        run: |
+          set -euo pipefail
+          V="${{ steps.ver.outputs.version }}"
+          before=$(git rev-parse HEAD)
+          node scripts/prepare-release.mjs "$V"
+          if git diff --quiet -- CHANGELOG.md; then
+            echo "CHANGELOG.md unchanged — nothing to commit."
+          else
+            git config user.name  "github-actions[bot]"
+            git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
+            git add CHANGELOG.md
+            git commit -m "docs(changelog): promote [Unreleased] into [${V}]" -m "[skip ci] Auto-generated by Release workflow."
+            # Push to the branch the workflow was triggered on (main).
+            git push origin "HEAD:${GITHUB_REF#refs/heads/}"
+          fi
+
       - name: Build all platform bundles
         run: |
           for t in darwin-arm64 darwin-x64 linux-x64 linux-arm64 win32-x64 win32-arm64; do
@@ -43,11 +77,10 @@ jobs:
           ( cd release && sha256sum codegraph-* > SHA256SUMS )
           cat release/SHA256SUMS
 
-      - name: Resolve version
-        id: ver
-        run: echo "version=$(node -p "require('./package.json').version")" >> "$GITHUB_OUTPUT"
-
       - name: Release notes from CHANGELOG.md
+        # The [<version>] block was guaranteed-populated by the
+        # "Promote" step above, so the [Unreleased] fallback should
+        # never be needed in practice. Kept for defense-in-depth.
         run: |
           V="${{ steps.ver.outputs.version }}"
           node scripts/extract-release-notes.mjs "$V" > notes.md 2>/dev/null \

+ 187 - 0
__tests__/prepare-release.test.ts

@@ -0,0 +1,187 @@
+/**
+ * Unit tests for `scripts/prepare-release.mjs`.
+ *
+ * The script reads CHANGELOG.md and package.json from `process.cwd()`,
+ * so the tests run it via `node` in a temp directory after staging
+ * those files. Real script, real fs — keeps the test honest about what
+ * the workflow will actually do.
+ */
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import { execFileSync } from 'node:child_process';
+import * as fs from 'node:fs';
+import * as path from 'node:path';
+import * as os from 'node:os';
+
+const SCRIPT = path.resolve(__dirname, '..', 'scripts', 'prepare-release.mjs');
+
+function run(cwd: string, ...args: string[]) {
+  const out = execFileSync('node', [SCRIPT, ...args], { cwd, encoding: 'utf8' });
+  return out.trim();
+}
+
+function setup(changelog: string, version = '1.2.3') {
+  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'prepare-release-'));
+  fs.writeFileSync(path.join(dir, 'CHANGELOG.md'), changelog);
+  fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'x', version }));
+  return dir;
+}
+
+const HEADER = `# Changelog
+
+Some intro.
+
+`;
+
+describe('prepare-release.mjs', () => {
+  let dir: string;
+  afterEach(() => {
+    if (dir && fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true });
+  });
+
+  describe('Case A: [version] block does not yet exist', () => {
+    it('renames [Unreleased] to [version] - <today> and adds a fresh empty [Unreleased]', () => {
+      dir = setup(
+        HEADER +
+          `## [Unreleased]\n\n### Added\n- New feature foo\n- New feature bar\n\n### Fixed\n- Fixed thing\n\n## [1.2.2] - 2026-01-01\n\n### Added\n- Old entry\n`,
+      );
+      const out = run(dir);
+      expect(out).toMatch(/renamed \[Unreleased\] to \[1\.2\.3\]/);
+      const result = fs.readFileSync(path.join(dir, 'CHANGELOG.md'), 'utf8');
+
+      // [Unreleased] is now empty and at the top.
+      expect(result).toMatch(/## \[Unreleased\]\n\n\n## \[1\.2\.3\]/);
+      // [1.2.3] gets a date.
+      expect(result).toMatch(/## \[1\.2\.3\] - \d{4}-\d{2}-\d{2}/);
+      // Promoted content lives under [1.2.3].
+      const v123Section = result.split('## [1.2.3]')[1].split('## [1.2.2]')[0];
+      expect(v123Section).toContain('### Added');
+      expect(v123Section).toContain('- New feature foo');
+      expect(v123Section).toContain('- New feature bar');
+      expect(v123Section).toContain('### Fixed');
+      expect(v123Section).toContain('- Fixed thing');
+      // [1.2.2] is intact.
+      expect(result).toContain('## [1.2.2] - 2026-01-01');
+      expect(result).toContain('- Old entry');
+    });
+  });
+
+  describe('Case B: [version] already exists AND [Unreleased] has content', () => {
+    it('merges Unreleased sub-sections into the matching [version] sub-sections', () => {
+      // The v0.9.5 scenario verbatim: sparse [0.9.5] with two Fixed
+      // entries, full [Unreleased] above it with Added + more Fixed.
+      dir = setup(
+        HEADER +
+          `## [Unreleased]\n\n### Added\n- Big feature 1\n- Big feature 2\n\n### Fixed\n- Watcher fix\n- Worktree fix\n\n## [1.2.3] - 2026-02-02\n\n### Fixed\n- Old fix A\n- Old fix B\n\n## [1.2.2] - 2026-01-01\n`,
+      );
+      const out = run(dir);
+      expect(out).toMatch(/merged \d+ Unreleased entries/);
+      const result = fs.readFileSync(path.join(dir, 'CHANGELOG.md'), 'utf8');
+
+      // [Unreleased] is emptied.
+      const unrelSection = result.split('## [Unreleased]')[1].split('## [1.2.3]')[0];
+      expect(unrelSection.trim()).toBe('');
+
+      // [1.2.3] now has BOTH the original Fixed entries AND the
+      // Unreleased Fixed entries, plus the new Added sub-section.
+      const v123Section = result.split('## [1.2.3]')[1].split('## [1.2.2]')[0];
+      expect(v123Section).toContain('### Added');
+      expect(v123Section).toContain('- Big feature 1');
+      expect(v123Section).toContain('- Big feature 2');
+      expect(v123Section).toContain('### Fixed');
+      expect(v123Section).toContain('- Old fix A');
+      expect(v123Section).toContain('- Old fix B');
+      expect(v123Section).toContain('- Watcher fix');
+      expect(v123Section).toContain('- Worktree fix');
+      // Date on [1.2.3] is preserved (we don't re-stamp it).
+      expect(result).toContain('## [1.2.3] - 2026-02-02');
+    });
+
+    it('appends sub-sections that exist only in [Unreleased] to the [version] block', () => {
+      dir = setup(
+        HEADER +
+          `## [Unreleased]\n\n### Security\n- CVE patch\n\n## [1.2.3] - 2026-02-02\n\n### Fixed\n- Old fix\n`,
+      );
+      run(dir);
+      const result = fs.readFileSync(path.join(dir, 'CHANGELOG.md'), 'utf8');
+      const v123 = result.split('## [1.2.3]')[1];
+      expect(v123).toContain('### Fixed');
+      expect(v123).toContain('- Old fix');
+      expect(v123).toContain('### Security');
+      expect(v123).toContain('- CVE patch');
+    });
+  });
+
+  describe('Case C: [Unreleased] has no entries', () => {
+    it('is a no-op when [Unreleased] is empty', () => {
+      dir = setup(HEADER + `## [Unreleased]\n\n## [1.2.3] - 2026-02-02\n\n### Fixed\n- thing\n`);
+      const before = fs.readFileSync(path.join(dir, 'CHANGELOG.md'), 'utf8');
+      const out = run(dir);
+      expect(out).toMatch(/nothing to do/);
+      const after = fs.readFileSync(path.join(dir, 'CHANGELOG.md'), 'utf8');
+      expect(after).toBe(before);
+    });
+
+    it('is a no-op when [Unreleased] has only sub-section headings with no bullets', () => {
+      dir = setup(
+        HEADER + `## [Unreleased]\n\n### Added\n\n### Fixed\n\n## [1.2.3] - 2026-02-02\n`,
+      );
+      const before = fs.readFileSync(path.join(dir, 'CHANGELOG.md'), 'utf8');
+      const out = run(dir);
+      expect(out).toMatch(/nothing to do/);
+      const after = fs.readFileSync(path.join(dir, 'CHANGELOG.md'), 'utf8');
+      expect(after).toBe(before);
+    });
+  });
+
+  describe('idempotency', () => {
+    it('running twice produces the same output as running once', () => {
+      dir = setup(
+        HEADER +
+          `## [Unreleased]\n\n### Added\n- Thing A\n\n## [1.2.2] - 2026-01-01\n\n### Added\n- Old\n`,
+      );
+      run(dir); // first run promotes
+      const afterFirst = fs.readFileSync(path.join(dir, 'CHANGELOG.md'), 'utf8');
+      const out2 = run(dir); // second run should be a no-op
+      const afterSecond = fs.readFileSync(path.join(dir, 'CHANGELOG.md'), 'utf8');
+      expect(out2).toMatch(/nothing to do/);
+      expect(afterSecond).toBe(afterFirst);
+    });
+  });
+
+  describe('version source', () => {
+    it('reads the target version from package.json by default', () => {
+      dir = setup(HEADER + `## [Unreleased]\n\n### Added\n- x\n`, '9.9.9');
+      run(dir);
+      const result = fs.readFileSync(path.join(dir, 'CHANGELOG.md'), 'utf8');
+      expect(result).toContain('## [9.9.9]');
+    });
+
+    it('accepts an explicit version argument that overrides package.json', () => {
+      dir = setup(HEADER + `## [Unreleased]\n\n### Added\n- x\n`, '9.9.9');
+      run(dir, '5.5.5');
+      const result = fs.readFileSync(path.join(dir, 'CHANGELOG.md'), 'utf8');
+      expect(result).toContain('## [5.5.5]');
+      expect(result).not.toContain('## [9.9.9]');
+    });
+  });
+
+  describe('extractor integration', () => {
+    it('the resulting [version] block is what extract-release-notes.mjs would surface', () => {
+      // Run prepare, then extract — confirm the output contains all the
+      // promoted entries.
+      dir = setup(
+        HEADER +
+          `## [Unreleased]\n\n### Added\n- Feature A\n- Feature B\n\n### Fixed\n- Bug fix\n\n## [1.2.2] - 2026-01-01\n`,
+      );
+      run(dir);
+
+      const extractor = path.resolve(__dirname, '..', 'scripts', 'extract-release-notes.mjs');
+      const notes = execFileSync('node', [extractor, '1.2.3'], { cwd: dir, encoding: 'utf8' });
+      expect(notes).toContain('### Added');
+      expect(notes).toContain('Feature A');
+      expect(notes).toContain('Feature B');
+      expect(notes).toContain('### Fixed');
+      expect(notes).toContain('Bug fix');
+    });
+  });
+});

+ 246 - 0
scripts/prepare-release.mjs

@@ -0,0 +1,246 @@
+#!/usr/bin/env node
+/**
+ * Promote `## [Unreleased]` content into `## [<version>]` in CHANGELOG.md
+ * so the release.yml workflow's `extract-release-notes.mjs <version>` call
+ * picks up everything that landed since the last release.
+ *
+ * **Why this exists:** the release workflow used to do a literal
+ * `extract-release-notes.mjs <version>` lookup with an `[Unreleased]`
+ * fallback. The fallback only triggers if the `[<version>]` block
+ * doesn't exist at all — and in practice maintainers sometimes had a
+ * sparse `[<version>]` block pre-populated (e.g. one early fix
+ * documented before the rest of the work landed). The workflow then
+ * extracted that sparse block, ignoring the much larger `[Unreleased]`
+ * section above it — so the published release notes were missing most
+ * of what shipped. See v0.9.5 for the canonical post-mortem.
+ *
+ * **What it does**, idempotently:
+ *
+ *   Case A — `[<version>]` does not exist yet:
+ *     Rename the `[Unreleased]` header to `[<version>] - <YYYY-MM-DD>`
+ *     and add a fresh empty `## [Unreleased]` block above it. This is
+ *     the common case.
+ *
+ *   Case B — `[<version>]` exists AND `[Unreleased]` has content:
+ *     Merge `[Unreleased]`'s sub-sections (### Added / ### Fixed /
+ *     ### Changed / ### Removed / ### Deprecated / ### Security) into
+ *     the corresponding sub-sections of `[<version>]`. Unmatched
+ *     sub-sections are appended to `[<version>]`. The `[Unreleased]`
+ *     block is then emptied.
+ *
+ *   Case C — `[Unreleased]` has no content:
+ *     No-op. Exit 0. Re-runs of the workflow are safe.
+ *
+ * **Where the date comes from:** for Case A, `<YYYY-MM-DD>` is the
+ * UTC date at run time. Matches the existing CHANGELOG convention.
+ *
+ * **Usage:**
+ *
+ *   node scripts/prepare-release.mjs                # reads version from package.json
+ *   node scripts/prepare-release.mjs 1.2.3          # explicit version
+ *
+ * **Output:**
+ *
+ *   Writes CHANGELOG.md in place. Prints a summary line to stdout
+ *   like `prepare-release: 0.9.5 — promoted 6 Unreleased entries`.
+ *   Exits non-zero on parse failures.
+ */
+
+import { readFileSync, writeFileSync } from 'node:fs';
+import { resolve } from 'node:path';
+
+const CHANGELOG_PATH = resolve(process.cwd(), 'CHANGELOG.md');
+
+function readPackageVersion() {
+  const pkg = JSON.parse(readFileSync(resolve(process.cwd(), 'package.json'), 'utf8'));
+  if (!pkg.version) throw new Error('package.json has no "version" field');
+  return pkg.version;
+}
+
+function todayUtcIsoDate() {
+  // YYYY-MM-DD in UTC. Matches the CHANGELOG's existing convention
+  // (the existing dated entries don't disclose a timezone, but UTC is
+  // stable across runners and is what the workflow's runner produces
+  // by default anyway).
+  return new Date().toISOString().slice(0, 10);
+}
+
+/**
+ * Split the CHANGELOG into a header preface + an ordered list of
+ * version blocks `{ header, body[] }`, preserving line content
+ * verbatim so we can re-join without surprises.
+ */
+function parseChangelog(text) {
+  const lines = text.split('\n');
+  const versionHeaderRe = /^## \[([^\]]+)\](?:\s+-\s+(.+))?\s*$/;
+  const preface = [];
+  const blocks = []; // { header: string, name: string, body: string[] }
+  let cur = null;
+  for (const line of lines) {
+    const m = line.match(versionHeaderRe);
+    if (m) {
+      if (cur) blocks.push(cur);
+      cur = { header: line, name: m[1], date: m[2] ?? null, body: [] };
+    } else if (cur) {
+      cur.body.push(line);
+    } else {
+      preface.push(line);
+    }
+  }
+  if (cur) blocks.push(cur);
+  return { preface, blocks };
+}
+
+function joinChangelog({ preface, blocks }) {
+  const parts = [preface.join('\n')];
+  for (const b of blocks) {
+    // Reconstruct: header + body. The block body INCLUDES the blank
+    // line after the header (it was captured verbatim).
+    parts.push([b.header, ...b.body].join('\n'));
+  }
+  return parts.join('\n');
+}
+
+/**
+ * Split a block body into ordered sub-sections keyed by their
+ * `### Heading`. Lines before the first `### Heading` go in
+ * `leading`. Preserves the original (line-array) body inside each
+ * sub-section so we can splice cleanly when merging.
+ */
+function splitSubsections(body) {
+  const subsectionRe = /^### (\w+)\s*$/;
+  const leading = [];
+  const subs = []; // { heading: 'Added' | 'Fixed' | …, headerLine: string, body: string[] }
+  let cur = null;
+  for (const line of body) {
+    const m = line.match(subsectionRe);
+    if (m) {
+      if (cur) subs.push(cur);
+      cur = { heading: m[1], headerLine: line, body: [] };
+    } else if (cur) {
+      cur.body.push(line);
+    } else {
+      leading.push(line);
+    }
+  }
+  if (cur) subs.push(cur);
+  return { leading, subs };
+}
+
+function rebuildBody({ leading, subs }) {
+  const parts = [];
+  if (leading.length) parts.push(leading.join('\n'));
+  for (const s of subs) {
+    parts.push([s.headerLine, ...s.body].join('\n'));
+  }
+  return parts.join('\n').split('\n');
+}
+
+/**
+ * Return true when the block has any meaningful entries (a bullet line
+ * starting with `-`, `*`, or a digit) — vs. being empty / just
+ * whitespace / just sub-section headers with nothing under them.
+ */
+function blockHasContent(body) {
+  for (const line of body) {
+    if (/^\s*([-*]|\d+\.)\s+/.test(line)) return true;
+  }
+  return false;
+}
+
+/**
+ * Trim trailing blank lines from an array of lines, then return.
+ * Keeps the output tidy when merging.
+ */
+function trimTrailingBlank(arr) {
+  let i = arr.length;
+  while (i > 0 && /^\s*$/.test(arr[i - 1])) i--;
+  return arr.slice(0, i);
+}
+
+function main() {
+  const versionArg = process.argv[2];
+  const version = versionArg || readPackageVersion();
+
+  const text = readFileSync(CHANGELOG_PATH, 'utf8');
+  const parsed = parseChangelog(text);
+
+  const unrelIdx = parsed.blocks.findIndex((b) => b.name === 'Unreleased');
+  const verIdx = parsed.blocks.findIndex((b) => b.name === version);
+
+  if (unrelIdx === -1) {
+    console.log(`prepare-release: no [Unreleased] block — nothing to do`);
+    return;
+  }
+
+  const unrel = parsed.blocks[unrelIdx];
+  if (!blockHasContent(unrel.body)) {
+    console.log(`prepare-release: [Unreleased] is empty — nothing to do`);
+    return;
+  }
+
+  if (verIdx === -1) {
+    // Case A — promote Unreleased → [version].
+    const today = todayUtcIsoDate();
+    const promoted = {
+      header: `## [${version}] - ${today}`,
+      name: version,
+      date: today,
+      body: trimTrailingBlank(unrel.body).concat(['']), // single trailing blank
+    };
+    const emptied = {
+      header: `## [Unreleased]`,
+      name: 'Unreleased',
+      date: null,
+      body: ['', ''], // two blank lines for the next round of entries
+    };
+    parsed.blocks.splice(unrelIdx, 1, emptied, promoted);
+    writeFileSync(CHANGELOG_PATH, joinChangelog(parsed));
+    console.log(`prepare-release: ${version} — renamed [Unreleased] to [${version}] - ${today}`);
+    return;
+  }
+
+  // Case B — merge Unreleased sub-sections into the existing
+  // [version] sub-sections. New sub-section headings encountered in
+  // Unreleased that don't exist in [version] get appended.
+  const ver = parsed.blocks[verIdx];
+  const unrelSubs = splitSubsections(unrel.body);
+  const verSubs = splitSubsections(ver.body);
+
+  let merged = 0;
+  for (const us of unrelSubs.subs) {
+    const target = verSubs.subs.find((s) => s.heading === us.heading);
+    const usBody = trimTrailingBlank(us.body);
+    if (usBody.length === 0) continue;
+    if (target) {
+      // Append Unreleased's entries to the end of the version's matching
+      // sub-section, keeping their original ordering. Insert a separating
+      // blank line if the existing sub-section doesn't already end in one.
+      const existing = trimTrailingBlank(target.body);
+      const sep = existing.length && !/^\s*$/.test(existing[existing.length - 1]) ? [''] : [];
+      target.body = existing.concat(sep, usBody, ['']);
+    } else {
+      // Append the whole sub-section to the end.
+      verSubs.subs.push({
+        heading: us.heading,
+        headerLine: us.headerLine,
+        body: usBody.concat(['']),
+      });
+    }
+    merged += usBody.filter((l) => /^\s*([-*]|\d+\.)\s+/.test(l)).length;
+  }
+
+  ver.body = rebuildBody(verSubs);
+  // Empty out Unreleased.
+  unrel.body = ['', ''];
+
+  writeFileSync(CHANGELOG_PATH, joinChangelog(parsed));
+  console.log(`prepare-release: ${version} — merged ${merged} Unreleased entries into existing [${version}] block`);
+}
+
+try {
+  main();
+} catch (err) {
+  console.error(`prepare-release: ${err?.message ?? err}`);
+  process.exit(1);
+}