From b24d9d74874c736c7781ba3cb667a9deee49de05 Mon Sep 17 00:00:00 2001 From: sudacode Date: Sat, 21 Mar 2026 23:49:43 -0700 Subject: [PATCH] fix(release): make changelog build idempotent for re-run tagged releases --- scripts/build-changelog.test.ts | 37 +++++++++++++++++++++++++++++++++ scripts/build-changelog.ts | 24 ++++++++++++++++++++- 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/scripts/build-changelog.test.ts b/scripts/build-changelog.test.ts index 4d1939f..6de8418 100644 --- a/scripts/build-changelog.test.ts +++ b/scripts/build-changelog.test.ts @@ -95,6 +95,43 @@ test('writeChangelogArtifacts ignores README, groups fragments by type, writes r } }); +test('writeChangelogArtifacts skips changelog prepend when release section already exists', async () => { + const { writeChangelogArtifacts } = await loadModule(); + const workspace = createWorkspace('write-artifacts-existing-version'); + const projectRoot = path.join(workspace, 'SubMiner'); + const existingChangelog = [ + '# Changelog', + '', + '## v0.4.1 (2026-03-07)', + '### Added', + '- Existing release bullet.', + '', + ].join('\n'); + + fs.mkdirSync(projectRoot, { recursive: true }); + fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true }); + fs.writeFileSync(path.join(projectRoot, 'CHANGELOG.md'), existingChangelog, 'utf8'); + fs.writeFileSync(path.join(projectRoot, 'changes', '001.md'), ['type: added', 'area: overlay', '', '- Stale release fragment.'].join('\n'), 'utf8'); + + try { + const result = writeChangelogArtifacts({ + cwd: projectRoot, + version: '0.4.1', + date: '2026-03-08', + }); + + assert.deepEqual(result.deletedFragmentPaths, [path.join(projectRoot, 'changes', '001.md')]); + assert.equal(fs.existsSync(path.join(projectRoot, 'changes', '001.md')), false); + + const changelog = fs.readFileSync(path.join(projectRoot, 'CHANGELOG.md'), 'utf8'); + assert.equal(changelog, existingChangelog); + const releaseNotes = fs.readFileSync(path.join(projectRoot, 'release', 'release-notes.md'), 'utf8'); + assert.match(releaseNotes, /## Highlights\n### Added\n- Existing release bullet\./); + } finally { + fs.rmSync(workspace, { recursive: true, force: true }); + } +}); + test('verifyChangelogReadyForRelease ignores README but rejects pending fragments and missing version sections', async () => { const { verifyChangelogReadyForRelease } = await loadModule(); const workspace = createWorkspace('verify-release'); diff --git a/scripts/build-changelog.ts b/scripts/build-changelog.ts index 142f650..76197cc 100644 --- a/scripts/build-changelog.ts +++ b/scripts/build-changelog.ts @@ -341,12 +341,34 @@ export function writeChangelogArtifacts(options?: ChangelogOptions): { const version = resolveVersion(options ?? {}); const date = resolveDate(options?.date); const fragments = readChangeFragments(cwd, options?.deps); - const releaseSection = buildReleaseSection(version, date, fragments); const existingChangelogPath = path.join(cwd, 'CHANGELOG.md'); const existingChangelog = existsSync(existingChangelogPath) ? readFileSync(existingChangelogPath, 'utf8') : ''; const outputPaths = resolveChangelogOutputPaths({ cwd }); + const existingReleaseSection = extractReleaseSectionBody(existingChangelog, version); + if (existingReleaseSection !== null) { + log(`Existing section found for v${version}; skipping changelog prepend.`); + for (const fragment of fragments) { + rmSync(fragment.path); + log(`Removed ${fragment.path}`); + } + + const releaseNotesPath = writeReleaseNotesFile( + cwd, + existingReleaseSection, + options?.deps, + ); + log(`Generated ${releaseNotesPath}`); + + return { + deletedFragmentPaths: fragments.map((fragment) => fragment.path), + outputPaths, + releaseNotesPath, + }; + } + + const releaseSection = buildReleaseSection(version, date, fragments); const nextChangelog = prependReleaseSection(existingChangelog, releaseSection, version); for (const outputPath of outputPaths) {