diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 879f00d5..4fa2f594 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -108,9 +108,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 }} - 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,8 +131,50 @@ jobs: # `--publish never` still emits the latest*.yml files we parse for sha512. # Use the dynamic config (electron-builder.js) so mac.binaries is # populated with the embedded backend's Mach-O files for signing. + # electron-builder signs but does NOT notarize (notarize:false); the + # dedicated step below notarizes + staples with retry. npx electron-builder ${{ matrix.eb_flags }} --config electron-builder.js --publish never + # Whether Mac signing secrets are present. `secrets` can't be used in a + # step `if:` directly, and step-level `env` isn't visible to that step's + # own `if:`, so surface it as an output for the notarize step to gate on. + - name: Check mac signing secrets + id: macsign + if: matrix.platform == 'mac' + shell: bash + env: + MAC_CSC_LINK: ${{ secrets.MAC_CSC_LINK }} + run: | + if [ -n "$MAC_CSC_LINK" ]; then + echo "enabled=true" >> "$GITHUB_OUTPUT" + else + echo "enabled=false" >> "$GITHUB_OUTPUT" + echo "::notice::MAC_CSC_LINK not set — skipping notarization (unsigned build)." + fi + + # Notarize + staple the dmg in a separate, retryable step. electron-builder's + # inline notarize aborts the whole build on a single notarytool poll timeout + # (NSURLErrorDomain -1001); this step retries so a flaky poll doesn't discard + # the signed build. Skipped for Windows and for unsigned builds (no cert). + - name: Notarize macOS dmg + if: matrix.platform == 'mac' && steps.macsign.outputs.enabled == 'true' + working-directory: desktop + shell: bash + env: + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + run: | + shopt -s nullglob + dmgs=(release/*.dmg) + if [ ${#dmgs[@]} -eq 0 ]; then + echo "::error::no dmg found to notarize" + exit 1 + fi + for dmg in "${dmgs[@]}"; do + bash build/notarize-dmg.sh "$dmg" + done + - name: Upload artifacts uses: actions/upload-artifact@v4 with: diff --git a/.gitignore b/.gitignore index 287a69ca..b22aa395 100644 --- a/.gitignore +++ b/.gitignore @@ -55,6 +55,7 @@ desktop/build/* !desktop/build/requirements-desktop.txt !desktop/build/build-backend.sh !desktop/build/entitlements.mac.plist +!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-dmg.sh b/desktop/build/notarize-dmg.sh new file mode 100755 index 00000000..3958c66e --- /dev/null +++ b/desktop/build/notarize-dmg.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +# +# Notarize + staple a macOS dmg with retry, so a transient notarytool +# status-polling timeout (NSURLErrorDomain -1001) doesn't throw away an +# otherwise-good signed build. +# +# electron-builder's inline notarize aborts the whole build on the first poll +# timeout. Here we retry the submit-and-wait, and before each retry we check +# whether a previous submission already reached a terminal state (Accepted), +# so a flaky poll on an already-successful submission still passes. +# +# Usage: notarize-dmg.sh +# Requires env: APPLE_ID, APPLE_APP_SPECIFIC_PASSWORD, APPLE_TEAM_ID +set -euo pipefail + +DMG="${1:?usage: notarize-dmg.sh }" + +: "${APPLE_ID:?APPLE_ID not set}" +: "${APPLE_APP_SPECIFIC_PASSWORD:?APPLE_APP_SPECIFIC_PASSWORD not set}" +: "${APPLE_TEAM_ID:?APPLE_TEAM_ID not set}" + +MAX_ATTEMPTS="${NOTARIZE_MAX_ATTEMPTS:-5}" + +auth=( + --apple-id "$APPLE_ID" + --password "$APPLE_APP_SPECIFIC_PASSWORD" + --team-id "$APPLE_TEAM_ID" +) + +# Check whether the most recent submission for this app is already Accepted. +# Used to short-circuit retries when the failure was only a status-poll timeout +# on a submission that Apple actually accepted. +latest_status_is_accepted() { + local out + out="$(xcrun notarytool history "${auth[@]}" --output-format json 2>/dev/null || true)" + # Grab the status of the first (most recent) history entry. + echo "$out" | python3 -c ' +import json, sys +try: + data = json.load(sys.stdin) + hist = data.get("history", []) + if hist and hist[0].get("status") == "Accepted": + sys.exit(0) +except Exception: + pass +sys.exit(1) +' 2>/dev/null +} + +echo "==> Notarizing: $DMG" + +attempt=1 +while :; do + echo "==> notarytool submit (attempt ${attempt}/${MAX_ATTEMPTS})" + if xcrun notarytool submit "$DMG" "${auth[@]}" --wait --timeout 30m; then + echo "==> Notarization Accepted" + break + fi + + echo "::warning::notarytool submit failed on attempt ${attempt}" + + # The submit may have been accepted but the status poll timed out; verify. + if latest_status_is_accepted; then + echo "==> Latest submission already Accepted (poll timed out); continuing" + break + fi + + if [ "$attempt" -ge "$MAX_ATTEMPTS" ]; then + echo "::error::Notarization failed after ${MAX_ATTEMPTS} attempts" + exit 1 + fi + + sleep_s=$(( attempt * 30 )) + echo "==> Retrying in ${sleep_s}s..." + sleep "$sleep_s" + attempt=$(( attempt + 1 )) +done + +echo "==> Stapling ticket into dmg" +# Staple has its own transient failures; retry a few times. +staple_attempt=1 +while :; do + if xcrun stapler staple "$DMG"; then + echo "==> Staple OK" + break + fi + if [ "$staple_attempt" -ge 3 ]; then + echo "::error::Stapling failed after 3 attempts" + exit 1 + fi + echo "==> Staple failed, retrying in 15s..." + sleep 15 + staple_attempt=$(( staple_attempt + 1 )) +done + +echo "==> Verifying staple" +xcrun stapler validate "$DMG" +echo "==> Notarization + staple complete: $DMG" diff --git a/desktop/electron-builder.js b/desktop/electron-builder.js index 7f63d696..d6f9bce1 100644 --- a/desktop/electron-builder.js +++ b/desktop/electron-builder.js @@ -71,7 +71,13 @@ function collectBackendBinaries() { if (process.platform === 'darwin') { const binaries = collectBackendBinaries() console.log(`[electron-builder.js] injecting ${binaries.length} backend binaries into mac.binaries`) - config.mac = { ...config.mac, binaries } + // Sign the app (+ backend binaries) here, but do NOT notarize inline. + // electron-builder runs `notarytool submit --wait`, which aborts the whole + // build on a single status-polling timeout (NSURLErrorDomain -1001) — a + // frequent transient failure on the runner -> Apple link. Notarization still + // happens: a dedicated CI step submits + staples the dmg with a retry loop, + // so a flaky poll retries instead of throwing away the signed build. + config.mac = { ...config.mac, binaries, notarize: false } } module.exports = config