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; readFileSync?: (candidate: string, encoding: BufferEncoding) => string; readdirSync?: (candidate: string, options: { withFileTypes: true }) => fs.Dirent[]; 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; version?: string; deps?: ChangelogFsDeps; }; type FragmentType = 'added' | 'changed' | 'fixed' | 'docs' | 'internal'; type ChangeFragment = { area: string; breaking: boolean; bullets: string[]; path: string; type: FragmentType; }; type PullRequestChangelogOptions = { changedEntries: Array<{ path: string; status: string; }>; changedLabels?: string[]; }; 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 SKIP_CHANGELOG_LABEL = 'skip-changelog'; function normalizeVersion(version: string): string { return version.replace(/^v/, ''); } function resolveDate(date?: string): string { return date ?? new Date().toISOString().slice(0, 10); } function resolvePackageVersion( cwd: string, readFileSync: (candidate: string, encoding: BufferEncoding) => string, ): string { const packageJsonPath = path.join(cwd, 'package.json'); const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as { version?: string }; if (!packageJson.version) { throw new Error(`Missing package.json version at ${packageJsonPath}`); } return normalizeVersion(packageJson.version); } function resolveVersion(options: Pick): string { const cwd = options.cwd ?? process.cwd(); const readFileSync = options.deps?.readFileSync ?? fs.readFileSync; return normalizeVersion(options.version ?? resolvePackageVersion(cwd, readFileSync)); } function isSupportedPrereleaseVersion(version: string): boolean { return /^\d+\.\d+\.\d+-(beta|rc)\.\d+$/u.test(normalizeVersion(version)); } function verifyRequestedVersionMatchesPackageVersion( options: Pick, ): void { if (!options.version) { return; } const cwd = options.cwd ?? process.cwd(); const existsSync = options.deps?.existsSync ?? fs.existsSync; const readFileSync = options.deps?.readFileSync ?? fs.readFileSync; const packageJsonPath = path.join(cwd, 'package.json'); if (!existsSync(packageJsonPath)) { return; } const packageVersion = resolvePackageVersion(cwd, readFileSync); const requestedVersion = normalizeVersion(options.version); if (packageVersion !== requestedVersion) { throw new Error( `package.json version (${packageVersion}) does not match requested release version (${requestedVersion}).`, ); } } function resolveChangesDir(cwd: string): string { return path.join(cwd, 'changes'); } function resolveFragmentPaths(cwd: string, deps?: ChangelogFsDeps): string[] { const changesDir = resolveChangesDir(cwd); const existsSync = deps?.existsSync ?? fs.existsSync; const readdirSync = deps?.readdirSync ?? fs.readdirSync; if (!existsSync(changesDir)) { return []; } return readdirSync(changesDir, { withFileTypes: true }) .filter( (entry) => entry.isFile() && entry.name.endsWith('.md') && entry.name.toLowerCase() !== 'readme.md', ) .map((entry) => path.join(changesDir, entry.name)) .sort(); } function normalizeFragmentBullets(content: string): string[] { const lines = content .split(/\r?\n/) .map((line) => line.trim()) .filter(Boolean) .map((line) => { const match = /^[-*]\s+(.*)$/.exec(line); return `- ${(match?.[1] ?? line).trim()}`; }); if (lines.length === 0) { throw new Error('Changelog fragment cannot be empty.'); } return lines; } function parseFragmentMetadata( content: string, fragmentPath: string, ): { area: string; body: string; breaking: boolean; type: FragmentType; } { const lines = content.split(/\r?\n/); let index = 0; while (index < lines.length && !(lines[index] ?? '').trim()) { index += 1; } const metadata = new Map(); while (index < lines.length) { const trimmed = (lines[index] ?? '').trim(); if (!trimmed) { index += 1; break; } const match = /^([a-z]+):\s*(.+)$/.exec(trimmed); if (!match) { break; } const [, rawKey = '', rawValue = ''] = match; metadata.set(rawKey, rawValue.trim()); index += 1; } const type = metadata.get('type'); if (!type || !CHANGE_TYPES.includes(type as FragmentType)) { throw new Error(`${fragmentPath} must declare type as one of: ${CHANGE_TYPES.join(', ')}.`); } const area = metadata.get('area'); if (!area) { throw new Error(`${fragmentPath} must declare area.`); } const body = lines.slice(index).join('\n').trim(); if (!body) { throw new Error(`${fragmentPath} must include at least one changelog bullet.`); } const breaking = metadata.get('breaking')?.toLowerCase() === 'true'; return { area, body, breaking, type: type as FragmentType, }; } function readChangeFragments(cwd: string, deps?: ChangelogFsDeps): ChangeFragment[] { const readFileSync = deps?.readFileSync ?? fs.readFileSync; return resolveFragmentPaths(cwd, deps).map((fragmentPath) => { const parsed = parseFragmentMetadata(readFileSync(fragmentPath, 'utf8'), fragmentPath); return { area: parsed.area, breaking: parsed.breaking, bullets: normalizeFragmentBullets(parsed.body), path: fragmentPath, type: parsed.type, }; }); } // 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', ]; const SECTION_HEADER_PATTERN = /^### (Breaking Changes|Added|Changed|Fixed|Docs|Internal)$/m; const POLISH_PROMPT_INSTRUCTIONS = `You are formatting a software release changelog for end users of SubMiner, an Electron app for Japanese sentence mining. 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. ## 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:
Internal changes ### Internal - …
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.", ); } throw new Error(`claude CLI invocation failed: ${err.message}`); } } 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 (!/
[\s\S]*[^<]*Internal[^<]*<\/summary>/m.test(trimmed)) { throw new Error( 'claude output is missing the expected
Internal changes 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(/
[\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/.'); } const polished = polishFragmentsWithClaude(fragments, { mode: 'changelog', version, date, deps, }); return [`## v${version} (${date})`, '', polished, ''].join('\n'); } function ensureChangelogHeader(existingChangelog: string): string { const trimmed = existingChangelog.trim(); if (!trimmed) { return `${CHANGELOG_HEADER}\n`; } if (trimmed.startsWith(CHANGELOG_HEADER)) { return `${trimmed}\n`; } return `${CHANGELOG_HEADER}\n\n${trimmed}\n`; } function prependReleaseSection( existingChangelog: string, releaseSection: string, version: string, ): string { const normalizedExisting = ensureChangelogHeader(existingChangelog); if (extractReleaseSectionBody(normalizedExisting, version) !== null) { throw new Error(`CHANGELOG already contains a section for v${version}.`); } const withoutHeader = normalizedExisting.replace(/^# Changelog\s*/, '').trimStart(); const body = [releaseSection.trimEnd(), withoutHeader.trimEnd()].filter(Boolean).join('\n\n'); return `${CHANGELOG_HEADER}\n\n${body}\n`; } function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } function extractReleaseSectionBody(changelog: string, version: string): string | null { const headingPattern = new RegExp( `^## v${escapeRegExp(normalizeVersion(version))} \\([^\\n]+\\)$`, 'm', ); const headingMatch = headingPattern.exec(changelog); if (!headingMatch) { return null; } const bodyStart = headingMatch.index + headingMatch[0].length + 1; const remaining = changelog.slice(bodyStart); const nextHeadingMatch = /^## /m.exec(remaining); const body = nextHeadingMatch ? remaining.slice(0, nextHeadingMatch.index) : remaining; return body.trim(); } export function resolveChangelogOutputPaths(options?: { cwd?: string }): string[] { const cwd = options?.cwd ?? process.cwd(); return [path.join(cwd, 'CHANGELOG.md')]; } function renderReleaseNotes( changes: string, options?: { disclaimer?: string; }, ): string { const prefix = options?.disclaimer ? [options.disclaimer, ''] : []; return [ ...prefix, '## Highlights', changes, '', '## Installation', '', 'See the README and docs/installation guide for full setup steps.', '', '## Assets', '', '- Linux: `SubMiner.AppImage`', '- macOS: `SubMiner-*.dmg` and `SubMiner-*.zip`', '- Optional extras: `subminer-assets.tar.gz` and the `subminer` launcher', '', 'Note: the `subminer` wrapper script uses Bun (`#!/usr/bin/env bun`), so `bun` must be installed and on `PATH`.', '', ].join('\n'); } function writeReleaseNotesFile( cwd: string, changes: string, deps?: ChangelogFsDeps, options?: { disclaimer?: string; outputPath?: string; }, ): string { const mkdirSync = deps?.mkdirSync ?? fs.mkdirSync; const writeFileSync = deps?.writeFileSync ?? fs.writeFileSync; const releaseNotesPath = path.join(cwd, options?.outputPath ?? RELEASE_NOTES_PATH); mkdirSync(path.dirname(releaseNotesPath), { recursive: true }); writeFileSync(releaseNotesPath, renderReleaseNotes(changes, options), 'utf8'); return releaseNotesPath; } export function writeChangelogArtifacts(options?: ChangelogOptions): { deletedFragmentPaths: string[]; outputPaths: string[]; releaseNotesPath: string; } { const cwd = options?.cwd ?? process.cwd(); const existsSync = options?.deps?.existsSync ?? fs.existsSync; const mkdirSync = options?.deps?.mkdirSync ?? fs.mkdirSync; const readFileSync = options?.deps?.readFileSync ?? fs.readFileSync; const rmSync = options?.deps?.rmSync ?? fs.rmSync; const writeFileSync = options?.deps?.writeFileSync ?? fs.writeFileSync; const log = options?.deps?.log ?? console.log; const version = resolveVersion(options ?? {}); const date = resolveDate(options?.date); const fragments = readChangeFragments(cwd, options?.deps); 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, stripDetailsBlocks(existingReleaseSection), options?.deps, ); log(`Generated ${releaseNotesPath}`); return { deletedFragmentPaths: fragments.map((fragment) => fragment.path), outputPaths, releaseNotesPath, }; } const releaseSection = buildReleaseSection(version, date, fragments, options?.deps); const nextChangelog = prependReleaseSection(existingChangelog, releaseSection, version); for (const outputPath of outputPaths) { mkdirSync(path.dirname(outputPath), { recursive: true }); writeFileSync(outputPath, nextChangelog, 'utf8'); log(`Updated ${outputPath}`); } 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) { rmSync(fragment.path); log(`Removed ${fragment.path}`); } return { deletedFragmentPaths: fragments.map((fragment) => fragment.path), outputPaths, releaseNotesPath, }; } export function writeStableReleaseArtifacts(options?: ChangelogOptions): { deletedFragmentPaths: string[]; docsChangelogPath: string; outputPaths: string[]; releaseNotesPath: string; } { const changelogResult = writeChangelogArtifacts(options); const docsChangelogPath = generateDocsChangelog(options); return { ...changelogResult, docsChangelogPath, }; } export function verifyChangelogFragments(options?: ChangelogOptions): void { readChangeFragments(options?.cwd ?? process.cwd(), options?.deps); } export function verifyChangelogReadyForRelease(options?: ChangelogOptions): void { const cwd = options?.cwd ?? process.cwd(); const readFileSync = options?.deps?.readFileSync ?? fs.readFileSync; verifyRequestedVersionMatchesPackageVersion(options ?? {}); const version = resolveVersion(options ?? {}); const pendingFragments = resolveFragmentPaths(cwd, options?.deps); if (pendingFragments.length > 0) { throw new Error( `Pending changelog fragments must be released first: ${pendingFragments.join(', ')}`, ); } const changelogPath = path.join(cwd, 'CHANGELOG.md'); if (!(options?.deps?.existsSync ?? fs.existsSync)(changelogPath)) { throw new Error(`Missing ${changelogPath}`); } const changelog = readFileSync(changelogPath, 'utf8'); if (extractReleaseSectionBody(changelog, version) === null) { throw new Error(`Missing CHANGELOG section for v${version}.`); } } function isFragmentPath(candidate: string): boolean { return /^changes\/.+\.md$/u.test(candidate) && !/\/?README\.md$/iu.test(candidate); } function isIgnoredPullRequestPath(candidate: string): boolean { return ( candidate === 'CHANGELOG.md' || candidate === 'release/release-notes.md' || candidate === 'AGENTS.md' || candidate === 'README.md' || candidate.startsWith('changes/') || candidate.startsWith('docs/') || candidate.startsWith('.github/') || candidate.startsWith('backlog/') ); } export function verifyPullRequestChangelog(options: PullRequestChangelogOptions): void { const labels = (options.changedLabels ?? []).map((label) => label.trim()).filter(Boolean); if (labels.includes(SKIP_CHANGELOG_LABEL)) { return; } const normalizedEntries = options.changedEntries .map((entry) => ({ path: entry.path.trim(), status: entry.status.trim().toUpperCase(), })) .filter((entry) => entry.path); if (normalizedEntries.length === 0) { return; } const hasFragment = normalizedEntries.some( (entry) => entry.status !== 'D' && isFragmentPath(entry.path), ); const requiresFragment = normalizedEntries.some((entry) => !isIgnoredPullRequestPath(entry.path)); if (requiresFragment && !hasFragment) { throw new Error( `This pull request changes release-relevant files and requires a changelog fragment under changes/ or the ${SKIP_CHANGELOG_LABEL} label.`, ); } } function resolveChangedPathsFromGit( cwd: string, baseRef: string, headRef: string, ): Array<{ path: string; status: string }> { const output = execFileSync('git', ['diff', '--name-status', `${baseRef}...${headRef}`], { cwd, encoding: 'utf8', }); return output .split(/\r?\n/) .map((line) => line.trim()) .filter(Boolean) .map((line) => { const [status = '', ...paths] = line.split(/\s+/); return { path: paths[paths.length - 1] ?? '', status, }; }) .filter((entry) => entry.path); } const DOCS_CHANGELOG_PATH = path.join('docs-site', 'changelog.md'); type VersionSection = { version: string; date: string; minor: string; body: string; }; function parseVersionSections(changelog: string): VersionSection[] { const sectionPattern = /^## v(\d+\.\d+\.\d+) \((\d{4}-\d{2}-\d{2})\)$/gm; const sections: VersionSection[] = []; let match: RegExpExecArray | null; while ((match = sectionPattern.exec(changelog)) !== null) { const version = match[1]!; const date = match[2]!; const minor = version.replace(/\.\d+$/, ''); const headingEnd = match.index + match[0].length; sections.push({ version, date, minor, body: '' }); if (sections.length > 1) { const prev = sections[sections.length - 2]!; prev.body = changelog.slice(prev.body as unknown as number, match.index).trim(); } (sections[sections.length - 1] as { body: unknown }).body = headingEnd; } if (sections.length > 0) { const last = sections[sections.length - 1]!; last.body = changelog.slice(last.body as unknown as number).trim(); } return sections; } export function generateDocsChangelog(options?: Pick): string { const cwd = options?.cwd ?? process.cwd(); const readFileSync = options?.deps?.readFileSync ?? fs.readFileSync; const writeFileSync = options?.deps?.writeFileSync ?? fs.writeFileSync; const log = options?.deps?.log ?? console.log; const changelogPath = path.join(cwd, 'CHANGELOG.md'); const changelog = readFileSync(changelogPath, 'utf8'); const sections = parseVersionSections(changelog); if (sections.length === 0) { throw new Error('No version sections found in CHANGELOG.md'); } const currentMinor = sections[0]!.minor; const currentSections = sections.filter((s) => s.minor === currentMinor); const olderSections = sections.filter((s) => s.minor !== currentMinor); const lines: string[] = ['# Changelog', '']; for (const section of currentSections) { const body = section.body.replace(/^### (.+)$/gm, '**$1**'); lines.push(`## v${section.version} (${section.date})`, '', body, ''); } if (olderSections.length > 0) { lines.push('## Previous Versions', ''); const minorGroups = new Map(); for (const section of olderSections) { const group = minorGroups.get(section.minor) ?? []; group.push(section); minorGroups.set(section.minor, group); } for (const [minor, group] of minorGroups) { lines.push('
', `v${minor}.x`, ''); for (const section of group) { const htmlBody = section.body.replace(/^### (.+)$/gm, '**$1**'); lines.push(`

v${section.version} (${section.date})

`, '', htmlBody, ''); } lines.push('
', ''); } } const output = lines .join('\n') .replace(/\n{3,}/g, '\n\n') .trimEnd() + '\n'; const outputPath = path.join(cwd, DOCS_CHANGELOG_PATH); writeFileSync(outputPath, output, 'utf8'); log(`Generated ${outputPath}`); return outputPath; } export function writeReleaseNotesForVersion(options?: ChangelogOptions): string { const cwd = options?.cwd ?? process.cwd(); const readFileSync = options?.deps?.readFileSync ?? fs.readFileSync; const version = resolveVersion(options ?? {}); const changelogPath = path.join(cwd, 'CHANGELOG.md'); const changelog = readFileSync(changelogPath, 'utf8'); const changes = extractReleaseSectionBody(changelog, version); if (changes === null) { throw new Error(`Missing CHANGELOG section for v${version}.`); } return writeReleaseNotesFile(cwd, stripDetailsBlocks(changes), options?.deps); } export function writePrereleaseNotesForVersion(options?: ChangelogOptions): string { verifyRequestedVersionMatchesPackageVersion(options ?? {}); const cwd = options?.cwd ?? process.cwd(); const version = resolveVersion(options ?? {}); if (!isSupportedPrereleaseVersion(version)) { throw new Error( `Unsupported prerelease version (${version}). Expected x.y.z-beta.N or x.y.z-rc.N.`, ); } const fragments = readChangeFragments(cwd, options?.deps); if (fragments.length === 0) { throw new Error('No changelog fragments found in changes/.'); } 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.', outputPath: PRERELEASE_NOTES_PATH, }); } function parseCliArgs(argv: string[]): { baseRef?: string; cwd?: string; date?: string; headRef?: string; labels?: string; version?: string; } { const parsed: { baseRef?: string; cwd?: string; date?: string; headRef?: string; labels?: string; version?: string; } = {}; for (let index = 0; index < argv.length; index += 1) { const current = argv[index]; const next = argv[index + 1]; if (current === '--cwd' && next) { parsed.cwd = next; index += 1; continue; } if (current === '--date' && next) { parsed.date = next; index += 1; continue; } if (current === '--version' && next) { parsed.version = next; index += 1; continue; } if (current === '--base-ref' && next) { parsed.baseRef = next; index += 1; continue; } if (current === '--head-ref' && next) { parsed.headRef = next; index += 1; continue; } if (current === '--labels' && next) { parsed.labels = next; index += 1; } } return parsed; } function main(): void { const [command = 'build', ...argv] = process.argv.slice(2); const options = parseCliArgs(argv); if (command === 'build') { writeChangelogArtifacts(options); return; } if (command === 'build-release') { writeStableReleaseArtifacts(options); return; } if (command === 'check') { verifyChangelogReadyForRelease(options); return; } if (command === 'lint') { verifyChangelogFragments(options); return; } if (command === 'pr-check') { verifyChangelogFragments(options); verifyPullRequestChangelog({ changedLabels: options.labels?.split(',') ?? [], changedEntries: resolveChangedPathsFromGit( options.cwd ?? process.cwd(), options.baseRef ?? 'origin/main', options.headRef ?? 'HEAD', ), }); return; } if (command === 'release-notes') { writeReleaseNotesForVersion(options); return; } if (command === 'prerelease-notes') { writePrereleaseNotesForVersion(options); return; } if (command === 'docs') { generateDocsChangelog(options); return; } throw new Error(`Unknown changelog command: ${command}`); } if (require.main === module) { main(); }