Strip the large one-shot release driver comment block at the top of tools/release.mjs and apply minor formatting cleanups (compact ok() logging line and adjust spacing for the `ahead` variable). No functional changes intended — just cleanup of comments and whitespace.
222 lines
7.2 KiB
JavaScript
222 lines
7.2 KiB
JavaScript
#!/usr/bin/env node
|
|
import { execSync, spawnSync } from 'node:child_process';
|
|
import { readFile } from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
const repoRoot = path.resolve(__dirname, '..');
|
|
|
|
// ───────────── arg parsing ─────────────
|
|
|
|
const args = process.argv.slice(2);
|
|
const flags = new Set(args.filter(a => a.startsWith('--')));
|
|
const positional = args.filter(a => !a.startsWith('--'));
|
|
|
|
const version = positional[0];
|
|
const dryRun = flags.has('--dry-run');
|
|
const noWait = flags.has('--no-wait');
|
|
|
|
if (!version) {
|
|
console.error('Usage: pnpm release <version> [--no-wait] [--dry-run]');
|
|
console.error('Example: pnpm release 1.8.0');
|
|
process.exit(1);
|
|
}
|
|
if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(version)) {
|
|
console.error(`Invalid semver: "${version}"`);
|
|
process.exit(1);
|
|
}
|
|
|
|
const tag = `v${version}`;
|
|
const releaseCommit = `chore(release): ${tag}`;
|
|
|
|
// ───────────── shell helpers ─────────────
|
|
|
|
function sh(cmd, { capture = false, allowFail = false } = {}) {
|
|
if (dryRun) {
|
|
console.log(`[dry-run] $ ${cmd}`);
|
|
return '';
|
|
}
|
|
if (capture) {
|
|
return execSync(cmd, { cwd: repoRoot, encoding: 'utf-8' }).trim();
|
|
}
|
|
const result = spawnSync(cmd, { cwd: repoRoot, shell: true, stdio: 'inherit' });
|
|
if (result.status !== 0 && !allowFail) {
|
|
throw new Error(`Command failed: ${cmd}`);
|
|
}
|
|
return '';
|
|
}
|
|
|
|
function shCapture(cmd) {
|
|
return execSync(cmd, { cwd: repoRoot, encoding: 'utf-8' }).trim();
|
|
}
|
|
|
|
function which(bin) {
|
|
try {
|
|
execSync(`command -v ${bin}`, { stdio: 'ignore' });
|
|
return true;
|
|
} catch { return false; }
|
|
}
|
|
|
|
function ok(msg) { console.log(`\x1b[32m✓\x1b[0m ${msg}`); }
|
|
function info(msg) { console.log(`\x1b[36mi\x1b[0m ${msg}`); }
|
|
function warn(msg) { console.log(`\x1b[33m!\x1b[0m ${msg}`); }
|
|
|
|
// ───────────── step 1: preflight ─────────────
|
|
|
|
function preflightFail(msg) {
|
|
if (dryRun) { warn(`[dry-run] would have aborted: ${msg}`); return; }
|
|
console.error(msg);
|
|
process.exit(1);
|
|
}
|
|
|
|
function preflight() {
|
|
const branch = shCapture('git rev-parse --abbrev-ref HEAD');
|
|
if (branch !== 'dev') {
|
|
preflightFail(`Must be on \`dev\` to start a release. Current: \`${branch}\``);
|
|
}
|
|
|
|
const dirty = shCapture('git status --porcelain');
|
|
if (dirty) {
|
|
preflightFail(`Working tree is not clean. Commit or stash first.\n${dirty}`);
|
|
}
|
|
|
|
// Make sure we're up to date with origin/dev so the bump commit doesn't
|
|
// collide with someone else's push.
|
|
shCapture('git fetch origin dev --quiet');
|
|
const local = shCapture('git rev-parse dev');
|
|
const remote = shCapture('git rev-parse origin/dev');
|
|
if (local !== remote) {
|
|
const ahead = shCapture('git rev-list --count origin/dev..dev');
|
|
const behind = shCapture('git rev-list --count dev..origin/dev');
|
|
preflightFail(`dev is not in sync with origin/dev (ahead=${ahead}, behind=${behind}). Pull / push first.`);
|
|
}
|
|
|
|
if (!dryRun) ok(`on dev, clean, in sync with origin/dev`);
|
|
}
|
|
|
|
// ───────────── step 2-4: bump + commit + push dev ─────────────
|
|
|
|
function bumpAndPushDev() {
|
|
sh(`node tools/bump-version.mjs ${version}`);
|
|
ok(`bumped all package.json files to ${version}`);
|
|
|
|
if (dryRun) {
|
|
info(`would commit "${releaseCommit}" and push origin dev`);
|
|
return;
|
|
}
|
|
|
|
const changed = shCapture('git status --porcelain');
|
|
if (!changed) {
|
|
// Already at this version. Probably re-running. Still try to push tag.
|
|
warn(`no version changes — package.json already at ${version}, skipping commit`);
|
|
return;
|
|
}
|
|
|
|
sh('git add package.json packages/*/package.json');
|
|
sh(`git commit -m "${releaseCommit}"`);
|
|
ok(`committed ${releaseCommit}`);
|
|
|
|
sh('git push origin dev');
|
|
ok(`pushed to origin/dev — Promote workflow should kick off shortly`);
|
|
}
|
|
|
|
// ───────────── step 5: wait for Promote PR + tag main ─────────────
|
|
|
|
async function waitAndTag() {
|
|
if (!which('gh')) {
|
|
warn('`gh` CLI not found — skipping auto-tag.');
|
|
info('Once the Promote PR merges, run:');
|
|
info(` git fetch && git checkout main && git pull && git tag ${tag} && git push origin ${tag}`);
|
|
return;
|
|
}
|
|
|
|
info('waiting for the Promote PR (head: dev → base: main) to merge...');
|
|
info('press Ctrl-C if you want to handle main / tag manually.');
|
|
|
|
const startedAt = Date.now();
|
|
const TIMEOUT_MS = 30 * 60 * 1000; // 30 min hard cap
|
|
const POLL_MS = 15 * 1000;
|
|
|
|
let prNumber = null;
|
|
while (Date.now() - startedAt < TIMEOUT_MS) {
|
|
if (!prNumber) {
|
|
try {
|
|
const list = shCapture(`gh pr list --base main --head dev --state all --json number,mergedAt,state --limit 5`);
|
|
const prs = JSON.parse(list);
|
|
// Pick the newest one that's open or merged after we pushed.
|
|
const fresh = prs.find(p => p.state === 'OPEN' || (p.mergedAt && Date.parse(p.mergedAt) > startedAt - 2 * POLL_MS));
|
|
if (fresh) {
|
|
prNumber = fresh.number;
|
|
info(`tracking PR #${prNumber}`);
|
|
}
|
|
} catch (e) {
|
|
warn(`gh pr list failed: ${e.message}`);
|
|
}
|
|
}
|
|
|
|
if (prNumber) {
|
|
try {
|
|
const view = shCapture(`gh pr view ${prNumber} --json state,mergedAt`);
|
|
const pr = JSON.parse(view);
|
|
if (pr.state === 'MERGED') {
|
|
ok(`PR #${prNumber} merged at ${pr.mergedAt}`);
|
|
break;
|
|
}
|
|
if (pr.state === 'CLOSED') {
|
|
console.error(`PR #${prNumber} was closed without merging — aborting tag step.`);
|
|
process.exit(1);
|
|
}
|
|
} catch (e) {
|
|
warn(`gh pr view failed: ${e.message}`);
|
|
}
|
|
}
|
|
|
|
await new Promise(r => setTimeout(r, POLL_MS));
|
|
}
|
|
|
|
if (Date.now() - startedAt >= TIMEOUT_MS) {
|
|
warn('30-minute timeout reached without seeing the PR merge.');
|
|
info(`Once it does, finish manually: git checkout main && git pull && git tag ${tag} && git push origin ${tag}`);
|
|
return;
|
|
}
|
|
|
|
sh('git fetch origin main --quiet');
|
|
sh('git checkout main');
|
|
sh('git pull origin main');
|
|
|
|
// Sanity: package.json on main matches version
|
|
const mainPkg = JSON.parse(await readFile(path.join(repoRoot, 'package.json'), 'utf-8'));
|
|
if (mainPkg.version !== version) {
|
|
warn(`main package.json is ${mainPkg.version} but you asked for ${version}.`);
|
|
warn('Tagging anyway, but the release artifact name may not match.');
|
|
}
|
|
|
|
sh(`git tag ${tag}`);
|
|
sh(`git push origin ${tag}`);
|
|
ok(`tagged and pushed ${tag} — release workflow will pick it up`);
|
|
|
|
// Hop back to dev so the user lands where they started.
|
|
sh('git checkout dev');
|
|
ok('back on dev');
|
|
}
|
|
|
|
// ───────────── go ─────────────
|
|
|
|
(async () => {
|
|
console.log(`Release pipeline → ${tag}${dryRun ? ' (dry-run)' : ''}\n`);
|
|
preflight();
|
|
bumpAndPushDev();
|
|
|
|
if (noWait) {
|
|
info('--no-wait set; not polling for PR merge.');
|
|
info(`Once it merges: git checkout main && git pull && git tag ${tag} && git push origin ${tag}`);
|
|
return;
|
|
}
|
|
|
|
await waitAndTag();
|
|
})().catch(err => {
|
|
console.error(err.stack || err.message);
|
|
process.exit(1);
|
|
});
|