diff --git a/.changeset/README.md b/.changeset/README.md index 83bf9f84a..94310153f 100644 --- a/.changeset/README.md +++ b/.changeset/README.md @@ -37,11 +37,13 @@ PRs that legitimately have no user-facing impact can add the `no-changelog` labe ## At release time +Promotion is **automatic**. The release workflow's `finalize` job runs: + ```bash -node scripts/changeset/cli.cjs render --version vX.Y.Z --date YYYY-MM-DD +node scripts/changeset/cli.cjs render --version vX.Y.Z --date YYYY-MM-DD --allow-empty ``` -Reads every fragment, groups bullets by `type:`, replaces `## [Unreleased]` with a new `## [vX.Y.Z] - YYYY-MM-DD` block, opens a fresh `## [Unreleased]` above, deletes consumed fragments. Idempotent. +This reads every fragment, groups bullets by `type:`, replaces `## [Unreleased]` with a new `## [vX.Y.Z] - YYYY-MM-DD` block, opens a fresh `## [Unreleased]` above, and deletes consumed fragments. The `--allow-empty` flag ensures a no-change release still gets a dated heading (with a `_No notable changes._` placeholder). A subsequent `verify` step confirms the promotion landed correctly. Maintainers do **not** run this by hand. ## Archived fragments diff --git a/.changeset/wire-changelog-render.md b/.changeset/wire-changelog-render.md new file mode 100644 index 000000000..1f6de5765 --- /dev/null +++ b/.changeset/wire-changelog-render.md @@ -0,0 +1,6 @@ +--- +type: Changed +pr: 715 +--- +The release pipeline now automatically runs `changeset render` during the finalize job, promoting `.changeset/` fragments into a dated `CHANGELOG.md` section before publishing — previously a manual step that was routinely skipped (leaving v1.3.0 and v1.3.1 unpromoted, #690). A new `--allow-empty` flag prevents the verify gate from hard-failing on no-change releases by emitting a dated heading with a `_No notable changes._` placeholder when there are zero fragments. + diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f0dd401dc..bc2494f99 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -495,6 +495,24 @@ jobs: node scripts/check-npm-integrity.cjs npm run test:coverage:unit + - name: Promote CHANGELOG (render fragments) + env: + VERSION: ${{ inputs.version }} + run: | + node scripts/changeset/cli.cjs render \ + --version "$VERSION" --date "$(date -u +%F)" --allow-empty + git add -A .changeset CHANGELOG.md + # Diff-preview guard: surface exactly what was promoted, in the log and + # the job summary, before the commit lands. + { + echo "### CHANGELOG promotion for v${VERSION}" + echo '```diff' + git diff --cached -- CHANGELOG.md + echo '```' + } >> "${GITHUB_STEP_SUMMARY:-/dev/null}" + git --no-pager diff --cached -- CHANGELOG.md + git diff --cached --quiet || git commit -m "chore: promote CHANGELOG for v${VERSION}" + - name: Verify CHANGELOG promoted env: VERSION: ${{ inputs.version }} diff --git a/scripts/changeset/cli.cjs b/scripts/changeset/cli.cjs index 097e06a04..5875b0c9d 100755 --- a/scripts/changeset/cli.cjs +++ b/scripts/changeset/cli.cjs @@ -40,6 +40,7 @@ function parseArgs(argv) { repoSlug: defaultRepoSlug, installCommand: `npx ${packageName}@latest`, json: false, + allowEmpty: false, }; if (argv.length === 0) return { ok: true, opts }; opts.cmd = argv[0]; @@ -59,6 +60,7 @@ function parseArgs(argv) { for (let i = 1; i < argv.length; i++) { const a = argv[i]; if (a === '--json') { opts.json = true; continue; } + if (a === '--allow-empty') { opts.allowEmpty = true; continue; } if ( a === '--repo' || a === '--version' || @@ -118,6 +120,18 @@ function splitChangelog(text) { return { lead, prior }; } +// FIX 2: tiny local helper so both render paths share identical assembly logic. +function assembleChangelog(lead, releaseBlock) { + return [ + lead || '# Changelog', + '', + '## [Unreleased]', + '', + releaseBlock.replace(/\s+$/, ''), + '', + ].join('\n'); +} + function cmdRender(opts) { const repo = path.resolve(opts.repo); const changesetDir = path.join(repo, '.changeset'); @@ -133,14 +147,83 @@ function cmdRender(opts) { else failures.push({ file: path.relative(repo, file), reason: r.reason, detail: r.detail || null }); } + // 1. parse-failure → exitCode 1 (unchanged). if (failures.length > 0) { return { exitCode: 1, report: { consumed: 0, failures } }; } - if (fragments.length === 0) { - return { exitCode: 0, report: { consumed: 0, failures: [] } }; + + // 2. Read priorText once; reuse in all subsequent branches. + const priorText = fs.existsSync(changelogPath) ? fs.readFileSync(changelogPath, 'utf8') : ''; + + // 3. FIX 1: idempotency guard — if the version is already promoted (a dated + // release heading for this version already exists in CHANGELOG), split on + // whether fragments are still present: + // • alreadyPromoted + zero fragments → legitimate CI-retry no-op (the prior + // render commit already deleted fragments and wrote the heading). + // • alreadyPromoted + fragments present → inconsistent state: the heading + // was written out-of-band but fragments were never consumed. Fail loudly + // so the operator resolves it manually rather than silently leaving stale + // fragments to be re-consumed in a later release. + const version = stripV(opts.version); + const { releases: existingReleases } = parseChangelog(priorText); + const alreadyPromoted = existingReleases.some( + (rel) => rel.version === version && rel.date, + ); + if (alreadyPromoted) { + if (fragments.length === 0) { + return { exitCode: 0, report: { consumed: 0, failures: [], alreadyPromoted: true } }; + } + const errMsg = + `CHANGELOG.md already has a dated heading for ${version} but ` + + `${fragments.length} unconsumed fragment(s) remain in .changeset/ — ` + + `resolve manually (the version was likely promoted out-of-band).`; + return { + exitCode: 1, + report: { consumed: 0, failures: [], alreadyPromoted: true, error: errMsg }, + }; } - const priorText = fs.existsSync(changelogPath) ? fs.readFileSync(changelogPath, 'utf8') : ''; + // 4. Zero-fragment + !allowEmpty early-exit: write nothing. + if (fragments.length === 0) { + if (!opts.allowEmpty) { + return { exitCode: 0, report: { consumed: 0, failures: [] } }; + } + // --allow-empty: emit a dated heading with a placeholder even though there + // are no fragments. This lets the render→verify CI chain succeed when a + // release contains no user-visible changes. + const { lead, prior } = splitChangelog(priorText); + // Build a header-only release block and inject the placeholder line. + const ir = renderChangelog({ + fragments: [], + version: opts.version, + date: opts.date, + priorChangelog: prior || null, + }); + const headerOnlyBlock = serializeChangelog(ir); + // Insert placeholder after the release header line. + // serializeChangelog with no sections yields just "## [v] - d\n" (single + // trailing newline, no blank line). We replace that trailing newline with + // a blank line + placeholder + blank line so parseChangelog still sees the + // dated heading first and the file is human-readable. + const releaseBlock = headerOnlyBlock.replace( + /^(##\s+\[[^\]]+\][^\n]*)\n+/, + '$1\n\n_No notable changes._\n\n', + ); + // FIX 2: use shared assembleChangelog helper. + const out = assembleChangelog(lead, releaseBlock); + fs.writeFileSync(changelogPath, out); + return { + exitCode: 0, + report: { + consumed: 0, + failures: [], + written: true, + release: { version: opts.version, date: opts.date }, + }, + }; + } + + // 5. Normal render path: fragments present — reuse priorText already read above. const { lead, prior } = splitChangelog(priorText); const ir = renderChangelog({ @@ -150,14 +233,8 @@ function cmdRender(opts) { priorChangelog: prior || null, }); const releaseBlock = serializeChangelog(ir); - const out = [ - lead || '# Changelog', - '', - '## [Unreleased]', - '', - releaseBlock.replace(/\s+$/, ''), - '', - ].join('\n'); + // FIX 2: use shared assembleChangelog helper. + const out = assembleChangelog(lead, releaseBlock); fs.writeFileSync(changelogPath, out); @@ -382,7 +459,7 @@ function cmdGithubReleaseNotes(opts) { function usage() { return [ 'usage:', - ' changeset/cli.cjs render --repo --version V --date D [--json]', + ' changeset/cli.cjs render --repo --version V --date D [--allow-empty] [--json]', ' changeset/cli.cjs github-release-notes --repo --from REF --to REF [--output FILE] [--repo-slug OWNER/REPO] [--install-command CMD] [--json]', ' changeset/cli.cjs extract --from VERSION --to VERSION [--changelog FILE] [--repo ] [--json]', ' Extracts changelog entries strictly after --from (exclusive) and up to', @@ -453,6 +530,9 @@ function main() { } else if (opts.cmd === 'github-release-notes' && report.body) { process.stdout.write(report.body); } else { + if (report.error) { + process.stderr.write(`${report.error}\n`); + } process.stdout.write(`Consumed: ${report.consumed} fragment(s)\n`); if (report.failures.length > 0) { process.stdout.write(`Failures: ${report.failures.length}\n`); @@ -466,4 +546,4 @@ function main() { if (require.main === module) main(); -module.exports = { cmdRender, cmdExtract, cmdVerify, cmdGithubReleaseNotes, parseArgs, splitChangelog, listFragmentFiles, usage }; +module.exports = { cmdRender, cmdExtract, cmdVerify, cmdGithubReleaseNotes, parseArgs, splitChangelog, assembleChangelog, listFragmentFiles, usage }; diff --git a/tests/changeset-cli.test.cjs b/tests/changeset-cli.test.cjs index 0e777eb2a..852354122 100644 --- a/tests/changeset-cli.test.cjs +++ b/tests/changeset-cli.test.cjs @@ -577,3 +577,354 @@ describe('changeset cli verify subcommand (not yet implemented — TDD red step) assert.equal(r.status, 1, `expected exit 1 for pre-release version; stderr=${r.stderr}`); }); }); + +// --------------------------------------------------------------------------- +// GROUP C — render --allow-empty +// --------------------------------------------------------------------------- + +describe('changeset cli render --allow-empty', () => { + // Helper: run render for a specific test-local tmp directory. + function runRenderIn(dir, args = []) { + const r = cp.spawnSync( + process.execPath, + [SCRIPT, 'render', '--repo', dir, ...args, '--json'], + { encoding: 'utf8' }, + ); + return { + status: r.status, + report: r.stdout && r.stdout.length ? JSON.parse(r.stdout) : null, + stderr: r.stderr || '', + }; + } + + function runVerifyIn(dir, version) { + const changelogPath = path.join(dir, 'CHANGELOG.md'); + const r = cp.spawnSync( + process.execPath, + [SCRIPT, 'verify', '--version', version, '--changelog', changelogPath], + { encoding: 'utf8' }, + ); + return { status: r.status, stdout: r.stdout || '', stderr: r.stderr || '' }; + } + + // Test 1: --allow-empty with zero fragments writes a dated heading + + // placeholder, and subsequent verify exits 0. + test('--allow-empty with zero fragments writes dated heading + placeholder; verify exits 0', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-ae-')); + try { + fs.mkdirSync(path.join(dir, '.changeset'), { recursive: true }); + // Only a README — no fragment files. + fs.writeFileSync( + path.join(dir, '.changeset', 'README.md'), + '# Changesets\n\nThis folder holds changeset fragments.\n', + ); + fs.writeFileSync( + path.join(dir, 'CHANGELOG.md'), + '# Changelog\n\n## [Unreleased]\n', + ); + + const r = runRenderIn(dir, ['--version', '9.9.9', '--date', '2026-06-05', '--allow-empty']); + assert.equal(r.status, 0, `render exited non-zero; stderr=${r.stderr}`); + assert.equal(r.report.consumed, 0, 'consumed must be 0 for empty run'); + assert.equal(r.report.failures.length, 0, 'no failures expected'); + assert.strictEqual(r.report.written, true, 'report.written must be true'); + + // The CHANGELOG must now contain a dated heading for 9.9.9. + const text = fs.readFileSync(path.join(dir, 'CHANGELOG.md'), 'utf8'); + const parsed = parseChangelog(text); + const rel = parsed.releases.find((r) => r.version === '9.9.9'); + assert.ok(rel, 'release 9.9.9 must appear in CHANGELOG after --allow-empty render'); + assert.equal(rel.date, '2026-06-05', 'release date must be 2026-06-05'); + + // The placeholder line must be present in the raw text. + assert.ok( + text.includes('_No notable changes._'), + 'CHANGELOG must contain _No notable changes._ placeholder', + ); + + // verify must exit 0 for this version. + const vr = runVerifyIn(dir, '9.9.9'); + assert.equal(vr.status, 0, `verify must exit 0 after --allow-empty render; stderr=${vr.stderr}`); + } finally { + cleanup(dir); + } + }); + + // Test 2: render WITHOUT --allow-empty and zero fragments early-exits — + // CHANGELOG is not created / not modified. Regression lock. + test('render without --allow-empty and zero fragments does NOT write CHANGELOG', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-ae-')); + try { + fs.mkdirSync(path.join(dir, '.changeset'), { recursive: true }); + // No fragment files, only README. + fs.writeFileSync( + path.join(dir, '.changeset', 'README.md'), + '# Changesets\n', + ); + // Write a known CHANGELOG so we can verify it is unchanged. + const originalChangelog = '# Changelog\n\n## [Unreleased]\n'; + fs.writeFileSync(path.join(dir, 'CHANGELOG.md'), originalChangelog); + + const r = runRenderIn(dir, ['--version', '9.9.8', '--date', '2026-06-05']); + assert.equal(r.status, 0, `render without --allow-empty exited non-zero; stderr=${r.stderr}`); + assert.equal(r.report.consumed, 0, 'consumed must be 0'); + assert.equal(r.report.failures.length, 0, 'no failures expected'); + // report.written must NOT be set (undefined or absent). + assert.ok( + r.report.written === undefined || r.report.written === null || r.report.written === false, + 'report.written must not be true in the no-op path', + ); + + // CHANGELOG must be byte-identical to the original. + const text = fs.readFileSync(path.join(dir, 'CHANGELOG.md'), 'utf8'); + assert.equal(text, originalChangelog, 'CHANGELOG must be unchanged when no fragments and no --allow-empty'); + } finally { + cleanup(dir); + } + }); + + // Test 3: render WITH fragments + --allow-empty behaves exactly like normal + // render (consumes/deletes fragments, NO placeholder injected). + test('render --allow-empty with fragments behaves like normal render (no placeholder)', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-ae-')); + try { + fs.mkdirSync(path.join(dir, '.changeset'), { recursive: true }); + fs.writeFileSync( + path.join(dir, '.changeset', 'fix-one.md'), + '---\ntype: Fixed\npr: 200\n---\nFragment-driven fix.\n', + ); + fs.writeFileSync( + path.join(dir, 'CHANGELOG.md'), + '# Changelog\n\n## [Unreleased]\n', + ); + + const r = runRenderIn(dir, ['--version', '9.9.7', '--date', '2026-06-05', '--allow-empty']); + assert.equal(r.status, 0, `render with fragments exited non-zero; stderr=${r.stderr}`); + assert.equal(r.report.consumed, 1, 'one fragment must be consumed'); + assert.equal(r.report.failures.length, 0, 'no failures expected'); + + const text = fs.readFileSync(path.join(dir, 'CHANGELOG.md'), 'utf8'); + // Placeholder must NOT appear when there are real fragments. + assert.ok( + !text.includes('_No notable changes._'), + 'placeholder must NOT appear when fragments were consumed', + ); + // The fragment bullet must be present. + const parsed = parseChangelog(text); + const rel = parsed.releases.find((r) => r.version === '9.9.7'); + assert.ok(rel, 'release 9.9.7 must appear in CHANGELOG'); + assert.equal(rel.date, '2026-06-05', 'release date must be 2026-06-05'); + const prs = rel.sections.flatMap((s) => s.bullets.map((b) => b.pr)); + assert.ok(prs.includes(200), 'pr=200 bullet must be present in the release'); + + // Fragment file must be deleted. + const remaining = fs.readdirSync(path.join(dir, '.changeset')) + .filter((f) => f.endsWith('.md') && f !== 'README.md'); + assert.deepEqual(remaining, [], 'fragment must be deleted after consumption'); + + // verify must exit 0. + const vr = runVerifyIn(dir, '9.9.7'); + assert.equal(vr.status, 0, `verify must exit 0 after render with fragments; stderr=${vr.stderr}`); + } finally { + cleanup(dir); + } + }); + + // Test 4 (idempotency): after a fragmentful render, a second render + // --allow-empty for the SAME version is now a NO-OP (FIX 1 guard). It must + // NOT add a second heading, must exit 0, and must report alreadyPromoted:true. + test('idempotency: second --allow-empty render for same version is a no-op (no duplicate heading)', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-ae-')); + try { + fs.mkdirSync(path.join(dir, '.changeset'), { recursive: true }); + fs.writeFileSync( + path.join(dir, '.changeset', 'fix-two.md'), + '---\ntype: Fixed\npr: 300\n---\nIdempotency test fix.\n', + ); + fs.writeFileSync( + path.join(dir, 'CHANGELOG.md'), + '# Changelog\n\n## [Unreleased]\n', + ); + + // First render: with a fragment. + const r1 = runRenderIn(dir, ['--version', '9.9.6', '--date', '2026-06-05']); + assert.equal(r1.status, 0, `first render exited non-zero; stderr=${r1.stderr}`); + assert.equal(r1.report.consumed, 1, 'first render must consume 1 fragment'); + + // verify after first render must succeed. + const vr1 = runVerifyIn(dir, '9.9.6'); + assert.equal(vr1.status, 0, `verify after first render must exit 0; stderr=${vr1.stderr}`); + + // Second render --allow-empty: zero fragments now, same version. + // This is now a NO-OP due to the already-promoted guard (FIX 1). + const r2 = runRenderIn(dir, ['--version', '9.9.6', '--date', '2026-06-05', '--allow-empty']); + assert.equal(r2.status, 0, `second render exited non-zero; stderr=${r2.stderr}`); + assert.equal(r2.report.consumed, 0, 'second render must consume 0 fragments'); + assert.strictEqual(r2.report.alreadyPromoted, true, 'second render must report alreadyPromoted:true'); + + // Exactly ONE ## [9.9.6] heading must be present — no duplicate. + const text = fs.readFileSync(path.join(dir, 'CHANGELOG.md'), 'utf8'); + const parsed = parseChangelog(text); + const all996 = parsed.releases.filter((r) => r.version === '9.9.6'); + assert.equal( + all996.length, 1, + `exactly one 9.9.6 heading must be present after two renders; found ${all996.length}`, + ); + + // verify must still exit 0. + const vr2 = runVerifyIn(dir, '9.9.6'); + assert.equal(vr2.status, 0, `verify after second render must still exit 0; stderr=${vr2.stderr}`); + } finally { + cleanup(dir); + } + }); + + // Test 4b (stale fragments): alreadyPromoted + fragments present → render exits + // NON-ZERO, CHANGELOG is NOT modified, and the fragment file is NOT deleted. + // This guards the inconsistent-state scenario identified in the adversarial review: + // a manual/partial promotion wrote the heading but left fragments unconsumed. + test('alreadyPromoted + fragments present: exits non-zero, CHANGELOG unchanged, fragment survives', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-ae-')); + try { + fs.mkdirSync(path.join(dir, '.changeset'), { recursive: true }); + + // CHANGELOG already has a dated heading for 3.3.3 (out-of-band promotion). + const originalChangelog = [ + '# Changelog', + '', + '## [Unreleased]', + '', + '## [3.3.3] - 2026-01-01', + '', + '### Fixed', + '', + '- x (#1)', + '', + ].join('\n'); + fs.writeFileSync(path.join(dir, 'CHANGELOG.md'), originalChangelog); + + // A fragment file is still present — unconsumed. + const fragmentPath = path.join(dir, '.changeset', 'leftover.md'); + fs.writeFileSync(fragmentPath, '---\ntype: Fixed\npr: 9\n---\nleftover fragment\n'); + + // Run render for the same version. + const r = runRenderIn(dir, ['--version', '3.3.3', '--date', '2026-06-05', '--allow-empty']); + + // Must exit non-zero. + assert.notEqual(r.status, 0, `expected non-zero exit for alreadyPromoted+fragments; got ${r.status}`); + + // report.alreadyPromoted must be true and report.error must be present. + assert.strictEqual(r.report.alreadyPromoted, true, 'report.alreadyPromoted must be true'); + assert.ok(typeof r.report.error === 'string' && r.report.error.length > 0, 'report.error must be a non-empty string'); + assert.ok(r.report.error.includes('3.3.3'), 'report.error must mention the version'); + + // Fragment file must still exist — render must NOT have deleted it. + assert.ok(fs.existsSync(fragmentPath), 'leftover fragment must still exist after non-zero exit'); + + // CHANGELOG must be byte-identical to the original — render must NOT have modified it. + const text = fs.readFileSync(path.join(dir, 'CHANGELOG.md'), 'utf8'); + assert.equal(text, originalChangelog, 'CHANGELOG must be unchanged'); + + // Exactly one ## [3.3.3] heading must be present (no duplicate added). + const parsed = parseChangelog(text); + const all333 = parsed.releases.filter((rel) => rel.version === '3.3.3'); + assert.equal(all333.length, 1, `exactly one 3.3.3 heading must be present; found ${all333.length}`); + } finally { + cleanup(dir); + } + }); + + // Test 5 (cold start): --allow-empty when CHANGELOG.md does NOT exist creates + // the file with # Changelog, ## [Unreleased], and a dated release with placeholder. + test('--allow-empty cold start (no CHANGELOG.md) creates file with expected structure', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-ae-')); + try { + fs.mkdirSync(path.join(dir, '.changeset'), { recursive: true }); + // No CHANGELOG.md, no fragments. + + const r = runRenderIn(dir, ['--version', '8.8.8', '--date', '2026-06-05', '--allow-empty']); + assert.equal(r.status, 0, `render exited non-zero; stderr=${r.stderr}`); + assert.equal(r.report.consumed, 0, 'consumed must be 0'); + assert.strictEqual(r.report.written, true, 'report.written must be true'); + + const changelogPath = path.join(dir, 'CHANGELOG.md'); + assert.ok(fs.existsSync(changelogPath), 'CHANGELOG.md must be created'); + + const text = fs.readFileSync(changelogPath, 'utf8'); + // Must contain a top-level heading. + assert.ok(text.includes('# Changelog'), 'must contain # Changelog heading'); + // Must contain an ## [Unreleased] block. + assert.ok(text.includes('## [Unreleased]'), 'must contain ## [Unreleased] block'); + // Must contain a dated release heading for 8.8.8. + assert.ok(text.includes('## [8.8.8] - 2026-06-05'), 'must contain dated release heading for 8.8.8'); + // Must contain the placeholder. + assert.ok(text.includes('_No notable changes._'), 'must contain _No notable changes._ placeholder'); + + // parseChangelog must see the release with the correct date. + const parsed = parseChangelog(text); + const rel = parsed.releases.find((r) => r.version === '8.8.8'); + assert.ok(rel, 'release 8.8.8 must appear in parsed CHANGELOG'); + assert.equal(rel.date, '2026-06-05', 'release date must be 2026-06-05'); + + // verify must exit 0. + const vr = runVerifyIn(dir, '8.8.8'); + assert.equal(vr.status, 0, `verify must exit 0 after cold-start --allow-empty render; stderr=${vr.stderr}`); + } finally { + cleanup(dir); + } + }); + + // Test 6: --allow-empty (zero fragments) on a CHANGELOG that already has a + // prior dated release PRESERVES that prior release in the output. + test('--allow-empty preserves prior dated release below new section', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-ae-')); + try { + fs.mkdirSync(path.join(dir, '.changeset'), { recursive: true }); + // Pre-existing CHANGELOG with a prior dated release. + const priorChangelog = [ + '# Changelog', + '', + '## [Unreleased]', + '', + '## [1.0.0] - 2026-01-01', + '', + '### Fixed', + '', + '- Old fix. (#1)', + '', + ].join('\n'); + fs.writeFileSync(path.join(dir, 'CHANGELOG.md'), priorChangelog); + + const r = runRenderIn(dir, ['--version', '2.0.0', '--date', '2026-06-05', '--allow-empty']); + assert.equal(r.status, 0, `render exited non-zero; stderr=${r.stderr}`); + assert.equal(r.report.consumed, 0, 'consumed must be 0'); + assert.strictEqual(r.report.written, true, 'report.written must be true'); + + const text = fs.readFileSync(path.join(dir, 'CHANGELOG.md'), 'utf8'); + + // New release must appear. + assert.ok(text.includes('## [2.0.0]'), 'new 2.0.0 heading must be present'); + assert.ok(text.includes('_No notable changes._'), 'placeholder must be present'); + + // Prior release must survive. + assert.ok(text.includes('## [1.0.0] - 2026-01-01'), 'prior 1.0.0 heading must be preserved'); + assert.ok(text.includes('- Old fix. (#1)'), 'prior release bullet must be preserved'); + + // Parse and assert both releases are present. + const parsed = parseChangelog(text); + const rel200 = parsed.releases.find((r) => r.version === '2.0.0'); + assert.ok(rel200, '2.0.0 must appear in parsed CHANGELOG'); + assert.equal(rel200.date, '2026-06-05', '2.0.0 date must be 2026-06-05'); + const rel100 = parsed.releases.find((r) => r.version === '1.0.0'); + assert.ok(rel100, '1.0.0 must still appear in parsed CHANGELOG'); + assert.equal(rel100.date, '2026-01-01', '1.0.0 date must be preserved'); + assert.equal(rel100.sections[0].bullets[0].pr, 1, 'pr=1 bullet in 1.0.0 must be preserved'); + + // verify for the new version must exit 0. + const vr = runVerifyIn(dir, '2.0.0'); + assert.equal(vr.status, 0, `verify must exit 0 after --allow-empty on existing CHANGELOG; stderr=${vr.stderr}`); + } finally { + cleanup(dir); + } + }); +});