#!/usr/bin/env node // One-shot release driver. // // pnpm release # full pipeline: dev → Promote → main → tag // pnpm release --no-wait # bump+push dev only, do main+tag manually // pnpm release --dry-run # print plan, change nothing // // What it does, in order: // 1. Sanity-check: on `dev`, clean tree. Must not be behind origin/dev. // Already being this version's `chore(release):` commit is a resume // (after a dropped push, a failed fetch-while-waiting, or a failed // tag push). Re-run `pnpm release ` as many times as needed. // 2. Full CI suite (`node tools/ci-check.mjs`) — the same commands as // `.github/workflows/ci.yml`. Skipped on resume. Fail here instead // of after the bump. // 3. `pnpm bump ` (writes every package.json). // 4. Commit `chore(release): vX.Y.Z` — that prefix triggers the // Promote workflow, which points `main` at the dev tip. Skipped // when HEAD is already that commit. // 5. Push to origin/dev (retries transient HTTPS / TLS failures). // 6. (Unless `--no-wait`) poll until `origin/main` equals the dev // tip we just pushed, then fetch and fast-forward `main` to // `origin/main`, create `vX.Y.Z` tag, push it — that triggers // `release.yml`. Fetch / tag push also retry. // 7. Switch back to `dev` so the next `git log` view is back where // you were. // // Re-run the same `pnpm release ` after any mid-pipeline // failure (push, wait, tag). Do not amend the release commit. import { execSync, spawnSync } from 'node:child_process'; import { readFileSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { parseVersion } from '../packages/common/src/semver.ts'; 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 [--no-wait] [--dry-run]'); console.error('Example: pnpm release 1.8.0'); process.exit(1); } if (!parseVersion(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 sleepSync(ms) { execSync(`sleep ${Math.max(1, Math.ceil(ms / 1000))}`); } function gitOk(cmd) { if (dryRun) { console.log(`[dry-run] $ ${cmd}`); return true; } const result = spawnSync(cmd, { cwd: repoRoot, shell: true, stdio: 'inherit' }); return result.status === 0; } /** Retry git fetch / push. TLS drops after a successful commit used to abort * the whole pipeline; re-run `pnpm release ` to resume. */ function shRetry(cmd, { attempts = 8 } = {}) { if (dryRun) { console.log(`[dry-run] $ ${cmd}`); return ''; } let delayMs = 2000; for (let i = 1; i <= attempts; i++) { if (i > 1) info(`retry ${i}/${attempts}: ${cmd}`); if (gitOk(cmd)) return ''; if (i === attempts) { throw new Error( `Command failed after ${attempts} attempts: ${cmd}\n` + `Re-run: pnpm release ${version}`, ); } warn(`failed, retrying in ${delayMs / 1000}s`); sleepSync(delayMs); delayMs = Math.min(delayMs * 2, 30_000); } return ''; } function headSubject() { return shCapture('git log -1 --format=%s'); } function packageVersion() { return JSON.parse(readFileSync(path.join(repoRoot, 'package.json'), 'utf-8')).version; } function headIsThisRelease() { return headSubject() === releaseCommit && packageVersion() === version; } /** The bump commit for this version, even if later tooling commits sit on top. */ function thisReleaseSha() { try { const sha = shCapture(`git log -1 --format=%H --grep=${JSON.stringify(`^${releaseCommit}$`)}`); return sha || null; } catch { return null; } } 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}`); } function printManualFinish() { info(`Re-run the same command to resume: pnpm release ${version}`); } function packageVersionAt(sha) { return JSON.parse(shCapture(`git show ${sha}:package.json`)).version; } // ───────────── 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 not behind origin/dev so the bump commit doesn't // collide with someone else's push. Already being this release commit // (in sync, or ahead by the unpushed bump) is a resume. shRetry('git fetch origin refs/heads/dev:refs/remotes/origin/dev'); 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'); if (behind !== '0') { preflightFail(`dev is behind origin/dev (behind=${behind}). Pull first.`); } if (headIsThisRelease()) { warn(`dev is ahead of origin/dev by ${ahead}; HEAD is ${releaseCommit}, will resume`); } else { preflightFail(`dev is not in sync with origin/dev (ahead=${ahead}, behind=${behind}). Pull / push first.`); } } if (!dryRun) ok(`on dev, clean, ready to release ${tag}`); } function runCiSuite() { if (!dryRun && headIsThisRelease()) { warn(`HEAD is already ${releaseCommit}; skipping CI suite (resume)`); return; } info('running the full CI suite (same commands as .github/workflows/ci.yml)'); sh('node tools/ci-check.mjs'); if (!dryRun) ok('CI suite passed'); } // ───────────── step 2-4: bump + commit + push dev ───────────── function bumpAndPushDev() { if (!dryRun && headIsThisRelease()) { warn(`HEAD is already ${releaseCommit}; skipping bump and commit`); } else { 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) { warn(`no version changes — package.json already at ${version}, skipping commit`); } else { sh('git add package.json packages/*/package.json'); sh(`git commit -m "${releaseCommit}"`); ok(`committed ${releaseCommit}`); } } const local = shCapture('git rev-parse dev'); const remote = shCapture('git rev-parse origin/dev'); if (!dryRun && local === remote) { info('origin/dev already has this tip; skipping push'); return; } shRetry('git push origin dev'); ok(`pushed to origin/dev — Promote workflow should kick off shortly`); } // ───────────── step 5: wait for main to match dev, then tag ───────────── function promoteRunFailed(expectedSha) { if (!which('gh')) return null; try { const list = shCapture('gh run list --workflow=promote-dev-to-main.yml --limit 8 --json conclusion,headSha,url,status'); const runs = JSON.parse(list); return runs.find((run) => run.headSha === expectedSha && run.conclusion === 'failure') ?? null; } catch (error) { warn(`gh run list failed: ${error.message}`); return null; } } function localTagSha() { const result = spawnSync('git', ['rev-parse', '-q', '--verify', `${tag}^{commit}`], { cwd: repoRoot, encoding: 'utf-8', }); if (result.status !== 0) return null; return (result.stdout || '').trim(); } function remoteTagSha() { try { const out = shCapture(`git ls-remote --tags origin refs/tags/${tag} refs/tags/${tag}^{}`); if (!out) return null; const lines = out.split('\n').filter(Boolean); const peeled = lines.find((line) => line.includes('^{}')); return (peeled || lines[0]).split(/\s+/)[0]; } catch { return null; } } function originMainSha() { try { return shCapture('git rev-parse refs/remotes/origin/main'); } catch { return null; } } function ensureTag(expectedSha) { const local = localTagSha(); if (local && local !== expectedSha) { throw new Error( `Local tag ${tag} points at ${local.slice(0, 12)}, expected ${expectedSha.slice(0, 12)}. ` + `Delete it first if you meant to retarget: git tag -d ${tag}`, ); } if (!local) sh(`git tag ${tag} ${expectedSha}`); else info(`tag ${tag} already points at ${expectedSha.slice(0, 12)}`); const remote = remoteTagSha(); if (remote === expectedSha) { info(`origin already has ${tag} at ${expectedSha.slice(0, 12)}`); return; } if (remote && remote !== expectedSha) { throw new Error( `origin ${tag} points at ${remote.slice(0, 12)}, expected ${expectedSha.slice(0, 12)}. ` + `Not moving a live tag.`, ); } shRetry(`git push origin ${tag}`); ok(`tagged and pushed ${tag} — release workflow will pick it up`); } async function waitUntilMainIs(expectedSha) { info(`waiting until origin/main equals dev @ ${expectedSha.slice(0, 12)}...`); info('press Ctrl-C if you want to stop; re-run the same command to resume.'); const startedAt = Date.now(); const TIMEOUT_MS = 30 * 60 * 1000; const POLL_MS = 15 * 1000; while (Date.now() - startedAt < TIMEOUT_MS) { if (gitOk('git fetch origin refs/heads/main:refs/remotes/origin/main')) { const mainSha = originMainSha(); if (mainSha === expectedSha) { ok(`origin/main is dev @ ${expectedSha.slice(0, 12)}`); return true; } } else { warn('fetch origin/main failed; will keep waiting (re-run this command if you abort)'); } const failed = promoteRunFailed(expectedSha); if (failed) { warn(`a Promote run failed for ${expectedSha.slice(0, 12)}: ${failed.url}`); warn('still waiting in case a later Promote succeeds'); } await new Promise((resolve) => setTimeout(resolve, POLL_MS)); } warn('30-minute timeout reached without origin/main matching dev.'); printManualFinish(); return false; } async function waitAndTag() { const expectedSha = dryRun ? 'dry-run' : (thisReleaseSha() || shCapture('git rev-parse dev')); if (dryRun) { info(`would wait until origin/main equals dev @ ${expectedSha.slice(0, 12)}`); info(`would tag ${tag} and push origin ${tag}`); return; } const alreadyTagged = remoteTagSha() === expectedSha || localTagSha() === expectedSha; if (alreadyTagged && remoteTagSha() === expectedSha) { ok(`origin already has ${tag} at ${expectedSha.slice(0, 12)}; nothing to do`); return; } gitOk('git fetch origin refs/heads/main:refs/remotes/origin/main'); if (originMainSha() !== expectedSha) { const ready = await waitUntilMainIs(expectedSha); if (!ready) return; } else { ok(`origin/main already equals the release commit @ ${expectedSha.slice(0, 12)}`); } const taggedVersion = packageVersionAt(expectedSha); if (taggedVersion !== version) { warn(`release commit package.json is ${taggedVersion} but you asked for ${version}.`); warn('Tagging anyway, but the release artifact name may not match.'); } ensureTag(expectedSha); } // ───────────── go ───────────── (async () => { console.log(`Release pipeline → ${tag}${dryRun ? ' (dry-run)' : ''}\n`); preflight(); runCiSuite(); bumpAndPushDev(); if (noWait) { info('--no-wait set; not waiting for origin/main to match dev.'); printManualFinish(); return; } await waitAndTag(); })().catch(err => { console.error(err.stack || err.message); process.exit(1); });