源码快照 @ 2026-09-02 (source-only, 剔除非源码素材)

This commit is contained in:
agent-radar
2026-09-02 01:05:41 +08:00
commit 04bac1f6e2
2200 changed files with 489415 additions and 0 deletions
+196
View File
@@ -0,0 +1,196 @@
#!/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' \
// --mac-arm64-url 'https://.../openwork-mac-arm64-0.12.0.zip' \
// --mac-x64-url 'https://.../openwork-mac-x64-0.12.0.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-arm64-url") out.macArm64Url = argv[++i];
else if (arg === "--mac-x64-url") out.macX64Url = argv[++i];
else if (arg === "--mac-sha256") out.macSha256 = argv[++i];
else if (arg === "--win-url") out.winUrl = argv[++i];
else if (arg === "--win-x64-url") out.winX64Url = argv[++i];
else if (arg === "--linux-url") out.linuxUrl = argv[++i];
else if (arg === "--linux-arm64-url") out.linuxArm64Url = argv[++i];
else if (arg === "--linux-x64-url") out.linuxX64Url = 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 (or --mac-arm64-url + --mac-x64-url)",
"Optional: --mac-sha256, --win-url, --win-x64-url, --linux-url, --linux-arm64-url, --linux-x64-url, --dry-run",
].join("\n"),
);
return;
}
if (!args.version) die("--version is required (e.g. --version 0.12.0)");
if (!args.macUrl && (!args.macArm64Url || !args.macX64Url)) {
die("--mac-url or both --mac-arm64-url + --mac-x64-url are 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 });
// Keep CI/release jobs using --frozen-lockfile green after bumping
// workspace package versions referenced by pnpm-lock.yaml.
run("pnpm", ["install", "--no-frozen-lockfile"], { 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.macArm64Url ? `VITE_OPENWORK_MIGRATION_MAC_ARM64_URL=${args.macArm64Url}` : "",
args.macX64Url ? `VITE_OPENWORK_MIGRATION_MAC_X64_URL=${args.macX64Url}` : "",
args.macSha256 ? `VITE_OPENWORK_MIGRATION_MAC_SHA256=${args.macSha256}` : "",
args.winUrl ? `VITE_OPENWORK_MIGRATION_WINDOWS_URL=${args.winUrl}` : "",
args.winX64Url ? `VITE_OPENWORK_MIGRATION_WINDOWS_X64_URL=${args.winX64Url}` : "",
args.linuxUrl ? `VITE_OPENWORK_MIGRATION_LINUX_URL=${args.linuxUrl}` : "",
args.linuxArm64Url ? `VITE_OPENWORK_MIGRATION_LINUX_ARM64_URL=${args.linuxArm64Url}` : "",
args.linuxX64Url ? `VITE_OPENWORK_MIGRATION_LINUX_X64_URL=${args.linuxX64Url}` : "",
"",
]
.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);
});
+205
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);
});
+225
View File
@@ -0,0 +1,225 @@
#!/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. Once Tauri is gone, make the sidecar helper's default output match the
// Electron resource layout. Before cleanup, Tauri still uses the old
// default and Electron passes --outdir explicitly.
await replaceInFile(
resolve(repoRoot, "apps/desktop/scripts/prepare-sidecar.mjs"),
[
[
/join\(__dirname, "\.\.", "src-tauri", "sidecars"\)/,
'join(__dirname, "..", "resources", "sidecars")',
],
],
{ dryRun: args.dryRun },
);
// 8. 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);
});
+146
View File
@@ -0,0 +1,146 @@
# 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 |
## Current safe-prep rollout
The next release is intentionally **non-destructive**:
- Tauri remains the main/stable desktop build and keeps using the existing
Tauri updater feed (`latest.json`).
- The alpha macOS arm64 release now carries both notarized Tauri artifacts
(`latest.json`) and notarized Electron artifacts (`latest-mac.yml`) on the
rolling `alpha-macos-latest` release.
- Electron is built as a preview artifact on every push by
`.github/workflows/build-electron-desktop.yml`.
- Pushes to `dev` or `main` refresh the rolling prerelease bucket at
<https://github.com/different-ai/openwork/releases/tag/electron-preview-latest>.
- The Debug settings migration controls are Tauri-only and developer-mode only.
The default action, **Prepare migration data**, only writes
`migration-snapshot.v1.json`; it does not quit, replace, or delete Tauri.
- The install handoff requires a pasted Electron artifact URL plus two explicit
confirmations. On macOS the native handoff keeps the previous bundle at
`OpenWork.app.migrate-bak` for rollback.
Use this safe-prep release to test migration data capture and preview downloads
before enabling any user-facing migration prompt.
### Sharing Electron preview downloads
1. Wait for `Build Electron Desktop Preview` to finish on the target commit.
2. Share the rolling preview release page:
<https://github.com/different-ai/openwork/releases/tag/electron-preview-latest>
3. Ask testers to download the matching platform artifact:
- macOS Apple Silicon: `openwork-mac-arm64-*.dmg` or `.zip`
- macOS Intel: `openwork-mac-x64-*.dmg` or `.zip`
- Windows: `openwork-win-x64-*.exe`
- Linux: `openwork-linux-x64-*.AppImage` or `.tar.gz`
Do not point stable Tauri users at these preview assets as an automatic update
until the explicit migration release is cut and validated.
## 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. Electron icons and generated
sidecars already live under `apps/desktop/resources/`, so this step no
longer removes anything the Electron packager needs.
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.