Polish changelog fragments with claude -p at release time

- Replace `renderGroupedChanges` with `polishFragmentsWithClaude` that pipes fragments through `claude -p --model sonnet` to merge related items, drop housekeeping noise, and produce user-facing release notes
- Internal fragments kept in CHANGELOG.md under a `<details>` collapse; dropped from GitHub release notes entirely
- CI no longer auto-runs `changelog:build` on tag-based releases — fails fast with a clear error if `changes/*.md` fragments are still pending; build locally and commit before tagging
- Add `runClaude` dep-injection seam to test surface; add failure-mode coverage (missing binary, empty output, missing headers, missing `<details>` wrapper)
- Delete implemented design doc; update `changes/README.md` and `docs/RELEASING.md` with claude CLI prerequisite and new workflow
This commit is contained in:
2026-05-02 19:51:43 -07:00
parent baabdb6d30
commit 27f5b2bb58
9 changed files with 586 additions and 318 deletions
+362 -17
View File
@@ -13,6 +13,75 @@ function createWorkspace(name: string): string {
return fs.mkdtempSync(path.join(baseDir, `${name}-`));
}
type RunClaudeArgs = { input: string; args: string[] };
function recordingRunClaude(responder: (input: string) => string): {
runClaude: (input: string, args: string[]) => string;
calls: RunClaudeArgs[];
} {
const calls: RunClaudeArgs[] = [];
return {
calls,
runClaude(input, args) {
calls.push({ input, args });
return responder(input);
},
};
}
function modeFromPrompt(input: string): 'changelog' | 'release-notes' | null {
// Anchor to start-of-line so we don't accidentally match the instructions text,
// which mentions "MODE: changelog" and "MODE: release-notes" mid-sentence.
const match = /^MODE: (changelog|release-notes)$/m.exec(input);
return (match?.[1] as 'changelog' | 'release-notes') ?? null;
}
function fragmentTypesInPrompt(input: string): string[] {
return input
.split(/\r?\n/)
.filter((line) => line.startsWith('type: '))
.map((line) => line.slice('type: '.length).trim());
}
function defaultPolishedBody(input: string): string {
const mode = modeFromPrompt(input);
const types = fragmentTypesInPrompt(input);
const sections: string[] = [];
const has = (t: string) => types.includes(t);
const hasBreaking = /^breaking: true$/m.test(input);
if (hasBreaking) {
sections.push('### Breaking Changes\n- Polished: breaking change.');
}
if (has('added')) {
sections.push('### Added\n- Polished: added entry.');
}
if (has('changed')) {
sections.push('### Changed\n- Polished: changed entry.');
}
if (has('fixed')) {
sections.push('### Fixed\n- Polished: fixed entry.');
}
if (has('docs')) {
sections.push('### Docs\n- Polished: docs entry.');
}
if (mode === 'changelog' && has('internal')) {
sections.push(
'<details>\n<summary>Internal changes</summary>\n\n### Internal\n- Polished: internal entry.\n\n</details>',
);
}
if (sections.length === 0) {
sections.push('### Changed\n- Polished: empty fallback.');
}
return sections.join('\n\n');
}
function defaultStubClaude() {
return recordingRunClaude(defaultPolishedBody);
}
test('resolveChangelogOutputPaths stays repo-local and never writes docs paths', async () => {
const { resolveChangelogOutputPaths } = await loadModule();
const workspace = createWorkspace('with-docs-repo');
@@ -62,10 +131,12 @@ test('writeChangelogArtifacts ignores README, groups fragments by type, writes r
);
try {
const stub = defaultStubClaude();
const result = writeChangelogArtifacts({
cwd: projectRoot,
version: '0.4.1',
date: '2026-03-07',
deps: { runClaude: stub.runClaude },
});
assert.deepEqual(result.outputPaths, [path.join(projectRoot, 'CHANGELOG.md')]);
@@ -77,18 +148,28 @@ test('writeChangelogArtifacts ignores README, groups fragments by type, writes r
assert.equal(fs.existsSync(path.join(projectRoot, 'changes', '002.md')), false);
assert.equal(fs.existsSync(path.join(projectRoot, 'changes', 'README.md')), true);
const changelog = fs.readFileSync(path.join(projectRoot, 'CHANGELOG.md'), 'utf8');
assert.match(
changelog,
/^# Changelog\n\n## v0\.4\.1 \(2026-03-07\)\n\n### Added\n- Overlay: Added release fragments\.\n\n### Fixed\n- Release: Fixed release notes generation\.\n\n## v0\.4\.0 \(2026-03-01\)\n- Existing fix\n$/m,
assert.equal(
stub.calls.length,
2,
'expected one Claude call per output (changelog + release notes)',
);
assert.deepEqual(
stub.calls.map((call) => modeFromPrompt(call.input)),
['changelog', 'release-notes'],
);
const changelog = fs.readFileSync(path.join(projectRoot, 'CHANGELOG.md'), 'utf8');
assert.match(changelog, /^# Changelog\n\n## v0\.4\.1 \(2026-03-07\)\n\n/);
assert.match(changelog, /### Added\n- Polished: added entry\./);
assert.match(changelog, /### Fixed\n- Polished: fixed entry\./);
assert.match(changelog, /## v0\.4\.0 \(2026-03-01\)\n- Existing fix\n$/);
const releaseNotes = fs.readFileSync(
path.join(projectRoot, 'release', 'release-notes.md'),
'utf8',
);
assert.match(releaseNotes, /## Highlights\n### Added\n- Overlay: Added release fragments\./);
assert.match(releaseNotes, /### Fixed\n- Release: Fixed release notes generation\./);
assert.match(releaseNotes, /## Highlights\n### Added\n- Polished: added entry\./);
assert.match(releaseNotes, /### Fixed\n- Polished: fixed entry\./);
assert.match(releaseNotes, /## Installation\n\nSee the README and docs\/installation guide/);
} finally {
fs.rmSync(workspace, { recursive: true, force: true });
@@ -159,10 +240,12 @@ test('writeStableReleaseArtifacts reuses the requested version and date for chan
);
try {
const stub = defaultStubClaude();
const result = writeStableReleaseArtifacts({
cwd: projectRoot,
version: '0.4.1',
date: '2026-03-07',
deps: { runClaude: stub.runClaude },
});
assert.deepEqual(result.outputPaths, [path.join(projectRoot, 'CHANGELOG.md')]);
@@ -260,10 +343,12 @@ test('writeChangelogArtifacts renders breaking changes section above type sectio
);
try {
const stub = defaultStubClaude();
writeChangelogArtifacts({
cwd: projectRoot,
version: '0.5.0',
date: '2026-04-06',
deps: { runClaude: stub.runClaude },
});
const changelog = fs.readFileSync(path.join(projectRoot, 'CHANGELOG.md'), 'utf8');
@@ -276,8 +361,14 @@ test('writeChangelogArtifacts renders breaking changes section above type sectio
assert.notEqual(fixedIndex, -1, 'Fixed section should exist');
assert.ok(breakingIndex < changedIndex, 'Breaking Changes should appear before Changed');
assert.ok(changedIndex < fixedIndex, 'Changed should appear before Fixed');
assert.match(changelog, /### Breaking Changes\n- Config: Renamed `foo` to `bar`\./);
assert.match(changelog, /### Changed\n- Config: Renamed `foo` to `bar`\./);
const changelogCall = stub.calls.find((call) => modeFromPrompt(call.input) === 'changelog');
assert.ok(changelogCall, 'expected at least one changelog-mode Claude invocation');
assert.match(
changelogCall.input,
/breaking: true/,
'breaking metadata should reach the prompt verbatim',
);
} finally {
fs.rmSync(workspace, { recursive: true, force: true });
}
@@ -384,9 +475,11 @@ test('writePrereleaseNotesForVersion writes cumulative beta notes without mutati
);
try {
const stub = defaultStubClaude();
const outputPath = writePrereleaseNotesForVersion({
cwd: projectRoot,
version: '0.11.3-beta.1',
deps: { runClaude: stub.runClaude },
});
assert.equal(outputPath, path.join(projectRoot, 'release', 'prerelease-notes.md'));
@@ -403,13 +496,13 @@ test('writePrereleaseNotesForVersion writes cumulative beta notes without mutati
assert.equal(fs.existsSync(path.join(projectRoot, 'changes', '001.md')), true);
assert.equal(fs.existsSync(path.join(projectRoot, 'changes', '002.md')), true);
assert.equal(stub.calls.length, 1, 'prerelease should issue exactly one Claude call');
assert.equal(modeFromPrompt(stub.calls[0]!.input), 'release-notes');
const prereleaseNotes = fs.readFileSync(outputPath, 'utf8');
assert.match(prereleaseNotes, /^> This is a prerelease build for testing\./m);
assert.match(
prereleaseNotes,
/## Highlights\n### Added\n- Overlay: Added prerelease coverage\./,
);
assert.match(prereleaseNotes, /### Fixed\n- Launcher: Fixed prerelease packaging checks\./);
assert.match(prereleaseNotes, /## Highlights\n### Added\n- Polished: added entry\./);
assert.match(prereleaseNotes, /### Fixed\n- Polished: fixed entry\./);
assert.match(prereleaseNotes, /## Installation\n\nSee the README and docs\/installation guide/);
} finally {
fs.rmSync(workspace, { recursive: true, force: true });
@@ -434,16 +527,15 @@ test('writePrereleaseNotesForVersion supports rc prereleases', async () => {
);
try {
const stub = defaultStubClaude();
const outputPath = writePrereleaseNotesForVersion({
cwd: projectRoot,
version: '0.11.3-rc.1',
deps: { runClaude: stub.runClaude },
});
const prereleaseNotes = fs.readFileSync(outputPath, 'utf8');
assert.match(
prereleaseNotes,
/## Highlights\n### Changed\n- Release: Prepared release candidate notes\./,
);
assert.match(prereleaseNotes, /## Highlights\n### Changed\n- Polished: changed entry\./);
} finally {
fs.rmSync(workspace, { recursive: true, force: true });
}
@@ -536,3 +628,256 @@ test('writePrereleaseNotesForVersion rejects empty prerelease note generation wh
fs.rmSync(workspace, { recursive: true, force: true });
}
});
test('writeChangelogArtifacts surfaces a clear error when claude is missing from PATH', async () => {
const { writeChangelogArtifacts } = await loadModule();
const workspace = createWorkspace('claude-missing');
const projectRoot = path.join(workspace, 'SubMiner');
fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
fs.writeFileSync(path.join(projectRoot, 'CHANGELOG.md'), '# Changelog\n', 'utf8');
fs.writeFileSync(
path.join(projectRoot, 'changes', '001.md'),
['type: added', 'area: overlay', '', '- A change.'].join('\n'),
'utf8',
);
// The production defaultRunClaude wrapper translates ENOENT into this friendly
// message; we simulate that contract here so the test exercises the propagation
// path through polishFragmentsWithClaude rather than re-implementing the
// execFileSync mock.
const enoent = (): string => {
throw new Error(
"claude CLI not found on PATH. Install Claude Code (https://claude.com/claude-code) and ensure 'claude' is on your PATH before running changelog:build.",
);
};
try {
assert.throws(
() =>
writeChangelogArtifacts({
cwd: projectRoot,
version: '0.5.0',
date: '2026-04-06',
deps: { runClaude: enoent },
}),
/claude CLI not found on PATH/,
);
} finally {
fs.rmSync(workspace, { recursive: true, force: true });
}
});
test('writeChangelogArtifacts rejects empty claude output', async () => {
const { writeChangelogArtifacts } = await loadModule();
const workspace = createWorkspace('claude-empty');
const projectRoot = path.join(workspace, 'SubMiner');
fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
fs.writeFileSync(path.join(projectRoot, 'CHANGELOG.md'), '# Changelog\n', 'utf8');
fs.writeFileSync(
path.join(projectRoot, 'changes', '001.md'),
['type: added', 'area: overlay', '', '- A change.'].join('\n'),
'utf8',
);
try {
assert.throws(
() =>
writeChangelogArtifacts({
cwd: projectRoot,
version: '0.5.0',
date: '2026-04-06',
deps: { runClaude: () => ' \n ' },
}),
/claude returned empty output/,
);
} finally {
fs.rmSync(workspace, { recursive: true, force: true });
}
});
test('writeChangelogArtifacts rejects claude output missing required section headers', async () => {
const { writeChangelogArtifacts } = await loadModule();
const workspace = createWorkspace('claude-no-headers');
const projectRoot = path.join(workspace, 'SubMiner');
fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
fs.writeFileSync(path.join(projectRoot, 'CHANGELOG.md'), '# Changelog\n', 'utf8');
fs.writeFileSync(
path.join(projectRoot, 'changes', '001.md'),
['type: added', 'area: overlay', '', '- A change.'].join('\n'),
'utf8',
);
try {
assert.throws(
() =>
writeChangelogArtifacts({
cwd: projectRoot,
version: '0.5.0',
date: '2026-04-06',
deps: { runClaude: () => 'Sure, here is your changelog: it is great.' },
}),
/missing the expected section heading/,
);
} finally {
fs.rmSync(workspace, { recursive: true, force: true });
}
});
test('writeChangelogArtifacts rejects changelog-mode output that omits the Internal <details> wrapper when internal fragments are present', async () => {
const { writeChangelogArtifacts } = await loadModule();
const workspace = createWorkspace('claude-no-details');
const projectRoot = path.join(workspace, 'SubMiner');
fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
fs.writeFileSync(path.join(projectRoot, 'CHANGELOG.md'), '# Changelog\n', 'utf8');
fs.writeFileSync(
path.join(projectRoot, 'changes', '001.md'),
['type: added', 'area: overlay', '', '- A user-facing change.'].join('\n'),
'utf8',
);
fs.writeFileSync(
path.join(projectRoot, 'changes', '002.md'),
['type: internal', 'area: release', '', '- An internal note.'].join('\n'),
'utf8',
);
const noDetailsResponder = (input: string): string => {
if (modeFromPrompt(input) === 'changelog') {
return '### Added\n- Polished: added.\n\n### Internal\n- Polished: internal (no details wrapper).';
}
return defaultPolishedBody(input);
};
try {
assert.throws(
() =>
writeChangelogArtifacts({
cwd: projectRoot,
version: '0.5.0',
date: '2026-04-06',
deps: { runClaude: noDetailsResponder },
}),
/<details><summary>Internal changes<\/summary> wrapper/,
);
} finally {
fs.rmSync(workspace, { recursive: true, force: true });
}
});
test('writeChangelogArtifacts filters internal fragments from the release-notes Claude prompt', async () => {
const { writeChangelogArtifacts } = await loadModule();
const workspace = createWorkspace('release-notes-internal-filter');
const projectRoot = path.join(workspace, 'SubMiner');
fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
fs.writeFileSync(path.join(projectRoot, 'CHANGELOG.md'), '# Changelog\n', 'utf8');
fs.writeFileSync(
path.join(projectRoot, 'changes', '001.md'),
['type: added', 'area: overlay', '', '- A user-facing change.'].join('\n'),
'utf8',
);
fs.writeFileSync(
path.join(projectRoot, 'changes', '002.md'),
['type: internal', 'area: release', '', '- An internal CI tweak.'].join('\n'),
'utf8',
);
try {
const stub = defaultStubClaude();
writeChangelogArtifacts({
cwd: projectRoot,
version: '0.5.0',
date: '2026-04-06',
deps: { runClaude: stub.runClaude },
});
const changelogCall = stub.calls.find((call) => modeFromPrompt(call.input) === 'changelog');
const releaseNotesCall = stub.calls.find(
(call) => modeFromPrompt(call.input) === 'release-notes',
);
assert.ok(changelogCall, 'expected a changelog-mode invocation');
assert.ok(releaseNotesCall, 'expected a release-notes-mode invocation');
assert.deepEqual(
fragmentTypesInPrompt(changelogCall.input).sort(),
['added', 'internal'],
'changelog mode keeps internal fragments',
);
assert.deepEqual(
fragmentTypesInPrompt(releaseNotesCall.input),
['added'],
'release-notes mode drops internal fragments',
);
const releaseNotes = fs.readFileSync(
path.join(projectRoot, 'release', 'release-notes.md'),
'utf8',
);
assert.doesNotMatch(releaseNotes, /<details>/);
assert.doesNotMatch(releaseNotes, /### Internal/);
const changelog = fs.readFileSync(path.join(projectRoot, 'CHANGELOG.md'), 'utf8');
assert.match(changelog, /<details>[\s\S]*<summary>Internal changes<\/summary>/);
} finally {
fs.rmSync(workspace, { recursive: true, force: true });
}
});
test('writeChangelogArtifacts strips <details> blocks from release notes when reusing an existing CHANGELOG section', async () => {
const { writeChangelogArtifacts } = await loadModule();
const workspace = createWorkspace('reuse-existing-section');
const projectRoot = path.join(workspace, 'SubMiner');
const existingChangelog = [
'# Changelog',
'',
'## v0.4.1 (2026-03-07)',
'### Added',
'- Polished: previously committed.',
'',
'<details>',
'<summary>Internal changes</summary>',
'',
'### Internal',
'- Polished: internal note.',
'',
'</details>',
'',
].join('\n');
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 fragment.'].join('\n'),
'utf8',
);
try {
const stub = defaultStubClaude();
writeChangelogArtifacts({
cwd: projectRoot,
version: '0.4.1',
date: '2026-03-08',
deps: { runClaude: stub.runClaude },
});
assert.equal(
stub.calls.length,
0,
'no Claude calls should fire when the section already exists',
);
const releaseNotes = fs.readFileSync(
path.join(projectRoot, 'release', 'release-notes.md'),
'utf8',
);
assert.match(releaseNotes, /## Highlights\n### Added\n- Polished: previously committed\./);
assert.doesNotMatch(releaseNotes, /<details>/);
assert.doesNotMatch(releaseNotes, /### Internal/);
} finally {
fs.rmSync(workspace, { recursive: true, force: true });
}
});
+185 -52
View File
@@ -2,6 +2,8 @@ import * as fs from 'node:fs';
import * as path from 'node:path';
import { execFileSync } from 'node:child_process';
type RunClaude = (input: string, args: string[]) => string;
type ChangelogFsDeps = {
existsSync?: (candidate: string) => boolean;
mkdirSync?: (candidate: string, options: { recursive: true }) => void;
@@ -10,8 +12,11 @@ type ChangelogFsDeps = {
rmSync?: (candidate: string) => void;
writeFileSync?: (candidate: string, content: string, encoding: BufferEncoding) => void;
log?: (message: string) => void;
runClaude?: RunClaude;
};
type PolishMode = 'changelog' | 'release-notes';
type ChangelogOptions = {
cwd?: string;
date?: string;
@@ -41,13 +46,6 @@ const RELEASE_NOTES_PATH = path.join('release', 'release-notes.md');
const PRERELEASE_NOTES_PATH = path.join('release', 'prerelease-notes.md');
const CHANGELOG_HEADER = '# Changelog';
const CHANGE_TYPES: FragmentType[] = ['added', 'changed', 'fixed', 'docs', 'internal'];
const CHANGE_TYPE_HEADINGS: Record<FragmentType, string> = {
added: 'Added',
changed: 'Changed',
fixed: 'Fixed',
docs: 'Docs',
internal: 'Internal',
};
const SKIP_CHANGELOG_LABEL = 'skip-changelog';
function normalizeVersion(version: string): string {
@@ -217,54 +215,179 @@ function readChangeFragments(cwd: string, deps?: ChangelogFsDeps): ChangeFragmen
});
}
function formatAreaLabel(area: string): string {
return area
.split(/[-_\s]+/)
.filter(Boolean)
.map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
.join(' ');
}
// We deliberately don't pass --bare here. --bare skips OAuth/keychain reads and
// requires ANTHROPIC_API_KEY, which most Claude Code users don't have set up.
// The polish prompt is self-contained and doesn't need tools, so loading the
// user's hooks/MCP/CLAUDE.md is harmless overhead.
const CLAUDE_CLI_ARGS = [
'-p',
'--model',
'sonnet',
'--permission-mode',
'bypassPermissions',
'--output-format',
'text',
];
function renderFragmentBullet(fragment: ChangeFragment, bullet: string): string {
return `- ${formatAreaLabel(fragment.area)}: ${bullet.replace(/^- /, '')}`;
}
const SECTION_HEADER_PATTERN = /^### (Breaking Changes|Added|Changed|Fixed|Docs|Internal)$/m;
function renderGroupedChanges(fragments: ChangeFragment[]): string {
const sections: string[] = [];
const POLISH_PROMPT_INSTRUCTIONS = `You are formatting a software release changelog for end users of SubMiner, an Electron app for Japanese sentence mining.
const breakingFragments = fragments.filter((fragment) => fragment.breaking);
if (breakingFragments.length > 0) {
const bullets = breakingFragments
.flatMap((fragment) =>
fragment.bullets.map((bullet) => renderFragmentBullet(fragment, bullet)),
)
.join('\n');
sections.push(`### Breaking Changes\n${bullets}`);
}
You will receive a list of FRAGMENT entries below. Each fragment has metadata (type, area, breaking) and one or more bullet points written by the engineer who shipped that change. Your job is to merge, dedupe, and rewrite these fragments into a polished, user-facing release body.
for (const type of CHANGE_TYPES) {
const typeFragments = fragments.filter((fragment) => fragment.type === type);
if (typeFragments.length === 0) {
continue;
## Output Rules
1. Output Markdown ONLY. No preamble, no commentary, no closing remarks. Start directly with the first section heading.
2. Use these section headings, in this order, omitting any that have no bullets:
### Breaking Changes
### Added
### Changed
### Fixed
### Docs
3. In MODE: changelog only, append a final section after Docs:
<details>
<summary>Internal changes</summary>
### Internal
- …
</details>
Do not include the Internal section at all in MODE: release-notes; internal fragments will not be present in the input for that mode.
4. Each bullet should:
- Lead with a short feature/area name in title case followed by a colon, e.g. "Playlist browser:", "Windows overlay:", "Stats dashboard:". Pick the name from the fragment's bullet content, not the raw 'area:' slug.
- Be written in user-facing language. Drop implementation jargon, internal class names, file paths, and PR numbers.
- Be merged with related bullets when possible. If five fragments all touch Windows overlay z-order/focus/restore, write one or two bullets that summarize the overall improvement instead of five.
- Drop bullets that only describe PR housekeeping, CodeRabbit follow-ups, or test-only changes that don't affect users.
- Preserve the substance of every breaking change in ### Breaking Changes. Do not soften or omit them.
5. Do not invent features. Every bullet must be grounded in the input fragments.
6. Do not include the version heading (## v...) — that wrapper is added by the caller.
The input begins below.
`;
function defaultRunClaude(input: string, args: string[]): string {
try {
return execFileSync('claude', args, {
input,
encoding: 'utf8',
maxBuffer: 10 * 1024 * 1024,
stdio: ['pipe', 'pipe', 'inherit'],
});
} catch (error) {
const err = error as NodeJS.ErrnoException;
if (err.code === 'ENOENT') {
throw new Error(
"claude CLI not found on PATH. Install Claude Code (https://claude.com/claude-code) and ensure 'claude' is on your PATH before running changelog:build.",
);
}
const bullets = typeFragments
.flatMap((fragment) =>
fragment.bullets.map((bullet) => renderFragmentBullet(fragment, bullet)),
)
.join('\n');
sections.push(`### ${CHANGE_TYPE_HEADINGS[type]}\n${bullets}`);
throw new Error(`claude CLI invocation failed: ${err.message}`);
}
return sections.join('\n\n');
}
function buildReleaseSection(version: string, date: string, fragments: ChangeFragment[]): string {
function serializeFragmentsForPrompt(
fragments: ChangeFragment[],
mode: PolishMode,
version: string,
date?: string,
): string {
const header: string[] = [`MODE: ${mode}`, `VERSION: ${version}`];
if (date) {
header.push(`DATE: ${date}`);
}
const fragmentBlocks = fragments.map((fragment) => {
const relativePath = fragment.path.replace(/^.*?(changes\/.*)$/u, '$1');
return [
`FRAGMENT ${relativePath}`,
`type: ${fragment.type}`,
`area: ${fragment.area}`,
`breaking: ${fragment.breaking}`,
...fragment.bullets,
].join('\n');
});
return [...header, '', ...fragmentBlocks].join('\n\n');
}
function validatePolishedOutput(
output: string,
mode: PolishMode,
hasInternalFragments: boolean,
): string {
const trimmed = output.trim();
if (!trimmed) {
throw new Error('claude returned empty output for changelog polish.');
}
if (!SECTION_HEADER_PATTERN.test(trimmed)) {
throw new Error(
`claude output is missing the expected section heading (### Added/Changed/Fixed/Docs/Breaking Changes). Got:\n${trimmed.slice(0, 400)}`,
);
}
if (mode === 'changelog' && hasInternalFragments) {
if (!/<details>[\s\S]*<summary>[^<]*Internal[^<]*<\/summary>/m.test(trimmed)) {
throw new Error(
'claude output is missing the expected <details><summary>Internal changes</summary> wrapper for the Internal section.',
);
}
}
return trimmed;
}
function polishFragmentsWithClaude(
fragments: ChangeFragment[],
options: {
mode: PolishMode;
version: string;
date?: string;
deps?: ChangelogFsDeps;
},
): string {
const { mode, version, date } = options;
const runClaude = options.deps?.runClaude ?? defaultRunClaude;
const filtered =
mode === 'release-notes'
? fragments.filter((fragment) => fragment.type !== 'internal')
: fragments;
const hasInternalFragments =
mode === 'changelog' && fragments.some((fragment) => fragment.type === 'internal');
if (filtered.length === 0) {
throw new Error(
mode === 'release-notes'
? 'No user-facing changelog fragments found in changes/ (only internal fragments are present, which are dropped from release notes).'
: 'No changelog fragments found in changes/.',
);
}
const prompt =
POLISH_PROMPT_INSTRUCTIONS + serializeFragmentsForPrompt(filtered, mode, version, date);
const output = runClaude(prompt, CLAUDE_CLI_ARGS);
return validatePolishedOutput(output, mode, hasInternalFragments);
}
function stripDetailsBlocks(body: string): string {
return body.replace(/<details>[\s\S]*?<\/details>\s*/gm, '').trim();
}
function buildReleaseSection(
version: string,
date: string,
fragments: ChangeFragment[],
deps?: ChangelogFsDeps,
): string {
if (fragments.length === 0) {
throw new Error('No changelog fragments found in changes/.');
}
return [`## v${version} (${date})`, '', renderGroupedChanges(fragments), ''].join('\n');
const polished = polishFragmentsWithClaude(fragments, {
mode: 'changelog',
version,
date,
deps,
});
return [`## v${version} (${date})`, '', polished, ''].join('\n');
}
function ensureChangelogHeader(existingChangelog: string): string {
@@ -392,7 +515,11 @@ export function writeChangelogArtifacts(options?: ChangelogOptions): {
log(`Removed ${fragment.path}`);
}
const releaseNotesPath = writeReleaseNotesFile(cwd, existingReleaseSection, options?.deps);
const releaseNotesPath = writeReleaseNotesFile(
cwd,
stripDetailsBlocks(existingReleaseSection),
options?.deps,
);
log(`Generated ${releaseNotesPath}`);
return {
@@ -402,7 +529,7 @@ export function writeChangelogArtifacts(options?: ChangelogOptions): {
};
}
const releaseSection = buildReleaseSection(version, date, fragments);
const releaseSection = buildReleaseSection(version, date, fragments, options?.deps);
const nextChangelog = prependReleaseSection(existingChangelog, releaseSection, version);
for (const outputPath of outputPaths) {
@@ -411,11 +538,13 @@ export function writeChangelogArtifacts(options?: ChangelogOptions): {
log(`Updated ${outputPath}`);
}
const releaseNotesPath = writeReleaseNotesFile(
cwd,
extractReleaseSectionBody(nextChangelog, version) ?? releaseSection,
options?.deps,
);
const releaseNotesBody = polishFragmentsWithClaude(fragments, {
mode: 'release-notes',
version,
date,
deps: options?.deps,
});
const releaseNotesPath = writeReleaseNotesFile(cwd, releaseNotesBody, options?.deps);
log(`Generated ${releaseNotesPath}`);
for (const fragment of fragments) {
@@ -645,7 +774,7 @@ export function writeReleaseNotesForVersion(options?: ChangelogOptions): string
throw new Error(`Missing CHANGELOG section for v${version}.`);
}
return writeReleaseNotesFile(cwd, changes, options?.deps);
return writeReleaseNotesFile(cwd, stripDetailsBlocks(changes), options?.deps);
}
export function writePrereleaseNotesForVersion(options?: ChangelogOptions): string {
@@ -664,7 +793,11 @@ export function writePrereleaseNotesForVersion(options?: ChangelogOptions): stri
throw new Error('No changelog fragments found in changes/.');
}
const changes = renderGroupedChanges(fragments);
const changes = polishFragmentsWithClaude(fragments, {
mode: 'release-notes',
version,
deps: options?.deps,
});
return writeReleaseNotesFile(cwd, changes, options?.deps, {
disclaimer:
'> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.',