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);
+ }
+ });
+});