Files
openwork/scripts/migration/02-validate-migration.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

206 lines
6.7 KiB
JavaScript
Executable File

#!/usr/bin/env node
// Guided validation for a migration release. Mix of automated probes and
// checklist prompts you acknowledge in-terminal. Designed to be run in a
// release-review pair session.
//
// Usage:
// node scripts/migration/02-validate-migration.mjs --tag v0.12.0
// node scripts/migration/02-validate-migration.mjs --tag v0.12.0 --skip-manual
import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { createReadStream, existsSync } from "node:fs";
import { mkdir, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { createInterface } from "node:readline/promises";
import { stdin as input, stdout as output } from "node:process";
const __dirname = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(__dirname, "../..");
function parseArgs(argv) {
const out = { skipManual: false };
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === "--skip-manual") out.skipManual = true;
else if (arg === "--tag") out.tag = argv[++i];
else if (arg === "--help" || arg === "-h") out.help = true;
}
return out;
}
function die(msg) {
console.error(`[validate] ${msg}`);
process.exit(1);
}
async function sha256(path) {
const hash = createHash("sha256");
return await new Promise((resolvePromise, rejectPromise) => {
createReadStream(path)
.on("data", (chunk) => hash.update(chunk))
.on("end", () => resolvePromise(hash.digest("hex")))
.on("error", rejectPromise);
});
}
async function confirm(prompt) {
const rl = createInterface({ input, output });
try {
const answer = (await rl.question(`${prompt} [y/N] `)).trim().toLowerCase();
return answer === "y" || answer === "yes";
} finally {
rl.close();
}
}
async function ghReleaseAssets(tag) {
const result = spawnSync(
"gh",
[
"release",
"view",
tag,
"--repo",
"different-ai/openwork",
"--json",
"assets",
],
{ encoding: "utf8" },
);
if (result.status !== 0) die(`gh release view ${tag} failed`);
const parsed = JSON.parse(result.stdout);
return parsed.assets ?? [];
}
async function ghDownload(tag, assetName, targetDir) {
await mkdir(targetDir, { recursive: true });
const target = join(targetDir, assetName);
const result = spawnSync(
"gh",
[
"release",
"download",
tag,
"--repo",
"different-ai/openwork",
"--pattern",
assetName,
"--dir",
targetDir,
"--clobber",
],
{ stdio: "inherit" },
);
if (result.status !== 0) die(`gh release download failed for ${assetName}`);
return target;
}
function codesignVerify(appPath) {
const result = spawnSync(
"codesign",
["--verify", "--deep", "--strict", "--verbose=2", appPath],
{ encoding: "utf8" },
);
return {
ok: result.status === 0,
message: (result.stderr ?? "").trim() || (result.stdout ?? "").trim(),
};
}
async function main() {
const args = parseArgs(process.argv.slice(2));
if (args.help || !args.tag) {
console.log("Usage: 02-validate-migration.mjs --tag vX.Y.Z [--skip-manual]");
process.exit(args.help ? 0 : 2);
}
const workDir = join(tmpdir(), `openwork-migrate-validate-${args.tag}`);
await rm(workDir, { recursive: true, force: true });
await mkdir(workDir, { recursive: true });
console.log(`[validate] scratch dir: ${workDir}`);
// 1. List assets on the release.
const assets = await ghReleaseAssets(args.tag);
const names = assets.map((a) => a.name);
console.log(`[validate] release ${args.tag} has ${assets.length} assets`);
names.forEach((n) => console.log(` - ${n}`));
const electronZip = names.find(
(n) => /electron|\.zip$/i.test(n) && n.includes("mac"),
) ?? names.find((n) => n.endsWith(".zip"));
const electronYml = names.find((n) => n === "latest-mac.yml");
const tauriLatestJson = names.find((n) => n === "latest.json");
const presenceChecks = [
["electron macOS zip", electronZip],
["electron-updater manifest (latest-mac.yml)", electronYml],
["tauri minisign feed (latest.json)", tauriLatestJson],
];
console.log("");
console.log("[validate] required release assets:");
presenceChecks.forEach(([label, name]) => {
console.log(` ${name ? "✓" : "✗"} ${label}${name ? `${name}` : ""}`);
});
if (!electronZip) die("no Electron zip asset on the release.");
// 2. Download + Apple sig check on the Electron zip.
console.log("");
console.log("[validate] downloading Electron zip for signature check…");
const zipPath = await ghDownload(args.tag, electronZip, workDir);
console.log(`[validate] got ${zipPath} (${(await readFile(zipPath)).byteLength} bytes)`);
const zipHash = await sha256(zipPath);
console.log(`[validate] sha256 = ${zipHash}`);
const unzipDir = join(workDir, "unzipped");
await mkdir(unzipDir, { recursive: true });
const unzipResult = spawnSync("unzip", ["-q", "-o", zipPath, "-d", unzipDir], {
stdio: "inherit",
});
if (unzipResult.status !== 0) die("unzip failed");
const appPath = join(unzipDir, "OpenWork.app");
if (!existsSync(appPath)) {
console.log(`[validate] ✗ OpenWork.app not found inside zip (got: ${unzipDir})`);
die("zip layout unexpected");
}
const sig = codesignVerify(appPath);
console.log(`[validate] ${sig.ok ? "✓" : "✗"} codesign verify on ${appPath}`);
if (!sig.ok) console.log(sig.message);
// 3. Manual steps.
if (args.skipManual) {
console.log("[validate] --skip-manual: stopping here.");
return;
}
console.log("");
console.log("[validate] manual checklist — acknowledge each step.");
const steps = [
"Install a fresh Tauri v0.11.x build on a test machine (or VM).",
`Launch it, "Check for updates" → migrates to ${args.tag}.`,
"Restart, see the 'OpenWork is moving' modal, click 'Install now'.",
"Confirm Electron app launches automatically after ~30s.",
"Confirm sidebar shows every workspace that was visible in Tauri.",
"Open one workspace; confirm last session is preselected.",
"Settings → Advanced: both runtimes show 'Connected'.",
"Reopen: confirm the migration modal does NOT reappear.",
];
for (const step of steps) {
const ok = await confirm(`${step}`);
if (!ok) die(`manual step declined: ${step}`);
}
console.log("");
console.log("[validate] all checks passed. Migration release is healthy.");
console.log("[validate] watch v0.12.0 telemetry for ~1-2 weeks before running");
console.log(" node scripts/migration/03-post-migration-cleanup.mjs");
}
main().catch((err) => {
console.error(err);
process.exit(1);
});