#!/usr/bin/env node /** * release:prepare [patch|minor|major] * * Bumps versions, runs lockfile check, runs release:review, * commits, and tags — but does NOT push. * * Flags: * --dry-run Print what would happen without mutating anything. * --ci Skip interactive-safety checks (branch, clean-tree). */ import { execSync } from "node:child_process"; import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import { fileURLToPath } from "node:url"; const root = resolve(fileURLToPath(new URL("../..", import.meta.url))); const args = process.argv.slice(2); const dryRun = args.includes("--dry-run"); const ci = args.includes("--ci"); const bumpType = args.find((a) => ["patch", "minor", "major"].includes(a)) ?? "patch"; const log = (msg) => console.log(` ${msg}`); const heading = (msg) => console.log(`\n▸ ${msg}`); const success = (msg) => console.log(` ✓ ${msg}`); const fail = (msg) => { console.error(` ✗ ${msg}`); process.exit(1); }; const run = (cmd, opts = {}) => { if (dryRun && !opts.readOnly) { log(`[dry-run] ${cmd}`); return ""; } try { return execSync(cmd, { cwd: root, encoding: "utf8", stdio: opts.stdio ?? "pipe" }).trim(); } catch (err) { if (opts.allowFail) return ""; fail(`Command failed: ${cmd}\n${err.stderr || err.message}`); } }; // ── Step 1: Verify state ──────────────────────────────────────────── heading("Checking git state"); if (!ci) { const branch = run("git rev-parse --abbrev-ref HEAD", { readOnly: true }); if (branch !== "dev") fail(`Must be on 'dev' branch (currently on '${branch}')`); success(`On branch ${branch}`); } const dirty = run("git status --porcelain", { readOnly: true }); if (dirty && !ci) fail(`Working tree is dirty:\n${dirty}`); success("Working tree clean"); heading("Syncing with origin/dev"); run("git fetch origin dev", { readOnly: true }); const behind = run("git rev-list HEAD..origin/dev --count", { readOnly: true }); if (behind !== "0" && !dryRun) { log(`Behind origin/dev by ${behind} commits — pulling…`); run("git pull --rebase origin dev"); } success("Up to date with origin/dev"); // ── Step 2: Bump versions ─────────────────────────────────────────── heading(`Bumping versions (${bumpType})`); const bumpOutput = run(`pnpm bump:${bumpType}`, { stdio: "pipe" }); if (!dryRun) { log(bumpOutput); } // Read the new version const appPkg = JSON.parse(readFileSync(resolve(root, "packages/app/package.json"), "utf8")); const version = appPkg.version; success(`Version is now ${version}`); // ── Step 3: Lockfile ──────────────────────────────────────────────── heading("Checking lockfile"); run("pnpm install --lockfile-only"); const lockfileChanged = run("git diff --name-only -- pnpm-lock.yaml", { readOnly: true }); if (lockfileChanged) { success("Lockfile updated"); } else { success("Lockfile unchanged"); } // ── Step 4: Release review ────────────────────────────────────────── heading("Running release review"); const reviewOutput = run("node scripts/release/review.mjs --strict", { readOnly: true, allowFail: false }); log(reviewOutput); success("Release review passed"); // ── Step 5: Commit ────────────────────────────────────────────────── heading("Committing version bump"); run("git add -A"); run(`git commit -m "chore: bump version to ${version}"`); success(`Committed: chore: bump version to ${version}`); // ── Step 6: Tag ───────────────────────────────────────────────────── heading("Creating tag"); const tag = `v${version}`; run(`git tag ${tag}`); success(`Tagged ${tag}`); // ── Summary ───────────────────────────────────────────────────────── console.log("\n" + "─".repeat(50)); console.log(` Release prepared: ${tag}`); console.log(` Version: ${version}`); console.log(` Bump type: ${bumpType}`); if (dryRun) { console.log(" Mode: DRY RUN (nothing was changed)"); } console.log(""); console.log(" Next step:"); console.log(` pnpm release:ship`); console.log("─".repeat(50) + "\n");