Skip to content

Commit bb8e55e

Browse files
tinmancodingopencode
andcommitted
feat: add automated release script for version synchronization
Adds comprehensive release script that updates package.json version and creates matching git tags to keep CLI --version, package.json, and git tags in sync. πŸ€– Generated with [opencode](https://opencode.ai) Co-Authored-By: opencode <noreply@opencode.ai>
1 parent 447c486 commit bb8e55e

2 files changed

Lines changed: 276 additions & 0 deletions

File tree

β€Žpackage.jsonβ€Ž

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
"build:darwin-x64": "bun build --compile --minify --target=bun-darwin-x64 src/index.ts --outfile dist/wt-darwin-x64",
2020
"build:darwin-arm64": "bun build --compile --minify --target=bun-darwin-arm64 src/index.ts --outfile dist/wt-darwin-arm64",
2121
"install-bin": "bun run build && mkdir -p ~/.local/bin && cp ./wt ~/.local/bin/wt && echo 'wt binary installed to ~/.local/bin/wt'",
22+
"release": "bun run scripts/release.ts",
2223
"test": "bun test",
2324
"test:unit": "bun test --bail tests/unit/",
2425
"test:integration": "bun test --bail tests/integration/",

β€Žscripts/release.tsβ€Ž

Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,275 @@
1+
#!/usr/bin/env bun
2+
3+
/**
4+
* Release Script
5+
* Updates package.json version and creates matching git tag
6+
*
7+
* Usage:
8+
* bun run release # Auto-increment patch version
9+
* bun run release patch # Auto-increment patch version
10+
* bun run release minor # Auto-increment minor version
11+
* bun run release major # Auto-increment major version
12+
* bun run release 1.2.3 # Set specific version
13+
* bun run release --dry-run # Preview changes without applying
14+
*/
15+
16+
import { readFile, writeFile } from 'fs/promises';
17+
import { spawn } from 'child_process';
18+
import { join } from 'path';
19+
20+
interface PackageJson {
21+
version: string;
22+
[key: string]: any;
23+
}
24+
25+
class ReleaseError extends Error {
26+
constructor(message: string) {
27+
super(message);
28+
this.name = 'ReleaseError';
29+
}
30+
}
31+
32+
async function executeCommand(command: string, args: string[]): Promise<{ stdout: string; stderr: string; exitCode: number }> {
33+
return new Promise((resolve) => {
34+
const child = spawn(command, args, { stdio: 'pipe' });
35+
let stdout = '';
36+
let stderr = '';
37+
38+
child.stdout?.on('data', (data) => {
39+
stdout += data.toString();
40+
});
41+
42+
child.stderr?.on('data', (data) => {
43+
stderr += data.toString();
44+
});
45+
46+
child.on('close', (exitCode) => {
47+
resolve({ stdout: stdout.trim(), stderr: stderr.trim(), exitCode: exitCode || 0 });
48+
});
49+
});
50+
}
51+
52+
async function checkGitStatus(): Promise<void> {
53+
const { stdout, exitCode } = await executeCommand('git', ['status', '--porcelain']);
54+
55+
if (exitCode !== 0) {
56+
throw new ReleaseError('Failed to check git status');
57+
}
58+
59+
if (stdout.trim()) {
60+
throw new ReleaseError('Working directory is not clean. Please commit or stash your changes before releasing.');
61+
}
62+
}
63+
64+
async function getCurrentBranch(): Promise<string> {
65+
const { stdout, exitCode } = await executeCommand('git', ['branch', '--show-current']);
66+
67+
if (exitCode !== 0) {
68+
throw new ReleaseError('Failed to get current branch');
69+
}
70+
71+
return stdout.trim();
72+
}
73+
74+
function parseVersion(version: string): { major: number; minor: number; patch: number } {
75+
const match = version.match(/^(\d+)\.(\d+)\.(\d+)$/);
76+
if (!match) {
77+
throw new ReleaseError(`Invalid version format: ${version}. Expected format: x.y.z`);
78+
}
79+
80+
return {
81+
major: parseInt(match[1], 10),
82+
minor: parseInt(match[2], 10),
83+
patch: parseInt(match[3], 10)
84+
};
85+
}
86+
87+
function incrementVersion(currentVersion: string, type: 'major' | 'minor' | 'patch'): string {
88+
const { major, minor, patch } = parseVersion(currentVersion);
89+
90+
switch (type) {
91+
case 'major':
92+
return `${major + 1}.0.0`;
93+
case 'minor':
94+
return `${major}.${minor + 1}.0`;
95+
case 'patch':
96+
return `${major}.${minor}.${patch + 1}`;
97+
default:
98+
throw new ReleaseError(`Invalid increment type: ${type}`);
99+
}
100+
}
101+
102+
async function updatePackageJson(newVersion: string, dryRun: boolean): Promise<void> {
103+
const packageJsonPath = join(process.cwd(), 'package.json');
104+
105+
try {
106+
const content = await readFile(packageJsonPath, 'utf-8');
107+
const packageJson: PackageJson = JSON.parse(content);
108+
109+
const oldVersion = packageJson.version;
110+
packageJson.version = newVersion;
111+
112+
console.log(`πŸ“¦ Package version: ${oldVersion} β†’ ${newVersion}`);
113+
114+
if (!dryRun) {
115+
await writeFile(packageJsonPath, JSON.stringify(packageJson, null, 2) + '\n');
116+
console.log('βœ… Updated package.json');
117+
}
118+
} catch (error) {
119+
throw new ReleaseError(`Failed to update package.json: ${error instanceof Error ? error.message : 'Unknown error'}`);
120+
}
121+
}
122+
123+
async function createGitTag(version: string, dryRun: boolean): Promise<void> {
124+
const tagName = `v${version}`;
125+
126+
// Check if tag already exists
127+
const { exitCode: tagExistsCode } = await executeCommand('git', ['tag', '-l', tagName]);
128+
if (tagExistsCode === 0) {
129+
const { stdout } = await executeCommand('git', ['tag', '-l', tagName]);
130+
if (stdout.includes(tagName)) {
131+
throw new ReleaseError(`Tag ${tagName} already exists`);
132+
}
133+
}
134+
135+
console.log(`🏷️ Git tag: ${tagName}`);
136+
137+
if (!dryRun) {
138+
const { exitCode } = await executeCommand('git', ['tag', '-a', tagName, '-m', `Release ${tagName}`]);
139+
if (exitCode !== 0) {
140+
throw new ReleaseError(`Failed to create git tag ${tagName}`);
141+
}
142+
console.log('βœ… Created git tag');
143+
}
144+
}
145+
146+
async function getCurrentVersion(): Promise<string> {
147+
const packageJsonPath = join(process.cwd(), 'package.json');
148+
149+
try {
150+
const content = await readFile(packageJsonPath, 'utf-8');
151+
const packageJson: PackageJson = JSON.parse(content);
152+
return packageJson.version;
153+
} catch (error) {
154+
throw new ReleaseError(`Failed to read current version: ${error instanceof Error ? error.message : 'Unknown error'}`);
155+
}
156+
}
157+
158+
function showHelp(): void {
159+
console.log(`
160+
Release Script - Update package.json version and create git tag
161+
162+
Usage:
163+
bun run release [version|increment] [--dry-run]
164+
165+
Arguments:
166+
version Specific version (e.g., 1.2.3)
167+
increment Auto-increment type: patch (default), minor, major
168+
169+
Options:
170+
--dry-run Preview changes without applying them
171+
--help Show this help message
172+
173+
Examples:
174+
bun run release # Auto-increment patch version
175+
bun run release patch # Auto-increment patch version
176+
bun run release minor # Auto-increment minor version
177+
bun run release major # Auto-increment major version
178+
bun run release 1.2.3 # Set specific version
179+
bun run release --dry-run # Preview changes
180+
`);
181+
}
182+
183+
async function main(): Promise<void> {
184+
const args = process.argv.slice(2);
185+
186+
// Check for help flag
187+
if (args.includes('--help') || args.includes('-h')) {
188+
showHelp();
189+
return;
190+
}
191+
192+
// Check for dry-run flag
193+
const dryRun = args.includes('--dry-run');
194+
const filteredArgs = args.filter(arg => arg !== '--dry-run');
195+
196+
if (dryRun) {
197+
console.log('πŸ” DRY RUN MODE - No changes will be applied\n');
198+
}
199+
200+
try {
201+
// Pre-flight checks
202+
if (!dryRun) {
203+
console.log('πŸ” Checking git status...');
204+
await checkGitStatus();
205+
206+
const branch = await getCurrentBranch();
207+
console.log(`πŸ“ Current branch: ${branch}`);
208+
209+
if (branch !== 'main' && branch !== 'master') {
210+
console.log('⚠️ Warning: You are not on the main/master branch');
211+
}
212+
}
213+
214+
// Determine new version
215+
const currentVersion = await getCurrentVersion();
216+
console.log(`πŸ“‹ Current version: ${currentVersion}`);
217+
218+
let newVersion: string;
219+
const versionArg = filteredArgs[0];
220+
221+
if (!versionArg || versionArg === 'patch') {
222+
newVersion = incrementVersion(currentVersion, 'patch');
223+
} else if (versionArg === 'minor') {
224+
newVersion = incrementVersion(currentVersion, 'minor');
225+
} else if (versionArg === 'major') {
226+
newVersion = incrementVersion(currentVersion, 'major');
227+
} else if (/^\d+\.\d+\.\d+$/.test(versionArg)) {
228+
// Validate that new version is greater than current
229+
const current = parseVersion(currentVersion);
230+
const target = parseVersion(versionArg);
231+
232+
if (target.major < current.major ||
233+
(target.major === current.major && target.minor < current.minor) ||
234+
(target.major === current.major && target.minor === current.minor && target.patch <= current.patch)) {
235+
throw new ReleaseError(`New version ${versionArg} must be greater than current version ${currentVersion}`);
236+
}
237+
238+
newVersion = versionArg;
239+
} else {
240+
throw new ReleaseError(`Invalid argument: ${versionArg}. Use 'patch', 'minor', 'major', or a specific version like '1.2.3'`);
241+
}
242+
243+
console.log('');
244+
245+
// Update package.json
246+
await updatePackageJson(newVersion, dryRun);
247+
248+
// Create git tag
249+
await createGitTag(newVersion, dryRun);
250+
251+
console.log('');
252+
253+
if (dryRun) {
254+
console.log('πŸ” Dry run completed. Use without --dry-run to apply changes.');
255+
} else {
256+
console.log('πŸŽ‰ Release completed successfully!');
257+
console.log('');
258+
console.log('Next steps:');
259+
console.log(' 1. Push the tag: git push origin v' + newVersion);
260+
console.log(' 2. This will trigger the GitHub release workflow');
261+
}
262+
263+
} catch (error) {
264+
if (error instanceof ReleaseError) {
265+
console.error(`❌ ${error.message}`);
266+
process.exit(1);
267+
} else {
268+
console.error(`❌ Unexpected error: ${error instanceof Error ? error.message : 'Unknown error'}`);
269+
process.exit(1);
270+
}
271+
}
272+
}
273+
274+
// Run the script
275+
await main();

0 commit comments

Comments
Β (0)