feat: add release helper

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-04-16 09:30:17 +02:00
co-authored by Copilot
parent c7910e6e4b
commit 59ea028a24
4 changed files with 539 additions and 0 deletions
+3
View File
@@ -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
+1
View File
@@ -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",
+313
View File
@@ -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<GitCommandResult>;
export interface ReleaseDependencies {
readText(path: string): Promise<string>;
writeText(path: string, content: string): Promise<void>;
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<GitCommandResult> {
const result = await runGit(args);
if (result.exitCode !== 0) {
throw new Error(`${context} ${formatGitFailure(args, result)}`);
}
return result;
}
async function ensureCleanWorktree(runGit: GitRunner): Promise<void> {
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<string> {
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<UpstreamTarget> {
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<void> {
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<ReleaseResult> {
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<void> {
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;
});
}
+222
View File
@@ -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'],
]);
});
});