diff --git a/README.md b/README.md index a7c6ef6..af0f088 100644 --- a/README.md +++ b/README.md @@ -95,9 +95,12 @@ bun run build # full build (electron + sidecar) bun run package # package for current platform → release/ bun run installer # create installable artifact bun run publish-release # publish to GitHub Releases +bun run release # bump patch version, commit, tag, and push +bun run release minor # bump minor version instead (use major for breaking releases) ``` Tagged releases use GitHub Actions to build and publish Windows (NSIS), macOS (DMG, signed + notarized), and Linux (AppImage) artifacts. The app uses `electron-updater` for in-app updates. +The release helper expects a clean git worktree, an upstream branch configured for the current branch, and permission to push commits and tags. ## Trademarks diff --git a/package.json b/package.json index 453009a..78fb520 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "package": "bun run scripts/clean-release.ts && bun run build:electron && bun run sidecar:publish && electron-builder --dir --publish never", "installer": "bun run scripts/clean-release.ts && bun run build:electron && bun run sidecar:publish && electron-builder --publish never", "publish-release": "bun run scripts/clean-release.ts && bun run build:electron && bun run sidecar:publish && electron-builder --publish always", + "release": "bun run scripts/release.ts", "preview": "electron-vite preview", "lsp:typescript": "typescript-language-server --stdio", "typecheck": "tsc --noEmit -p tsconfig.json", diff --git a/scripts/release.ts b/scripts/release.ts new file mode 100644 index 0000000..a87a5f3 --- /dev/null +++ b/scripts/release.ts @@ -0,0 +1,313 @@ +import { spawn } from 'node:child_process'; +import { readFile, writeFile } from 'node:fs/promises'; +import { dirname, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const scriptDirectory = dirname(fileURLToPath(import.meta.url)); +const repositoryRoot = resolve(scriptDirectory, '..'); +const packageJsonPath = resolve(repositoryRoot, 'package.json'); + +export type ReleaseBump = 'patch' | 'minor' | 'major'; + +interface PackageJsonShape { + version?: unknown; + [key: string]: unknown; +} + +export interface GitCommandResult { + exitCode: number; + stdout: string; + stderr: string; +} + +export type GitRunner = (args: string[]) => Promise; + +export interface ReleaseDependencies { + readText(path: string): Promise; + writeText(path: string, content: string): Promise; + runGit: GitRunner; + log(message: string): void; +} + +export interface ReleaseWorkflowOptions { + repositoryRoot: string; + packageJsonPath: string; + bumpArg?: string; +} + +export interface ReleaseResult { + bump: ReleaseBump; + currentVersion: string; + nextVersion: string; + tagName: string; + localBranch: string; + remote: string; + upstreamBranch: string; +} + +interface UpstreamTarget { + remote: string; + branch: string; +} + +function createGitRunner(cwd: string): GitRunner { + return async (args) => + new Promise((resolvePromise, rejectPromise) => { + const child = spawn('git', args, { + cwd, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + let stdout = ''; + let stderr = ''; + + child.stdout?.on('data', (chunk: Buffer | string) => { + stdout += chunk.toString(); + }); + + child.stderr?.on('data', (chunk: Buffer | string) => { + stderr += chunk.toString(); + }); + + child.on('error', rejectPromise); + child.on('close', (code, signal) => { + if (signal) { + rejectPromise(new Error(`git ${args.join(' ')} exited because of signal ${signal}.`)); + return; + } + + resolvePromise({ + exitCode: code ?? 1, + stdout, + stderr, + }); + }); + }); +} + +function createReleaseDependencies(cwd: string): ReleaseDependencies { + return { + readText: (path) => readFile(path, 'utf8'), + writeText: (path, content) => writeFile(path, content, 'utf8'), + runGit: createGitRunner(cwd), + log: console.log, + }; +} + +function parsePackageJson(packageJsonText: string): PackageJsonShape { + try { + return JSON.parse(packageJsonText) as PackageJsonShape; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Could not parse package.json: ${message}`); + } +} + +export function parseReleaseBump(value?: string): ReleaseBump { + if (value === undefined) { + return 'patch'; + } + + if (value === 'patch' || value === 'minor' || value === 'major') { + return value; + } + + throw new Error(`Unsupported release bump "${value}". Use patch, minor, or major.`); +} + +export function readPackageVersion(packageJsonText: string): string { + const packageJson = parsePackageJson(packageJsonText); + + if (typeof packageJson.version !== 'string') { + throw new Error('package.json is missing a string version field.'); + } + + return packageJson.version; +} + +function parseSemver(version: string): [number, number, number] { + const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.exec(version); + + if (!match) { + throw new Error(`Unsupported version "${version}". Expected a simple x.y.z semantic version.`); + } + + return [Number(match[1]), Number(match[2]), Number(match[3])]; +} + +export function incrementVersion(version: string, bump: ReleaseBump): string { + const [major, minor, patch] = parseSemver(version); + + switch (bump) { + case 'major': + return `${major + 1}.0.0`; + case 'minor': + return `${major}.${minor + 1}.0`; + case 'patch': + return `${major}.${minor}.${patch + 1}`; + } +} + +function detectNewline(packageJsonText: string): '\r\n' | '\n' { + return packageJsonText.includes('\r\n') ? '\r\n' : '\n'; +} + +export function updatePackageJsonVersion(packageJsonText: string, nextVersion: string): string { + const newline = detectNewline(packageJsonText); + const packageJson = parsePackageJson(packageJsonText); + + if (typeof packageJson.version !== 'string') { + throw new Error('package.json is missing a string version field.'); + } + + packageJson.version = nextVersion; + + return `${JSON.stringify(packageJson, null, 2).replace(/\n/g, newline)}${newline}`; +} + +function formatGitFailure(args: string[], result: GitCommandResult): string { + const detail = result.stderr.trim() || result.stdout.trim(); + + if (detail.length > 0) { + return `git ${args.join(' ')} failed: ${detail}`; + } + + return `git ${args.join(' ')} failed with exit code ${result.exitCode}.`; +} + +async function runGitOrThrow(runGit: GitRunner, args: string[], context: string): Promise { + const result = await runGit(args); + + if (result.exitCode !== 0) { + throw new Error(`${context} ${formatGitFailure(args, result)}`); + } + + return result; +} + +async function ensureCleanWorktree(runGit: GitRunner): Promise { + const result = await runGitOrThrow(runGit, ['status', '--porcelain'], 'Could not inspect the current git worktree.'); + + if (result.stdout.trim().length > 0) { + throw new Error('Release requires a clean git worktree. Commit, stash, or discard changes first.'); + } +} + +async function resolveLocalBranch(runGit: GitRunner): Promise { + const result = await runGit(['symbolic-ref', '--quiet', '--short', 'HEAD']); + + if (result.exitCode !== 0) { + throw new Error('Release requires a checked-out branch. Detached HEAD is not supported.'); + } + + const branch = result.stdout.trim(); + + if (branch.length === 0) { + throw new Error('Release could not determine the current branch name.'); + } + + return branch; +} + +function normalizeUpstreamBranch(mergeRef: string): string { + const prefix = 'refs/heads/'; + + if (!mergeRef.startsWith(prefix)) { + throw new Error(`Release expected an upstream branch ref, received "${mergeRef}".`); + } + + return mergeRef.slice(prefix.length); +} + +async function resolveUpstreamTarget(runGit: GitRunner, localBranch: string): Promise { + const remoteResult = await runGit(['config', '--get', `branch.${localBranch}.remote`]); + const mergeResult = await runGit(['config', '--get', `branch.${localBranch}.merge`]); + + if (remoteResult.exitCode !== 0 || mergeResult.exitCode !== 0) { + throw new Error(`Release requires an upstream branch for ${localBranch}. Configure tracking before running the release helper.`); + } + + const remote = remoteResult.stdout.trim(); + const mergeRef = mergeResult.stdout.trim(); + + if (remote.length === 0 || mergeRef.length === 0) { + throw new Error(`Release requires an upstream branch for ${localBranch}. Configure tracking before running the release helper.`); + } + + return { + remote, + branch: normalizeUpstreamBranch(mergeRef), + }; +} + +async function ensureTagDoesNotExist(runGit: GitRunner, tagName: string): Promise { + const result = await runGitOrThrow(runGit, ['tag', '--list', tagName], 'Could not inspect existing git tags.'); + + if (result.stdout.trim().length > 0) { + throw new Error(`Tag ${tagName} already exists. Choose a different release version.`); + } +} + +export async function runReleaseWorkflow( + options: ReleaseWorkflowOptions, + dependencies: ReleaseDependencies, +): Promise { + const bump = parseReleaseBump(options.bumpArg); + const packageJsonText = await dependencies.readText(options.packageJsonPath); + const currentVersion = readPackageVersion(packageJsonText); + const nextVersion = incrementVersion(currentVersion, bump); + const tagName = `v${nextVersion}`; + const packageJsonFile = relative(options.repositoryRoot, options.packageJsonPath); + + await ensureCleanWorktree(dependencies.runGit); + + const localBranch = await resolveLocalBranch(dependencies.runGit); + const upstreamTarget = await resolveUpstreamTarget(dependencies.runGit, localBranch); + + await ensureTagDoesNotExist(dependencies.runGit, tagName); + + const updatedPackageJson = updatePackageJsonVersion(packageJsonText, nextVersion); + const commitMessage = `chore: release ${tagName}`; + const tagMessage = `Release ${tagName}`; + + await dependencies.writeText(options.packageJsonPath, updatedPackageJson); + await runGitOrThrow(dependencies.runGit, ['add', packageJsonFile], `Could not stage ${packageJsonFile}.`); + await runGitOrThrow(dependencies.runGit, ['commit', '-m', commitMessage], 'Could not create the release commit.'); + await runGitOrThrow(dependencies.runGit, ['tag', '-a', tagName, '-m', tagMessage], 'Could not create the annotated release tag.'); + await runGitOrThrow( + dependencies.runGit, + ['push', '--follow-tags', upstreamTarget.remote, `HEAD:${upstreamTarget.branch}`], + 'Could not push the release commit and tag.', + ); + + dependencies.log(`Released ${tagName} from ${localBranch} to ${upstreamTarget.remote}/${upstreamTarget.branch}.`); + + return { + bump, + currentVersion, + nextVersion, + tagName, + localBranch, + remote: upstreamTarget.remote, + upstreamBranch: upstreamTarget.branch, + }; +} + +export async function main(argv: string[]): Promise { + await runReleaseWorkflow( + { + repositoryRoot, + packageJsonPath, + bumpArg: argv[0], + }, + createReleaseDependencies(repositoryRoot), + ); +} + +if (import.meta.main) { + await main(process.argv.slice(2)).catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + console.error(message); + process.exitCode = 1; + }); +} diff --git a/tests/scripts/release.test.ts b/tests/scripts/release.test.ts new file mode 100644 index 0000000..3e2e7e8 --- /dev/null +++ b/tests/scripts/release.test.ts @@ -0,0 +1,222 @@ +import { describe, expect, test } from 'bun:test'; + +import { + incrementVersion, + parseReleaseBump, + runReleaseWorkflow, + type GitCommandResult, + type ReleaseDependencies, +} from '../../scripts/release'; + +function gitSuccess(stdout = '', stderr = ''): GitCommandResult { + return { + exitCode: 0, + stdout, + stderr, + }; +} + +function gitFailure(exitCode = 1, stdout = '', stderr = ''): GitCommandResult { + return { + exitCode, + stdout, + stderr, + }; +} + +function createReleaseDependencies(options?: { + packageJsonText?: string; + gitResults?: GitCommandResult[]; +}) { + let packageJsonText = + options?.packageJsonText ?? + `{ + "name": "aryx", + "version": "0.0.26" +} +`; + + const gitResults = [...(options?.gitResults ?? [])]; + const commands: string[][] = []; + const writes: Array<{ path: string; content: string }> = []; + const logs: string[] = []; + + const dependencies: ReleaseDependencies = { + readText: async () => packageJsonText, + writeText: async (path, content) => { + packageJsonText = content; + writes.push({ path, content }); + }, + runGit: async (args) => { + commands.push(args); + const result = gitResults.shift(); + + if (!result) { + throw new Error(`Unexpected git command: ${args.join(' ')}`); + } + + return result; + }, + log: (message) => { + logs.push(message); + }, + }; + + return { + dependencies, + commands, + writes, + logs, + getPackageJsonText: () => packageJsonText, + }; +} + +describe('release bump parsing', () => { + test('defaults to patch when no bump argument is provided', () => { + expect(parseReleaseBump()).toBe('patch'); + }); + + test('rejects unsupported bump arguments', () => { + expect(() => parseReleaseBump('prerelease')).toThrow( + 'Unsupported release bump "prerelease". Use patch, minor, or major.', + ); + }); +}); + +describe('version incrementing', () => { + test('supports patch, minor, and major bumps', () => { + expect(incrementVersion('0.0.26', 'patch')).toBe('0.0.27'); + expect(incrementVersion('0.0.26', 'minor')).toBe('0.1.0'); + expect(incrementVersion('0.0.26', 'major')).toBe('1.0.0'); + }); +}); + +describe('release workflow', () => { + test('bumps package.json, commits, tags, and pushes the tracked branch', async () => { + const { dependencies, commands, writes, logs, getPackageJsonText } = createReleaseDependencies({ + gitResults: [ + gitSuccess(), + gitSuccess('main\n'), + gitSuccess('origin\n'), + gitSuccess('refs/heads/main\n'), + gitSuccess(), + gitSuccess(), + gitSuccess(), + gitSuccess(), + gitSuccess(), + ], + }); + + const result = await runReleaseWorkflow( + { + repositoryRoot: 'C:\\workspace\\personal\\projects\\aryx', + packageJsonPath: 'C:\\workspace\\personal\\projects\\aryx\\package.json', + }, + dependencies, + ); + + expect(result).toMatchObject({ + bump: 'patch', + currentVersion: '0.0.26', + nextVersion: '0.0.27', + tagName: 'v0.0.27', + localBranch: 'main', + remote: 'origin', + upstreamBranch: 'main', + }); + expect(writes).toHaveLength(1); + expect(getPackageJsonText()).toContain('"version": "0.0.27"'); + expect(commands).toEqual([ + ['status', '--porcelain'], + ['symbolic-ref', '--quiet', '--short', 'HEAD'], + ['config', '--get', 'branch.main.remote'], + ['config', '--get', 'branch.main.merge'], + ['tag', '--list', 'v0.0.27'], + ['add', 'package.json'], + ['commit', '-m', 'chore: release v0.0.27'], + ['tag', '-a', 'v0.0.27', '-m', 'Release v0.0.27'], + ['push', '--follow-tags', 'origin', 'HEAD:main'], + ]); + expect(logs).toEqual(['Released v0.0.27 from main to origin/main.']); + }); + + test('fails before writing package.json when the worktree is dirty', async () => { + const { dependencies, commands, writes } = createReleaseDependencies({ + gitResults: [gitSuccess(' M README.md\n')], + }); + + await expect( + runReleaseWorkflow( + { + repositoryRoot: 'C:\\workspace\\personal\\projects\\aryx', + packageJsonPath: 'C:\\workspace\\personal\\projects\\aryx\\package.json', + }, + dependencies, + ), + ).rejects.toThrow('Release requires a clean git worktree. Commit, stash, or discard changes first.'); + + expect(writes).toHaveLength(0); + expect(commands).toEqual([['status', '--porcelain']]); + }); + + test('fails before writing package.json when the next release tag already exists', async () => { + const { dependencies, commands, writes } = createReleaseDependencies({ + gitResults: [ + gitSuccess(), + gitSuccess('main\n'), + gitSuccess('origin\n'), + gitSuccess('refs/heads/main\n'), + gitSuccess('v0.1.0\n'), + ], + }); + + await expect( + runReleaseWorkflow( + { + repositoryRoot: 'C:\\workspace\\personal\\projects\\aryx', + packageJsonPath: 'C:\\workspace\\personal\\projects\\aryx\\package.json', + bumpArg: 'minor', + }, + dependencies, + ), + ).rejects.toThrow('Tag v0.1.0 already exists. Choose a different release version.'); + + expect(writes).toHaveLength(0); + expect(commands).toEqual([ + ['status', '--porcelain'], + ['symbolic-ref', '--quiet', '--short', 'HEAD'], + ['config', '--get', 'branch.main.remote'], + ['config', '--get', 'branch.main.merge'], + ['tag', '--list', 'v0.1.0'], + ]); + }); + + test('fails when the current branch does not have an upstream', async () => { + const { dependencies, commands, writes } = createReleaseDependencies({ + gitResults: [ + gitSuccess(), + gitSuccess('release\n'), + gitFailure(), + gitFailure(), + ], + }); + + await expect( + runReleaseWorkflow( + { + repositoryRoot: 'C:\\workspace\\personal\\projects\\aryx', + packageJsonPath: 'C:\\workspace\\personal\\projects\\aryx\\package.json', + }, + dependencies, + ), + ).rejects.toThrow('Release requires an upstream branch for release. Configure tracking before running the release helper.'); + + expect(writes).toHaveLength(0); + expect(commands).toEqual([ + ['status', '--porcelain'], + ['symbolic-ref', '--quiet', '--short', 'HEAD'], + ['config', '--get', 'branch.release.remote'], + ['config', '--get', 'branch.release.merge'], + ]); + }); +});