From 8cd053bf525a593a021ea44cd5b581b70f0de593 Mon Sep 17 00:00:00 2001 From: Gabriel Garcia Date: Fri, 9 Oct 2026 12:45:19 +0200 Subject: [PATCH 1/2] chore(release): group changelog entries by commit type feat -> Features, fix -> Fixes, docs -> Docs, everything else -> Chores, in both CHANGELOG.md and the release PR body, so the changelog no longer needs regrouping by hand after npm run release. Create the release PR with execFileSync so descriptions containing backticks or quotes reach gh unmangled instead of being shell-interpolated. Co-Authored-By: Claude Opus 5.5 (1M context) --- scripts/release.ts | 42 ++++++++++++++++++++++++++++++++++-------- 1 file changed, 34 insertions(+), 8 deletions(-) diff --git a/scripts/release.ts b/scripts/release.ts index 36621f2f9..c7386ecaa 100755 --- a/scripts/release.ts +++ b/scripts/release.ts @@ -1,6 +1,6 @@ #!/usr/bin/env tsx -import { execSync } from 'child_process'; +import { execFileSync, execSync } from 'child_process'; import { readFileSync, writeFileSync } from 'fs'; import { createInterface } from 'readline'; import { $TSFixMe } from './types'; @@ -150,6 +150,12 @@ function parseConventionalCommit(commit: Commit): ParsedCommit | null { }; } +const CHANGELOG_SECTIONS: Record = { + feat: 'Features', + fix: 'Fixes', + docs: 'Docs', +}; + function generateChangesetContent(commits: Commit[]): Changeset | null { const parsedCommits = commits .map(parseConventionalCommit) @@ -174,20 +180,28 @@ function generateChangesetContent(commits: Commit[]): Changeset | null { if (groups.major.length > 0) finalVersionBump = 'major'; else if (groups.minor.length > 0) finalVersionBump = 'minor'; - // Generate simple changeset content (no markdown headers) - const changesetItems: string[] = []; + const sections: Record = { + Features: [], + Fixes: [], + Docs: [], + Chores: [], + }; - // Add all commits as simple bullet points parsedCommits.forEach((commit) => { const prText = commit.prNumber ? ` [#${commit.prNumber}](https://github.com/remoteoss/remote-flows/pull/${commit.prNumber})` : ''; - changesetItems.push(`- ${commit.description}${prText}`); + sections[CHANGELOG_SECTIONS[commit.type] ?? 'Chores'].push( + `- ${commit.description}${prText}`, + ); }); return { versionBump: finalVersionBump, - content: changesetItems.join('\n'), + content: Object.entries(sections) + .filter(([, items]) => items.length > 0) + .map(([title, items]) => `#### ${title}\n\n${items.join('\n')}`) + .join('\n\n'), }; } @@ -391,8 +405,20 @@ ${changeset.content} This release was automatically generated from conventional commits.`; - execSync( - `gh pr create --title "${newVersion}" --body "${prBody}" --base main --head ${branchName}`, + execFileSync( + 'gh', + [ + 'pr', + 'create', + '--title', + newVersion, + '--body', + prBody, + '--base', + 'main', + '--head', + branchName, + ], { stdio: 'inherit' }, ); console.log(`✅ Created PR: Release v${newVersion}`); From 0ea606a409da36435f0c30bce59b5d8303cf493f Mon Sep 17 00:00:00 2001 From: Gabriel Garcia Date: Fri, 9 Oct 2026 12:49:57 +0200 Subject: [PATCH 2/2] test(release): cover changelog grouping and release notes Move commit parsing, grouping, version bumping and the changelog/PR body formatting out of release.ts into release-changelog.ts so they can be unit-tested without running the release (release.ts runs main() on import). Output is unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) --- scripts/release-changelog.test.ts | 142 +++++++++++++++++++++++++ scripts/release-changelog.ts | 130 +++++++++++++++++++++++ scripts/release.ts | 171 +++--------------------------- 3 files changed, 285 insertions(+), 158 deletions(-) create mode 100644 scripts/release-changelog.test.ts create mode 100644 scripts/release-changelog.ts diff --git a/scripts/release-changelog.test.ts b/scripts/release-changelog.test.ts new file mode 100644 index 000000000..362d800e2 --- /dev/null +++ b/scripts/release-changelog.test.ts @@ -0,0 +1,142 @@ +import { + bumpVersion, + Commit, + formatReleaseNotes, + formatReleasePrBody, + generateChangesetContent, +} from './release-changelog'; + +const commit = (subject: string, body = ''): Commit => ({ + hash: 'abc1234', + subject, + body, +}); + +const pr = (n: number) => + `[#${n}](https://github.com/remoteoss/remote-flows/pull/${n})`; + +describe('generateChangesetContent', () => { + it('groups commits by type, keeping commit order within each section', () => { + const changeset = generateChangesetContent([ + commit('ci(e2e): only surface failures (#5)'), + commit('fix(onboarding): disable submit while checking (#2)'), + commit('feat(onboarding): wire onBlur events (#1)'), + commit('docs: keep public JSDoc short (#4)'), + commit('test(e2e): cover China salary conversion (#6)'), + commit('fix: pre-fill resumed steps (#3)'), + commit('chore(example): bump GBR schema version (#7)'), + commit('refactor: extract helper (#8)'), + ]); + + expect(changeset).toEqual({ + versionBump: 'minor', + content: `#### Features + +- wire onBlur events (#1) ${pr(1)} + +#### Fixes + +- disable submit while checking (#2) ${pr(2)} +- pre-fill resumed steps (#3) ${pr(3)} + +#### Docs + +- keep public JSDoc short (#4) ${pr(4)} + +#### Chores + +- only surface failures (#5) ${pr(5)} +- cover China salary conversion (#6) ${pr(6)} +- bump GBR schema version (#7) ${pr(7)} +- extract helper (#8) ${pr(8)}`, + }); + }); + + it('leaves out empty sections', () => { + expect( + generateChangesetContent([ + commit('fix: stable pricing plan schema (#9)'), + commit('chore: bump tsx (#10)'), + ]), + ).toEqual({ + versionBump: 'patch', + content: `#### Fixes + +- stable pricing plan schema (#9) ${pr(9)} + +#### Chores + +- bump tsx (#10) ${pr(10)}`, + }); + }); + + it('bumps major when a feat or fix body has a BREAKING CHANGE footer', () => { + expect( + generateChangesetContent([ + commit('feat: add flag (#1)'), + commit('fix: drop legacy prop (#2)', 'BREAKING CHANGE: prop removed'), + ])?.versionBump, + ).toBe('major'); + }); + + it('keeps backticks and quotes in descriptions as-is', () => { + expect( + generateChangesetContent([ + commit('fix: stop stripping the required `name` field "now" (#11)'), + ])?.content, + ).toBe( + `#### Fixes\n\n- stop stripping the required \`name\` field "now" (#11) ${pr(11)}`, + ); + }); + + it('omits the PR link when the commit has no PR number', () => { + expect(generateChangesetContent([commit('chore: tidy up')])?.content).toBe( + '#### Chores\n\n- tidy up', + ); + }); + + it('returns null when no commit is conventional', () => { + expect(generateChangesetContent([commit('Update README')])).toBeNull(); + }); +}); + +describe('bumpVersion', () => { + it.each([ + ['patch', '1.60.1'], + ['minor', '1.61.0'], + ['major', '2.0.0'], + ] as const)('applies a %s bump', (versionBump, expected) => { + expect(bumpVersion('1.60.0', versionBump)).toBe(expected); + }); +}); + +describe('release notes', () => { + const changeset = { + versionBump: 'minor' as const, + content: `#### Features\n\n- wire onBlur events (#1) ${pr(1)}`, + }; + + it('formats the changelog entry', () => { + expect(formatReleaseNotes('1.61.0', changeset)).toBe(`## 1.61.0 + +### Minor Changes + +#### Features + +- wire onBlur events (#1) ${pr(1)}`); + }); + + it('appends the generated-by footer to the PR body', () => { + expect(formatReleasePrBody('1.61.0', changeset)).toBe(`## 1.61.0 + +### Minor Changes + +#### Features + +- wire onBlur events (#1) ${pr(1)} + +--- + +This release was automatically generated from conventional commits.`); + }); +}); diff --git a/scripts/release-changelog.ts b/scripts/release-changelog.ts new file mode 100644 index 000000000..f825276b8 --- /dev/null +++ b/scripts/release-changelog.ts @@ -0,0 +1,130 @@ +export type VersionBump = 'patch' | 'minor' | 'major'; + +export interface Commit { + hash: string; + subject: string; + body: string; +} + +export interface ParsedCommit { + type: string; + scope?: string; + description: string; + versionBump: VersionBump; + prNumber?: string; + hash: string; +} + +export interface Changeset { + versionBump: VersionBump; + content: string; +} + +const CHANGELOG_SECTIONS: Record = { + feat: 'Features', + fix: 'Fixes', + docs: 'Docs', +}; + +export function parseConventionalCommit(commit: Commit): ParsedCommit | null { + const { subject, body } = commit; + + // Updated regex to handle both colon and dash separators + const match = subject.match(/^(\w+)(?:\(([^)]+)\))?\s*[-:]\s*(.+)$/); + + if (!match) return null; + + const [, type, scope, description] = match; + let versionBump: VersionBump = 'patch'; + + if (type === 'feat') versionBump = 'minor'; + if (type === 'feat' && body.includes('BREAKING CHANGE')) + versionBump = 'major'; + if (type === 'fix' && body.includes('BREAKING CHANGE')) versionBump = 'major'; + + const prMatch = (description + ' ' + body).match(/#(\d+)/); + const prNumber = prMatch ? prMatch[1] : undefined; + + return { + type, + scope, + description, + versionBump, + prNumber, + hash: commit.hash, + }; +} + +export function generateChangesetContent(commits: Commit[]): Changeset | null { + const parsedCommits = commits + .map(parseConventionalCommit) + .filter((commit): commit is ParsedCommit => commit !== null); + + if (parsedCommits.length === 0) return null; + + let versionBump: VersionBump = 'patch'; + if (parsedCommits.some((commit) => commit.versionBump === 'major')) + versionBump = 'major'; + else if (parsedCommits.some((commit) => commit.versionBump === 'minor')) + versionBump = 'minor'; + + const sections: Record = { + Features: [], + Fixes: [], + Docs: [], + Chores: [], + }; + + parsedCommits.forEach((commit) => { + const prText = commit.prNumber + ? ` [#${commit.prNumber}](https://github.com/remoteoss/remote-flows/pull/${commit.prNumber})` + : ''; + sections[CHANGELOG_SECTIONS[commit.type] ?? 'Chores'].push( + `- ${commit.description}${prText}`, + ); + }); + + return { + versionBump, + content: Object.entries(sections) + .filter(([, items]) => items.length > 0) + .map(([title, items]) => `#### ${title}\n\n${items.join('\n')}`) + .join('\n\n'), + }; +} + +export function bumpVersion(version: string, versionBump: VersionBump): string { + const [major, minor, patch] = version.split('.').map(Number); + if (versionBump === 'major') return `${major + 1}.0.0`; + if (versionBump === 'minor') return `${major}.${minor + 1}.0`; + return `${major}.${minor}.${patch + 1}`; +} + +export function formatReleaseNotes( + version: string, + changeset: Changeset, +): string { + const versionType = + changeset.versionBump === 'major' + ? 'Major' + : changeset.versionBump === 'minor' + ? 'Minor' + : 'Patch'; + + return `## ${version} + +### ${versionType} Changes + +${changeset.content}`; +} + +export function formatReleasePrBody( + version: string, + changeset: Changeset, +): string { + return `${formatReleaseNotes(version, changeset)} + +--- + +This release was automatically generated from conventional commits.`; +} diff --git a/scripts/release.ts b/scripts/release.ts index c7386ecaa..5dc717ac5 100755 --- a/scripts/release.ts +++ b/scripts/release.ts @@ -3,28 +3,16 @@ import { execFileSync, execSync } from 'child_process'; import { readFileSync, writeFileSync } from 'fs'; import { createInterface } from 'readline'; +import { + bumpVersion, + Commit, + formatReleaseNotes, + formatReleasePrBody, + generateChangesetContent, + VersionBump, +} from './release-changelog'; import { $TSFixMe } from './types'; -interface Commit { - hash: string; - subject: string; - body: string; -} - -interface ParsedCommit { - type: string; - scope?: string; - description: string; - versionBump: 'patch' | 'minor' | 'major'; - prNumber?: string; - hash: string; -} - -interface Changeset { - versionBump: 'patch' | 'minor' | 'major'; - content: string; -} - async function getLatestPublishedVersion(): Promise { console.log('📦 Reading version from package.json...'); const packageJson = JSON.parse(readFileSync('package.json', 'utf8')); @@ -121,90 +109,6 @@ async function getCommitsFromGitHubAPI(): Promise { } } -function parseConventionalCommit(commit: Commit): ParsedCommit | null { - const { subject, body } = commit; - - // Updated regex to handle both colon and dash separators - const match = subject.match(/^(\w+)(?:\(([^)]+)\))?\s*[-:]\s*(.+)$/); - - if (!match) return null; - - const [, type, scope, description] = match; - let versionBump: 'patch' | 'minor' | 'major' = 'patch'; - - if (type === 'feat') versionBump = 'minor'; - if (type === 'feat' && body.includes('BREAKING CHANGE')) - versionBump = 'major'; - if (type === 'fix' && body.includes('BREAKING CHANGE')) versionBump = 'major'; - - const prMatch = (description + ' ' + body).match(/#(\d+)/); - const prNumber = prMatch ? prMatch[1] : undefined; - - return { - type, - scope, - description, - versionBump, - prNumber, - hash: commit.hash, - }; -} - -const CHANGELOG_SECTIONS: Record = { - feat: 'Features', - fix: 'Fixes', - docs: 'Docs', -}; - -function generateChangesetContent(commits: Commit[]): Changeset | null { - const parsedCommits = commits - .map(parseConventionalCommit) - .filter((commit): commit is ParsedCommit => commit !== null); - - if (parsedCommits.length === 0) { - console.log('No conventional commits found'); - return null; - } - - const groups: Record<'major' | 'minor' | 'patch', ParsedCommit[]> = { - major: [], - minor: [], - patch: [], - }; - - parsedCommits.forEach((commit) => { - groups[commit.versionBump].push(commit); - }); - - let finalVersionBump: 'patch' | 'minor' | 'major' = 'patch'; - if (groups.major.length > 0) finalVersionBump = 'major'; - else if (groups.minor.length > 0) finalVersionBump = 'minor'; - - const sections: Record = { - Features: [], - Fixes: [], - Docs: [], - Chores: [], - }; - - parsedCommits.forEach((commit) => { - const prText = commit.prNumber - ? ` [#${commit.prNumber}](https://github.com/remoteoss/remote-flows/pull/${commit.prNumber})` - : ''; - sections[CHANGELOG_SECTIONS[commit.type] ?? 'Chores'].push( - `- ${commit.description}${prText}`, - ); - }); - - return { - versionBump: finalVersionBump, - content: Object.entries(sections) - .filter(([, items]) => items.length > 0) - .map(([title, items]) => `#### ${title}\n\n${items.join('\n')}`) - .join('\n\n'), - }; -} - async function main(): Promise { console.log('🚀 Preparing release...'); @@ -253,26 +157,10 @@ async function main(): Promise { const validVersions = ['patch', 'minor', 'major']; if (validVersions.includes(userVersionBump.toLowerCase())) { - finalVersionBump = userVersionBump.toLowerCase() as - | 'patch' - | 'minor' - | 'major'; - - // Calculate what the new version will be - const [major, minor, patch] = latestPublishedVersion - .split('.') - .map(Number); - let newVersion: string; - if (finalVersionBump === 'major') { - newVersion = `${major + 1}.0.0`; - } else if (finalVersionBump === 'minor') { - newVersion = `${major}.${minor + 1}.0`; - } else { - newVersion = `${major}.${minor}.${patch + 1}`; - } + finalVersionBump = userVersionBump.toLowerCase() as VersionBump; console.log( - `📝 Using version bump: ${finalVersionBump} (${latestPublishedVersion} → ${newVersion})`, + `📝 Using version bump: ${finalVersionBump} (${latestPublishedVersion} → ${bumpVersion(latestPublishedVersion, finalVersionBump)})`, ); } else { console.log('Invalid version bump. Exiting.'); @@ -297,18 +185,7 @@ async function main(): Promise { // Manual version bumping and changelog generation console.log('📦 Updating version and changelog...'); - // Use the latest published version as the base - const [major, minor, patch] = latestPublishedVersion.split('.').map(Number); - - // Bump version based on changeset - let newVersion: string; - if (changeset.versionBump === 'major') { - newVersion = `${major + 1}.0.0`; - } else if (changeset.versionBump === 'minor') { - newVersion = `${major}.${minor + 1}.0`; - } else { - newVersion = `${major}.${minor}.${patch + 1}`; - } + const newVersion = bumpVersion(latestPublishedVersion, changeset.versionBump); console.log(`📈 Version bump: ${latestPublishedVersion} → ${newVersion}`); @@ -317,20 +194,7 @@ async function main(): Promise { packageJson.version = newVersion; writeFileSync('package.json', JSON.stringify(packageJson, null, 2) + '\n'); - // Generate changelog entry to match existing format - const versionType = - changeset.versionBump === 'major' - ? 'Major' - : changeset.versionBump === 'minor' - ? 'Minor' - : 'Patch'; - const changelogEntry = `## ${newVersion} - -### ${versionType} Changes - -${changeset.content} - -`; + const changelogEntry = `${formatReleaseNotes(newVersion, changeset)}\n\n`; // Read existing changelog let changelog = ''; @@ -394,16 +258,7 @@ ${changeset.content} // Auto-create PR with changelog content as body console.log(`🔗 Creating PR...`); try { - // Create PR with changelog content as the body - const prBody = `## ${newVersion} - -### ${versionType} Changes - -${changeset.content} - ---- - -This release was automatically generated from conventional commits.`; + const prBody = formatReleasePrBody(newVersion, changeset); execFileSync( 'gh',