import { execFileSync } from 'node:child_process' import process from 'node:process' export type Component = 'capgo' | 'cli' | 'notifications' export type ReleaseAs = 'patch' | 'minor' | 'major' type GitRunner = (args: string[]) => string export interface PendingReleaseScope { base: string | null releaseAs: ReleaseAs shouldRelease: boolean } const capgoRootMatchers = [ /^package\.json$/, /^bun\.lock$/, /^\.npmrc$/, /^\.typos\.toml$/, /^bunfig\.toml$/, /^tsconfig\.json$/, /^vitest\.config\.ts$/, /^vitest\.config\.cloudflare\.ts$/, /^vitest\.config\.cloudflare-plugin\.ts$/, ] export const componentMatchers: Record = { capgo: [ ...capgoRootMatchers, /^\.github\/workflows\/build_and_deploy\.yml$/, /^\.github\/workflows\/build_mobile_ios\.yml$/, /^scripts\/deploy-scope\.ts$/, /^scripts\/(check-read-replica-hyperdrive-schema\.sh|sync-read-replica-schema\.ts)$/, /^aliproxy\//, /^android\//, /^assets\//, /^cloudflare_workers\//, /^configs\.json$/, /^deno-env\.d\.ts$/, /^deno\.lock$/, /^icons\//, /^index\.html$/, /^internal\//, /^ionic\.config\.json$/, /^ios\//, /^jean\.json$/, /^messages\//, /^public\//, /^read_replicate\//, /^scriptable\//, /^scripts\/ensure-native-notification-queues\.ts$/, /^scripts\/ensure-r2-inventory-queues\.ts$/, /^shared\//, /^sql\//, /^src\//, /^supabase\//, /^capacitor\.config\.ts$/, /^vite\.config\.mts$/, /^wrangler\.jsonc$/, ], cli: [ /^cli\/src\//, /^cli\/skills\/[^/]+\/SKILL\.md$/, /^cli\/skills\/(?!.*\.(md|mdx)$)/, /^cli\/package\.json$/, /^cli\/build\.mjs$/, /^cli\/tsconfig\.json$/, /^cli\/\.npmrc$/, ], notifications: [ /^packages\/capacitor-notifications\//, ], } function runGit(args: string[]): string { return execFileSync('git', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], }).trim() } function isDescribeWithoutMatchingTag(error: unknown): boolean { if (!(error instanceof Error)) return false const stderr = typeof (error as NodeJS.ErrnoException & { stderr?: string }).stderr === 'string' ? (error as NodeJS.ErrnoException & { stderr: string }).stderr : '' const message = `${error.message}\n${stderr}` return /no names found|no tags can describe|no tags exactly match|not enough tags|not a valid object name/i.test(message) } function getCommitShas(before: string, after: string, run: GitRunner = runGit): string[] { const isZero = before === '' || /^0+$/.test(before) if (!isZero) { const commits = run(['rev-list', '--reverse', `${before}..${after}`]) return commits ? commits.split('\n').filter(Boolean) : [] } const parents = run(['rev-list', '--parents', '-n', '1', after]).split(' ') if (parents.length > 1) { const commits = run(['rev-list', '--reverse', `${after}^..${after}`]) return commits ? commits.split('\n').filter(Boolean) : [] } return [after] } function getChangedFiles(commit: string, run: GitRunner = runGit): string[] { const files = run(['show', '--format=', '--name-only', commit]) return files ? files.split('\n').filter(Boolean) : [] } function getCommitMessage(commit: string, run: GitRunner = runGit): { subject: string, body: string } { return { subject: run(['log', '-1', '--format=%s', commit]), body: run(['log', '-1', '--format=%b', commit]), } } export function matchesComponent(component: Component, files: string[]): boolean { return files.some(file => componentMatchers[component].some(pattern => pattern.test(file))) } export function getSeverity(subject: string, body: string): number { const conventionalMatch = subject.match(/^([a-z]+)(\([^)]+\))?(!)?:/) const hasBreakingChange = body.includes('BREAKING CHANGE:') || conventionalMatch?.[3] === '!' if (hasBreakingChange) { return 3 } if (conventionalMatch?.[1] === 'feat') { return 2 } return 1 } export function toReleaseAs(severity: number): ReleaseAs { if (severity >= 3) { return 'major' } if (severity === 2) { return 'minor' } return 'patch' } function resolveCommitsScope(component: Component, commits: string[], run: GitRunner) { let shouldRelease = false let highestSeverity = 0 for (const commit of commits) { const files = getChangedFiles(commit, run) if (!matchesComponent(component, files)) { continue } const message = getCommitMessage(commit, run) if (message.subject.startsWith('chore(auto-sync):')) continue shouldRelease = true highestSeverity = Math.max(highestSeverity, getSeverity(message.subject, message.body)) } return { shouldRelease, releaseAs: shouldRelease ? capReleaseAs(component, toReleaseAs(highestSeverity)) : 'patch' as ReleaseAs, } } /** * Capacitor plugins keep their major version equal to the Capacitor major they * target (8.x.y for Capacitor 8), so a breaking change only bumps the minor. * The major moves by hand together with the Capacitor upgrade. */ export function capReleaseAs(component: Component, releaseAs: ReleaseAs): ReleaseAs { if (component === 'notifications' && releaseAs === 'major') return 'minor' return releaseAs } export function resolveReleaseScope(component: Component, before: string, after: string, run: GitRunner = runGit) { const commits = getCommitShas(before, after, run) return resolveCommitsScope(component, commits, run) } export function resolvePendingReleaseScope( component: Component, after: string, includePrereleaseTags: boolean, run: GitRunner = runGit, ): PendingReleaseScope { const describeArgs = includePrereleaseTags ? ['describe', '--tags', '--match', `${component}-*-alpha.*`, '--abbrev=0', after] : [ 'describe', '--tags', '--match', `${component}-[0-9]*`, '--exclude', `${component}-*-alpha.*`, '--abbrev=0', after, ] let base: string | null = null try { base = run(describeArgs) || null } catch (error) { if (!isDescribeWithoutMatchingTag(error)) throw error base = null } const range = base ? `${base}..${after}` : after const commitsOutput = run(['rev-list', '--reverse', range]) const commits = commitsOutput ? commitsOutput.split('\n').filter(Boolean) : [] const scope = resolveCommitsScope(component, commits, run) return { base, ...scope, } } if (import.meta.main) { const componentArg = process.argv[2] const modeOrBefore = process.argv[3] ?? '' const after = process.argv[4] ?? 'HEAD' if (componentArg !== 'capgo' && componentArg !== 'cli' && componentArg !== 'notifications') { console.error('Usage: bun scripts/release-scope.ts [--latest-stable|--latest-alpha|before] [after]') process.exit(1) } const isLatestMode = modeOrBefore === '--latest-stable' || modeOrBefore === '--latest-alpha' const scope = isLatestMode ? resolvePendingReleaseScope(componentArg, after, modeOrBefore === '--latest-alpha') : resolveReleaseScope(componentArg, modeOrBefore, after) if ('base' in scope) console.log(`base=${scope.base ?? ''}`) console.log(`should_release=${scope.shouldRelease}`) console.log(`release_as=${scope.releaseAs}`) }