Files
手瓜一十雪 aaed36aa19 Remove verbose header and tidy release script
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.
2026-05-26 18:29:32 +08:00

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);
});