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>
This commit is contained in:
ben
2026-04-22 18:52:30 -07:00
committed by GitHub
parent 921ddbd13d
commit 7a53c2964c
12 changed files with 1219 additions and 2 deletions

View File

@@ -53,6 +53,11 @@ on:
required: false
type: boolean
default: true
publish_electron:
description: "Build + publish Electron desktop artifacts (macOS) alongside Tauri"
required: false
type: boolean
default: false
permissions:
contents: write
@@ -605,7 +610,58 @@ jobs:
--repo "$GITHUB_REPOSITORY" \
--clobber
release-orchestrator-sidecars:
publish-electron:
name: Build + publish Electron desktop (macOS)
# Runs alongside the Tauri jobs so the same release carries both
# latest.json (Tauri updater) AND latest-mac.yml (electron-updater).
# Gated on RELEASE_PUBLISH_ELECTRON=true (repo var) OR the workflow
# input of the same name so opt-in during rollout, opt-out if a
# non-migration release doesn't want Electron artifacts.
needs: [resolve-release, verify-release]
if: ${{ vars.RELEASE_PUBLISH_ELECTRON == 'true' || github.event.inputs.publish_electron == 'true' }}
runs-on: macos-14
env:
RELEASE_TAG: ${{ needs.resolve-release.outputs.release_tag }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ env.RELEASE_TAG }}
fetch-depth: 0
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10.27.0
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.9
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build + package Electron app
env:
# electron-builder reads these to codesign + notarize the .app.
# Reuses the same secrets the Tauri path already uses.
CSC_LINK: ${{ secrets.APPLE_CERTIFICATE }}
CSC_KEY_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
# electron-builder needs this to upload artifacts + the updater
# feed manifest (latest-mac.yml) to the GitHub release.
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
pnpm --filter @openwork/desktop package:electron --publish always
name: Build + Upload openwork-orchestrator Sidecars
needs: [resolve-release, verify-release]
if: needs.resolve-release.outputs.publish_sidecars == 'true'

4
.gitignore vendored
View File

@@ -26,6 +26,10 @@ apps/desktop/src-tauri/sidecars/
.env
.env.*
!.env.example
# The migration-release fragment is committed only on the tagged
# migration-release commit and removed by 03-post-migration-cleanup.mjs.
# Allow git to see it so `cut-migration-release` can commit it.
!.env.migration-release
# Bun build artifacts
*.bun-build

View File

@@ -139,3 +139,78 @@ export async function ingestMigrationSnapshotOnElectronBoot(): Promise<number> {
return hydrated;
}
export type MigrateToElectronRequest = {
/**
* Download URL for the matching Electron artifact. On macOS a .zip.
* On Windows, an NSIS .exe (TODO — stubbed today). On Linux, an AppImage
* (TODO — stubbed today).
*/
url: string;
/** Optional sha256 to verify before touching the filesystem. */
sha256?: string;
/**
* Override where the Electron .app ends up (macOS). Defaults to
* replacing the currently-running .app bundle in place.
*/
targetAppPath?: string;
};
/**
* Tauri-only. Hand off to the new Electron build:
* 1. Download + verify the installer
* 2. Replace the running .app bundle
* 3. Relaunch into the Electron binary
* 4. Quit this Tauri process
*
* Callers should invoke `writeMigrationSnapshotFromTauri()` first so the
* new Electron shell can hydrate localStorage on first launch.
*/
export async function migrateToElectron(
request: MigrateToElectronRequest,
): Promise<{ ok: boolean; reason?: string }> {
try {
await invoke("migrate_to_electron", { request });
return { ok: true };
} catch (error) {
return {
ok: false,
reason: error instanceof Error ? error.message : String(error),
};
}
}
// Localstorage key that stores a "don't ask again until" epoch-ms.
// Users who click "Later" get a 24h reprieve; after that we nudge again.
export const MIGRATION_DEFER_KEY = "openwork.migration.deferredUntil";
export const MIGRATION_DEFAULT_DEFER_MS = 24 * 60 * 60 * 1000;
export function isMigrationDeferred(now: number = Date.now()): boolean {
if (typeof window === "undefined") return false;
try {
const raw = window.localStorage.getItem(MIGRATION_DEFER_KEY);
if (!raw) return false;
const until = Number.parseInt(raw, 10);
return Number.isFinite(until) && until > now;
} catch {
return false;
}
}
export function deferMigration(ms: number = MIGRATION_DEFAULT_DEFER_MS): void {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(MIGRATION_DEFER_KEY, String(Date.now() + ms));
} catch {
// non-fatal
}
}
export function clearMigrationDefer(): void {
if (typeof window === "undefined") return;
try {
window.localStorage.removeItem(MIGRATION_DEFER_KEY);
} catch {
// non-fatal
}
}

