Files
openwork/scripts/migration/01-cut-migration-release.mjs
ben 7a53c2964c feat(desktop): Tauri → Electron migration release kit (#1526)
Ships the last-missing piece of the Electron migration. After this PR
merges there are three one-shot scripts under scripts/migration/ that
carry the whole lifecycle from "cut v0.12.0" through "delete src-tauri":

  01-cut-migration-release.mjs   # bumps versions, writes the release env
                                 # fragment, tags, pushes.
  02-validate-migration.mjs      # guided smoke test against a cut release.
  03-post-migration-cleanup.mjs  # deletes src-tauri, flips defaults,
                                 # scrubs Tauri docs (dry-run by default).

Code landing in the same PR (dormant until a release sets
VITE_OPENWORK_MIGRATION_RELEASE=1):

- apps/desktop/src-tauri/src/commands/migration.rs gains
  migrate_to_electron() — downloads the matching Electron .zip, verifies
  the Apple signature, swaps the .app bundle in place via a detached
  shell script, relaunches, and exits. Windows + Linux branches are
  stubbed with clear TODOs for the follow-up.
- apps/app/src/app/lib/migration.ts grows migrateToElectron() + a
  "later" defer helper.
- apps/app/src/react-app/shell/migration-prompt.tsx adds the one-time
  "OpenWork is moving to a new engine" modal. Mounted from
  providers.tsx. Gated on isTauriRuntime() AND the build-time flag, so
  Electron users and all non-migration-release builds never render it.
- apps/app/vite.config.ts loads apps/app/.env.migration-release when
  present so the prompt gets the release-specific download URLs.
- .gitignore allows the migration-release fragment to be committed only
  on the tagged migration-release commit (removed in cleanup step).

Release workflow:
- .github/workflows/release-macos-aarch64.yml gains a publish-electron
  job alongside the Tauri jobs. Gated on RELEASE_PUBLISH_ELECTRON repo
  var OR the new publish_electron workflow_dispatch input (default
  false). Uses the existing Apple Dev ID secrets — no new credential
  story. Produces latest-mac.yml alongside Tauri's latest.json so a
  v0.12.0 release serves both updaters.

Verified:
  pnpm --filter @openwork/app typecheck   ✓
  cargo check --manifest-path apps/desktop/src-tauri/Cargo.toml   ✓
  node --check on all mjs scripts   ✓
  python yaml parse of release-macos-aarch64.yml   ✓

Not verified (needs a real release cycle):
  end-to-end migration from a signed Tauri .app to a signed Electron
  .app through the detached-script install swap.

Co-authored-by: Benjamin Shafii <benjamin@openworklabs.com>
2026-04-22 18:52:30 -07:00

179 lines
5.9 KiB
JavaScript
Executable File

#!/usr/bin/env node
// Cut the Tauri → Electron migration release (v0.12.0 or whatever).
// Bumps versions, writes the migration-release env fragment, commits, tags,
// pushes. Idempotent safeties: refuses to run on a dirty tree.
//
// Usage:
// node scripts/migration/01-cut-migration-release.mjs \
// --version 0.12.0 \
// --mac-url 'https://.../OpenWork-darwin-arm64-0.12.0-mac.zip' \
// --win-url 'https://.../OpenWork-Setup-0.12.0.exe' (optional) \
// --linux-url 'https://.../OpenWork-0.12.0.AppImage' (optional) \
// --dry-run
import { spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
import { writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(__dirname, "../..");
function parseArgs(argv) {
const out = { dryRun: false };
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === "--dry-run") out.dryRun = true;
else if (arg === "--version") out.version = argv[++i];
else if (arg === "--mac-url") out.macUrl = argv[++i];
else if (arg === "--mac-sha256") out.macSha256 = argv[++i];
else if (arg === "--win-url") out.winUrl = argv[++i];
else if (arg === "--linux-url") out.linuxUrl = argv[++i];
else if (arg === "--help" || arg === "-h") out.help = true;
else {
console.error(`unknown arg: ${arg}`);
process.exit(2);
}
}
return out;
}
function die(msg) {
console.error(`[cut-release] ${msg}`);
process.exit(1);
}
function run(cmd, args, opts = {}) {
console.log(`[cut-release] $ ${cmd} ${args.join(" ")}`);
if (opts.dryRun) return "";
const result = spawnSync(cmd, args, {
cwd: opts.cwd ?? repoRoot,
stdio: opts.capture ? ["ignore", "pipe", "inherit"] : "inherit",
encoding: "utf8",
});
if (result.status !== 0) die(`command failed: ${cmd}`);
return (result.stdout ?? "").trim();
}
function gitStatusClean() {
const result = spawnSync("git", ["status", "--porcelain"], {
cwd: repoRoot,
encoding: "utf8",
});
return result.status === 0 && (result.stdout ?? "").trim().length === 0;
}
function currentBranch() {
const result = spawnSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
cwd: repoRoot,
encoding: "utf8",
});
return (result.stdout ?? "").trim();
}
function tagExists(tag) {
const result = spawnSync("git", ["rev-parse", "-q", "--verify", `refs/tags/${tag}`], {
cwd: repoRoot,
encoding: "utf8",
});
return result.status === 0;
}
function remoteTagExists(tag) {
const result = spawnSync("git", ["ls-remote", "--tags", "origin", tag], {
cwd: repoRoot,
encoding: "utf8",
});
return result.status === 0 && (result.stdout ?? "").includes(`refs/tags/${tag}`);
}
async function main() {
const args = parseArgs(process.argv.slice(2));
if (args.help) {
console.log(
[
"Cut the Tauri → Electron migration release.",
"",
"Required: --version, --mac-url",
"Optional: --mac-sha256, --win-url, --linux-url, --dry-run",
].join("\n"),
);
return;
}
if (!args.version) die("--version is required (e.g. --version 0.12.0)");
if (!args.macUrl) die("--mac-url is required");
if (!/^\d+\.\d+\.\d+$/.test(args.version)) {
die(`--version must look like X.Y.Z, got "${args.version}"`);
}
if (!gitStatusClean()) {
die("working tree is dirty. commit or stash before cutting a release.");
}
const branch = currentBranch();
if (branch !== "dev" && branch !== "main") {
console.warn(
`[cut-release] WARNING: current branch is "${branch}". Releases are usually cut from main/dev.`,
);
}
const tag = `v${args.version}`;
if (tagExists(tag)) die(`tag ${tag} already exists locally`);
if (remoteTagExists(tag)) die(`tag ${tag} already exists on origin`);
// 1. Bump all 5 sync files via the existing helper.
run("pnpm", ["bump:set", "--", args.version], { dryRun: args.dryRun });
// 2. Write the migration-release env fragment. Gets picked up by the
// app build step during `Release App` via --copy-config.
const envFragment =
[
"# Generated by scripts/migration/01-cut-migration-release.mjs.",
"# Consumed by apps/app Vite build during the v0.12.0 release only.",
"VITE_OPENWORK_MIGRATION_RELEASE=1",
`VITE_OPENWORK_MIGRATION_VERSION=${args.version}`,
args.macUrl ? `VITE_OPENWORK_MIGRATION_MAC_URL=${args.macUrl}` : "",
args.macSha256 ? `VITE_OPENWORK_MIGRATION_MAC_SHA256=${args.macSha256}` : "",
args.winUrl ? `VITE_OPENWORK_MIGRATION_WINDOWS_URL=${args.winUrl}` : "",
args.linuxUrl ? `VITE_OPENWORK_MIGRATION_LINUX_URL=${args.linuxUrl}` : "",
"",
]
.filter(Boolean)
.join("\n");
const envPath = resolve(repoRoot, "apps/app/.env.migration-release");
console.log(`[cut-release] writing ${envPath}`);
if (!args.dryRun) {
await writeFile(envPath, envFragment, "utf8");
}
// 3. Commit version bump + env fragment.
run("git", ["add", "-A"], { dryRun: args.dryRun });
run(
"git",
["commit", "-m", `chore(release): cut v${args.version} (Tauri → Electron migration)`],
{ dryRun: args.dryRun },
);
// 4. Tag + push.
run("git", ["tag", tag], { dryRun: args.dryRun });
run("git", ["push", "origin", branch], { dryRun: args.dryRun });
run("git", ["push", "origin", tag], { dryRun: args.dryRun });
console.log("");
console.log(`[cut-release] pushed ${tag}.`);
console.log(`[cut-release] watch the workflow:`);
console.log(` gh run list --repo different-ai/openwork --workflow "Release App" --limit 3`);
console.log(` gh run watch --repo different-ai/openwork`);
console.log("");
console.log(`[cut-release] once the workflow finishes, run:`);
console.log(` node scripts/migration/02-validate-migration.mjs --tag ${tag}`);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});