Skip to content

Commit cec48ea

Browse files
author
Søren Louv-Jansen
committed
Add release script
1 parent 27c0e65 commit cec48ea

3 files changed

Lines changed: 300 additions & 1 deletion

File tree

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
"prepare": "husky",
99
"lint": "eslint './**/*.{ts,js}'",
1010
"test": "vitest run",
11-
"test:e2e": "npx tsx scripts/smoke-test/smoke-test.ts"
11+
"test:e2e": "npx tsx scripts/smoke-test/smoke-test.ts",
12+
"release": "npx tsx scripts/release/release.ts"
1213
},
1314
"engines": {
1415
"node": ">=22.0.0"

scripts/release/release.ts

Lines changed: 294 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,294 @@
1+
import { execSync } from 'node:child_process';
2+
import { readFileSync, writeFileSync } from 'node:fs';
3+
import { resolve, dirname } from 'node:path';
4+
import { createInterface } from 'node:readline';
5+
import { fileURLToPath } from 'node:url';
6+
import chalk from 'chalk';
7+
8+
const __dirname = dirname(fileURLToPath(import.meta.url));
9+
const ACTION_ROOT = resolve(__dirname, '../..');
10+
const BACKPORT_ROOT = resolve(ACTION_ROOT, '../backport');
11+
12+
// ---------------------------------------------------------------------------
13+
// Helpers
14+
// ---------------------------------------------------------------------------
15+
16+
function log(msg: string) {
17+
console.error(`${chalk.cyan('[release]')} ${msg}`);
18+
}
19+
20+
function step(msg: string) {
21+
console.error('');
22+
console.error(`${chalk.cyan.bold('[release]')} ${chalk.bold(msg)}`);
23+
console.error(`${chalk.cyan('[release]')} ${'='.repeat(60)}`);
24+
}
25+
26+
function run(cmd: string, cwd: string): string {
27+
log(`$ ${chalk.dim(cmd)}`);
28+
return execSync(cmd, { cwd, stdio: ['inherit', 'pipe', 'inherit'] })
29+
.toString()
30+
.trim();
31+
}
32+
33+
function runPassthrough(cmd: string, cwd: string): void {
34+
log(`$ ${chalk.dim(cmd)}`);
35+
execSync(cmd, { cwd, stdio: 'inherit' });
36+
}
37+
38+
function waitForEnter(prompt: string): Promise<void> {
39+
const rl = createInterface({ input: process.stdin, output: process.stderr });
40+
return new Promise((resolve) => {
41+
rl.question(`${chalk.yellow.bold('[ACTION REQUIRED]')} ${prompt}`, () => {
42+
rl.close();
43+
resolve();
44+
});
45+
});
46+
}
47+
48+
function readJson(filePath: string): Record<string, unknown> {
49+
return JSON.parse(readFileSync(filePath, 'utf-8'));
50+
}
51+
52+
function replaceInFile(
53+
filePath: string,
54+
search: string | RegExp,
55+
replacement: string,
56+
): number {
57+
const content = readFileSync(filePath, 'utf-8');
58+
const updated = content.replaceAll(search, replacement);
59+
if (content === updated) return 0;
60+
writeFileSync(filePath, updated);
61+
const count =
62+
typeof search === 'string'
63+
? content.split(search).length - 1
64+
: (content.match(search) ?? []).length;
65+
return count;
66+
}
67+
68+
// ---------------------------------------------------------------------------
69+
// Parse arguments
70+
// ---------------------------------------------------------------------------
71+
72+
const version = process.argv[2];
73+
if (!version || !/^\d+\.\d+\.\d+(-[\w.]+)?$/.test(version)) {
74+
console.error(
75+
`Usage: npm run release -- <version>\n\nExamples:\n npm run release -- 12.0.0 (stable)\n npm run release -- 12.0.0-beta.0 (beta)`,
76+
);
77+
process.exit(1);
78+
}
79+
80+
const isBeta = version.includes('-');
81+
const major = version.split('.')[0]!;
82+
const mode = isBeta ? 'beta' : 'stable';
83+
84+
log(`Version: ${chalk.bold(version)}`);
85+
log(`Mode: ${chalk.bold(mode)}`);
86+
log(`Major: ${chalk.bold(major)}`);
87+
88+
// Detect current major from README (for stable releases with major bumps)
89+
const readmePath = resolve(ACTION_ROOT, 'README.md');
90+
const readmeContent = readFileSync(readmePath, 'utf-8');
91+
const currentMajorMatch = readmeContent.match(/backport-github-action@v(\d+)/);
92+
const currentMajor = currentMajorMatch?.[1];
93+
94+
if (!currentMajor) {
95+
console.error('Could not detect current major version from README.md');
96+
process.exit(1);
97+
}
98+
99+
const isMajorBump = major !== currentMajor;
100+
log(
101+
`Current major: ${chalk.bold(currentMajor)}${isMajorBump ? chalk.bold(`${major} (major bump)`) : 'same'}`,
102+
);
103+
104+
// ---------------------------------------------------------------------------
105+
// Phase 1: Release backport npm package
106+
// ---------------------------------------------------------------------------
107+
108+
step('Phase 1: Release backport npm package');
109+
110+
const backportStatus = run('git status --porcelain', BACKPORT_ROOT);
111+
if (backportStatus) {
112+
console.error(
113+
`Error: backport repo has uncommitted changes:\n${backportStatus}`,
114+
);
115+
process.exit(1);
116+
}
117+
118+
const backportBranch = run('git rev-parse --abbrev-ref HEAD', BACKPORT_ROOT);
119+
if (backportBranch !== 'main') {
120+
console.error(
121+
`Error: backport repo is on branch "${backportBranch}", expected "main"`,
122+
);
123+
process.exit(1);
124+
}
125+
126+
log('Building backport...');
127+
runPassthrough('npm run build', BACKPORT_ROOT);
128+
129+
log(`Running npm version ${version}...`);
130+
run(`npm version ${version}`, BACKPORT_ROOT);
131+
132+
log('Pushing to origin...');
133+
runPassthrough('git push origin main --tags', BACKPORT_ROOT);
134+
135+
const publishCmd = isBeta ? 'npm publish --tag beta' : 'npm publish';
136+
await waitForEnter(
137+
`Run ${chalk.bold(publishCmd)} in ${chalk.bold(BACKPORT_ROOT)}\nPress Enter when done...`,
138+
);
139+
140+
log('Verifying publish...');
141+
const publishedVersion = run(
142+
`npm view backport@${version} version`,
143+
BACKPORT_ROOT,
144+
);
145+
if (publishedVersion !== version) {
146+
console.error(
147+
`Error: expected backport@${version} on npm, got "${publishedVersion}"`,
148+
);
149+
process.exit(1);
150+
}
151+
log(`Verified: backport@${version} is on npm`);
152+
153+
// ---------------------------------------------------------------------------
154+
// Phase 2: Update backport-github-action
155+
// ---------------------------------------------------------------------------
156+
157+
step('Phase 2: Update backport-github-action');
158+
159+
if (isBeta) {
160+
const betaBranch = `v${major}-beta`;
161+
const currentBranch = run('git rev-parse --abbrev-ref HEAD', ACTION_ROOT);
162+
163+
if (currentBranch !== betaBranch) {
164+
const branchExists =
165+
run('git branch --list ' + betaBranch, ACTION_ROOT) !== '';
166+
if (branchExists) {
167+
log(`Switching to existing branch ${betaBranch}`);
168+
runPassthrough(`git checkout ${betaBranch}`, ACTION_ROOT);
169+
} else {
170+
log(`Creating branch ${betaBranch}`);
171+
runPassthrough(`git checkout -b ${betaBranch}`, ACTION_ROOT);
172+
}
173+
} else {
174+
log(`Already on ${betaBranch}`);
175+
}
176+
} else {
177+
const currentBranch = run('git rev-parse --abbrev-ref HEAD', ACTION_ROOT);
178+
if (currentBranch !== 'main') {
179+
console.error(
180+
`Error: action repo is on branch "${currentBranch}", expected "main" for stable release`,
181+
);
182+
process.exit(1);
183+
}
184+
}
185+
186+
const pkgPath = resolve(ACTION_ROOT, 'package.json');
187+
log('Updating package.json...');
188+
replaceInFile(
189+
pkgPath,
190+
`"version": "${readJson(pkgPath).version}"`,
191+
`"version": "${version}"`,
192+
);
193+
const currentBackportDep = (
194+
readJson(pkgPath) as { dependencies: Record<string, string> }
195+
).dependencies.backport;
196+
replaceInFile(
197+
pkgPath,
198+
`"backport": "${currentBackportDep}"`,
199+
`"backport": "${version}"`,
200+
);
201+
202+
if (!isBeta && isMajorBump) {
203+
log('Updating README.md version references...');
204+
const readmeCount = replaceInFile(
205+
readmePath,
206+
`backport-github-action@v${currentMajor}`,
207+
`backport-github-action@v${major}`,
208+
);
209+
log(` Updated ${readmeCount} occurrences in README.md`);
210+
211+
log('Updating smoke test restore target...');
212+
const smokeTestPath = resolve(
213+
ACTION_ROOT,
214+
'scripts/smoke-test/github-api.ts',
215+
);
216+
replaceInFile(
217+
smokeTestPath,
218+
`Restoring workflow to original version (@v${currentMajor})`,
219+
`Restoring workflow to original version (@v${major})`,
220+
);
221+
replaceInFile(
222+
smokeTestPath,
223+
`makeWorkflowContent('v${currentMajor}')`,
224+
`makeWorkflowContent('v${major}')`,
225+
);
226+
replaceInFile(
227+
smokeTestPath,
228+
`e2e: restore action to @v${currentMajor}`,
229+
`e2e: restore action to @v${major}`,
230+
);
231+
} else if (!isBeta) {
232+
log('Same major version — skipping README and smoke test updates');
233+
}
234+
235+
log('Running npm install...');
236+
runPassthrough('npm install', ACTION_ROOT);
237+
238+
log('Committing...');
239+
runPassthrough(
240+
`git add -A && git commit -m "chore: release v${version}"`,
241+
ACTION_ROOT,
242+
);
243+
244+
// ---------------------------------------------------------------------------
245+
// Phase 3: Tag and push
246+
// ---------------------------------------------------------------------------
247+
248+
step('Phase 3: Tag and push backport-github-action');
249+
250+
if (isBeta) {
251+
const betaBranch = `v${major}-beta`;
252+
log(`Pushing ${betaBranch}...`);
253+
runPassthrough(`git push origin ${betaBranch}`, ACTION_ROOT);
254+
} else {
255+
log('Creating tags...');
256+
run(`git tag -f v${major}`, ACTION_ROOT);
257+
run(`git tag v${version}`, ACTION_ROOT);
258+
259+
log('Pushing...');
260+
runPassthrough('git push origin main', ACTION_ROOT);
261+
runPassthrough(`git push origin v${major} v${version} --force`, ACTION_ROOT);
262+
263+
log('Creating GitHub Release...');
264+
run(
265+
`gh release create v${version} --title "v${version}" --notes "Update backport to ${version}" --latest`,
266+
ACTION_ROOT,
267+
);
268+
}
269+
270+
// ---------------------------------------------------------------------------
271+
// Phase 4: Next steps
272+
// ---------------------------------------------------------------------------
273+
274+
step('Done!');
275+
276+
if (isBeta) {
277+
const betaBranch = `v${major}-beta`;
278+
log(`Beta ${chalk.bold(`v${version}`)} published!`);
279+
console.error('');
280+
log('Next steps:');
281+
log(
282+
` 1. Run smoke test: ${chalk.bold(`npm run test:e2e -- ${betaBranch}`)}`,
283+
);
284+
log(` 2. Verify: https://www.npmjs.com/package/backport/v/${version}`);
285+
} else {
286+
log(`Release ${chalk.bold(`v${version}`)} complete!`);
287+
console.error('');
288+
log('Next steps:');
289+
log(` 1. Run smoke test: ${chalk.bold(`npm run test:e2e -- v${major}`)}`);
290+
log(
291+
` 2. Verify: https://github.com/sorenlouv/backport-github-action/releases`,
292+
);
293+
log(` 3. Verify: https://www.npmjs.com/package/backport/v/${version}`);
294+
}

scripts/release/tsconfig.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"extends": "../../tsconfig.json",
3+
"include": ["./**/*"]
4+
}

0 commit comments

Comments
 (0)