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..e628b2d9f --- /dev/null +++ b/scripts/release-changelog.ts @@ -0,0 +1,144 @@ +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 ec446f7ec..5dc717ac5 100755 --- a/scripts/release.ts +++ b/scripts/release.ts @@ -1,30 +1,18 @@ #!/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 { + 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,86 +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, - }; -} - -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'; - } - - // Generate simple changeset content (no markdown headers) - const changesetItems: string[] = []; - - // 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}`); - }); - - return { - versionBump: finalVersionBump, - content: changesetItems.join('\n'), - }; -} - async function main(): Promise { console.log('🚀 Preparing release...'); @@ -249,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.'); @@ -293,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}`); @@ -313,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 = ''; @@ -390,19 +258,22 @@ ${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.`; - - execSync( - `gh pr create --title "${newVersion}" --body "${prBody}" --base main --head ${branchName}`, + const prBody = formatReleasePrBody(newVersion, changeset); + + execFileSync( + 'gh', + [ + 'pr', + 'create', + '--title', + newVersion, + '--body', + prBody, + '--base', + 'main', + '--head', + branchName, + ], { stdio: 'inherit' }, ); console.log(`✅ Created PR: Release v${newVersion}`);