diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4fa2f594..1b804167 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -152,10 +152,9 @@ jobs: 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). + # Notarize + staple the dmg: submit ONCE, then poll that submission id + # until Accepted/Invalid (see notarize-dmg.sh — never resubmits). + # 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 @@ -175,7 +174,13 @@ jobs: bash build/notarize-dmg.sh "$dmg" done + # Always upload artifacts, even when notarization timed out. The + # notarization ticket is keyed to the dmg's hash on Apple's servers: if + # the submission is eventually Accepted, THIS exact dmg becomes valid + # as-is (download it, `xcrun stapler staple`, publish manually) — no + # rebuild needed. Rebuilding would change the hash and waste the ticket. - name: Upload artifacts + if: always() uses: actions/upload-artifact@v4 with: # One bundle per platform/arch so the publish job can collect them all. diff --git a/desktop/build/notarize-dmg.sh b/desktop/build/notarize-dmg.sh index 3958c66e..caf2dd85 100755 --- a/desktop/build/notarize-dmg.sh +++ b/desktop/build/notarize-dmg.sh @@ -1,16 +1,19 @@ #!/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. +# Notarize + staple a macOS dmg. # -# 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. +# Submits ONCE and then polls that same submission id until it reaches a +# terminal state. Never resubmits: a previous version resubmitted on every +# wait timeout, which piled up duplicate "In Progress" submissions in Apple's +# queue and made everything slower. +# +# Notarization is fully automated on Apple's side (malware scan, no humans); +# most submissions finish in minutes, but large bundles or Apple-side backlog +# can take much longer, so we poll patiently instead of failing fast. # # Usage: notarize-dmg.sh # Requires env: APPLE_ID, APPLE_APP_SPECIFIC_PASSWORD, APPLE_TEAM_ID +# Tunables: NOTARIZE_MAX_WAIT_MINUTES (default 180), NOTARIZE_POLL_SECONDS (default 60) set -euo pipefail DMG="${1:?usage: notarize-dmg.sh }" @@ -19,7 +22,8 @@ DMG="${1:?usage: notarize-dmg.sh }" : "${APPLE_APP_SPECIFIC_PASSWORD:?APPLE_APP_SPECIFIC_PASSWORD not set}" : "${APPLE_TEAM_ID:?APPLE_TEAM_ID not set}" -MAX_ATTEMPTS="${NOTARIZE_MAX_ATTEMPTS:-5}" +MAX_WAIT_MINUTES="${NOTARIZE_MAX_WAIT_MINUTES:-180}" +POLL_SECONDS="${NOTARIZE_POLL_SECONDS:-60}" auth=( --apple-id "$APPLE_ID" @@ -27,57 +31,49 @@ auth=( --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 +json_field() { + # json_field — read a top-level string field from JSON on stdin. + python3 -c "import json,sys; print(json.load(sys.stdin).get('$1',''))" 2>/dev/null } -echo "==> Notarizing: $DMG" +echo "==> Submitting for notarization: $DMG" +submit_out="$(xcrun notarytool submit "$DMG" "${auth[@]}" --no-wait --output-format json)" +submission_id="$(echo "$submit_out" | json_field id)" -attempt=1 +if [ -z "$submission_id" ]; then + echo "::error::could not parse submission id from notarytool output" + echo "$submit_out" + exit 1 +fi +echo "==> Submission id: $submission_id (uploaded OK, polling status...)" + +deadline=$(( $(date +%s) + MAX_WAIT_MINUTES * 60 )) while :; do - echo "==> notarytool submit (attempt ${attempt}/${MAX_ATTEMPTS})" - if xcrun notarytool submit "$DMG" "${auth[@]}" --wait --timeout 30m; then - echo "==> Notarization Accepted" - break - fi + # info can fail transiently (network); treat as unknown and keep polling. + status="$(xcrun notarytool info "$submission_id" "${auth[@]}" --output-format json 2>/dev/null | json_field status || true)" + echo "==> [$(date '+%H:%M:%S')] status: ${status:-}" - echo "::warning::notarytool submit failed on attempt ${attempt}" + case "$status" in + Accepted) + echo "==> Notarization Accepted" + break + ;; + Invalid|Rejected) + echo "::error::Notarization ${status}; fetching log for diagnostics" + xcrun notarytool log "$submission_id" "${auth[@]}" || true + exit 1 + ;; + esac - # 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" + if [ "$(date +%s)" -ge "$deadline" ]; then + echo "::error::Notarization still not finished after ${MAX_WAIT_MINUTES} minutes (id: $submission_id)." + echo "::error::NOT resubmitting. Check later with: xcrun notarytool info $submission_id" exit 1 fi - - sleep_s=$(( attempt * 30 )) - echo "==> Retrying in ${sleep_s}s..." - sleep "$sleep_s" - attempt=$(( attempt + 1 )) + sleep "$POLL_SECONDS" 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