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 <noreply@anthropic.com>
feat/1109-macos-signing
diffwireauto 3 days ago
parent c9447959d7
commit e8419522f9

@ -0,0 +1,65 @@
"""Polls Apple's notary service for one submission until it settles or a deadline passes.
Usage: notary-wait.py <submission-id> <deadline-minutes>
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)

@ -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

@ -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}"

Loading…
Cancel
Save

Powered by TurnKey Linux.