diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 0d2411cb..8d40bed9 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -1,13 +1,15 @@
name: Release desktop
-# Builds the Electron desktop app for macOS, Windows, and Linux and
-# publishes the artifacts to a GitHub draft release. Trigger by pushing
-# a `v*` tag (e.g. `git tag v0.1.50 && git push origin v0.1.50`) or
-# manually from the Actions tab.
+# Builds the Electron desktop app for macOS (arm64 + Intel x64), Windows, and
+# Linux and publishes the artifacts to a GitHub draft release. Trigger by pushing
+# a `v*` tag (e.g. `git tag v0.1.50 && git push origin v0.1.50`) or manually from
+# the Actions tab.
#
-# Binaries are **not code-signed** (see docs/architecture/desktop.md). First-run on
-# macOS/Windows will show an "unidentified developer" / SmartScreen
-# warning until signing certs are wired up — tracked on ROADMAP.md.
+# macOS builds are **Developer ID code-signed** here (CSC_LINK / CSC_KEY_PASSWORD
+# secrets, gated to the macOS runners). Notarization is done AFTER CI by
+# scripts/notarize-release.mjs (Pattern C) so the build never blocks on Apple's
+# notary queue. Windows ships unsigned (SmartScreen prompt) until an EV cert is
+# wired up — tracked on ROADMAP.md.
on:
push:
@@ -38,12 +40,18 @@ jobs:
include:
- os: macos-latest
name: macOS (arm64)
- dist_script: dist:desktop:mac
+ dist_script: dist:desktop:mac:arm64
+ # Native Intel runner — building x64 here means better-sqlite3 + the
+ # Prisma engine are compiled as x64 natively (no broken cross-build).
+ # macos-15-intel is GitHub's Intel image (available until ~Fall 2027).
+ - os: macos-15-intel
+ name: macOS (x64)
+ dist_script: dist:desktop:mac:x64
- os: windows-latest
name: Windows (x64)
dist_script: dist:desktop:win
- os: ubuntu-latest
- name: Linux (x64 + arm64)
+ name: Linux (x64)
dist_script: dist:desktop:linux
steps:
@@ -69,6 +77,11 @@ jobs:
# We only set it on tag pushes so manual workflow_dispatch runs with
# dry_run=true leave no trace.
GH_TOKEN: ${{ (github.event_name == 'push' && !inputs.dry_run) && secrets.GITHUB_TOKEN || '' }}
+ # macOS Developer ID signing. Gated to the macOS runners so the Windows
+ # job never tries to load a mac .p12 from CSC_LINK. Notarization is NOT
+ # done here (mac.notarize: false) — see scripts/notarize-release.mjs.
+ CSC_LINK: ${{ startsWith(matrix.os, 'macos') && secrets.CSC_LINK || '' }}
+ CSC_KEY_PASSWORD: ${{ startsWith(matrix.os, 'macos') && secrets.CSC_KEY_PASSWORD || '' }}
- name: Upload build artifacts
uses: actions/upload-artifact@v4
diff --git a/.gitignore b/.gitignore
index 212e5a62..dbdf73d0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -70,10 +70,15 @@ packages/ui/src/**/*.js
packages/ui/src/**/*.js.map
packages/ui/src/**/*.d.ts
packages/ui/src/**/*.d.ts.map
+# assets.d.ts is a hand-written ambient shim (Vite asset + import.meta.env
+# types), not a tsc-emitted artifact — whitelist it so CI typecheck has it.
+!packages/ui/src/assets.d.ts
packages/web/src/**/*.js
packages/web/src/**/*.js.map
packages/web/src/**/*.d.ts
packages/web/src/**/*.d.ts.map
+# vite-env.d.ts is hand-written (references ui/src/assets.d.ts) — whitelist it.
+!packages/web/src/vite-env.d.ts
# findings.md #33 — these collide with Vite's `.js`-over-`.ts`
# resolver and shipped on a stale `tsc --emit`. ambient.d.ts is the
# only legitimate `.d.ts` in apps/desktop/src; whitelist it back.
diff --git a/apps/desktop/electron-builder.yml b/apps/desktop/electron-builder.yml
index ccf0dde5..6eca2fd9 100644
--- a/apps/desktop/electron-builder.yml
+++ b/apps/desktop/electron-builder.yml
@@ -35,14 +35,46 @@ extraResources:
mac:
category: public.app-category.developer-tools
- icon: resources/icons/512x512.png
- x64ArchFiles: 'Contents/Resources/app.asar.unpacked/node_modules/**/*.node'
+ icon: resources/icons/icon.icns
+ # Developer ID code-signing. The cert is supplied in CI via CSC_LINK /
+ # CSC_KEY_PASSWORD (gated to the macOS runners in release.yml) and locally via
+ # the matching identity in the login keychain.
+ identity: Julia Kafarska (8Y2UTZ2NBZ)
+ hardenedRuntime: true
+ gatekeeperAssess: false
+ # Entitlements live in resources/ (the buildResources dir, git-tracked) — NOT
+ # in build/, which is gitignored: the file would be absent in a fresh CI
+ # checkout and the custom entitlements (network.server,
+ # disable-library-validation, …) silently dropped during signing.
+ entitlements: resources/entitlements.mac.plist
+ entitlementsInherit: resources/entitlements.mac.plist
+ # Notarization is done locally (Pattern C) by scripts/notarize-release.mjs
+ # after CI, so CI never blocks on Apple's notary queue. Keep this false.
+ notarize: false
+ # No arch pinned on purpose: each native runner picks its own slice via the
+ # CLI flag (dist:mac:arm64 → --arm64, dist:mac:x64 → --x64). Pinning arch here
+ # overrides the CLI flag and cross-builds the broken off-arch slice (better-
+ # sqlite3 / Prisma engine come out wrong-arch on a non-native runner).
target:
- - target: dmg
- arch: [arm64]
- - target: zip
- arch: [arm64]
- hardenedRuntime: false
+ - dmg
+ - zip
+
+# Branded install window (background + drag-to-Applications layout). ${arch} in
+# the name keeps the arm64 and x64 DMGs (built on separate runners) distinct.
+dmg:
+ background: resources/dmg-background.png
+ iconSize: 80
+ window:
+ width: 500
+ height: 320
+ contents:
+ - x: 145
+ y: 75
+ - x: 350
+ y: 75
+ type: link
+ path: /Applications
+ artifactName: ${productName}-${version}-${arch}.${ext}
win:
icon: resources/icons/icon.ico
@@ -53,14 +85,19 @@ win:
nsis:
oneClick: false
allowToChangeInstallationDirectory: true
+ installerIcon: resources/icons/icon.ico
+ uninstallerIcon: resources/icons/icon.ico
artifactName: ICE-Setup-${version}.${ext}
linux:
icon: resources/icons
category: Development
+ # x64 only: ubuntu-latest is x64-only (no QEMU), so an arm64 target would fail
+ # the native rebuild and drop the whole Linux job. Add a native arm64 runner if
+ # arm64 Linux is ever required.
target:
- target: AppImage
- arch: [x64, arm64]
+ arch: [x64]
- target: deb
arch: [x64]
diff --git a/apps/desktop/resources/dmg-background.png b/apps/desktop/resources/dmg-background.png
new file mode 100644
index 00000000..a6c5aa63
Binary files /dev/null and b/apps/desktop/resources/dmg-background.png differ
diff --git a/apps/desktop/resources/dmg-background.svg b/apps/desktop/resources/dmg-background.svg
new file mode 100644
index 00000000..da3438d2
--- /dev/null
+++ b/apps/desktop/resources/dmg-background.svg
@@ -0,0 +1,6 @@
+
diff --git a/apps/desktop/resources/entitlements.mac.plist b/apps/desktop/resources/entitlements.mac.plist
new file mode 100644
index 00000000..6948ef99
--- /dev/null
+++ b/apps/desktop/resources/entitlements.mac.plist
@@ -0,0 +1,20 @@
+
+
+
+
+ com.apple.security.cs.allow-jit
+
+ com.apple.security.cs.allow-unsigned-executable-memory
+
+ com.apple.security.cs.allow-dyld-environment-variables
+
+ com.apple.security.cs.disable-library-validation
+
+ com.apple.security.network.client
+
+ com.apple.security.network.server
+
+ com.apple.security.files.user-selected.read-write
+
+
+
diff --git a/apps/desktop/resources/icon-rounded.png b/apps/desktop/resources/icon-rounded.png
index 02938802..562636f3 100644
Binary files a/apps/desktop/resources/icon-rounded.png and b/apps/desktop/resources/icon-rounded.png differ
diff --git a/apps/desktop/resources/icons/1024x1024.png b/apps/desktop/resources/icons/1024x1024.png
index 2213c012..562636f3 100644
Binary files a/apps/desktop/resources/icons/1024x1024.png and b/apps/desktop/resources/icons/1024x1024.png differ
diff --git a/apps/desktop/resources/icons/128x128.png b/apps/desktop/resources/icons/128x128.png
index 1f8eeb35..2ea81fef 100644
Binary files a/apps/desktop/resources/icons/128x128.png and b/apps/desktop/resources/icons/128x128.png differ
diff --git a/apps/desktop/resources/icons/16x16.png b/apps/desktop/resources/icons/16x16.png
index 5e246bef..97a31495 100644
Binary files a/apps/desktop/resources/icons/16x16.png and b/apps/desktop/resources/icons/16x16.png differ
diff --git a/apps/desktop/resources/icons/24x24.png b/apps/desktop/resources/icons/24x24.png
index 0347f441..4275875d 100644
Binary files a/apps/desktop/resources/icons/24x24.png and b/apps/desktop/resources/icons/24x24.png differ
diff --git a/apps/desktop/resources/icons/256x256.png b/apps/desktop/resources/icons/256x256.png
index e958031e..1a5f4a21 100644
Binary files a/apps/desktop/resources/icons/256x256.png and b/apps/desktop/resources/icons/256x256.png differ
diff --git a/apps/desktop/resources/icons/32x32.png b/apps/desktop/resources/icons/32x32.png
index cac2993d..ec54faca 100644
Binary files a/apps/desktop/resources/icons/32x32.png and b/apps/desktop/resources/icons/32x32.png differ
diff --git a/apps/desktop/resources/icons/48x48.png b/apps/desktop/resources/icons/48x48.png
index eb6c7e9c..a3bbc43b 100644
Binary files a/apps/desktop/resources/icons/48x48.png and b/apps/desktop/resources/icons/48x48.png differ
diff --git a/apps/desktop/resources/icons/512x512.png b/apps/desktop/resources/icons/512x512.png
index 137828c2..83343c9f 100644
Binary files a/apps/desktop/resources/icons/512x512.png and b/apps/desktop/resources/icons/512x512.png differ
diff --git a/apps/desktop/resources/icons/64x64.png b/apps/desktop/resources/icons/64x64.png
index f0ebc6ae..689d5c42 100644
Binary files a/apps/desktop/resources/icons/64x64.png and b/apps/desktop/resources/icons/64x64.png differ
diff --git a/apps/desktop/resources/icons/icon.icns b/apps/desktop/resources/icons/icon.icns
index 387f9d43..d20f4384 100644
Binary files a/apps/desktop/resources/icons/icon.icns and b/apps/desktop/resources/icons/icon.icns differ
diff --git a/apps/desktop/resources/icons/icon.ico b/apps/desktop/resources/icons/icon.ico
index 53c4819f..dea64d63 100644
Binary files a/apps/desktop/resources/icons/icon.ico and b/apps/desktop/resources/icons/icon.ico differ
diff --git a/docs/architecture/desktop.md b/docs/architecture/desktop.md
index 627c6b19..c5cce0d0 100644
--- a/docs/architecture/desktop.md
+++ b/docs/architecture/desktop.md
@@ -5,18 +5,18 @@ The Electron desktop app is a fully self-contained ICE: no separate server, no D
## Status
- Works for daily dev.
-- Build pipeline is wired (`pnpm dist:desktop`, `dist:desktop:mac`, `:win`, `:linux`).
-- **v0.1 binaries are not yet code-signed or notarized.** First-run on macOS shows the standard "unidentified developer" prompt; Windows shows SmartScreen. See "First-run instructions" below.
-- Auto-update is wired through `electron-updater` against GitHub Releases - will activate once signed binaries are published.
+- Build pipeline is wired (`pnpm dist:desktop`, `dist:desktop:mac:arm64`/`:x64`, `:win`, `:linux`).
+- macOS ships **Apple Silicon (arm64) and Intel (x64)** builds, each built on a native runner; Linux ships **x64** AppImage + `.deb`; Windows ships an x64 NSIS installer.
+- **macOS binaries are Developer ID code-signed and notarized** — signed in CI (`CSC_LINK`/`CSC_KEY_PASSWORD`) and notarized after CI via `scripts/notarize-release.mjs` ("Pattern C"). First-run is clean, no "unidentified developer" prompt. **Windows is still unsigned** (SmartScreen prompt) until an EV cert is wired up.
+- Auto-update is wired through `electron-updater` against GitHub Releases. Because the two macOS arches are built on separate runners, publishing a single merged `latest-mac.yml` (so both arches get an update channel) is a pending follow-up.
-## First-run instructions for v0.1 (unsigned)
+## First-run instructions
### macOS
-1. Download the `.dmg` from the GitHub release.
-2. Drag ICE to Applications.
-3. The first time you double-click, macOS will refuse and say it can't verify the developer. **Right-click → Open** → confirm the dialog. After the first run macOS remembers the choice.
-4. Alternative: System Settings → Privacy & Security → scroll to the "ICE was blocked" message → **Open Anyway**.
+Signed + notarized: download the `.dmg` (arm64 for Apple Silicon, x64 for Intel), drag ICE to Applications, and double-click — it opens with no Gatekeeper dialog.
+
+> If a release was ever published before notarization completed, macOS may show "cannot verify the developer." Fallback: **Right-click → Open** → confirm, or System Settings → Privacy & Security → **Open Anyway**.
### Windows
@@ -28,17 +28,13 @@ The Electron desktop app is a fully self-contained ICE: no separate server, no D
`.AppImage` and `.deb` builds are unsigned but Linux distros generally don't gate on that. If the AppImage refuses to launch, install `libfuse2` (`sudo apt install libfuse2` on Debian/Ubuntu).
-## v0.2 code-signing plan
-
-Targets for v0.2:
+## Code-signing status
-- **macOS**: Apple Developer ID Application certificate + notarytool submission. Removes the "unidentified developer" prompt and activates Gatekeeper trust on first launch.
-- **Windows**: EV (Extended Validation) certificate from a recognized CA, signing both `.exe` and `.msi`. EV is what gets SmartScreen to trust the binary without the "more info" prompt.
+- **macOS — done.** Apple Developer ID Application certificate + `notarytool` submission. Signing runs in CI; notarization runs locally after CI (`scripts/notarize-release.mjs`, "Pattern C") so the build never blocks on Apple's notary queue. Removes the "unidentified developer" prompt. See [releasing.md](#) / `scripts/notarize-release.mjs` for the keychain-profile setup (`ice-notary`).
+- **Windows — pending.** EV (Extended Validation) certificate from a recognized CA to sign the `.exe`. EV is what gets SmartScreen to trust the binary without the "more info" prompt. Wire via `WIN_CSC_LINK`/`WIN_CSC_KEY_PASSWORD`.
- **Linux**: keep `.AppImage` + `.deb` as the primary distribution; explore `flathub` and Snap once usage justifies it.
-Once those certs are in place, `electron-updater` activates and the in-app updater takes over - no more "go to GitHub Releases" step for end users.
-
-Code-signing cost is part of the project's operational budget; the ROADMAP entry tracks the procurement timeline.
+Windows code-signing cost is part of the project's operational budget; the ROADMAP entry tracks the procurement timeline.
## Package layout
diff --git a/package.json b/package.json
index 6d1dcbfb..2fc20d32 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "ice",
"license": "Apache-2.0",
- "version": "0.1.900",
+ "version": "0.1.902",
"description": "ICE - Integrated Cloud Environment (Web + Backend)",
"private": true,
"type": "module",
@@ -23,6 +23,8 @@
"build:desktop": "pnpm --filter @ice/desktop build",
"dist:desktop": "pnpm --filter @ice/desktop dist",
"dist:desktop:mac": "pnpm --filter @ice/desktop dist:mac",
+ "dist:desktop:mac:arm64": "pnpm --filter @ice/desktop dist:mac:arm64",
+ "dist:desktop:mac:x64": "pnpm --filter @ice/desktop dist:mac:x64",
"dist:desktop:win": "pnpm --filter @ice/desktop dist:win",
"dist:desktop:linux": "pnpm --filter @ice/desktop dist:linux",
"release": "node scripts/release.mjs",
diff --git a/packages/ui/src/assets.d.ts b/packages/ui/src/assets.d.ts
new file mode 100644
index 00000000..1c506510
--- /dev/null
+++ b/packages/ui/src/assets.d.ts
@@ -0,0 +1,51 @@
+// Type shims for asset imports resolved by Vite.
+// SVG and image imports become string URLs when bundled.
+
+interface ImportMetaEnv {
+ readonly VITE_API_URL?: string;
+ readonly [key: string]: string | undefined;
+}
+
+interface ImportMeta {
+ readonly env: ImportMetaEnv;
+}
+
+declare module '*.svg' {
+ const src: string;
+ export default src;
+}
+
+declare module '*.svg?url' {
+ const src: string;
+ export default src;
+}
+
+declare module '*.svg?raw' {
+ const src: string;
+ export default src;
+}
+
+declare module '*.png' {
+ const src: string;
+ export default src;
+}
+
+declare module '*.jpg' {
+ const src: string;
+ export default src;
+}
+
+declare module '*.jpeg' {
+ const src: string;
+ export default src;
+}
+
+declare module '*.webp' {
+ const src: string;
+ export default src;
+}
+
+declare module '*.gif' {
+ const src: string;
+ export default src;
+}
diff --git a/packages/web/src/vite-env.d.ts b/packages/web/src/vite-env.d.ts
new file mode 100644
index 00000000..ca66c823
--- /dev/null
+++ b/packages/web/src/vite-env.d.ts
@@ -0,0 +1,2 @@
+// eslint-disable-next-line @typescript-eslint/triple-slash-reference
+///
diff --git a/scripts/notarize-release.mjs b/scripts/notarize-release.mjs
new file mode 100644
index 00000000..785aec6d
--- /dev/null
+++ b/scripts/notarize-release.mjs
@@ -0,0 +1,226 @@
+#!/usr/bin/env node
+// Manual macOS notarization step for a GitHub Release.
+//
+// Pattern C: CI ships signed-but-not-notarized DMGs so the build job never
+// blocks on Apple's notary queue. This script is the maintainer-side companion:
+// download each macOS DMG from the release, submit to Apple, wait for the
+// verdict, staple, and re-upload — all WHILE THE RELEASE IS STILL A DRAFT.
+//
+// Usage:
+// node scripts/notarize-release.mjs # uses package.json version
+// node scripts/notarize-release.mjs v0.1.901 # explicit tag
+//
+// Prerequisites:
+// - Keychain profile "ice-notary" stored via:
+// xcrun notarytool store-credentials "ice-notary" \
+// --apple-id "" --team-id "8Y2UTZ2NBZ"
+// - `gh` authenticated with write access to the repo
+// - Run on macOS (needs xcrun notarytool / stapler).
+//
+// The script is idempotent: if a DMG is already stapled, re-running just
+// verifies and re-uploads. Safe to retry after partial failures.
+
+import { spawnSync } from "node:child_process";
+import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const REPO = "light-cloud-com/ice";
+const KEYCHAIN_PROFILE = "ice-notary";
+
+const projectRoot = resolve(fileURLToPath(import.meta.url), "..", "..");
+
+function readVersionFromPkg() {
+ const pkg = JSON.parse(readFileSync(join(projectRoot, "package.json"), "utf8"));
+ return `v${pkg.version}`;
+}
+
+function run(cmd, args, opts = {}) {
+ const printable = `${cmd} ${args.map((a) => (a.includes(" ") ? `"${a}"` : a)).join(" ")}`;
+ console.log(`\n$ ${printable}`);
+ const res = spawnSync(cmd, args, { stdio: "inherit", ...opts });
+ if (res.status !== 0) {
+ throw new Error(`${cmd} exited with code ${res.status}`);
+ }
+ return res;
+}
+
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+
+// Run a command, retrying on failure with linear backoff. Used for steps that
+// can fail transiently — chiefly `stapler staple` right after notarization,
+// when Apple's ticket CDN hasn't yet published the ticket that `notarytool
+// submit --wait` just reported as Accepted (a few seconds of lag is common).
+async function runWithRetry(cmd, args, { attempts = 5, delayMs = 15_000, label } = {}) {
+ for (let attempt = 1; attempt <= attempts; attempt++) {
+ const printable = `${cmd} ${args.map((a) => (a.includes(" ") ? `"${a}"` : a)).join(" ")}`;
+ console.log(`\n$ ${printable}${attempt > 1 ? ` (attempt ${attempt}/${attempts})` : ""}`);
+ const res = spawnSync(cmd, args, { stdio: "inherit" });
+ if (res.status === 0) return res;
+ if (attempt === attempts) {
+ throw new Error(`${label || cmd} failed after ${attempts} attempts (exit ${res.status})`);
+ }
+ console.log(` ${label || cmd} failed (exit ${res.status}); retrying in ${delayMs / 1000}s…`);
+ await sleep(delayMs);
+ }
+}
+
+function runCapture(cmd, args) {
+ const res = spawnSync(cmd, args, { encoding: "utf8" });
+ if (res.status !== 0) {
+ throw new Error(`${cmd} ${args.join(" ")} failed: ${res.stderr}`);
+ }
+ return res.stdout;
+}
+
+function macDmgsForTag(tag) {
+ // gh release view --json assets returns a JSON list with name + url.
+ const json = runCapture("gh", ["release", "view", tag, "--repo", REPO, "--json", "assets"]);
+ const { assets } = JSON.parse(json);
+ return assets
+ .filter((a) => a.name.toLowerCase().endsWith(".dmg"))
+ .filter((a) => !a.name.endsWith(".blockmap"))
+ .map((a) => a.name);
+}
+
+async function notarizeAndStaple(dmgPath) {
+ console.log(`\n=== ${dmgPath} ===`);
+
+ // Quick precheck: if already stapled, skip the slow Apple round-trip.
+ const validate = spawnSync("xcrun", ["stapler", "validate", dmgPath], {
+ encoding: "utf8",
+ });
+ if (validate.status === 0 && /worked|valid/i.test(validate.stdout)) {
+ console.log("Already stapled — skipping notarytool submit.");
+ return;
+ }
+
+ // Submit + block until Apple returns Accepted / Invalid. Apple's normal
+ // SLA is 5-15 min; can be longer when the notary service is congested.
+ run("xcrun", [
+ "notarytool",
+ "submit",
+ dmgPath,
+ "--keychain-profile",
+ KEYCHAIN_PROFILE,
+ "--wait",
+ "--timeout",
+ "2h",
+ ]);
+
+ // Embed the ticket in the DMG so Gatekeeper trusts it offline. Retry: the
+ // ticket can take a few seconds to propagate to Apple's CDN after `submit
+ // --wait` returns Accepted, so a bare `staple` here can spuriously fail.
+ await runWithRetry("xcrun", ["stapler", "staple", dmgPath], { label: "stapler staple" });
+
+ // Verify the ticket is actually embedded. `stapler validate` is the
+ // authoritative check for a stapled DMG — DO NOT use `spctl --assess` on the
+ // unmounted .dmg here: a disk image has no code signature of its own, so
+ // spctl reports "no usable signature" even when the ticket is correctly
+ // stapled. The real Gatekeeper assessment applies to the .app inside, which
+ // we check below after mounting.
+ run("xcrun", ["stapler", "validate", dmgPath]);
+}
+
+// Mount the stapled DMG and assess the .app the way Gatekeeper actually will,
+// confirming the end-user "open downloaded app" experience is clean.
+function assessAppInside(dmgPath) {
+ const attach = spawnSync(
+ "hdiutil",
+ ["attach", dmgPath, "-nobrowse", "-noverify", "-noautoopen"],
+ { encoding: "utf8" }
+ );
+ const mount = (attach.stdout || "")
+ .split("\n")
+ .map((l) => l.match(/(\/Volumes\/.+)$/)?.[1])
+ .filter(Boolean)
+ .pop();
+ if (!mount) {
+ console.log(` (could not mount ${dmgPath} for app assessment — skipping)`);
+ return;
+ }
+ try {
+ const apps = spawnSync("sh", ["-c", `ls -d "${mount}"/*.app`], { encoding: "utf8" });
+ const app = (apps.stdout || "").trim().split("\n")[0];
+ if (app) {
+ run("spctl", ["-a", "-vvv", "-t", "exec", app]);
+ }
+ } finally {
+ spawnSync("hdiutil", ["detach", mount, "-quiet"]);
+ }
+}
+
+async function main() {
+ const tag = process.argv[2] || readVersionFromPkg();
+ if (!/^v\d+\.\d+\.\d+/.test(tag)) {
+ throw new Error(`Tag "${tag}" doesn't look like vX.Y.Z`);
+ }
+
+ console.log(`Notarizing macOS artifacts for ${tag} (repo: ${REPO})`);
+
+ const dmgs = macDmgsForTag(tag);
+ if (dmgs.length === 0) {
+ throw new Error(`No .dmg assets found on release ${tag}.`);
+ }
+ console.log(`Found ${dmgs.length} DMG(s) on the release: ${dmgs.join(", ")}`);
+
+ const workDir = mkdtempSync(join(tmpdir(), "ice-notarize-"));
+ console.log(`Working directory: ${workDir}`);
+
+ try {
+ // Download all DMGs in one call.
+ const patterns = dmgs.flatMap((d) => ["--pattern", d]);
+ run("gh", ["release", "download", tag, "--repo", REPO, "--dir", workDir, ...patterns]);
+
+ // Process each DMG independently — a failure on one (e.g. Apple rejects a
+ // single arch) must not prevent the others from being notarized.
+ const succeeded = [];
+ const failed = [];
+ for (const name of dmgs) {
+ const path = join(workDir, name);
+ try {
+ if (!existsSync(path)) {
+ throw new Error(`Expected file ${path} after gh download, but it's missing.`);
+ }
+ await notarizeAndStaple(path);
+ assessAppInside(path);
+ succeeded.push(name);
+ } catch (err) {
+ console.error(`\n[notarize-release] ${name} FAILED: ${err.message}`);
+ failed.push(name);
+ }
+ }
+
+ // Re-upload only the DMGs we actually stapled (--clobber overwrites the
+ // unstapled versions that CI uploaded). Never push a half-processed DMG.
+ if (succeeded.length > 0) {
+ const uploadPaths = succeeded.map((d) => join(workDir, d));
+ run("gh", ["release", "upload", tag, "--repo", REPO, "--clobber", ...uploadPaths]);
+ }
+
+ console.log(`\n--- Summary -------------------------------------------------`);
+ console.log(
+ `Notarized + stapled + re-uploaded (${succeeded.length}): ${succeeded.join(", ") || "none"}`
+ );
+ if (failed.length > 0) {
+ console.log(`Failed (${failed.length}): ${failed.join(", ")}`);
+ throw new Error(`${failed.length} of ${dmgs.length} DMG(s) failed to notarize.`);
+ }
+
+ console.log(`\nAll ${dmgs.length} DMG(s) notarized, stapled, and re-uploaded.`);
+ console.log(
+ `Next: install the stapled DMG; double-clicking the app should now open with no Gatekeeper dialog.`
+ );
+ console.log(
+ ` Then publish the release: gh release edit ${tag} --repo ${REPO} --draft=false`
+ );
+ } finally {
+ rmSync(workDir, { recursive: true, force: true });
+ }
+}
+
+main().catch((err) => {
+ console.error("\n[notarize-release] FAILED:", err.message);
+ process.exit(1);
+});
diff --git a/scripts/release.mjs b/scripts/release.mjs
new file mode 100644
index 00000000..f0d5af21
--- /dev/null
+++ b/scripts/release.mjs
@@ -0,0 +1,274 @@
+#!/usr/bin/env node
+// One-command release for ICE.
+//
+// node scripts/release.mjs 0.1.900 # explicit version
+// node scripts/release.mjs patch # or minor / major
+// pnpm release 0.1.900
+//
+// Runs the whole pipeline, and is RESUMABLE — if it stops partway (CI, network,
+// a build hiccup), just run the same command again and it picks up where it
+// left off (it detects the existing branch / PR / tag / release and skips the
+// steps already done):
+// 1. Preflight (fresh start only): gh authed, clean `main` matching origin.
+// 2. Bump the version in BOTH package.json files (root + apps/desktop) on a
+// release branch, open a PR, wait for CI, squash-merge.
+// 3. Tag the merged commit and push → triggers the "Release desktop" workflow.
+// 4. Wait for the macOS/Windows/Linux builds + the draft release to finish.
+// 5. Notarize the macOS DMGs (scripts/notarize-release.mjs) WHILE the release
+// is still a DRAFT — "Immutable releases" freezes assets at publish time,
+// so the stapled DMGs must be in place BEFORE un-drafting.
+// 6. Publish the release (un-draft) — captures the notarized assets.
+//
+// Why both package.json files: electron-builder reads apps/desktop/package.json
+// for the version it stamps into artifact names (ICE-Setup-.exe) and
+// the GitHub release tag it publishes to. The git tag we push and that version
+// MUST match, so we keep root and desktop versions in lockstep. (Today they have
+// drifted — root 0.1.899 vs desktop 0.1.0 — and the first release through this
+// script realigns them.)
+//
+// macOS binaries are Developer ID code-signed in CI; notarization runs locally
+// (Pattern C) via scripts/notarize-release.mjs, so CI never blocks on Apple's
+// notary queue. Windows ships unsigned (SmartScreen) until an EV cert lands.
+//
+// Prerequisites (one-time):
+// - `gh` authenticated with write access to the repo.
+// - macOS notary keychain profile "ice-notary" (see notarize-release.mjs).
+// - CI secrets CSC_LINK / CSC_KEY_PASSWORD set on the repo (signing cert).
+// - Use a NEW version each time — bump forward, never reuse a released tag.
+
+import { spawnSync } from "node:child_process";
+import { readFileSync, writeFileSync } from "node:fs";
+import { join, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const REPO = "light-cloud-com/ice";
+const WORKFLOW = "Release desktop"; // matches .github/workflows/release.yml `name:`
+const projectRoot = resolve(fileURLToPath(import.meta.url), "..", "..");
+
+// package.json files whose "version" we keep in lockstep with the release tag.
+const VERSIONED_MANIFESTS = ["package.json", "apps/desktop/package.json"];
+
+function run(cmd, args, opts = {}) {
+ console.log(`\n$ ${cmd} ${args.join(" ")}`);
+ const res = spawnSync(cmd, args, { stdio: "inherit", cwd: projectRoot, ...opts });
+ if (res.status !== 0) throw new Error(`${cmd} exited with code ${res.status}`);
+ return res;
+}
+
+function capture(cmd, args) {
+ const res = spawnSync(cmd, args, { cwd: projectRoot, encoding: "utf8" });
+ if (res.status !== 0) throw new Error(`${cmd} ${args.join(" ")} failed: ${res.stderr || ""}`);
+ return res.stdout.trim();
+}
+
+// Returns trimmed stdout, or null if the command failed (non-fatal probe).
+function tryCapture(cmd, args) {
+ const res = spawnSync(cmd, args, { cwd: projectRoot, encoding: "utf8" });
+ return res.status === 0 ? res.stdout.trim() : null;
+}
+
+function ghJson(args) {
+ const out = tryCapture("gh", args);
+ if (!out) return null;
+ try {
+ return JSON.parse(out);
+ } catch {
+ return null;
+ }
+}
+
+function fail(msg) {
+ console.error(`\n✖ ${msg}`);
+ process.exit(1);
+}
+
+function sleep(seconds) {
+ spawnSync("sleep", [String(seconds)], { stdio: "ignore" });
+}
+
+function currentVersion() {
+ return JSON.parse(readFileSync(join(projectRoot, "package.json"), "utf8")).version;
+}
+
+// Resolve "1.2.3" | "patch" | "minor" | "major" → "1.2.3"
+function resolveVersion(arg) {
+ if (/^\d+\.\d+\.\d+$/.test(arg)) return arg;
+ const [maj, min, pat] = currentVersion().split(".").map(Number);
+ if (arg === "major") return `${maj + 1}.0.0`;
+ if (arg === "minor") return `${maj}.${min + 1}.0`;
+ if (arg === "patch") return `${maj}.${min}.${pat + 1}`;
+ fail(`Invalid version "${arg}" — use a semver like 0.1.900, or patch/minor/major.`);
+}
+
+// Rewrite the top-level "version" field in each manifest, preserving formatting
+// by touching only the version line.
+function bumpManifests(version) {
+ for (const rel of VERSIONED_MANIFESTS) {
+ const path = join(projectRoot, rel);
+ const src = readFileSync(path, "utf8");
+ const next = src.replace(/("version"\s*:\s*")[^"]*(")/, `$1${version}$2`);
+ if (next === src) fail(`Could not find a "version" field to bump in ${rel}.`);
+ writeFileSync(path, next);
+ console.log(` • ${rel} → ${version}`);
+ }
+}
+
+// Wait for the PR's CI to pass. Robust against the gap right after a PR is
+// created, when `gh pr checks --watch` errors with "no checks reported" instead
+// of waiting — we just retry until the checks register, then it blocks to the end.
+function waitForChecks(branch) {
+ console.log("\n⏳ Waiting for CI checks (retries until they register)…");
+ for (let i = 0; i < 120; i++) {
+ const res = spawnSync("gh", ["pr", "checks", branch, "--repo", REPO, "--watch", "--fail-fast"], {
+ cwd: projectRoot,
+ encoding: "utf8",
+ });
+ const text = (res.stdout || "") + (res.stderr || "");
+ if (res.status === 0) {
+ process.stdout.write(res.stdout || "");
+ return;
+ }
+ if (/no checks reported/i.test(text)) {
+ sleep(8);
+ continue;
+ }
+ process.stdout.write(res.stdout || "");
+ process.stderr.write(res.stderr || "");
+ fail("CI checks did not pass — fix them, push to the branch, and re-run.");
+ }
+ fail("Timed out waiting for CI checks to register.");
+}
+
+// Squash-merge the PR. Prefer a plain merge; fall back to --admin if branch
+// protection blocks it (and the maintainer holds an admin bypass).
+function mergePr(branch) {
+ const base = ["pr", "merge", branch, "--repo", REPO, "--squash", "--delete-branch"];
+ const plain = spawnSync("gh", base, { cwd: projectRoot, encoding: "utf8" });
+ if (plain.status === 0) {
+ process.stdout.write(plain.stdout || "");
+ return;
+ }
+ process.stderr.write(plain.stderr || "");
+ console.log("• Plain squash-merge was blocked — retrying with --admin bypass…");
+ run("gh", [...base, "--admin"]);
+}
+
+function pushTag(tag) {
+ console.log(`\nPushing tag ${tag}…`);
+ const res = spawnSync("git", ["push", "origin", tag], {
+ cwd: projectRoot,
+ encoding: "utf8",
+ stdio: ["inherit", "inherit", "pipe"],
+ });
+ if (res.status === 0) return;
+ process.stderr.write(res.stderr || "");
+ fail(`git push of tag ${tag} failed. If the tag already exists remotely from a previous attempt, delete it (git push origin :${tag}) or bump to a new version.`);
+}
+
+// ---- main -------------------------------------------------------------------
+
+const arg = process.argv[2];
+if (!arg) fail("Usage: node scripts/release.mjs ");
+
+const version = resolveVersion(arg);
+const tag = `v${version}`;
+const branch = `release-${tag}`;
+
+console.log(`\n=== Releasing ${tag} (current: ${currentVersion()}) ===`);
+
+if (spawnSync("gh", ["auth", "status"], { stdio: "ignore" }).status !== 0) {
+ fail("GitHub CLI not authenticated. Run: gh auth login");
+}
+
+// Detect what already exists, so a re-run resumes instead of starting over.
+const tagOnRemote = !!tryCapture("git", ["ls-remote", "--tags", "origin", tag]);
+const release = ghJson(["release", "view", tag, "--repo", REPO, "--json", "isDraft,assets"]);
+const prInfo = ghJson(["pr", "view", branch, "--repo", REPO, "--json", "state"]);
+const prState = prInfo ? prInfo.state : null; // OPEN | MERGED | CLOSED | null
+
+// ---- Stage A: get the version bump merged into main -------------------------
+const bumpMerged = tagOnRemote || release !== null || prState === "MERGED";
+
+if (bumpMerged) {
+ console.log("• Version bump already merged — resuming at the tag/build stage.");
+} else {
+ const branchPushed = !!tryCapture("git", ["ls-remote", "--heads", "origin", branch]);
+
+ if (!branchPushed) {
+ // Fresh start — require a clean main that matches origin.
+ const onBranch = capture("git", ["rev-parse", "--abbrev-ref", "HEAD"]);
+ if (onBranch !== "main") fail(`Switch to main first (on "${onBranch}").`);
+ if (capture("git", ["status", "--porcelain"]))
+ fail("Working tree is not clean — commit or stash first.");
+ run("git", ["fetch", "origin", "main", "--quiet"]);
+ if (capture("git", ["rev-parse", "main"]) !== capture("git", ["rev-parse", "origin/main"]))
+ fail("Local main differs from origin/main. Run: git pull --ff-only");
+
+ run("git", ["checkout", "-b", branch]);
+ console.log(`\nBumping version → ${version}:`);
+ bumpManifests(version);
+ run("git", ["commit", "-am", `release: ${version}`]);
+ run("git", ["push", "-u", "origin", branch]);
+ } else {
+ console.log("• Release branch already pushed — resuming (skipping the version bump).");
+ }
+
+ if (prState !== "OPEN") {
+ run("gh", [
+ "pr", "create", "--repo", REPO, "--base", "main", "--head", branch,
+ "--title", `release: ${version}`, "--body", `Release ${tag}.`,
+ ]);
+ } else {
+ console.log(`• PR for ${branch} already open — reusing it.`);
+ }
+
+ waitForChecks(branch);
+ mergePr(branch);
+}
+
+// ---- Stage B: tag the merged commit → triggers the build --------------------
+run("git", ["checkout", "main"]);
+run("git", ["pull", "--ff-only", "origin", "main"]);
+if (!tagOnRemote) {
+ if (!tryCapture("git", ["rev-parse", "-q", "--verify", `refs/tags/${tag}`])) {
+ run("git", ["tag", tag]);
+ }
+ pushTag(tag);
+} else {
+ console.log(`• Tag ${tag} already on origin — skipping tag push.`);
+}
+
+// ---- Stage C: wait for the Release build (skip if assets already present) ---
+const built = ghJson(["release", "view", tag, "--repo", REPO, "--json", "assets"]);
+if (!built || (built.assets || []).length === 0) {
+ console.log("\n⏳ Waiting for the Release build to start…");
+ let runId = null;
+ for (let i = 0; i < 20 && !runId; i++) {
+ sleep(6);
+ const runs = ghJson([
+ "run", "list", "--repo", REPO, "--workflow", WORKFLOW,
+ "--branch", tag, "--json", "databaseId", "--limit", "1",
+ ]);
+ if (runs && runs.length) runId = runs[0].databaseId;
+ }
+ if (!runId)
+ fail(`Couldn't find the "${WORKFLOW}" run for ${tag}. Check the Actions tab, then re-run this script.`);
+ console.log(`\n⏳ Building (run ${runId}) — the slow part (~10–15 min across 3 platforms)…`);
+ run("gh", ["run", "watch", String(runId), "--repo", REPO, "--exit-status"]);
+} else {
+ console.log(`• Build artifacts already attached to ${tag} — skipping the build wait.`);
+}
+
+// ---- Stage D: notarize the macOS DMGs WHILE STILL A DRAFT -------------------
+// CI ships signed-but-not-notarized DMGs (Pattern C) so it never blocks on
+// Apple's notary queue. Notarize + staple + re-upload now, while the release is
+// still a draft: GitHub "Immutable releases" freezes a release's assets at
+// publish (un-draft) time, so the stapled DMGs must be in place BEFORE we flip
+// it live. notarize-release.mjs is idempotent (skips already-stapled DMGs).
+run("node", [join(projectRoot, "scripts", "notarize-release.mjs"), tag]);
+
+// ---- Stage E: publish (un-draft) — idempotent; freezes the stapled assets ---
+// electron-builder publishes to a DRAFT GitHub release by default; flip it live.
+run("gh", ["release", "edit", tag, "--repo", REPO, "--draft=false"]);
+
+console.log(`\n✅ Released ${tag}: https://github.com/${REPO}/releases/tag/${tag}`);