mirror of
https://github.com/davidkaya/aryx.git
synced 2026-07-23 13:08:36 +02:00
feat: add release helper
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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'],
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user