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}`);