From e8419522f9f3ca89f8b03ea326bfbecd0c714107 Mon Sep 17 00:00:00 2001 From: diffwireauto <330573775+diffwireauto@users.noreply.github.com> Date: Mon, 9 Nov 2026 12:26:36 -0500 Subject: [PATCH] fix(#1109): never wait on Apple in a Mac job, never block the release Split the macOS lane into three jobs. A Mac job builds, signs and submits without waiting. A Linux job polls Apple for that submission, 45 minutes at most. A short Mac job staples the ticket and verifies. If Apple holds the submission longer, the signed app stays an artifact for 30 days and "Re-run failed jobs" checks the same submission again, with no rebuild. The release no longer needs macOS. Android, Windows and Linux publish on their own; macos-attach adds the notarized zip once Apple accepts it. The lane moves to its own apple-macos Environment and its own notary key (MACOS_NOTARY_*), sharing nothing with the iOS lane in apple-signing. Co-Authored-By: Claude Opus 5.5 --- .github/scripts/notary-wait.py | 65 ++++++++++++ .github/workflows/macos-signed.yml | 141 +++++++++++++++++++-------- .github/workflows/release-signed.yml | 37 +++++-- 3 files changed, 193 insertions(+), 50 deletions(-) create mode 100644 .github/scripts/notary-wait.py diff --git a/.github/scripts/notary-wait.py b/.github/scripts/notary-wait.py new file mode 100644 index 00000000..c16a6a82 --- /dev/null +++ b/.github/scripts/notary-wait.py @@ -0,0 +1,65 @@ +"""Polls Apple's notary service for one submission until it settles or a deadline passes. + +Usage: notary-wait.py +Env: P8 (path to the App Store Connect .p8), NOTARY_KEY_ID, NOTARY_ISSUER_ID + +Exit 0 only on Accepted. Invalid/Rejected print Apple's log and exit 1. Still +In Progress at the deadline exits 1 with how to resume: the submission keeps +going at Apple, and "Re-run failed jobs" polls the same id again (#1109). +""" +import json +import os +import sys +import time +import urllib.error +import urllib.request + +import jwt + +API = "https://appstoreconnect.apple.com/notary/v2/submissions/" +POLL_SECONDS = 60 + +submission_id = sys.argv[1] +deadline = time.time() + int(sys.argv[2]) * 60 +with open(os.environ["P8"], encoding="utf-8") as fh: + private_key = fh.read() + + +def get(url, auth=True): + headers = {} + if auth: + now = int(time.time()) + token = jwt.encode( + {"iss": os.environ["NOTARY_ISSUER_ID"], "iat": now, "exp": now + 600, "aud": "appstoreconnect-v1"}, + private_key, algorithm="ES256", headers={"kid": os.environ["NOTARY_KEY_ID"], "typ": "JWT"}, + ) + headers["Authorization"] = f"Bearer {token}" + with urllib.request.urlopen(urllib.request.Request(url, headers=headers), timeout=30) as response: + return json.load(response) + + +while True: + try: + status = get(API + submission_id)["data"]["attributes"]["status"] + except urllib.error.HTTPError as error: + if error.code < 500: + print(f"::error::Notary API returned HTTP {error.code} for {submission_id}.") + sys.exit(1) + status = f"HTTP {error.code}, retrying" + except urllib.error.URLError as error: + status = f"network error ({error.reason}), retrying" + print(time.strftime("%H:%M:%S"), status, flush=True) + + if status == "Accepted": + sys.exit(0) + if status in ("Invalid", "Rejected"): + print(f"::error::Apple returned {status} for notary submission {submission_id}.") + log_url = get(API + submission_id + "/logs")["data"]["attributes"]["developerLogUrl"] + print(json.dumps(get(log_url, auth=False), indent=2)) + sys.exit(1) + if time.time() > deadline: + print(f"::error::Notary submission {submission_id} is still In Progress at Apple. " + "Nothing is lost: the signed app is kept as an artifact of this run. " + "Use 'Re-run failed jobs' later to check the same submission again.") + sys.exit(1) + time.sleep(POLL_SECONDS) diff --git a/.github/workflows/macos-signed.yml b/.github/workflows/macos-signed.yml index df01dd0c..c0591fee 100644 --- a/.github/workflows/macos-signed.yml +++ b/.github/workflows/macos-signed.yml @@ -4,19 +4,33 @@ name: macOS signed build # notarizes it with Apple and staples the ticket, so it opens on any Mac with # no Gatekeeper warning (#1109, epic #1107). Called by release-signed.yml. # -# SECURITY: this repo is PUBLIC, and this job holds the Developer ID key and -# the App Store Connect API key. +# Three jobs, so no Mac runner ever sits waiting on Apple's queue: +# 1. build-sign-submit (Mac): build, sign, submit, keep the signed app. +# 2. notary-wait (Linux): poll Apple for that submission, bounded. +# 3. staple (Mac): staple the ticket, verify, publish the zip. +# If Apple holds a submission past the poll deadline, job 2 fails, the signed +# app stays an artifact of the run, and "Re-run failed jobs" checks the SAME +# submission again later. Nothing is rebuilt or resubmitted. +# +# SEPARATION: this lane uses its own `apple-macos` Environment and its own +# App Store Connect key. It shares no Environment, secret or key with the +# iOS lane (`apple-signing`), so a hold, revocation or failure in one Apple +# lane cannot break the other. +# +# SECURITY: this repo is PUBLIC, and jobs 1 and 2 hold the Developer ID key +# and the notary API key. # # - NEVER call this from a `pull_request` workflow. A PR (incl. from a fork) # must never run a job that can read these secrets. -# - Only this job references the `apple-signing` Environment, so only it -# receives them, and only after the required reviewer approves. +# - Only jobs 1 and 2 reference the `apple-macos` Environment, so only they +# receive secrets, and only after the required reviewer approves. Job 3 +# needs none: stapling fetches the public ticket by the app's hash. # - Secret files live in RUNNER_TEMP and are removed with `if: always()`. # - Values are passed via env:, never echoed. The identity name holds the # owner's legal name and is masked. # # There is deliberately no skip-notarization input: an unsigned or -# unnotarized app must fail this job, never pass it (#904). +# unnotarized app must fail this lane, never pass it (#904). on: workflow_call: @@ -30,12 +44,12 @@ permissions: contents: read jobs: - macos-signed: + build-sign-submit: runs-on: macos-latest - environment: apple-signing # required-reviewer gate; do not remove - # Apple's notary queue can hold a new Developer ID's submissions for hours. - # GitHub's job limit is 6h; the notarytool wait below fits inside it. - timeout-minutes: 355 + environment: apple-macos # required-reviewer gate; do not remove + timeout-minutes: 60 + outputs: + submission-id: ${{ steps.submit.outputs.id }} steps: - uses: actions/checkout@v4 @@ -43,14 +57,14 @@ jobs: env: MACOS_DEVID_P12_BASE64: ${{ secrets.MACOS_DEVID_P12_BASE64 }} MACOS_DEVID_P12_PASSWORD: ${{ secrets.MACOS_DEVID_P12_PASSWORD }} - ASC_API_KEY_P8_BASE64: ${{ secrets.ASC_API_KEY_P8_BASE64 }} - ASC_API_KEY_ID: ${{ secrets.ASC_API_KEY_ID }} - ASC_API_ISSUER_ID: ${{ secrets.ASC_API_ISSUER_ID }} + MACOS_NOTARY_KEY_P8_BASE64: ${{ secrets.MACOS_NOTARY_KEY_P8_BASE64 }} + MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }} + MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }} run: | set -eu missing=0 for name in MACOS_DEVID_P12_BASE64 MACOS_DEVID_P12_PASSWORD \ - ASC_API_KEY_P8_BASE64 ASC_API_KEY_ID ASC_API_ISSUER_ID; do + MACOS_NOTARY_KEY_P8_BASE64 MACOS_NOTARY_KEY_ID MACOS_NOTARY_ISSUER_ID; do if [ -z "${!name:-}" ]; then echo "::error::Secret ${name} is empty or absent in this run." missing=1 @@ -125,47 +139,43 @@ jobs: codesign --verify --deep --strict --verbose=2 "${APP}" lipo -archs "${APP}/Contents/MacOS/offband_meshcore" - - name: Notarize and staple + # ditto, not zip: it preserves the .app bundle's symlinks (#1108). The + # same zip is what Apple notarizes and what job 3 staples. + - name: Zip the signed app + run: ditto -c -k --keepParent "${APP}" offband-macos-signed.zip + + # Upload only, no --wait: the verdict is job 2's business. + - name: Submit for notarization + id: submit env: - ASC_API_KEY_P8_BASE64: ${{ secrets.ASC_API_KEY_P8_BASE64 }} - ASC_API_KEY_ID: ${{ secrets.ASC_API_KEY_ID }} - ASC_API_ISSUER_ID: ${{ secrets.ASC_API_ISSUER_ID }} + MACOS_NOTARY_KEY_P8_BASE64: ${{ secrets.MACOS_NOTARY_KEY_P8_BASE64 }} + MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }} + MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }} run: | set -eu umask 077 P8="${RUNNER_TEMP}/AuthKey.p8" - printf '%s' "${ASC_API_KEY_P8_BASE64}" | base64 --decode > "${P8}" - SUBMIT="${RUNNER_TEMP}/notarize-submit.zip" - ditto -c -k --keepParent "${APP}" "${SUBMIT}" + printf '%s' "${MACOS_NOTARY_KEY_P8_BASE64}" | base64 --decode > "${P8}" OUT="${RUNNER_TEMP}/notarize.json" - xcrun notarytool submit "${SUBMIT}" --key "${P8}" --key-id "${ASC_API_KEY_ID}" \ - --issuer "${ASC_API_ISSUER_ID}" --wait --timeout 5h --output-format json > "${OUT}" || true + xcrun notarytool submit offband-macos-signed.zip --key "${P8}" \ + --key-id "${MACOS_NOTARY_KEY_ID}" --issuer "${MACOS_NOTARY_ISSUER_ID}" \ + --output-format json > "${OUT}" cat "${OUT}" - STATUS="$(jq -r '.status // empty' "${OUT}")" ID="$(jq -r '.id // empty' "${OUT}")" - if [ "${STATUS}" != "Accepted" ]; then - echo "::error::Notarization status is '${STATUS:-none}', not Accepted." - if [ -n "${ID}" ]; then - xcrun notarytool log "${ID}" --key "${P8}" --key-id "${ASC_API_KEY_ID}" \ - --issuer "${ASC_API_ISSUER_ID}" || true - fi + if [ -z "${ID}" ]; then + echo "::error::notarytool returned no submission id." exit 1 fi - xcrun stapler staple "${APP}" - xcrun stapler validate "${APP}" - spctl --assess --type execute --verbose=4 "${APP}" + echo "id=${ID}" >> "$GITHUB_OUTPUT" + echo "Submitted for notarization: ${ID}" - # ditto, not zip: it preserves the .app bundle's symlinks (#1108). - - name: Zip macOS app - run: | - set -eu - ditto -c -k --keepParent "${APP}" offband-macos.zip + # Kept 30 days so a held submission can be finished by a re-run. - uses: actions/upload-artifact@v4 with: - name: release-macos - path: offband-macos.zip + name: macos-signed-unstapled + path: offband-macos-signed.zip if-no-files-found: error - retention-days: 7 + retention-days: 30 - name: Remove the signing material if: always() @@ -173,3 +183,52 @@ jobs: set +e if [ -n "${KC:-}" ]; then security delete-keychain "${KC}"; fi rm -f dart_defines.json "${RUNNER_TEMP}/devid.p12" "${RUNNER_TEMP}/AuthKey.p8" + + notary-wait: + needs: build-sign-submit + runs-on: ubuntu-latest + environment: apple-macos # holds the notary key; do not remove + timeout-minutes: 50 + steps: + - uses: actions/checkout@v4 + - run: pip install --quiet "PyJWT[crypto]==2.10.1" + - name: Wait for Apple's verdict (45 min max) + env: + SUBMISSION_ID: ${{ needs.build-sign-submit.outputs.submission-id }} + MACOS_NOTARY_KEY_P8_BASE64: ${{ secrets.MACOS_NOTARY_KEY_P8_BASE64 }} + NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }} + NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }} + run: | + set -eu + umask 077 + export P8="${RUNNER_TEMP}/AuthKey.p8" + printf '%s' "${MACOS_NOTARY_KEY_P8_BASE64}" | base64 --decode > "${P8}" + python3 .github/scripts/notary-wait.py "${SUBMISSION_ID}" 45 + - name: Remove the notary key + if: always() + run: rm -f "${RUNNER_TEMP}/AuthKey.p8" + + staple: + needs: notary-wait + runs-on: macos-latest + timeout-minutes: 20 + steps: + - uses: actions/download-artifact@v4 + with: + name: macos-signed-unstapled + - name: Staple and verify + run: | + set -eu + ditto -x -k offband-macos-signed.zip . + APP=offband_meshcore.app + xcrun stapler staple "${APP}" + xcrun stapler validate "${APP}" + codesign --verify --deep --strict --verbose=2 "${APP}" + spctl --assess --type execute --verbose=4 "${APP}" + ditto -c -k --keepParent "${APP}" offband-macos.zip + - uses: actions/upload-artifact@v4 + with: + name: release-macos + path: offband-macos.zip + if-no-files-found: error + retention-days: 7 diff --git a/.github/workflows/release-signed.yml b/.github/workflows/release-signed.yml index c8b947fd..689237f0 100644 --- a/.github/workflows/release-signed.yml +++ b/.github/workflows/release-signed.yml @@ -1,8 +1,9 @@ name: Release (signed, all platforms) # Produces a COMPLETE, published release from a `v*` tag: signed Android APK + -# AAB, Windows, Linux, and notarized macOS (#1109), attached to a GitHub -# release whose body is the human-authored release notes. This is what makes a partial release (the +# AAB, Windows and Linux, attached to a GitHub release whose body is the +# human-authored release notes. The notarized macOS zip (#1109) is added after +# Apple accepts it, by `macos-attach`. This is what makes a partial release (the # rc.2/rc.3 failure) impossible. # # SECURITY: this repo is PUBLIC and the `android` job has the signing keystore, @@ -14,8 +15,8 @@ name: Release (signed, all platforms) # - Only the `android` job references the `release-signing` Environment, so # ONLY it receives the keystore secrets, and only after the required # reviewer approves. The windows/linux/release jobs never see them. The -# `ios` and `macos` jobs use the `apple-signing` Environment, in -# ios-signed.yml and macos-signed.yml. +# `ios` job uses the `apple-signing` Environment (ios-signed.yml); the +# `macos` jobs use the separate `apple-macos` one (macos-signed.yml). # - Keystore written to disk only for the build, deleted with `if: always()`. # - Signing values passed via env:, never on a command line, never echoed. # @@ -251,9 +252,11 @@ jobs: if-no-files-found: error retention-days: 7 - # Developer ID signed and notarized macOS build, a release asset (#1109). - # It blocks the release like the other desktop jobs: an unsigned or - # unnotarized macOS app is never published. + # Developer ID signed and notarized macOS build (#1109). Deliberately not in + # `release`'s needs: Apple's notary queue is outside our control, so it must + # not hold up Android, Windows and Linux. `macos-attach` adds the asset to + # the published release once Apple accepts it. An unnotarized macOS app is + # never published: if notarization fails, the release ships without it. macos: needs: gate uses: ./.github/workflows/macos-signed.yml @@ -270,7 +273,7 @@ jobs: # check) have passed, so a signing failure yields no release rather than an # empty one. Published directly: the notes were reviewed in the cut PR. release: - needs: [android, windows, linux, macos] + needs: [android, windows, linux] runs-on: ubuntu-latest permissions: contents: write # create the release + upload assets @@ -294,7 +297,6 @@ jobs: cp incoming/release-android/* out/ cp incoming/release-windows/* out/ cp incoming/release-linux/* out/ - cp incoming/release-macos/* out/ echo "Assets for the release:" ls -la out/ # Draft-first so publication is ATOMIC: the release is not visible to @@ -356,3 +358,20 @@ jobs: gh release edit "${TAG}" "${PRERELEASE_FLAG}" --draft=false fi echo "Release ${TAG} published with $(ls out | wc -l) assets." + + # Adds the notarized macOS zip to the release after both are done (#1109). + # If macOS was held at Apple, "Re-run failed jobs" finishes the macOS lane + # and then this job, with no rebuild. + macos-attach: + needs: [release, macos] + runs-on: ubuntu-latest + permissions: + contents: write # upload one asset to the existing release + steps: + - uses: actions/download-artifact@v4 + with: + name: release-macos + - name: Attach macOS asset + env: + GH_TOKEN: ${{ github.token }} + run: gh release upload "${GITHUB_REF_NAME}" offband-macos.zip --clobber --repo "${GITHUB_REPOSITORY}"