fix(release): filter internal notes from public body

This commit is contained in:
4gray committed 2026-07-30 07:59:06 +02:00
1 parent 2290a0b4a2
commit 6a65ee4efb
2 files changed
+332 -46

No files matched your search

+85 -19
View File
@@ -24,6 +24,11 @@ const workspaceRoot = path.resolve(
'../..'
);
const INTERNAL_DETAILS_BLOCK =
/(?:^|\n\n)<details>\n<summary>Internal changes<\/summary>\n\n[\s\S]*?\n\n<\/details>(?=\n\n|$)/g;
const CLI_USAGE =
'Usage: extract-changelog-section.mjs [--public] <version>';
/**
* @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 <version> (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.
+247 -27
View File
@@ -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.',
'',
'<!-- next-release -->',
'',
'# [0.24.0](https://github.com/4gray/iptvnator/compare/v0.23.0...v0.24.0) (2026-08-01)',
'',
'### Features',
'',
'- **playback** — Up Next rail.',
'',
'<details>',
'<summary>Internal changes</summary>',
'',
'- **deps** — parser bump.',
'',
'</details>',
'',
'# [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)',
'',
'<details>',
'<summary>Internal changes</summary>',
'',
'- **deps** — parser bump.',
'',
'</details>',
].join('\n');
describe('extractSection', () => {
const changelog = [
'# Changelog',
'',
'Intro paragraph with a pointer.',
'',
'<!-- next-release -->',
'',
'# [0.24.0](https://github.com/4gray/iptvnator/compare/v0.23.0...v0.24.0) (2026-08-01)',
'',
'### Features',
'',
'- **playback** — Up Next rail.',
'',
'<details>',
'<summary>Internal changes</summary>',
'',
'- **deps** — parser bump.',
'',
'</details>',
'',
'# [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)',
'',
'<details>',
'<summary>Migration guide</summary>',
'',
'Run the migration.',
'',
'</details>',
'',
'<details>',
'<summary>Internal changes</summary>',
'',
'- **deps** — parser bump.',
'',
'</details>',
].join('\n');
assert.equal(
extractPublicSection(release, '0.24.0'),
'<details>\n<summary>Migration guide</summary>\n\nRun the migration.\n\n</details>'
);
});
it('preserves near-match internal summaries', () => {
const release = [
'# 0.24.0 (2026-08-01)',
'',
'<details>',
'<summary>Internal changes </summary>',
'',
'- trailing space.',
'',
'</details>',
'',
'<details>',
'<summary>internal changes</summary>',
'',
'- different case.',
'',
'</details>',
].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<details>\n<summary>Internal changes</summary>\n\n- **deps** — parser bump.\n\n</details>\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');