#!/usr/bin/env node // Minimal safe release script. // Usage: node scripts/release.js [major|minor|patch|] const { execSync } = require("child_process"); const fs = require("fs"); const path = require("path"); const root = path.resolve(__dirname, ".."); const pkgPath = path.join(root, "package.json"); function run(cmd, opts = {}) { return execSync(cmd, { stdio: "inherit", cwd: root, ...opts }); } function runOutput(cmd) { return execSync(cmd, { cwd: root }).toString().trim(); } function bumpSemver(current, spec) { if (["major","minor","patch"].includes(spec)) { const [maj, min, patch] = current.split(".").map(n=>parseInt(n,10)); if (spec==="major") return `${maj+1}.0.0`; if (spec==="minor") return `${maj}.${min+1}.0`; return `${maj}.${min}.${patch+1}`; } if (!/^\d+\.\d+\.\d+$/.test(spec)) throw new Error("Invalid version spec"); return spec; } (async () => { const arg = process.argv[2] || "patch"; const pkgRaw = fs.readFileSync(pkgPath, "utf8"); const pkg = JSON.parse(pkgRaw); const oldVersion = pkg.version; const newVersion = bumpSemver(oldVersion, arg); let committed = false; let tagged = false; let pushedTags = false; try { // refuse to run if there are unstaged/uncommitted changes const status = runOutput("git status --porcelain"); if (status) throw new Error("Repository has uncommitted changes; please commit or stash before releasing."); console.log("Running tests..."); run("npm run test:ci"); console.log("Building..."); run("npm run build"); // write new version pkg.version = newVersion; fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n", "utf8"); console.log(`Bumped version: ${oldVersion} -> ${newVersion}`); // commit run(`git add ${pkgPath}`); run(`git commit -m "chore(release): v${newVersion} - bump from v${oldVersion}"`); committed = true; // ensure tag doesn't already exist locally let localTagExists = false; try { runOutput(`git rev-parse --verify refs/tags/v${newVersion}`); localTagExists = true; } catch (_) { localTagExists = false; } if (localTagExists) throw new Error(`Tag v${newVersion} already exists locally — aborting to avoid overwrite.`); // ensure tag doesn't exist on remote const remoteTagInfo = (() => { try { return runOutput(`git ls-remote --tags origin v${newVersion}`); } catch (_) { return ""; } })(); if (remoteTagInfo) throw new Error(`Tag v${newVersion} already exists on remote — aborting to avoid overwrite.`); // tag run(`git tag -a v${newVersion} -m "Release v${newVersion}"`); tagged = true; // push commit and tags run("git push"); run("git push --tags"); pushedTags = true; // publish console.log("Publishing to npm..."); const publishCmd = pkg.name && pkg.name.startsWith("@") ? "npm publish --access public" : "npm publish"; run(publishCmd); console.log(`Release v${newVersion} succeeded.`); process.exit(0); } catch (err) { console.error("Release failed:", err.message || err); try { // delete local tag if (tagged) { try { run(`git tag -d v${newVersion}`); } catch {} if (pushedTags) { try { run(`git push origin :refs/tags/v${newVersion}`); } catch {} } } // undo commit if made if (committed) { try { run("git reset --hard HEAD~1"); } catch { // fallback: restore package.json content fs.writeFileSync(pkgPath, pkgRaw, "utf8"); } } else { // restore package.json fs.writeFileSync(pkgPath, pkgRaw, "utf8"); } } catch (rbErr) { console.error("Rollback error:", rbErr.message || rbErr); } process.exit(1); } })();