diff --git a/package.json b/package.json index c71a5b66..e2a13b54 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,10 @@ "bump:major": "pnpm --filter @different-ai/openwork-ui bump:major", "bump:set": "pnpm --filter @different-ai/openwork-ui bump:set", "release:review": "node scripts/release/review.mjs", + "release:prepare": "node scripts/release/prepare.mjs", + "release:prepare:dry": "node scripts/release/prepare.mjs --dry-run", + "release:ship": "node scripts/release/ship.mjs", + "release:ship:watch": "node scripts/release/ship.mjs --watch", "tauri": "pnpm --filter @different-ai/openwork exec tauri" }, "pnpm": { diff --git a/scripts/release/prepare.mjs b/scripts/release/prepare.mjs new file mode 100644 index 00000000..9070415a --- /dev/null +++ b/scripts/release/prepare.mjs @@ -0,0 +1,118 @@ +#!/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"); diff --git a/scripts/release/ship.mjs b/scripts/release/ship.mjs new file mode 100644 index 00000000..00d1654e --- /dev/null +++ b/scripts/release/ship.mjs @@ -0,0 +1,116 @@ +#!/usr/bin/env node +/** + * release:ship + * + * Pushes the current tag + dev branch to origin, then prints the + * GitHub Actions workflow URL. Optionally tails the workflow run. + * + * Flags: + * --dry-run Print what would happen without pushing. + * --watch Tail the GHA workflow run after push. + */ +import { execSync } from "node:child_process"; +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 watch = args.includes("--watch"); + +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.inherit ? "inherit" : "pipe", + }).trim(); + } catch (err) { + if (opts.allowFail) return ""; + fail(`Command failed: ${cmd}\n${err.stderr || err.message}`); + } +}; + +// ── Step 1: Resolve tag from HEAD ─────────────────────────────────── +heading("Resolving tag"); + +const tag = run("git describe --tags --exact-match HEAD", { + readOnly: true, + allowFail: true, +}); + +if (!tag) { + fail( + "HEAD is not tagged. Run 'pnpm release:prepare' first.\n" + + " (Expected a vX.Y.Z tag on HEAD)" + ); +} + +if (!/^v\d+\.\d+\.\d+/.test(tag)) { + fail(`Tag '${tag}' does not look like a release tag (expected vX.Y.Z)`); +} + +success(`Found tag: ${tag}`); + +// ── Step 2: Push tag ──────────────────────────────────────────────── +heading("Pushing tag to origin"); +run(`git push origin ${tag}`); +success(`Pushed ${tag}`); + +// ── Step 3: Push dev ──────────────────────────────────────────────── +heading("Pushing dev to origin"); +run("git push origin dev"); +success("Pushed dev"); + +// ── Step 4: Print workflow URL ────────────────────────────────────── +heading("GitHub Actions"); + +const repo = "different-ai/openwork"; +const url = `https://github.com/${repo}/actions/workflows/release-macos-aarch64.yml`; +log(`Workflow: ${url}`); +log(`Release: https://github.com/${repo}/releases/tag/${tag}`); + +// ── Step 5: Optionally watch ──────────────────────────────────────── +if (watch && !dryRun) { + heading("Watching workflow run"); + log("Waiting for workflow to appear…"); + + // Give GitHub a moment to register the run + execSync("sleep 10", { cwd: root }); + + try { + const runs = run( + `gh run list --repo ${repo} --workflow "Release App" --limit 1 --json databaseId,headBranch,event -q ".[0].databaseId"`, + { readOnly: true } + ); + if (runs) { + log(`Run ID: ${runs}`); + run(`gh run watch ${runs} --repo ${repo} --exit-status`, { inherit: true }); + } else { + log("Could not find the workflow run. Check the Actions tab manually."); + } + } catch { + log("Workflow watch exited (check status on GitHub)."); + } +} + +// ── Summary ───────────────────────────────────────────────────────── +console.log("\n" + "─".repeat(50)); +console.log(` Shipped: ${tag}`); +if (dryRun) { + console.log(" Mode: DRY RUN (nothing was pushed)"); +} +console.log("─".repeat(50) + "\n");