From cb310135844b9027dbc4c3f1546c323f7a93863e Mon Sep 17 00:00:00 2001 From: zhayujie Date: Mon, 6 Jul 2026 19:24:55 +0800 Subject: [PATCH] build(desktop): decouple macOS notarization from CI into 3-stage release --- .github/workflows/publish-desktop.yml | 167 ++++++++++++++++++++++++++ .github/workflows/release.yml | 88 ++++++-------- .gitignore | 2 +- desktop/build/notarize-app.js | 123 ------------------- desktop/build/notarize-dmg.sh | 147 +++++++++++++++++++++++ desktop/electron-builder.js | 14 +-- desktop/src/renderer/index.html | 2 +- 7 files changed, 357 insertions(+), 186 deletions(-) create mode 100644 .github/workflows/publish-desktop.yml delete mode 100644 desktop/build/notarize-app.js create mode 100755 desktop/build/notarize-dmg.sh diff --git a/.github/workflows/publish-desktop.yml b/.github/workflows/publish-desktop.yml new file mode 100644 index 00000000..41658146 --- /dev/null +++ b/.github/workflows/publish-desktop.yml @@ -0,0 +1,167 @@ +name: Publish Desktop + +# STAGE 3 of the decoupled release pipeline: PROMOTE a built + notarized version +# to "live". By this point: +# - stage 1 (Release Desktop) built the installers, mirrored them to R2, and +# registered them in D1 as unpublished (is_latest=0); +# - stage 2 (local desktop/build/notarize-dmg.sh) notarized + stapled the mac +# dmgs and re-uploaded the stapled bytes to R2. +# +# This workflow, triggered manually with the version to publish: +# 1. pulls every artifact for that version back from R2, +# 2. recomputes sha512 from the real (stapled) bytes and updates D1, +# 3. flips is_latest=1 for that version (clearing the previous latest per +# platform) UNLESS it's a pre-release, which is recorded but never latest, +# 4. creates/updates the GitHub Release and attaches the installers. +# +# Run only after the mac dmgs for this version are notarized + re-uploaded. +on: + workflow_dispatch: + inputs: + version: + description: "Version to publish (e.g. 1.2.0). Must already be built + notarized." + type: string + required: true + make_latest: + description: "Mark this version as latest on the site (uncheck for a dry re-hash only)." + type: boolean + default: true + github_release: + description: "Create/update the GitHub Release and attach installers." + type: boolean + default: true + +permissions: + contents: write + +jobs: + publish: + name: Publish ${{ github.event.inputs.version }} + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Guard on Cloudflare secrets + id: guard + env: + CF_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + run: | + if [ -z "$CF_TOKEN" ]; then + echo "::error::CLOUDFLARE_API_TOKEN not set — cannot publish." + exit 1 + fi + + - name: Resolve version artifacts from D1 + id: rows + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + VER: ${{ github.event.inputs.version }} + run: | + # The build stage inserted one row per artifact with filename = v/. + # Read them back so we know exactly which objects to pull from R2. + out="$(npx --yes wrangler@latest d1 execute cow-desktop --remote --json \ + --command "SELECT platform, filename FROM releases WHERE version = '${VER}';")" + echo "$out" + echo "$out" | node -e ' + const fs = require("fs"); + const data = JSON.parse(fs.readFileSync(0, "utf8")); + const rows = (Array.isArray(data) ? data : [data]) + .flatMap(r => (r.results || [])); + if (!rows.length) { + console.error("No D1 rows for this version — did stage 1 (build) run?"); + process.exit(1); + } + fs.writeFileSync(process.env.GITHUB_OUTPUT, "count=" + rows.length + "\n", { flag: "a" }); + fs.writeFileSync("rows.json", JSON.stringify(rows)); + ' + + - name: Download version artifacts from R2 + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + R2_BUCKET: ${{ vars.R2_BUCKET != '' && vars.R2_BUCKET || 'cow-skills' }} + run: | + mkdir -p dist + # filename is "v/"; the R2 key is "desktop/". + for filename in $(node -e 'JSON.parse(require("fs").readFileSync("rows.json")).forEach(r => console.log(r.filename))'); do + base="$(basename "$filename")" + key="desktop/${filename}" + echo "==> Downloading r2://${R2_BUCKET}/${key} -> dist/${base}" + npx --yes wrangler@latest r2 object get "${R2_BUCKET}/${key}" \ + --file "dist/${base}" --remote + done + echo "Downloaded:"; ls -la dist + + - name: Reminder — mac dmgs must be notarized before publishing + run: | + # Stapling can only be validated on macOS (xcrun stapler validate), + # which this Linux runner doesn't have. The authoritative check runs in + # stage 2 (desktop/build/notarize-dmg.sh) before re-uploading to R2. + # This step is just a loud reminder in the log. + echo "::notice::Publishing assumes the mac dmgs pulled from R2 are already notarized + stapled (stage 2). If you skipped stage 2, users will hit Gatekeeper warnings." + ls -la dist/*.dmg 2>/dev/null || echo "(no dmg in this version — win-only publish)" + + - name: Update D1 (recompute sha512 + set latest) + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + VER: ${{ github.event.inputs.version }} + MAKE_LATEST: ${{ github.event.inputs.make_latest }} + run: | + sql_file="$(mktemp)" + sha_for_file() { openssl dgst -sha512 -binary "$1" | openssl base64 -A; } + + # Pre-releases (e.g. 1.2.0-beta / -rc.1 / -test) are recorded but never + # become latest, so the site keeps serving the last stable build. + case "$VER" in + *-*) is_pre=1 ;; + *) is_pre=0 ;; + esac + if [ "$MAKE_LATEST" = "true" ] && [ "$is_pre" = "0" ]; then + do_latest=1; echo "==> Publishing $VER as latest." + else + do_latest=0; echo "==> Publishing $VER without latest flag (pre-release or dry re-hash)." + fi + + for f in dist/*; do + base="$(basename "$f")" + size="$(stat -c%s "$f")" + case "$base" in + *arm64.dmg) platform="mac-arm64" ;; + *x64.dmg) platform="mac-x64" ;; + *.exe) platform="win" ;; + *) echo "Skipping unrecognized artifact: $base"; continue ;; + esac + key="v${VER}/${base}" + sha="$(sha_for_file "$f")" + sha="${sha//\'/\'\'}" + if [ "$do_latest" = "1" ]; then + echo "UPDATE releases SET is_latest = 0 WHERE platform = '${platform}';" >> "$sql_file" + fi + # Re-store the row with the authoritative (post-staple) sha and the + # resolved latest flag. + echo "INSERT OR REPLACE INTO releases (version, platform, filename, size, sha512, is_latest) VALUES ('${VER}', '${platform}', '${key}', ${size}, '${sha}', ${do_latest});" >> "$sql_file" + done + echo "==> D1 statements:"; cat "$sql_file" + npx --yes wrangler@latest d1 execute cow-desktop --remote --file "$sql_file" + + - name: Create/update GitHub Release and attach installers + if: github.event.inputs.github_release == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VER: ${{ github.event.inputs.version }} + run: | + tag="v${VER}" + case "$VER" in + *-*) prerelease="--prerelease" ;; + *) prerelease="" ;; + esac + if ! gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + gh release create "$tag" --repo "$GITHUB_REPOSITORY" \ + --title "$tag" --generate-notes $prerelease + fi + # --clobber so re-runs overwrite the stapled/updated assets. + gh release upload "$tag" dist/*.dmg dist/*.exe \ + --repo "$GITHUB_REPOSITORY" --clobber diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b1e40be1..1c4690ab 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,9 +1,20 @@ name: Release Desktop -# Tag-driven release: push a tag like `v1.2.0` to build and publish the -# desktop client for macOS (arm64 + x64) and Windows (x64). The tag is the -# single source of truth for the version — it's written into package.json at -# build time, so the maintainer never edits the version by hand. +# STAGE 1 of the decoupled release pipeline: BUILD ONLY. +# Builds the desktop client for macOS (arm64 + x64) and Windows (x64), mirrors +# the installers to R2, and registers them in D1 as UNPUBLISHED (is_latest=0) +# so the website keeps serving the previous release. It does NOT notarize +# (Apple's notary service stalls this large bundle for hours) and does NOT +# create a GitHub Release. +# +# Full flow: +# 1. (this workflow) build + upload to R2 + D1 as unpublished. +# 2. (local) download the mac dmgs, run desktop/build/notarize-dmg.sh to +# notarize + staple + re-upload the stapled dmgs to R2. +# 3. (Publish Desktop workflow) flip D1 is_latest=1 and attach GitHub +# Release assets — makes the version live on the site. +# +# Push a tag like `v1.2.0` (or use workflow_dispatch) to run stage 1. on: push: tags: @@ -108,10 +119,6 @@ jobs: # is the correct state for unsigned builds. MAC_CSC_LINK: ${{ secrets.MAC_CSC_LINK }} MAC_CSC_KEY_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }} - # electron-builder's built-in notarization reads these. - APPLE_ID: ${{ secrets.APPLE_ID }} - APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} - APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} WIN_CSC_LINK: ${{ secrets.WIN_CSC_LINK }} WIN_CSC_KEY_PASSWORD: ${{ secrets.WIN_CSC_KEY_PASSWORD }} run: | @@ -134,9 +141,12 @@ jobs: # installers to R2 and register them in D1 ourselves (publish-r2 job). # `--publish never` still emits the latest*.yml files. # Use the dynamic config (electron-builder.js): it populates - # mac.binaries (backend Mach-O files to sign) and enables built-in - # notarization, which zips the .app and submits that (the path Apple - # actually accepts for this large bundle) then staples + packs the dmg. + # mac.binaries (backend Mach-O files to sign). Notarization is NOT done + # here — Apple's notary service keeps this large bundle "In Progress" + # for hours, so it's decoupled into a manual local step + # (desktop/build/notarize-dmg.sh) run after this build produces the + # signed dmg. The dmg is signed + hardened-runtime, so it only needs a + # ticket stapled later. npx electron-builder ${{ matrix.eb_flags }} --config electron-builder.js --publish never # Upload artifacts regardless of outcome, so a failed run still surfaces @@ -250,21 +260,19 @@ jobs: # name (…-arm64.dmg / …-x64.dmg); .exe is the Windows installer. sql_file="$(mktemp)" - # Pre-releases (e.g. 1.0.0-test / -beta / -rc.1 / -alpha / -dev) are - # recorded but NEVER marked latest, so /download/

