Files
iptvnator/tools/release/extract-changelog-section.mjs
4gray 2ac0de752f fix(skills): align repository guidance with implementation (#1315)
* docs(skills): design implementation synchronization

* docs(skills): plan implementation synchronization

* fix(release): filter internal notes from public body

* docs(release): synchronize release workflow guidance

* fix(stalker): normalize catalog series flags

* fix(stalker): preserve progress with scoped episode IDs

* fix(playback): expose strict position persistence

* docs(stalker): record series position compatibility

* test(skills): validate repository skill contracts

* fix(database): keep SQL trace values private

* docs(skills): refresh Nx and SQLite ownership

* docs(skills): align provider and UI guidance

* docs(skills): tighten validated guidance

* docs(release): require exact release pushes

* style(electron): remove trailing blank line

* fix(ci): classify repository skills coverage
2026-07-31 08:00:59 +02:00

166 lines
4.9 KiB
JavaScript

#!/usr/bin/env node
/**
* Prints the CHANGELOG.md section for one version to stdout.
*
* node tools/release/extract-changelog-section.mjs 0.24.0
*
* Used by the tag-build release job to put the authored notes into the
* GitHub release body. `.changes/*.md` files are consumed before the tag
* exists, so at tag time the changelog section IS the authored source of
* truth — this reads what the generator already wrote.
*
* Exits non-zero when the section is missing so a forgotten
* `release:notes:changelog` fails the release instead of silently shipping
* PR-title-only notes.
*/
import { readFileSync } from 'node:fs';
import path from 'node:path';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
const workspaceRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'../..'
);
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`
* @returns {string | null} section body without its own H1 heading
*/
export function extractSection(changelog, version) {
const lines = changelog.replace(/\r\n/g, '\n').split('\n');
// The CLI validates its argument, but this function is exported on its
// own — escape every regex metacharacter rather than only dots so no
// caller can inject pattern syntax.
const escapedVersion = version.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// Matches both heading shapes used in this file:
// # [0.24.0](compare-url) (2026-08-01)
// # 0.24.0 (2026-08-01)
const headingPattern = new RegExp(
`^#\\s+\\[?${escapedVersion}\\]?\\s*[( ]`
);
const start = lines.findIndex((line) => headingPattern.test(line));
if (start === -1) {
return null;
}
let end = lines.length;
for (let index = start + 1; index < lines.length; index += 1) {
if (/^#\s/.test(lines[index])) {
end = index;
break;
}
}
return lines.slice(start + 1, end).join('\n').trim();
}
/**
* @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);
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;
}
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) {
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')}\n`,
};
}
if (section === '' && !options.publicOnly) {
return {
exitCode: 1,
stdout: '',
stderr: `CHANGELOG.md section for ${options.version} is empty.\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.
if (process.argv[1] === fileURLToPath(import.meta.url)) {
main();
}