mirror of
https://github.com/different-ai/openwork
synced 2026-04-25 17:15:34 +02:00
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.
This commit is contained in:
@@ -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": {
|
||||
|
||||
118
scripts/release/prepare.mjs
Normal file
118
scripts/release/prepare.mjs
Normal file
@@ -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");
|
||||
116
scripts/release/ship.mjs
Normal file
116
scripts/release/ship.mjs
Normal file
@@ -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");
|
||||
Reference in New Issue
Block a user