diff --git a/tools/release/extract-changelog-section.mjs b/tools/release/extract-changelog-section.mjs index 65a5aa697..09103104e 100644 --- a/tools/release/extract-changelog-section.mjs +++ b/tools/release/extract-changelog-section.mjs @@ -24,6 +24,11 @@ const workspaceRoot = path.resolve( '../..' ); +const INTERNAL_DETAILS_BLOCK = + /(?:^|\n\n)
\nInternal changes<\/summary>\n\n[\s\S]*?\n\n<\/details>(?=\n\n|$)/g; +const CLI_USAGE = + 'Usage: extract-changelog-section.mjs [--public] '; + /** * @param {string} changelog full CHANGELOG.md content * @param {string} version bare semver, e.g. `0.24.0` @@ -59,38 +64,99 @@ export function extractSection(changelog, version) { return lines.slice(start + 1, end).join('\n').trim(); } -function main() { - const version = process.argv[2]; +/** + * @param {string} changelog full CHANGELOG.md content + * @param {string} version bare semver, e.g. `0.24.0` + * @returns {string | null} public section body without internal details + */ +export function extractPublicSection(changelog, version) { + const section = extractSection(changelog, version); - if (!version || !/^\d+\.\d+\.\d+$/.test(version)) { - console.error( - 'Usage: extract-changelog-section.mjs (for example 0.24.0)' - ); - process.exit(2); + return section === null + ? null + : section.replace(INTERNAL_DETAILS_BLOCK, '').trim(); +} + +export function parseExtractArguments(args) { + const publicFlagCount = args.filter( + (argument) => argument === '--public' + ).length; + const positional = args.filter( + (argument) => argument !== '--public' + ); + + if ( + publicFlagCount > 1 || + positional.length !== 1 || + !/^\d+\.\d+\.\d+$/.test(positional[0]) + ) { + return null; } - const changelogPath = path.join(workspaceRoot, 'CHANGELOG.md'); - const section = extractSection(readFileSync(changelogPath, 'utf8'), version); + return { + version: positional[0], + publicOnly: publicFlagCount === 1, + }; +} + +export function runExtractorCli(changelog, args) { + const options = parseExtractArguments(args); + + if (options === null) { + return { exitCode: 2, stdout: '', stderr: `${CLI_USAGE}\n` }; + } + + const section = options.publicOnly + ? extractPublicSection(changelog, options.version) + : extractSection(changelog, options.version); if (section === null) { - console.error( - [ - `CHANGELOG.md has no section for ${version}.`, + return { + exitCode: 1, + stdout: '', + stderr: `${[ + `CHANGELOG.md has no section for ${options.version}.`, 'The release flow writes it before tagging:', ' pnpm run release:notes:changelog', ' node tools/release/build-release-notes.mjs --consume', 'Commit the changelog, then re-tag.', - ].join('\n') - ); - process.exit(1); + ].join('\n')}\n`, + }; } - if (section === '') { - console.error(`CHANGELOG.md section for ${version} is empty.`); - process.exit(1); + if (section === '' && !options.publicOnly) { + return { + exitCode: 1, + stdout: '', + stderr: `CHANGELOG.md section for ${options.version} is empty.\n`, + }; } - process.stdout.write(`${section}\n`); + return { + exitCode: 0, + stdout: section === '' ? '' : `${section}\n`, + stderr: '', + }; +} + +export function runExtractorProcess(args, readChangelog) { + if (parseExtractArguments(args) === null) { + return runExtractorCli('', args); + } + + return runExtractorCli(readChangelog(), args); +} + +function main() { + const changelogPath = path.join(workspaceRoot, 'CHANGELOG.md'); + const result = runExtractorProcess( + process.argv.slice(2), + () => readFileSync(changelogPath, 'utf8') + ); + + process.stdout.write(result.stdout); + process.stderr.write(result.stderr); + process.exitCode = result.exitCode; } // Allow importing extractSection from tests without running the CLI. diff --git a/tools/release/release-notes.test.mjs b/tools/release/release-notes.test.mjs index 6445d5f59..9717bb964 100644 --- a/tools/release/release-notes.test.mjs +++ b/tools/release/release-notes.test.mjs @@ -18,7 +18,13 @@ import { renderGithubBody, upsertChangelogSection, } from './release-notes-render.mjs'; -import { extractSection } from './extract-changelog-section.mjs'; +import { + extractPublicSection, + extractSection, + parseExtractArguments, + runExtractorCli, + runExtractorProcess, +} from './extract-changelog-section.mjs'; const tempDirs = []; @@ -443,33 +449,45 @@ describe('upsertChangelogSection', () => { }); }); +const changelog = [ + '# Changelog', + '', + 'Intro paragraph with a pointer.', + '', + '', + '', + '# [0.24.0](https://github.com/4gray/iptvnator/compare/v0.23.0...v0.24.0) (2026-08-01)', + '', + '### Features', + '', + '- **playback** — Up Next rail.', + '', + '
', + 'Internal changes', + '', + '- **deps** — parser bump.', + '', + '
', + '', + '# [0.12.0](https://github.com/4gray/iptvnator/compare/v0.11.1...v0.12.0) (2023-03-11)', + '', + '### Bug Fixes', + '', + '- old entry', +].join('\n'); + +const internalOnlyChangelog = [ + '# 0.24.0 (2026-08-01)', + '', + '
', + 'Internal changes', + '', + '- **deps** — parser bump.', + '', + '
', +].join('\n'); + describe('extractSection', () => { - const changelog = [ - '# Changelog', - '', - 'Intro paragraph with a pointer.', - '', - '', - '', - '# [0.24.0](https://github.com/4gray/iptvnator/compare/v0.23.0...v0.24.0) (2026-08-01)', - '', - '### Features', - '', - '- **playback** — Up Next rail.', - '', - '
', - 'Internal changes', - '', - '- **deps** — parser bump.', - '', - '
', - '', - '# [0.12.0](https://github.com/4gray/iptvnator/compare/v0.11.1...v0.12.0) (2023-03-11)', - '', - '### Bug Fixes', - '', - '- old entry', - ].join('\n'); it('returns the section body without its own heading', () => { const section = extractSection(changelog, '0.24.0'); @@ -491,6 +509,16 @@ describe('extractSection', () => { assert.match(extractSection(plain, '0.24.0'), /^### Fixes/); }); + it('normalizes CRLF line endings in the extracted section', () => { + const crlf = + '# 0.24.0 (2026-08-01)\r\n\r\n### Fixes\r\n\r\n- entry\r\n'; + + assert.equal( + extractSection(crlf, '0.24.0'), + '### Fixes\n\n- entry' + ); + }); + it('does not match a different patch of the same minor', () => { assert.equal(extractSection(changelog, '0.24.1'), null); }); @@ -509,6 +537,198 @@ describe('extractSection', () => { }); }); +describe('extractPublicSection', () => { + it('strips the generated internal block from a mixed release', () => { + assert.equal( + extractPublicSection(changelog, '0.24.0'), + '### Features\n\n- **playback** — Up Next rail.' + ); + }); + + it('keeps a public-only release unchanged', () => { + const publicOnly = '# 0.24.0 (2026-08-01)\n\n### Fixes\n\n- Fixed it.'; + + assert.equal( + extractPublicSection(publicOnly, '0.24.0'), + '### Fixes\n\n- Fixed it.' + ); + }); + + it('returns an empty string for an internal-only release', () => { + assert.equal(extractPublicSection(internalOnlyChangelog, '0.24.0'), ''); + }); + + it('preserves unrelated details blocks', () => { + const release = [ + '# 0.24.0 (2026-08-01)', + '', + '
', + 'Migration guide', + '', + 'Run the migration.', + '', + '
', + '', + '
', + 'Internal changes', + '', + '- **deps** — parser bump.', + '', + '
', + ].join('\n'); + + assert.equal( + extractPublicSection(release, '0.24.0'), + '
\nMigration guide\n\nRun the migration.\n\n
' + ); + }); + + it('preserves near-match internal summaries', () => { + const release = [ + '# 0.24.0 (2026-08-01)', + '', + '
', + 'Internal changes ', + '', + '- trailing space.', + '', + '
', + '', + '
', + 'internal changes', + '', + '- different case.', + '', + '
', + ].join('\n'); + + assert.equal( + extractPublicSection(release, '0.24.0'), + release.split('\n').slice(2).join('\n') + ); + }); +}); + +describe('extract-changelog-section CLI contracts', () => { + it('parses the public flag', () => { + assert.deepEqual(parseExtractArguments(['--public', '0.24.0']), { + version: '0.24.0', + publicOnly: true, + }); + }); + + it('reports usage for invalid arguments', () => { + for (const args of [ + [], + ['0.24'], + ['--unknown', '0.24.0'], + ['--public', '--public', '0.24.0'], + ['0.24.0', 'extra'], + ]) { + assert.equal(parseExtractArguments(args), null); + const result = runExtractorCli(changelog, args); + + assert.equal(result.exitCode, 2); + assert.equal(result.stdout, ''); + assert.match(result.stderr, /Usage:/); + } + }); + + it('allows an empty public body for an internal-only release', () => { + assert.deepEqual( + runExtractorCli(internalOnlyChangelog, ['--public', '0.24.0']), + { exitCode: 0, stdout: '', stderr: '' } + ); + }); + + it('validates process arguments before reading the changelog', () => { + const result = runExtractorProcess(['--public'], () => { + throw new Error('changelog should not be read'); + }); + + assert.equal(result.exitCode, 2); + assert.equal(result.stdout, ''); + assert.match(result.stderr, /Usage:/); + }); + + it('keeps raw process output and errors compatible', () => { + assert.deepEqual( + runExtractorProcess(['0.24.0'], () => '# 0.24.0 (2026-08-01)'), + { + exitCode: 1, + stdout: '', + stderr: 'CHANGELOG.md section for 0.24.0 is empty.\n', + } + ); + assert.deepEqual( + runExtractorProcess(['9.9.9'], () => changelog), + { + exitCode: 1, + stdout: '', + stderr: [ + 'CHANGELOG.md has no section for 9.9.9.', + 'The release flow writes it before tagging:', + ' pnpm run release:notes:changelog', + ' node tools/release/build-release-notes.mjs --consume', + 'Commit the changelog, then re-tag.', + '', + ].join('\n'), + } + ); + assert.deepEqual( + runExtractorProcess(['0.24.0'], () => changelog), + { + exitCode: 0, + stdout: + '### Features\n\n- **playback** — Up Next rail.\n\n
\nInternal changes\n\n- **deps** — parser bump.\n\n
\n', + stderr: '', + } + ); + }); + + it('normalizes CRLF output and appends exactly one trailing LF', () => { + const crlf = + '# 0.24.0 (2026-08-01)\r\n\r\n### Fixes\r\n\r\n- entry\r\n'; + const expected = { + exitCode: 0, + stdout: '### Fixes\n\n- entry\n', + stderr: '', + }; + + assert.deepEqual(runExtractorCli(crlf, ['0.24.0']), expected); + assert.deepEqual( + runExtractorProcess(['0.24.0'], () => crlf), + expected + ); + assert.match(expected.stdout, /[^\n]\n$/); + assert.doesNotMatch(expected.stdout, /\n\n$/); + }); + + it('reports the detailed missing-version diagnostic in public mode', () => { + const expected = { + exitCode: 1, + stdout: '', + stderr: [ + 'CHANGELOG.md has no section for 9.9.9.', + 'The release flow writes it before tagging:', + ' pnpm run release:notes:changelog', + ' node tools/release/build-release-notes.mjs --consume', + 'Commit the changelog, then re-tag.', + '', + ].join('\n'), + }; + + assert.deepEqual( + runExtractorCli(changelog, ['--public', '9.9.9']), + expected + ); + assert.deepEqual( + runExtractorProcess(['--public', '9.9.9'], () => changelog), + expected + ); + }); +}); + describe('helpers', () => { it('formats dates and release slugs', () => { assert.equal(formatLongDate('2026-08-01'), 'August 1, 2026');