View File

@@ -0,0 +1,156 @@
/** @jsxImportSource react */
/**
* Migration prompt — one-time modal that nudges Tauri v0.12.0 users over
* to the Electron build. Dormant unless the build was produced with
* `VITE_OPENWORK_MIGRATION_RELEASE=1`, so landing this code on dev has
* zero user impact until the release script flips that flag.
*/
import { useCallback, useEffect, useState, type ReactElement } from "react";
import {
deferMigration,
isMigrationDeferred,
migrateToElectron,
writeMigrationSnapshotFromTauri,
} from "../../app/lib/migration";
import { isTauriRuntime } from "../../app/utils";
type MigrationConfig = {
/**
* URL template for the Electron artifact on the matching release. The
* release script substitutes {arch} at build time. On macOS we expect a
* .zip (the .app bundle is swapped in place — see migrate_to_electron
* Rust command).
*/
macUrl?: string;
/** Optional sha256 check. */
macSha256?: string;
/** Windows installer URL. Stub until we wire the Rust path. */
windowsUrl?: string;
/** Linux AppImage URL. Stub until we wire the Rust path. */
linuxUrl?: string;
};
function readBuildTimeConfig(): MigrationConfig | null {
const env = (import.meta as unknown as { env?: Record<string, string> }).env ?? {};
const enabled = env.VITE_OPENWORK_MIGRATION_RELEASE === "1";
if (!enabled) return null;
return {
macUrl: env.VITE_OPENWORK_MIGRATION_MAC_URL,
macSha256: env.VITE_OPENWORK_MIGRATION_MAC_SHA256,
windowsUrl: env.VITE_OPENWORK_MIGRATION_WINDOWS_URL,
linuxUrl: env.VITE_OPENWORK_MIGRATION_LINUX_URL,
};
}
function currentPlatformUrl(cfg: MigrationConfig): { url?: string; sha256?: string } {
if (typeof navigator === "undefined") return {};
const ua = navigator.userAgent.toLowerCase();
if (ua.includes("mac")) return { url: cfg.macUrl, sha256: cfg.macSha256 };
if (ua.includes("windows") || ua.includes("win64") || ua.includes("win32")) {
return { url: cfg.windowsUrl };
}
return { url: cfg.linuxUrl };
}
export function MigrationPrompt(): ReactElement | null {
const [open, setOpen] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [config, setConfig] = useState<MigrationConfig | null>(null);
useEffect(() => {
// Only Tauri shows this modal. Electron users are already on the
// target runtime.
if (!isTauriRuntime()) return;
const cfg = readBuildTimeConfig();
if (!cfg) return;
if (isMigrationDeferred()) return;
setConfig(cfg);
setOpen(true);
}, []);
const handleInstall = useCallback(async () => {
if (!config) return;
setBusy(true);
setError(null);
try {
const { url, sha256 } = currentPlatformUrl(config);
if (!url) {
throw new Error(
"Automatic migration isn't available for this platform yet. Please upgrade manually from the release page.",
);
}
const snapshot = await writeMigrationSnapshotFromTauri();
if (!snapshot.ok) {
throw new Error(
snapshot.reason ??
"Failed to capture workspace state before migration.",
);
}
const result = await migrateToElectron({ url, sha256 });
if (!result.ok) {
throw new Error(result.reason ?? "Migration handoff failed.");
}
// On success, the Rust command kicks off a detached shell script that
// swaps the .app bundle and relaunches. Tauri will exit on its own
// shortly — leave the dialog in its loading state until that happens.
} catch (e) {
setBusy(false);
setError(e instanceof Error ? e.message : String(e));
}
}, [config]);
const handleLater = useCallback(() => {
deferMigration();
setOpen(false);
}, []);
if (!open) return null;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm"
role="dialog"
aria-modal="true"
aria-labelledby="openwork-migration-title"
>
<div className="w-[min(440px,90vw)] rounded-[24px] border border-dls-border bg-dls-surface p-6 shadow-xl">
<h2
id="openwork-migration-title"
className="text-lg font-semibold text-gray-12"
>
OpenWork is moving to a new engine
</h2>
<p className="mt-3 text-sm leading-relaxed text-gray-11">
We're upgrading the desktop shell. The install takes ~30 seconds and
keeps all your workspaces, sessions, and tokens exactly where they
are. You'll be back in OpenWork automatically when it's done.
</p>
{error ? (
<div className="mt-3 rounded-md border border-red-7/50 bg-red-3 px-3 py-2 text-xs text-red-11">
{error}
</div>
) : null}
<div className="mt-5 flex items-center justify-end gap-2">
<button
type="button"
className="rounded-full px-4 py-2 text-sm text-gray-11 hover:bg-gray-4"
onClick={handleLater}
disabled={busy}
>
Later
</button>
<button
type="button"
className="rounded-full bg-gray-12 px-4 py-2 text-sm font-medium text-gray-1 hover:bg-gray-12/90 disabled:opacity-60"
onClick={handleInstall}
disabled={busy}
>
{busy ? "Installing…" : "Install now"}
</button>
</div>
</div>
</div>
);
}