/latest keeps - # serving the last stable build. They also must not clear an existing - # stable's latest flag. Only a final version (no pre-release suffix) - # becomes the new latest and clears the previous one per platform. - case "$VER" in - *-*) is_latest=0; echo "==> $VER is a pre-release; not marking latest." ;; - *) is_latest=1; echo "==> $VER is a stable release; marking latest." ;; - esac + # This build job ALWAYS registers rows as unpublished (is_latest=0), so + # /download/

/latest keeps serving the previous release and the new + # version stays invisible on the site. macOS dmgs still need to be + # notarized+stapled locally (build/notarize-dmg.sh) before they're + # safe to ship. Promotion to latest happens later, only after + # notarization, via the separate "Publish Desktop" workflow. + echo "==> Registering $VER as unpublished (is_latest=0)." # Compute sha512 (base64, the format electron-updater validates) from - # the actual staged files rather than electron-builder's latest*.yml: - # stapling rewrites the dmg AFTER those yml files are generated, so - # the yml hashes are stale for mac. Hashing the real bytes keeps the - # /update feed (generated from D1) consistent with what R2 serves. + # the actual staged files rather than electron-builder's latest*.yml. + # NOTE: for macOS the dmg is re-hashed later by the publish workflow + # after stapling (stapling rewrites the dmg bytes), so the sha stored + # here is a placeholder for mac and authoritative only for win. sha_for_file() { openssl dgst -sha512 -binary "$1" | openssl base64 -A; } for f in dist/*; do @@ -280,35 +288,9 @@ jobs: sha="$(sha_for_file "$f")" # SQL-escape single quotes defensively (base64 has none, but be safe). sha="${sha//\'/\'\'}" - # Stable only: clear the previous latest for THIS platform first, so - # a partial backfill never wipes other platforms' latest flag. - if [ "$is_latest" = "1" ]; then - echo "UPDATE releases SET is_latest = 0 WHERE platform = '${platform}';" >> "$sql_file" - fi - echo "INSERT OR REPLACE INTO releases (version, platform, filename, size, sha512, is_latest) VALUES ('${VER}', '${platform}', '${key}', ${size}, '${sha}', ${is_latest});" >> "$sql_file" + # Never mark latest here — the row is unpublished until the separate + # publish workflow promotes it after notarization. + echo "INSERT OR REPLACE INTO releases (version, platform, filename, size, sha512, is_latest) VALUES ('${VER}', '${platform}', '${key}', ${size}, '${sha}', 0);" >> "$sql_file" done echo "==> D1 statements:"; cat "$sql_file" npx --yes wrangler@latest d1 execute cow-desktop --remote --file "$sql_file" - - # Attach installers to the GitHub Release for the tag. Only on tag - # pushes (manual dispatch has no tag to attach to). Pre-release suffix - # (e.g. 1.2.0-beta) marks the Release as a pre-release. - - name: Upload GitHub Release assets - if: github.event_name == 'push' && steps.stage.outputs.has_files == 'true' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - VER: ${{ steps.ver.outputs.version }} - run: | - tag="v${VER}" - case "$VER" in - *-*) prerelease="--prerelease" ;; - *) prerelease="" ;; - esac - # Create the release if it doesn't exist yet; keep it if it does. - if ! gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then - gh release create "$tag" --repo "$GITHUB_REPOSITORY" \ - --title "$tag" --generate-notes $prerelease - fi - # --clobber so re-runs (e.g. after a notarization retry) overwrite. - gh release upload "$tag" dist/*.dmg dist/*.exe \ - --repo "$GITHUB_REPOSITORY" --clobber diff --git a/.gitignore b/.gitignore index 5a3ee548..b22aa395 100644 --- a/.gitignore +++ b/.gitignore @@ -55,7 +55,7 @@ desktop/build/* !desktop/build/requirements-desktop.txt !desktop/build/build-backend.sh !desktop/build/entitlements.mac.plist -!desktop/build/notarize-app.js +!desktop/build/notarize-dmg.sh # Icon authoring scratch dir: intermediate assets used to produce the final # icons. Only the finished icons under desktop/resources/ should be committed. diff --git a/desktop/build/notarize-app.js b/desktop/build/notarize-app.js deleted file mode 100644 index b8aeff26..00000000 --- a/desktop/build/notarize-app.js +++ /dev/null @@ -1,123 +0,0 @@ -/** - * electron-builder afterSign hook: notarize the signed .app ourselves. - * - * Why not electron-builder's built-in notarize (mac.notarize): - * it runs `notarytool submit --wait`, which aborts the whole build on ANY - * network blip during status polling (NSURLErrorDomain -1001 timeout / -1009 - * offline), with no retry — and on failure it re-submits a fresh upload, - * piling up duplicate submissions. We saw arm64 get "Accepted" yet the build - * still failed because the poll request dropped. - * - * This hook runs after signing and BEFORE the dmg is built, so we: - * 1. zip the .app with ditto (the payload Apple actually accepts for this - * large PyInstaller bundle — submitting the dmg got stuck In Progress), - * 2. `notarytool submit --no-wait` ONCE to get a submission id, - * 3. poll that SAME id until Accepted/Invalid — a failed poll (network) is - * ignored and retried; we never re-submit, - * 4. staple the ticket onto the .app; electron-builder then packs the dmg. - * - * Requires env: APPLE_ID, APPLE_APP_SPECIFIC_PASSWORD, APPLE_TEAM_ID. - * If they're absent (unsigned/dry build) the hook is a no-op. - * Tunables: NOTARIZE_MAX_WAIT_MINUTES (default 180), NOTARIZE_POLL_SECONDS (default 30). - */ -const { execFileSync } = require('child_process') -const fs = require('fs') -const os = require('os') -const path = require('path') - -function sh(cmd, args, opts = {}) { - return execFileSync(cmd, args, { encoding: 'utf8', ...opts }) -} - -exports.default = async function notarizeApp(context) { - const { electronPlatformName, appOutDir, packager } = context - if (electronPlatformName !== 'darwin') return - - const appleId = process.env.APPLE_ID - const applePassword = process.env.APPLE_APP_SPECIFIC_PASSWORD - const teamId = process.env.APPLE_TEAM_ID - if (!appleId || !applePassword || !teamId) { - console.log('[notarize-app] APPLE_* env not set — skipping notarization') - return - } - - const appName = packager.appInfo.productFilename - const appPath = path.join(appOutDir, `${appName}.app`) - if (!fs.existsSync(appPath)) { - console.log(`[notarize-app] no .app at ${appPath}, skipping`) - return - } - - const auth = [ - '--apple-id', appleId, - '--password', applePassword, - '--team-id', teamId, - ] - - const maxWaitMin = parseInt(process.env.NOTARIZE_MAX_WAIT_MINUTES || '180', 10) - const pollSec = parseInt(process.env.NOTARIZE_POLL_SECONDS || '30', 10) - - // 1) zip the .app (ditto preserves symlinks/metadata; --keepParent keeps .app). - const zipPath = path.join(os.tmpdir(), `${appName}-${context.arch}-notarize.zip`) - console.log(`[notarize-app] zipping ${appPath} -> ${zipPath}`) - sh('ditto', ['-c', '-k', '--sequesterRsrc', '--keepParent', appPath, zipPath], { stdio: 'inherit' }) - - // 2) submit ONCE, no --wait, capture the submission id. - console.log('[notarize-app] submitting to notary service (no-wait)...') - const submitOut = sh('xcrun', ['notarytool', 'submit', zipPath, ...auth, '--no-wait', '--output-format', 'json']) - let submissionId - try { - submissionId = JSON.parse(submitOut).id - } catch (e) { - throw new Error(`[notarize-app] could not parse submission id:\n${submitOut}`) - } - if (!submissionId) throw new Error(`[notarize-app] empty submission id:\n${submitOut}`) - console.log(`[notarize-app] submission id: ${submissionId} (polling same id, never resubmitting)`) - - // 3) poll the SAME id; a failed poll (network) is ignored and retried. - const deadline = Date.now() + maxWaitMin * 60 * 1000 - const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) - for (;;) { - let status = '' - try { - const infoOut = sh('xcrun', ['notarytool', 'info', submissionId, ...auth, '--output-format', 'json']) - status = JSON.parse(infoOut).status || '' - } catch (e) { - status = '' // treat query failure as unknown; keep polling - } - const ts = new Date().toISOString().slice(11, 19) - console.log(`[notarize-app] [${ts}] status: ${status || ''}`) - - if (status === 'Accepted') break - if (status === 'Invalid' || status === 'Rejected') { - try { - console.log(sh('xcrun', ['notarytool', 'log', submissionId, ...auth])) - } catch {} - throw new Error(`[notarize-app] notarization ${status} (id: ${submissionId})`) - } - if (Date.now() >= deadline) { - throw new Error( - `[notarize-app] not finished after ${maxWaitMin} min (id: ${submissionId}). ` + - `NOT resubmitting. Check later: xcrun notarytool info ${submissionId}` - ) - } - await sleep(pollSec * 1000) - } - - // 4) staple the ticket onto the .app (dmg is built afterwards by electron-builder). - console.log('[notarize-app] Accepted; stapling ticket to .app') - let stapleTry = 1 - for (;;) { - try { - sh('xcrun', ['stapler', 'staple', appPath], { stdio: 'inherit' }) - break - } catch (e) { - if (stapleTry >= 3) throw e - console.log('[notarize-app] staple failed, retrying in 15s...') - await sleep(15000) - stapleTry++ - } - } - try { fs.unlinkSync(zipPath) } catch {} - console.log('[notarize-app] notarization + staple complete') -} diff --git a/desktop/build/notarize-dmg.sh b/desktop/build/notarize-dmg.sh new file mode 100755 index 00000000..c0def427 --- /dev/null +++ b/desktop/build/notarize-dmg.sh @@ -0,0 +1,147 @@ +#!/usr/bin/env bash +# +# STAGE 2 of the decoupled release pipeline: notarize signed dmgs locally. +# +# CI (stage 1) already produced code-signed, hardened-runtime dmgs and mirrored +# them to R2 as unpublished. Apple's notary service keeps this large PyInstaller +# bundle "In Progress" for hours, so we notarize here — off the CI clock — and +# staple the ticket straight onto the dmg (users download it ready-to-run). +# +# What it does for each dmg passed on the command line: +# 1. submit the dmg to the notary service ONCE (--no-wait) and remember the id, +# 2. poll that SAME id until Accepted/Invalid; network errors are ignored and +# retried, and it NEVER resubmits (avoids piling up duplicate submissions), +# 3. staple the ticket onto the dmg, +# 4. (optional) re-upload the stapled dmg to R2, overwriting the unpublished +# copy, so the CDN serves the notarized bytes. +# +# Auth: uses a stored keychain profile (default: cow-notary). Create it once via +# xcrun notarytool store-credentials cow-notary \ +# --apple-id --team-id --password +# +# Usage: +# # notarize + staple only (no upload): +# desktop/build/notarize-dmg.sh path/to/CowAgent-1.2.3-arm64.dmg [more.dmg ...] +# +# # notarize + staple + re-upload to R2 (needs wrangler + Cloudflare creds): +# VER=1.2.3 UPLOAD=1 desktop/build/notarize-dmg.sh *.dmg +# +# Env: +# PROFILE keychain profile name (default: cow-notary) +# UPLOAD set to 1 to re-upload stapled dmgs to R2 +# VER version string for the R2 key desktop/v${VER}/ (required if UPLOAD=1) +# R2_BUCKET R2 bucket (default: cow-skills) +# POLL_SECONDS status poll interval (default: 60) +# MAX_WAIT_MINUTES give up polling after this long (default: 720 = 12h) +# +set -euo pipefail + +PROFILE="${PROFILE:-cow-notary}" +R2_BUCKET="${R2_BUCKET:-cow-skills}" +POLL_SECONDS="${POLL_SECONDS:-60}" +MAX_WAIT_MINUTES="${MAX_WAIT_MINUTES:-720}" + +if [ "$#" -eq 0 ]; then + echo "usage: $0 [dmg ...]" >&2 + echo " set UPLOAD=1 and VER= to also re-upload stapled dmgs to R2" >&2 + exit 2 +fi + +if [ "${UPLOAD:-0}" = "1" ] && [ -z "${VER:-}" ]; then + echo "error: UPLOAD=1 requires VER= (used for the R2 key)" >&2 + exit 2 +fi + +log() { echo "[notarize-dmg] $*"; } + +notarize_one() { + local dmg="$1" + if [ ! -f "$dmg" ]; then + log "SKIP: not a file: $dmg" + return 1 + fi + + # If it's already stapled (e.g. re-run), skip straight to (optional) upload. + if xcrun stapler validate "$dmg" >/dev/null 2>&1; then + log "$dmg already stapled — skipping notarization." + else + log "submitting $dmg (no-wait)..." + local submit_out submission_id + submit_out="$(xcrun notarytool submit "$dmg" \ + --keychain-profile "$PROFILE" --no-wait --output-format json)" + submission_id="$(echo "$submit_out" | /usr/bin/plutil -extract id raw - 2>/dev/null || true)" + if [ -z "$submission_id" ] || [ "$submission_id" = "null" ]; then + # Fallback parse without plutil (json is single-line). + submission_id="$(echo "$submit_out" | sed -n 's/.*"id":"\([^"]*\)".*/\1/p')" + fi + if [ -z "$submission_id" ]; then + log "ERROR: could not parse submission id from:" + echo "$submit_out" >&2 + return 1 + fi + log "submission id: $submission_id (polling same id, never resubmitting)" + + local deadline status ts + deadline=$(( $(date +%s) + MAX_WAIT_MINUTES * 60 )) + while :; do + status="" + status="$(xcrun notarytool info "$submission_id" \ + --keychain-profile "$PROFILE" --output-format json 2>/dev/null \ + | sed -n 's/.*"status":"\([^"]*\)".*/\1/p' || true)" + ts="$(date +%H:%M:%S)" + log "[$ts] status: ${status:-}" + + case "$status" in + Accepted) break ;; + Invalid|Rejected) + log "notarization $status — fetching log:" + xcrun notarytool log "$submission_id" --keychain-profile "$PROFILE" || true + return 1 + ;; + esac + + if [ "$(date +%s)" -ge "$deadline" ]; then + log "ERROR: not finished after ${MAX_WAIT_MINUTES} min (id: $submission_id)." + log "NOT resubmitting. Check later: xcrun notarytool info $submission_id --keychain-profile $PROFILE" + return 1 + fi + sleep "$POLL_SECONDS" + done + + log "Accepted; stapling ticket to $dmg" + local staple_try=1 + until xcrun stapler staple "$dmg"; do + if [ "$staple_try" -ge 3 ]; then + log "ERROR: stapling failed after 3 attempts" + return 1 + fi + log "staple failed, retrying in 15s..." + sleep 15 + staple_try=$((staple_try + 1)) + done + xcrun stapler validate "$dmg" + log "$dmg notarized + stapled." + fi + + if [ "${UPLOAD:-0}" = "1" ]; then + local base key + base="$(basename "$dmg")" + key="desktop/v${VER}/${base}" + log "re-uploading stapled dmg -> r2://${R2_BUCKET}/${key}" + npx --yes wrangler@latest r2 object put "${R2_BUCKET}/${key}" \ + --file "$dmg" --remote + log "uploaded $base" + fi +} + +rc=0 +for dmg in "$@"; do + echo "======================================================================" + notarize_one "$dmg" || rc=1 +done + +if [ "$rc" -ne 0 ]; then + log "one or more dmgs failed — see output above." + exit 1 +fi +log "all done." diff --git a/desktop/electron-builder.js b/desktop/electron-builder.js index ea245531..9b272197 100644 --- a/desktop/electron-builder.js +++ b/desktop/electron-builder.js @@ -71,15 +71,13 @@ function collectBackendBinaries() { if (process.platform === 'darwin') { const binaries = collectBackendBinaries() console.log(`[electron-builder.js] injecting ${binaries.length} backend binaries into mac.binaries`) - // Disable the built-in notarize: it runs `notarytool submit --wait`, which - // aborts the whole build on any network blip during polling (-1001/-1009) - // and re-submits fresh uploads on failure. Instead we notarize in an - // afterSign hook (build/notarize-app.js) that zips the .app (the payload - // Apple accepts for this bundle), submits ONCE, and polls the same id with - // network-fault tolerance. The hook runs after signing, before the dmg is - // built, and staples the .app so the dmg ships an offline-valid ticket. + // Sign the backend binaries here, but do NOT notarize in CI: Apple's notary + // service routinely keeps this large PyInstaller bundle "In Progress" for + // hours, which no CI job can afford to block on. Notarization is decoupled + // into a manual local step (build/notarize-dmg.sh) run after the CI produces + // the signed dmg. The dmg is code-signed and hardened-runtime enabled here, + // so it only needs the notarization ticket stapled afterwards. config.mac = { ...config.mac, binaries, notarize: false } - config.afterSign = 'build/notarize-app.js' } module.exports = config diff --git a/desktop/src/renderer/index.html b/desktop/src/renderer/index.html index 183267de..48187e56 100644 --- a/desktop/src/renderer/index.html +++ b/desktop/src/renderer/index.html @@ -3,7 +3,7 @@ - + CowAgent