mirror of
https://github.com/different-ai/openwork
synced 2026-04-25 17:15:34 +02:00
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>
206 lines
6.7 KiB
JavaScript
Executable File
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);
|
|
});
|