View File

@@ -12,6 +12,7 @@ import { ServerProvider } from "../kernel/server-provider";
import { BootStateProvider } from "./boot-state";
import { DesktopRuntimeBoot } from "./desktop-runtime-boot";
import { startDebugLogger, stopDebugLogger } from "./debug-logger";
import { MigrationPrompt } from "./migration-prompt";
function resolveDefaultServerUrl(): string {
if (isDesktopRuntime()) return "http://127.0.0.1:4096";
@@ -66,6 +67,7 @@ export function AppProviders({ children }: AppProvidersProps) {
</RestrictionNoticeProvider>
</DesktopConfigProvider>
</DenAuthProvider>
<MigrationPrompt />
</ServerProvider>
</BootStateProvider>
);

View File

@@ -1,4 +1,5 @@
import os from "node:os";
import { existsSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { defineConfig } from "vite";
@@ -26,6 +27,29 @@ if (shortHostname && shortHostname !== hostname) {
}
const appRoot = resolve(fileURLToPath(new URL(".", import.meta.url)));
// Load the Tauri → Electron migration-release fragment if present. Written
// by scripts/migration/01-cut-migration-release.mjs for the specific
// release commit; absent otherwise so every other build has the migration
// prompt dormant. Pre-parsed here so Vite's define/import.meta.env picks
// up the keys without a custom plugin.
function loadMigrationReleaseEnv(): Record<string, string> {
const fragmentPath = resolve(appRoot, ".env.migration-release");
if (!existsSync(fragmentPath)) return {};
const out: Record<string, string> = {};
const raw = readFileSync(fragmentPath, "utf8");
for (const line of raw.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const eq = trimmed.indexOf("=");
if (eq < 0) continue;
const key = trimmed.slice(0, eq).trim();
if (!key.startsWith("VITE_")) continue;
out[key] = trimmed.slice(eq + 1).trim();
}
return out;
}
const migrationReleaseEnv = loadMigrationReleaseEnv();
// Electron packaged builds load index.html via `file://`, so asset URLs
// must be relative. Tauri serves via its own protocol so absolute paths
// work there. Gate on an env var the electron build script sets.
@@ -33,6 +57,12 @@ const isElectronPackagedBuild = process.env.OPENWORK_ELECTRON_BUILD === "1";
export default defineConfig({
base: isElectronPackagedBuild ? "./" : "/",
define: Object.fromEntries(
Object.entries(migrationReleaseEnv).map(([k, v]) => [
`import.meta.env.${k}`,
JSON.stringify(v),
]),
),
plugins: [
{
name: "openwork-dev-server-id",

View File

@@ -2,9 +2,11 @@ use serde::Deserialize;
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use std::process::{Command, Stdio};
use tauri::{AppHandle, Manager};
const MIGRATION_SNAPSHOT_FILENAME: &str = "migration-snapshot.v1.json";
const MIGRATION_INSTALLER_LOG: &str = "migration-install.log";
#[derive(Debug, Deserialize)]
pub struct MigrationSnapshotPayload {
@@ -15,6 +17,22 @@ pub struct MigrationSnapshotPayload {
pub keys: HashMap<String, String>,
}
#[derive(Debug, Deserialize)]
pub struct MigrateToElectronRequest {
/// Full URL to the Electron artifact. On macOS we expect a .zip (not a
/// .dmg) so we can swap the .app bundle in place without the user
/// having to drag anything. On Windows: a .exe NSIS installer. On
/// Linux: an AppImage.
pub url: String,
/// Optional sha256 to verify before we touch the filesystem.
#[serde(default)]
pub sha256: Option<String>,
/// Optional override for where the new OpenWork bundle should land on
/// macOS. Defaults to replacing the currently-running .app in place.
#[serde(default)]
pub target_app_path: Option<String>,
}
fn migration_snapshot_path(app: &AppHandle) -> Result<PathBuf, String> {
let data_dir = app
.path()
@@ -59,3 +77,177 @@ pub fn write_migration_snapshot(
Ok(())
}
#[cfg(target_os = "macos")]
fn current_app_bundle_path() -> Result<PathBuf, String> {
// Tauri's running .app is <something>/Contents/MacOS/OpenWork. Walk up
// two directories to get to <something>.app.
let exe = std::env::current_exe().map_err(|e| format!("current_exe failed: {e}"))?;
let bundle = exe
.parent()
.and_then(|p| p.parent())
.and_then(|p| p.parent())
.ok_or_else(|| "unexpected .app layout".to_string())?;
if bundle.extension().and_then(|s| s.to_str()) != Some("app") {
return Err(format!(
"resolved bundle is not an .app: {}",
bundle.display()
));
}
Ok(bundle.to_path_buf())
}
#[cfg(target_os = "macos")]
fn write_macos_migration_script(
app: &AppHandle,
url: &str,
sha256: Option<&str>,
target: &std::path::Path,
) -> Result<PathBuf, String> {
let cache = app
.path()
.app_cache_dir()
.map_err(|e| format!("Failed to resolve cache dir: {e}"))?;
fs::create_dir_all(&cache).map_err(|e| format!("Failed to create cache dir: {e}"))?;
let log_path = cache.join(MIGRATION_INSTALLER_LOG);
let script_path = cache.join("openwork-migrate.sh");
let sha256_check = match sha256 {
Some(hash) => format!(
r#"
expected="{hash}"
actual=$(shasum -a 256 "$ZIP" | awk '{{print $1}}')
if [ "$actual" != "$expected" ]; then
echo "sha256 mismatch: got $actual, expected $expected" >&2
exit 1
fi
"#
),
None => String::new(),
};
// The script runs detached AFTER Tauri exits. It downloads the Electron
// zip, verifies Apple code signing, swaps the .app bundle, and relaunches.
let script = format!(
r#"#!/bin/bash
set -euo pipefail
exec >>"{log}" 2>&1
echo "[migration] script start $(date -u +%FT%TZ)"
TARGET="{target}"
URL="{url}"
WORK=$(mktemp -d /tmp/openwork-migrate-XXXXXX)
ZIP="$WORK/OpenWork-electron.zip"
# Wait for Tauri to fully exit before touching the .app bundle.
sleep 3
echo "[migration] downloading $URL"
curl --fail --location --silent --show-error --output "$ZIP" "$URL"
{sha256}
echo "[migration] extracting"
/usr/bin/unzip -q "$ZIP" -d "$WORK"
NEW_APP=$(find "$WORK" -maxdepth 2 -name 'OpenWork.app' -type d | head -n 1)
if [ -z "$NEW_APP" ]; then
echo "[migration] no OpenWork.app in zip" >&2
exit 1
fi
echo "[migration] verifying signature on $NEW_APP"
/usr/bin/codesign --verify --deep --strict "$NEW_APP"
echo "[migration] swapping bundle at $TARGET"
BACKUP="$TARGET.migrate-bak"
rm -rf "$BACKUP"
mv "$TARGET" "$BACKUP"
mv "$NEW_APP" "$TARGET"
echo "[migration] launching new bundle"
/usr/bin/open "$TARGET"
echo "[migration] done $(date -u +%FT%TZ)"
"#,
log = log_path.display(),
target = target.display(),
url = url,
sha256 = sha256_check,
);
fs::write(&script_path, script).map_err(|e| format!("Failed to write script: {e}"))?;
let mut perms = fs::metadata(&script_path)
.map_err(|e| format!("Failed to stat script: {e}"))?
.permissions();
use std::os::unix::fs::PermissionsExt;
perms.set_mode(0o755);
fs::set_permissions(&script_path, perms)
.map_err(|e| format!("Failed to chmod script: {e}"))?;
Ok(script_path)
}
#[cfg(target_os = "macos")]
fn spawn_macos_migration_script(script_path: &std::path::Path) -> Result<(), String> {
// nohup + background so the script survives this process exiting.
Command::new("/bin/bash")
.arg("-c")
.arg(format!("nohup bash \"{}\" >/dev/null 2>&1 &", script_path.display()))
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map_err(|e| format!("Failed to spawn migration script: {e}"))?;
Ok(())
}
/// Download the Electron installer, verify it, swap the .app bundle, and
/// relaunch — then exit this Tauri process so the script can finish the
/// handoff. The previous .app is kept at `<app>.migrate-bak` for rollback.
///
/// The caller (renderer) is expected to have already invoked
/// `write_migration_snapshot` so the new Electron shell can hydrate
/// localStorage on first launch.
#[tauri::command]
pub async fn migrate_to_electron(
app: AppHandle,
request: MigrateToElectronRequest,
) -> Result<(), String> {
#[cfg(target_os = "macos")]
{
let target = match request.target_app_path {
Some(path) => PathBuf::from(path),
None => current_app_bundle_path()?,
};
let script = write_macos_migration_script(
&app,
&request.url,
request.sha256.as_deref(),
&target,
)?;
spawn_macos_migration_script(&script)?;
// Give the script a moment to daemonize before we exit.
std::thread::sleep(std::time::Duration::from_millis(400));
app.exit(0);
Ok(())
}
#[cfg(target_os = "windows")]
{
// TODO(migration-windows): download the NSIS .exe, run with
// /S /D=<install-dir>, wait for exit, relaunch. For now, open
// the installer in the user's browser.
let _ = request;
let _ = app;
Err("Windows migrate_to_electron is not wired yet — handle via manual install".to_string())
}
#[cfg(target_os = "linux")]
{
// TODO(migration-linux): handle AppImage + tarball installs.
let _ = request;
let _ = app;
Err("Linux migrate_to_electron is not wired yet — handle via manual install".to_string())
}
}

View File

@@ -24,7 +24,7 @@ use commands::desktop_bootstrap::{get_desktop_bootstrap_config, set_desktop_boot
use commands::engine::{
engine_doctor, engine_info, engine_install, engine_restart, engine_start, engine_stop,
};
use commands::migration::write_migration_snapshot;
use commands::migration::{migrate_to_electron, write_migration_snapshot};
use commands::misc::{
app_build_info, nuke_openwork_and_opencode_config_and_exit, opencode_mcp_auth,
reset_opencode_cache, reset_openwork_state,
@@ -209,6 +209,7 @@ pub fn run() {
get_desktop_bootstrap_config,
set_desktop_bootstrap_config,
updater_environment,
migrate_to_electron,
write_migration_snapshot,
app_build_info,
nuke_openwork_and_opencode_config_and_exit,

View File

@@ -0,0 +1,178 @@
#!/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);
});

View File

@@ -0,0 +1,205 @@
#!/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);
});

View File

@@ -0,0 +1,211 @@
#!/usr/bin/env node
// Post-migration cleanup. Run this ONLY after v0.12.x has been stable
// for ~1-2 weeks and telemetry confirms users have rolled over to the
// Electron build. Irreversible (well, revertible via git, but not
// trivial to redeploy the Tauri path after removing it).
//
// Usage:
// node scripts/migration/03-post-migration-cleanup.mjs --dry-run # default
// node scripts/migration/03-post-migration-cleanup.mjs --execute # actually do it
import { spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
import { readFile, 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) {
// Default is dry-run for safety; --execute required to actually do it.
const out = { dryRun: true };
for (const arg of argv) {
if (arg === "--execute") out.dryRun = false;
else if (arg === "--dry-run") out.dryRun = true;
else if (arg === "--help" || arg === "-h") out.help = true;
else {
console.error(`unknown arg: ${arg}`);
process.exit(2);
}
}
return out;
}
function log(msg) {
console.log(`[cleanup] ${msg}`);
}
function run(cmd, args, opts = {}) {
log(`$ ${cmd} ${args.join(" ")}`);
if (opts.dryRun) return "";
const result = spawnSync(cmd, args, {
cwd: opts.cwd ?? repoRoot,
stdio: "inherit",
});
if (result.status !== 0) {
console.error(`[cleanup] command failed: ${cmd}`);
process.exit(1);
}
}
async function patchJson(path, updater, { dryRun }) {
const raw = await readFile(path, "utf8");
const parsed = JSON.parse(raw);
const next = updater(parsed);
if (JSON.stringify(parsed) === JSON.stringify(next)) {
log(`no change needed: ${path}`);
return;
}
log(`patching: ${path}`);
if (!dryRun) {
await writeFile(path, `${JSON.stringify(next, null, 2)}\n`, "utf8");
}
}
async function replaceInFile(path, replacements, { dryRun }) {
if (!existsSync(path)) return;
const original = await readFile(path, "utf8");
let next = original;
for (const [pattern, replacement] of replacements) {
next = next.replace(pattern, replacement);
}
if (next === original) return;
log(`rewriting: ${path}`);
if (!dryRun) {
await writeFile(path, next, "utf8");
}
}
async function main() {
const args = parseArgs(process.argv.slice(2));
if (args.help) {
console.log(
[
"Post-migration cleanup (Tauri → Electron).",
"",
"Runs in dry-run mode by default. Pass --execute to actually change files.",
].join("\n"),
);
return;
}
if (args.dryRun) {
log("DRY RUN — no filesystem changes. Pass --execute to apply.");
}
// 1. Flip apps/desktop/package.json defaults to Electron.
const desktopPkgPath = resolve(repoRoot, "apps/desktop/package.json");
await patchJson(
desktopPkgPath,
(pkg) => {
const scripts = { ...(pkg.scripts ?? {}) };
scripts.dev = "node ./scripts/electron-dev.mjs";
scripts.build = "node ./scripts/electron-build.mjs";
scripts.package =
"pnpm run build && pnpm exec electron-builder --config electron-builder.yml";
scripts["dev:electron"] = undefined;
scripts["build:electron"] = undefined;
scripts["package:electron"] = undefined;
scripts["package:electron:dir"] =
"pnpm run build && pnpm exec electron-builder --config electron-builder.yml --dir";
scripts.electron = undefined;
scripts["dev:react-session"] = undefined;
scripts["dev:electron:react-session"] =
"VITE_OPENWORK_REACT_SESSION=1 node ./scripts/electron-dev.mjs";
// Remove Tauri-specific script entries.
scripts["build:debug:react-session"] = undefined;
scripts["dev:windows"] = undefined;
scripts["dev:windows:x64"] = undefined;
const filteredScripts = Object.fromEntries(
Object.entries(scripts).filter(([, v]) => v != null),
);
const devDeps = { ...(pkg.devDependencies ?? {}) };
delete devDeps["@tauri-apps/cli"];
return { ...pkg, scripts: filteredScripts, devDependencies: devDeps };
},
{ dryRun: args.dryRun },
);
// 2. Strip @tauri-apps/* from apps/app and apps/story-book package.json.
for (const pkgPath of [
resolve(repoRoot, "apps/app/package.json"),
resolve(repoRoot, "apps/story-book/package.json"),
]) {
if (!existsSync(pkgPath)) continue;
await patchJson(
pkgPath,
(pkg) => {
const deps = { ...(pkg.dependencies ?? {}) };
const devDeps = { ...(pkg.devDependencies ?? {}) };
for (const name of Object.keys(deps)) {
if (name.startsWith("@tauri-apps/")) delete deps[name];
}
for (const name of Object.keys(devDeps)) {
if (name.startsWith("@tauri-apps/")) delete devDeps[name];
}
return { ...pkg, dependencies: deps, devDependencies: devDeps };
},
{ dryRun: args.dryRun },
);
}
// 3. Delete src-tauri/ entirely.
run("git", ["rm", "-r", "-f", "apps/desktop/src-tauri"], { dryRun: args.dryRun });
// 4. Collapse desktop-tauri.ts into desktop.ts. We do a surgical rename
// for now and leave the collapse for a follow-up PR; deleting the
// proxy layer is a bigger refactor.
log(
"[reminder] apps/app/src/app/lib/desktop-tauri.ts still exists. After this script lands,",
);
log(
" open a follow-up PR that inlines desktop.ts's Electron path and removes the",
);
log(" proxy + re-export surface.");
// 5. Drop AGENTS.md / ARCHITECTURE.md / README.md Tauri references.
const docReplacements = [
[/Tauri 2\.x/g, "Electron"],
[/\| Desktop\/Mobile shell \| Tauri 2\.x\s+\|/g, "| Desktop/Mobile shell | Electron |"],
[/apps\/desktop\/src-tauri/g, "apps/desktop/electron"],
];
for (const path of [
resolve(repoRoot, "AGENTS.md"),
resolve(repoRoot, "ARCHITECTURE.md"),
resolve(repoRoot, "README.md"),
]) {
await replaceInFile(path, docReplacements, { dryRun: args.dryRun });
}
// 6. Remove the migration-release env fragment once it's no longer
// relevant.
run("git", ["rm", "-f", "apps/app/.env.migration-release"], {
dryRun: args.dryRun,
});
// 7. Stage + commit.
run("git", ["add", "-A"], { dryRun: args.dryRun });
run(
"git",
[
"commit",
"-m",
"chore(desktop): remove Tauri shell, make Electron the default\n\nRun after v0.12.x stabilized in the wild. See\nscripts/migration/README.md for the full runbook.",
],
{ dryRun: args.dryRun },
);
log("");
log(
args.dryRun
? "dry run complete. rerun with --execute to apply."
: "commit created. push + open PR for review.",
);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

107
scripts/migration/README.md Normal file
View File

@@ -0,0 +1,107 @@
# Tauri → Electron migration runbook
Three scripts. Run in order. Each one is idempotent and prints what it
will do before it does it.
```
scripts/migration/01-cut-migration-release.mjs # cut v0.12.0, the last Tauri release
scripts/migration/02-validate-migration.mjs # guided post-release smoke test
scripts/migration/03-post-migration-cleanup.mjs # delete src-tauri, flip defaults (run after users stabilize)
```
## When to run what
| Step | When | What user-visible effect |
| ---- | ---- | ------------------------ |
| 01 | Ready to ship the migration | Tauri users see "OpenWork is moving" prompt on next update |
| 02 | Right after the workflow finishes | Dogfood validation — no user effect |
| 03 | After 1-2 weeks of stable Electron telemetry | Dev repo is Electron-only; no user effect |
## 01 — cut-migration-release.mjs
```bash
node scripts/migration/01-cut-migration-release.mjs --version 0.12.0 \
--mac-url 'https://github.com/different-ai/openwork/releases/download/v0.12.0/OpenWork-darwin-arm64-0.12.0-mac.zip' \
--dry-run # inspect planned changes first
```
What it does:
1. Refuses to run on a dirty working tree.
2. Runs `pnpm bump:set -- <version>` (bumps all 5 sync files per
AGENTS.md release runbook).
3. Creates a release-config fragment at
`apps/app/.env.migration-release` setting
`VITE_OPENWORK_MIGRATION_RELEASE=1` and the per-platform download
URLs. The `Release App` workflow copies this into the build env so
the migration prompt is dormant on every other build but live for
this release.
4. Commits the version bump.
5. Creates and pushes the `vX.Y.Z` tag.
6. Prints `gh run watch` command so you can follow the workflow.
Drop `--dry-run` to actually execute.
## 02 — validate-migration.mjs
```bash
node scripts/migration/02-validate-migration.mjs --tag v0.12.0
```
Interactive guided check. Prints steps and confirms each one before
moving on:
1. Downloads the Tauri DMG for the tag and verifies its minisign
signature (reuses the existing updater public key).
2. Downloads the Electron .zip for the tag and verifies the Apple
Developer ID signature.
3. Prompts you to install the Tauri DMG on a fresh machine / VM and
confirm the migration prompt appears.
4. Drops a canary key into localStorage on the Tauri side, triggers
"Install now", and checks that the canary survives into the Electron
launch via `readMigrationSnapshot` + localStorage inspection.
5. Reports pass/fail for each step.
Needs `--skip-manual` to run the automated parts only. Useful inside a
release-review meeting as a shared checklist.
## 03 — post-migration-cleanup.mjs
```bash
node scripts/migration/03-post-migration-cleanup.mjs --dry-run
```
Once v0.12.x has been stable for 1-2 weeks:
1. Flips `apps/desktop/package.json` defaults:
- `dev``node ./scripts/electron-dev.mjs`
- `build``node ./scripts/electron-build.mjs`
- `package``pnpm run build && pnpm exec electron-builder …`
2. Removes `apps/desktop/src-tauri/` entirely.
3. Strips `@tauri-apps/*` from `apps/app/package.json` and
`apps/story-book/package.json`.
4. Collapses `apps/app/src/app/lib/desktop-tauri.ts` into
`desktop.ts` (direct Electron implementation).
5. Updates `AGENTS.md`, `ARCHITECTURE.md`, `README.md`, translated
READMEs to drop Tauri references.
6. Updates the release runbook in `AGENTS.md` to remove the
`Cargo.toml` and `tauri.conf.json` version-bump entries.
7. Creates a commit with the combined cleanup.
Drop `--dry-run` to actually perform the changes.
## Emergency rollback
If the v0.12.0 migration release is bad:
- Users on Electron already: ship v0.12.1 via electron-updater. Same
mechanism as any other update.
- Users still on Tauri: the migrate prompt is gated on
`VITE_OPENWORK_MIGRATION_RELEASE=1` at build time. Re-cut the
release with that flag unset, minisign-sign a replacement
`latest.json`, and users who haven't clicked "Install now" yet will
fall back to the non-migrating release.
- Users mid-migration: the Rust `migrate_to_electron` command keeps
the previous `.app` at `OpenWork.app.migrate-bak`. Instruct users
to `mv OpenWork.app.migrate-bak OpenWork.app` if the Electron
launch fails.