Files
openwork/scripts/release/prepare.mjs
Benjamin Shafii 5b8d957b68 feat(release): add prepare + ship scripts for two-command releases
- scripts/release/prepare.mjs: bump, lockfile, review, commit, tag
- scripts/release/ship.mjs: push tag + dev, print GHA URLs
- Both support --dry-run for safe testing
- Add pnpm aliases: release:prepare, release:ship, etc.
2026-02-05 18:35:19 -08:00

119 lines
4.6 KiB
JavaScript

#!/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");