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