From 42f79429c0e1d40b317ac607a3847ee248605372 Mon Sep 17 00:00:00 2001 From: pranc1ngpegasus Date: Tue, 28 Jul 2026 13:53:26 +0900 Subject: [PATCH 1/3] ci(release): sign with Developer ID and notarize the app and DMG Replace ad-hoc signing in the release workflow with real Developer ID Application signing, Apple notarization, and stapling so downloaded builds no longer trigger the 'unidentified developer' Gatekeeper warning. - Import the Developer ID certificate from a base64 secret into a throwaway keychain and derive the signing identity automatically. Fall back to ad-hoc signing when the secret is absent (e.g. forks). - Sign inside-out (helper binary, then app bundle) with hardened runtime, entitlements, and a secure timestamp. - Notarize and staple the .app itself, not just the DMG, so Gatekeeper approves it on first launch even with Wi-Fi off (Wisp is offline-first). - Notarize and staple the DMG as well. Requires new repository secrets: MACOS_CERTIFICATE_P12_BASE64, MACOS_CERTIFICATE_PASSWORD, APPLE_NOTARY_KEY_ID, APPLE_NOTARY_ISSUER_ID, APPLE_NOTARY_KEY_P8_BASE64. --- .github/workflows/release.yaml | 149 ++++++++++++++++++++++++++++++--- 1 file changed, 136 insertions(+), 13 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index ea93585..850ea2f 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -77,22 +77,121 @@ jobs: apps/wisp-desktop/Info.plist > "$APP/Contents/Info.plist" /usr/bin/plutil -lint "$APP/Contents/Info.plist" - - name: Ad-hoc codesign - # No Apple Developer ID yet; ad-hoc (`--sign -`) is enough to make - # Gatekeeper let users open the app via right-click → Open. Once a - # cert is available, swap in the real identity and add notarization. - # - # The entitlements file is required even for ad-hoc signing because - # we enable hardened runtime (--options runtime). Without + - name: Import Developer ID certificate + id: signing + # Imports the Developer ID Application cert into a throwaway keychain + # so `codesign` can find a real identity. When the cert secret is not + # configured (e.g. a fork), we fall back to ad-hoc signing so the + # build still produces an openable — if unnotarized — app. + env: + CERT_P12_BASE64: ${{ secrets.MACOS_CERTIFICATE_P12_BASE64 }} + CERT_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }} + run: | + set -euo pipefail + if [ -z "${CERT_P12_BASE64:-}" ]; then + echo "::warning::No Developer ID certificate configured; signing ad-hoc (app will show the 'unidentified developer' warning)." + echo "mode=adhoc" >> "$GITHUB_OUTPUT" + exit 0 + fi + + KEYCHAIN_PATH="$RUNNER_TEMP/app-signing.keychain-db" + KEYCHAIN_PASSWORD="$(openssl rand -base64 24)" + CERT_PATH="$RUNNER_TEMP/certificate.p12" + + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + + echo "$CERT_P12_BASE64" | base64 --decode > "$CERT_PATH" + security import "$CERT_PATH" -P "$CERT_PASSWORD" \ + -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH" + security set-key-partition-list \ + -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + # Make the temp keychain searchable alongside the login keychain. + security list-keychains -d user -s "$KEYCHAIN_PATH" \ + $(security list-keychains -d user | sed s/\"//g) + + IDENTITY="$(security find-identity -v -p codesigning "$KEYCHAIN_PATH" \ + | awk -F '"' '/Developer ID Application/ {print $2; exit}')" + if [ -z "$IDENTITY" ]; then + echo "::error::No 'Developer ID Application' identity found in the imported certificate." >&2 + security find-identity -v -p codesigning "$KEYCHAIN_PATH" >&2 || true + exit 1 + fi + + rm -f "$CERT_PATH" + echo "mode=developer-id" >> "$GITHUB_OUTPUT" + echo "identity=$IDENTITY" >> "$GITHUB_OUTPUT" + echo "Using signing identity: $IDENTITY" + + - name: Codesign Wisp.app + # The entitlements file is required because we enable hardened runtime + # (--options runtime, itself required for notarization). Without # `com.apple.security.device.audio-input`, AVCaptureDevice and - # AVAudioApplication silently return "denied" for the mic - # permission request — no OS prompt, no TCC entry. + # AVAudioApplication silently return "denied" for the mic permission + # request — no OS prompt, no TCC entry. + # + # We sign inside-out (helper binary first, then the app bundle) as + # Apple recommends, and add a secure timestamp (--timestamp) which + # notarization requires. The mcp helper does not touch the mic, so it + # is signed with hardened runtime but without the app entitlements. run: | set -euo pipefail - codesign --force --deep --options runtime \ - --entitlements apps/wisp-desktop/wisp-desktop.entitlements \ - --sign - Wisp.app - codesign --verify --verbose Wisp.app + APP="Wisp.app" + ENTITLEMENTS="apps/wisp-desktop/wisp-desktop.entitlements" + + if [ "${{ steps.signing.outputs.mode }}" = "developer-id" ]; then + SIGN_ID="${{ steps.signing.outputs.identity }}" + codesign --force --options runtime --timestamp \ + --sign "$SIGN_ID" "$APP/Contents/MacOS/wisp-mcp" + codesign --force --options runtime --timestamp \ + --entitlements "$ENTITLEMENTS" \ + --sign "$SIGN_ID" "$APP/Contents/MacOS/wisp-desktop" + codesign --force --options runtime --timestamp \ + --entitlements "$ENTITLEMENTS" \ + --sign "$SIGN_ID" "$APP" + else + codesign --force --deep --options runtime \ + --entitlements "$ENTITLEMENTS" \ + --sign - "$APP" + fi + codesign --verify --strict --verbose=2 "$APP" + + - name: Notarize and staple Wisp.app + # Submit the signed app to Apple's notary service and staple the + # ticket onto the bundle itself. Stapling the .app (not just the DMG) + # matters because Wisp is offline-first: a stapled ticket lets + # Gatekeeper approve the app on first launch with Wi-Fi turned off. + if: ${{ steps.signing.outputs.mode == 'developer-id' }} + env: + APPLE_API_KEY_ID: ${{ secrets.APPLE_NOTARY_KEY_ID }} + APPLE_API_ISSUER_ID: ${{ secrets.APPLE_NOTARY_ISSUER_ID }} + APPLE_API_KEY_P8_BASE64: ${{ secrets.APPLE_NOTARY_KEY_P8_BASE64 }} + run: | + set -euo pipefail + if [ -z "${APPLE_API_KEY_P8_BASE64:-}" ]; then + echo "::error::Developer ID signing is active but notary credentials are missing (APPLE_NOTARY_* secrets)." >&2 + exit 1 + fi + + KEY_PATH="$RUNNER_TEMP/notary_key.p8" + echo "$APPLE_API_KEY_P8_BASE64" | base64 --decode > "$KEY_PATH" + + # notarytool cannot take a bare .app, so submit a zip of it. + NOTARIZE_ZIP="$RUNNER_TEMP/Wisp-notarize.zip" + ditto -c -k --sequesterRsrc --keepParent Wisp.app "$NOTARIZE_ZIP" + + xcrun notarytool submit "$NOTARIZE_ZIP" \ + --key "$KEY_PATH" \ + --key-id "$APPLE_API_KEY_ID" \ + --issuer "$APPLE_API_ISSUER_ID" \ + --wait + + xcrun stapler staple Wisp.app + xcrun stapler validate Wisp.app + spctl --assess --type execute --verbose=4 Wisp.app + + rm -f "$KEY_PATH" "$NOTARIZE_ZIP" - name: Create DMG id: dmg @@ -119,6 +218,30 @@ jobs: "$DMG" echo "path=$DMG" >> "$GITHUB_OUTPUT" + - name: Notarize and staple DMG + # The app inside is already notarized and stapled; we also notarize + # the DMG itself so the disk image opens cleanly when downloaded. + if: ${{ steps.signing.outputs.mode == 'developer-id' }} + env: + APPLE_API_KEY_ID: ${{ secrets.APPLE_NOTARY_KEY_ID }} + APPLE_API_ISSUER_ID: ${{ secrets.APPLE_NOTARY_ISSUER_ID }} + APPLE_API_KEY_P8_BASE64: ${{ secrets.APPLE_NOTARY_KEY_P8_BASE64 }} + run: | + set -euo pipefail + KEY_PATH="$RUNNER_TEMP/notary_key.p8" + echo "$APPLE_API_KEY_P8_BASE64" | base64 --decode > "$KEY_PATH" + + xcrun notarytool submit "${{ steps.dmg.outputs.path }}" \ + --key "$KEY_PATH" \ + --key-id "$APPLE_API_KEY_ID" \ + --issuer "$APPLE_API_ISSUER_ID" \ + --wait + + xcrun stapler staple "${{ steps.dmg.outputs.path }}" + xcrun stapler validate "${{ steps.dmg.outputs.path }}" + + rm -f "$KEY_PATH" + - name: Upload test app artifact if: ${{ github.event_name == 'workflow_dispatch' && inputs.artifact_only }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 From 41220077b97af6a248c1fb93fa1e49dbbc6d7dd5 Mon Sep 17 00:00:00 2001 From: pranc1ngpegasus Date: Mon, 3 Aug 2026 00:52:43 +0900 Subject: [PATCH 2/3] ci(release): poll notarization asynchronously --- .github/workflows/notary-monitor.yaml | 345 ++++++++++++++++++++++++++ .github/workflows/release.yaml | 98 +++++--- 2 files changed, 405 insertions(+), 38 deletions(-) create mode 100644 .github/workflows/notary-monitor.yaml diff --git a/.github/workflows/notary-monitor.yaml b/.github/workflows/notary-monitor.yaml new file mode 100644 index 0000000..33edd62 --- /dev/null +++ b/.github/workflows/notary-monitor.yaml @@ -0,0 +1,345 @@ +name: notary-monitor +on: + schedule: + # GitHub may delay scheduled runs during heavy load. Notarization is + # asynchronous, so a delayed poll is harmless. + - cron: "7,22,37,52 * * * *" + workflow_dispatch: + +concurrency: + group: notary-monitor + cancel-in-progress: false + +permissions: + actions: write + contents: write + +jobs: + find-pending: + runs-on: ubuntu-latest + outputs: + found: ${{ steps.find.outputs.found }} + artifact_id: ${{ steps.find.outputs.artifact_id }} + artifact_name: ${{ steps.find.outputs.artifact_name }} + phase: ${{ steps.find.outputs.phase }} + token: ${{ steps.find.outputs.token }} + steps: + - name: Find the oldest actionable notarization + id: find + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + + ARTIFACTS_JSON="$(gh api --paginate --slurp --method GET \ + "repos/$GITHUB_REPOSITORY/actions/artifacts?per_page=100")" + FLAT_JSON="$(jq -c \ + '[.[] | .artifacts[] | select(.expired == false)]' \ + <<< "$ARTIFACTS_JSON")" + + SELECTED="" + while IFS=$'\t' read -r _ NAME ID; do + case "$NAME" in + wisp-notary-app-*) + PHASE="app" + TOKEN="${NAME#wisp-notary-app-}" + NEXT_NAME="wisp-notary-dmg-$TOKEN" + ;; + wisp-notary-dmg-*) + PHASE="dmg" + TOKEN="${NAME#wisp-notary-dmg-}" + NEXT_NAME="" + ;; + *) + continue + ;; + esac + + DONE_NAME="wisp-notary-done-$TOKEN" + if jq -e --arg name "$DONE_NAME" \ + 'any(.[]; .name == $name)' <<< "$FLAT_JSON" >/dev/null; then + continue + fi + FAILURE_NAME="wisp-notary-failure-$TOKEN" + if jq -e --arg name "$FAILURE_NAME" \ + 'any(.[]; .name == $name)' <<< "$FLAT_JSON" >/dev/null; then + continue + fi + if [ -n "$NEXT_NAME" ] && jq -e --arg name "$NEXT_NAME" \ + 'any(.[]; .name == $name)' <<< "$FLAT_JSON" >/dev/null; then + continue + fi + + SELECTED="$NAME"$'\t'"$ID"$'\t'"$PHASE"$'\t'"$TOKEN" + break + done < <(jq -r \ + 'sort_by(.created_at)[] | [.created_at, .name, (.id | tostring)] | @tsv' \ + <<< "$FLAT_JSON") + + if [ -z "$SELECTED" ]; then + echo "found=false" >> "$GITHUB_OUTPUT" + echo "No pending Wisp notarization artifacts." + exit 0 + fi + + IFS=$'\t' read -r NAME ID PHASE TOKEN <<< "$SELECTED" + { + echo "found=true" + echo "artifact_id=$ID" + echo "artifact_name=$NAME" + echo "phase=$PHASE" + echo "token=$TOKEN" + } >> "$GITHUB_OUTPUT" + echo "Found $PHASE notarization state: $NAME" + + process: + needs: find-pending + if: ${{ needs.find-pending.outputs.found == 'true' }} + runs-on: macos-26 + outputs: + result: ${{ steps.process.outputs.result }} + next_state_dir: ${{ steps.process.outputs.next_state_dir }} + final_dir: ${{ steps.process.outputs.final_dir }} + diagnostic_dir: ${{ steps.process.outputs.diagnostic_dir }} + artifact_only: ${{ steps.process.outputs.artifact_only }} + tag: ${{ steps.process.outputs.tag }} + dmg_path: ${{ steps.process.outputs.dmg_path }} + done_dir: ${{ steps.process.outputs.done_dir }} + source_sha: ${{ steps.process.outputs.source_sha }} + steps: + - name: Select Xcode + run: | + sudo xcode-select -s /Applications/Xcode.app/Contents/Developer + xcrun notarytool --version + + - name: Download notarization state + env: + GH_TOKEN: ${{ github.token }} + ARTIFACT_ID: ${{ needs.find-pending.outputs.artifact_id }} + run: | + set -euo pipefail + mkdir -p "$RUNNER_TEMP/notary-input" + gh api \ + "repos/$GITHUB_REPOSITORY/actions/artifacts/$ARTIFACT_ID/zip" \ + > "$RUNNER_TEMP/notary-state.zip" + ditto -x -k "$RUNNER_TEMP/notary-state.zip" \ + "$RUNNER_TEMP/notary-input" + jq -e '.schema == 1 and (.phase == "app" or .phase == "dmg")' \ + "$RUNNER_TEMP/notary-input/request.json" >/dev/null + + - name: Check history and resume release + id: process + env: + APPLE_API_KEY_ID: ${{ secrets.APPLE_NOTARY_KEY_ID }} + APPLE_API_ISSUER_ID: ${{ secrets.APPLE_NOTARY_ISSUER_ID }} + APPLE_API_KEY_P8_BASE64: ${{ secrets.APPLE_NOTARY_KEY_P8_BASE64 }} + EXPECTED_PHASE: ${{ needs.find-pending.outputs.phase }} + TOKEN: ${{ needs.find-pending.outputs.token }} + run: | + set -euo pipefail + + INPUT_DIR="$RUNNER_TEMP/notary-input" + REQUEST="$INPUT_DIR/request.json" + KEY_PATH="$RUNNER_TEMP/notary-key.p8" + echo "$APPLE_API_KEY_P8_BASE64" | base64 --decode > "$KEY_PATH" + + PHASE="$(jq -r '.phase' "$REQUEST")" + SUBMISSION_ID="$(jq -r '.submission_id' "$REQUEST")" + if [ "$PHASE" != "$EXPECTED_PHASE" ]; then + echo "::error::Artifact phase $PHASE does not match $EXPECTED_PHASE." >&2 + exit 1 + fi + + # Keep a history snapshot for observability, then query the exact + # request so similarly named submissions cannot be confused. + HISTORY_JSON="$RUNNER_TEMP/notary-history.json" + INFO_JSON="$RUNNER_TEMP/notary-info.json" + xcrun notarytool history \ + --key "$KEY_PATH" \ + --key-id "$APPLE_API_KEY_ID" \ + --issuer "$APPLE_API_ISSUER_ID" \ + --no-progress \ + --output-format json > "$HISTORY_JSON" + jq -e . "$HISTORY_JSON" >/dev/null + + xcrun notarytool info "$SUBMISSION_ID" \ + --key "$KEY_PATH" \ + --key-id "$APPLE_API_KEY_ID" \ + --issuer "$APPLE_API_ISSUER_ID" \ + --no-progress \ + --output-format json > "$INFO_JSON" + STATUS="$(jq -r '.status' "$INFO_JSON")" + echo "Notarization $SUBMISSION_ID ($PHASE): $STATUS" + + ARTIFACT_ONLY="$(jq -r '.artifact_only' "$REQUEST")" + TAG="$(jq -r '.tag' "$REQUEST")" + SOURCE_SHA="$(jq -r '.source_sha' "$REQUEST")" + { + echo "artifact_only=$ARTIFACT_ONLY" + echo "tag=$TAG" + echo "source_sha=$SOURCE_SHA" + } >> "$GITHUB_OUTPUT" + + case "$STATUS" in + "In Progress") + echo "result=pending" >> "$GITHUB_OUTPUT" + exit 0 + ;; + "Accepted") + ;; + *) + DIAGNOSTIC_DIR="$RUNNER_TEMP/notary-diagnostic" + mkdir -p "$DIAGNOSTIC_DIR" + cp "$REQUEST" "$INFO_JSON" "$HISTORY_JSON" "$DIAGNOSTIC_DIR/" + xcrun notarytool log "$SUBMISSION_ID" \ + --key "$KEY_PATH" \ + --key-id "$APPLE_API_KEY_ID" \ + --issuer "$APPLE_API_ISSUER_ID" \ + "$DIAGNOSTIC_DIR/notary-log.json" || true + echo "result=failed" >> "$GITHUB_OUTPUT" + echo "diagnostic_dir=$DIAGNOSTIC_DIR" >> "$GITHUB_OUTPUT" + exit 0 + ;; + esac + + if [ "$PHASE" = "app" ]; then + APP_DIR="$RUNNER_TEMP/notarized-app" + mkdir -p "$APP_DIR" + ditto -x -k "$INPUT_DIR/$(jq -r '.app_archive' "$REQUEST")" \ + "$APP_DIR" + + xcrun stapler staple "$APP_DIR/Wisp.app" + xcrun stapler validate "$APP_DIR/Wisp.app" + spctl --assess --type execute --verbose=4 "$APP_DIR/Wisp.app" + + SHORT_SHA="${SOURCE_SHA::7}" + if [ "$ARTIFACT_ONLY" = "true" ]; then + DMG_NAME="wisp-test-$SHORT_SHA-aarch64-apple-darwin.dmg" + ZIP_NAME="wisp-test-$SHORT_SHA-aarch64-apple-darwin.zip" + else + VERSION="$(jq -r '.version' "$REQUEST")" + DMG_NAME="wisp-$VERSION-aarch64-apple-darwin.dmg" + ZIP_NAME="" + fi + + NEXT_DIR="$RUNNER_TEMP/wisp-notary-next" + mkdir -p "$NEXT_DIR" + if [ -n "$ZIP_NAME" ]; then + ditto -c -k --sequesterRsrc --keepParent \ + "$APP_DIR/Wisp.app" "$NEXT_DIR/$ZIP_NAME" + fi + + STAGE="$RUNNER_TEMP/dmg-stage" + mkdir -p "$STAGE" + ditto "$APP_DIR/Wisp.app" "$STAGE/Wisp.app" + ln -s /Applications "$STAGE/Applications" + hdiutil create \ + -volname "Wisp" \ + -srcfolder "$STAGE" \ + -ov -format UDZO \ + "$NEXT_DIR/$DMG_NAME" + + SUBMIT_JSON="$RUNNER_TEMP/dmg-submit.json" + xcrun notarytool submit "$NEXT_DIR/$DMG_NAME" \ + --key "$KEY_PATH" \ + --key-id "$APPLE_API_KEY_ID" \ + --issuer "$APPLE_API_ISSUER_ID" \ + --no-progress \ + --output-format json > "$SUBMIT_JSON" + DMG_SUBMISSION_ID="$(jq -r '.id' "$SUBMIT_JSON")" + + jq \ + --arg phase "dmg" \ + --arg submission_id "$DMG_SUBMISSION_ID" \ + --arg dmg_name "$DMG_NAME" \ + --arg zip_name "$ZIP_NAME" \ + '.phase = $phase + | .submission_id = $submission_id + | .dmg_name = $dmg_name + | .zip_name = $zip_name' \ + "$REQUEST" > "$NEXT_DIR/request.json" + + echo "result=app-accepted" >> "$GITHUB_OUTPUT" + echo "next_state_dir=$NEXT_DIR" >> "$GITHUB_OUTPUT" + echo "Submitted DMG for notarization: $DMG_SUBMISSION_ID" + exit 0 + fi + + DMG_NAME="$(jq -r '.dmg_name' "$REQUEST")" + DMG_PATH="$INPUT_DIR/$DMG_NAME" + xcrun stapler staple "$DMG_PATH" + xcrun stapler validate "$DMG_PATH" + + FINAL_DIR="$RUNNER_TEMP/wisp-final" + DONE_DIR="$RUNNER_TEMP/wisp-notary-done" + mkdir -p "$FINAL_DIR" "$DONE_DIR" + cp "$DMG_PATH" "$FINAL_DIR/" + ZIP_NAME="$(jq -r '.zip_name' "$REQUEST")" + if [ -n "$ZIP_NAME" ]; then + cp "$INPUT_DIR/$ZIP_NAME" "$FINAL_DIR/" + fi + jq \ + --arg completed_at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + '. + {completed_at: $completed_at}' \ + "$REQUEST" > "$DONE_DIR/request.json" + + { + echo "result=dmg-accepted" + echo "final_dir=$FINAL_DIR" + echo "dmg_path=$FINAL_DIR/$DMG_NAME" + echo "done_dir=$DONE_DIR" + } >> "$GITHUB_OUTPUT" + + - name: Save pending DMG notarization + if: ${{ steps.process.outputs.result == 'app-accepted' }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: wisp-notary-dmg-${{ needs.find-pending.outputs.token }} + path: ${{ steps.process.outputs.next_state_dir }} + if-no-files-found: error + retention-days: 90 + + - name: Upload notarized test app + if: ${{ steps.process.outputs.result == 'dmg-accepted' && steps.process.outputs.artifact_only == 'true' }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: wisp-macos-app-${{ steps.process.outputs.source_sha }} + path: ${{ steps.process.outputs.final_dir }} + if-no-files-found: error + retention-days: 14 + + - name: Publish notarized release + if: ${{ steps.process.outputs.result == 'dmg-accepted' && steps.process.outputs.artifact_only != 'true' }} + uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 + with: + tag_name: ${{ steps.process.outputs.tag }} + name: ${{ steps.process.outputs.tag }} + draft: false + prerelease: false + generate_release_notes: true + fail_on_unmatched_files: true + files: ${{ steps.process.outputs.dmg_path }} + + - name: Mark notarization complete + if: ${{ steps.process.outputs.result == 'dmg-accepted' }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: wisp-notary-done-${{ needs.find-pending.outputs.token }} + path: ${{ steps.process.outputs.done_dir }} + if-no-files-found: error + retention-days: 90 + + - name: Upload notarization diagnostics + if: ${{ steps.process.outputs.result == 'failed' }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: wisp-notary-failure-${{ needs.find-pending.outputs.token }} + path: ${{ steps.process.outputs.diagnostic_dir }} + if-no-files-found: error + retention-days: 30 + + - name: Fail rejected notarization + if: ${{ steps.process.outputs.result == 'failed' }} + run: | + echo "::error::Apple rejected or invalidated the notarization request." + exit 1 diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 850ea2f..bf89c3e 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -108,8 +108,12 @@ jobs: security set-key-partition-list \ -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" # Make the temp keychain searchable alongside the login keychain. + EXISTING_KEYCHAINS=() + while IFS= read -r KEYCHAIN; do + EXISTING_KEYCHAINS+=("${KEYCHAIN//\"/}") + done < <(security list-keychains -d user) security list-keychains -d user -s "$KEYCHAIN_PATH" \ - $(security list-keychains -d user | sed s/\"//g) + "${EXISTING_KEYCHAINS[@]}" IDENTITY="$(security find-identity -v -p codesigning "$KEYCHAIN_PATH" \ | awk -F '"' '/Developer ID Application/ {print $2; exit}')" @@ -158,10 +162,11 @@ jobs: codesign --verify --strict --verbose=2 "$APP" - name: Notarize and staple Wisp.app - # Submit the signed app to Apple's notary service and staple the - # ticket onto the bundle itself. Stapling the .app (not just the DMG) - # matters because Wisp is offline-first: a stapled ticket lets - # Gatekeeper approve the app on first launch with Wi-Fi turned off. + id: notary + # Upload the signed app, persist its Submission ID with the app as an + # Actions artifact, and release this expensive macOS runner. The + # scheduled notary-monitor workflow polls Apple and resumes the + # release after the request reaches a terminal state. if: ${{ steps.signing.outputs.mode == 'developer-id' }} env: APPLE_API_KEY_ID: ${{ secrets.APPLE_NOTARY_KEY_ID }} @@ -177,24 +182,56 @@ jobs: KEY_PATH="$RUNNER_TEMP/notary_key.p8" echo "$APPLE_API_KEY_P8_BASE64" | base64 --decode > "$KEY_PATH" - # notarytool cannot take a bare .app, so submit a zip of it. - NOTARIZE_ZIP="$RUNNER_TEMP/Wisp-notarize.zip" + STATE_DIR="$RUNNER_TEMP/wisp-notary-state" + mkdir -p "$STATE_DIR" + NOTARIZE_ZIP="$STATE_DIR/Wisp-notarize.zip" ditto -c -k --sequesterRsrc --keepParent Wisp.app "$NOTARIZE_ZIP" + SUBMIT_JSON="$RUNNER_TEMP/notary-submit.json" xcrun notarytool submit "$NOTARIZE_ZIP" \ --key "$KEY_PATH" \ --key-id "$APPLE_API_KEY_ID" \ --issuer "$APPLE_API_ISSUER_ID" \ - --wait + --no-progress \ + --output-format json > "$SUBMIT_JSON" - xcrun stapler staple Wisp.app - xcrun stapler validate Wisp.app - spctl --assess --type execute --verbose=4 Wisp.app + SUBMISSION_ID="$(jq -r '.id' "$SUBMIT_JSON")" + if ! [[ "$SUBMISSION_ID" =~ ^[0-9A-Fa-f-]{36}$ ]]; then + echo "::error::notarytool returned an invalid Submission ID." >&2 + jq . "$SUBMIT_JSON" >&2 + exit 1 + fi + + ARTIFACT_ONLY="${{ github.event_name == 'workflow_dispatch' && inputs.artifact_only }}" + jq -n \ + --arg phase "app" \ + --arg submission_id "$SUBMISSION_ID" \ + --arg source_run_id "$GITHUB_RUN_ID" \ + --arg source_run_attempt "$GITHUB_RUN_ATTEMPT" \ + --arg source_sha "$GITHUB_SHA" \ + --arg tag "${{ steps.meta.outputs.tag }}" \ + --arg version "${{ steps.meta.outputs.version }}" \ + --arg artifact_only "$ARTIFACT_ONLY" \ + '{ + schema: 1, + phase: $phase, + submission_id: $submission_id, + source_run_id: $source_run_id, + source_run_attempt: $source_run_attempt, + source_sha: $source_sha, + tag: $tag, + version: $version, + artifact_only: ($artifact_only == "true"), + app_archive: "Wisp-notarize.zip" + }' > "$STATE_DIR/request.json" - rm -f "$KEY_PATH" "$NOTARIZE_ZIP" + echo "submission_id=$SUBMISSION_ID" >> "$GITHUB_OUTPUT" + echo "state_dir=$STATE_DIR" >> "$GITHUB_OUTPUT" + echo "Submitted Wisp.app for notarization: $SUBMISSION_ID" - name: Create DMG id: dmg + if: ${{ steps.signing.outputs.mode == 'adhoc' }} run: | set -euo pipefail SHORT_SHA="${GITHUB_SHA::7}" @@ -218,32 +255,17 @@ jobs: "$DMG" echo "path=$DMG" >> "$GITHUB_OUTPUT" - - name: Notarize and staple DMG - # The app inside is already notarized and stapled; we also notarize - # the DMG itself so the disk image opens cleanly when downloaded. + - name: Save pending notarization if: ${{ steps.signing.outputs.mode == 'developer-id' }} - env: - APPLE_API_KEY_ID: ${{ secrets.APPLE_NOTARY_KEY_ID }} - APPLE_API_ISSUER_ID: ${{ secrets.APPLE_NOTARY_ISSUER_ID }} - APPLE_API_KEY_P8_BASE64: ${{ secrets.APPLE_NOTARY_KEY_P8_BASE64 }} - run: | - set -euo pipefail - KEY_PATH="$RUNNER_TEMP/notary_key.p8" - echo "$APPLE_API_KEY_P8_BASE64" | base64 --decode > "$KEY_PATH" - - xcrun notarytool submit "${{ steps.dmg.outputs.path }}" \ - --key "$KEY_PATH" \ - --key-id "$APPLE_API_KEY_ID" \ - --issuer "$APPLE_API_ISSUER_ID" \ - --wait - - xcrun stapler staple "${{ steps.dmg.outputs.path }}" - xcrun stapler validate "${{ steps.dmg.outputs.path }}" - - rm -f "$KEY_PATH" + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: wisp-notary-app-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ steps.notary.outputs.state_dir }} + if-no-files-found: error + retention-days: 90 - - name: Upload test app artifact - if: ${{ github.event_name == 'workflow_dispatch' && inputs.artifact_only }} + - name: Upload ad-hoc test app artifact + if: ${{ steps.signing.outputs.mode == 'adhoc' && github.event_name == 'workflow_dispatch' && inputs.artifact_only }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: wisp-macos-app-${{ github.sha }} @@ -253,8 +275,8 @@ jobs: if-no-files-found: error retention-days: 14 - - name: Publish release - if: ${{ github.event_name != 'workflow_dispatch' || !inputs.artifact_only }} + - name: Publish ad-hoc release + if: ${{ steps.signing.outputs.mode == 'adhoc' && (github.event_name != 'workflow_dispatch' || !inputs.artifact_only) }} uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 with: tag_name: ${{ steps.meta.outputs.tag }} From 4603c9051d15a788ec9468d0fc128c6cb8ab710d Mon Sep 17 00:00:00 2001 From: pranc1ngpegasus Date: Mon, 3 Aug 2026 00:57:33 +0900 Subject: [PATCH 3/3] ci(release): expose manual notary polling --- .github/workflows/notary-monitor.yaml | 1 + .github/workflows/release.yaml | 13 ++++++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/.github/workflows/notary-monitor.yaml b/.github/workflows/notary-monitor.yaml index 33edd62..bed4609 100644 --- a/.github/workflows/notary-monitor.yaml +++ b/.github/workflows/notary-monitor.yaml @@ -5,6 +5,7 @@ on: # asynchronous, so a delayed poll is harmless. - cron: "7,22,37,52 * * * *" workflow_dispatch: + workflow_call: concurrency: group: notary-monitor diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index bf89c3e..41ed075 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -13,6 +13,11 @@ on: required: true type: boolean default: true + monitor_only: + description: "Poll and resume the oldest pending notarization instead of building." + required: true + type: boolean + default: false concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: false @@ -22,6 +27,7 @@ permissions: id-token: write jobs: macos: + if: ${{ github.event_name != 'workflow_dispatch' || !inputs.monitor_only }} # wisp-desktop links against APIs that ship only in macOS 26 (Tahoe), # so the build host has to be Tahoe too. macos-26 is the # GitHub-hosted Apple Silicon runner image for it. @@ -161,7 +167,7 @@ jobs: fi codesign --verify --strict --verbose=2 "$APP" - - name: Notarize and staple Wisp.app + - name: Submit Wisp.app for notarization id: notary # Upload the signed app, persist its Submission ID with the app as an # Actions artifact, and release this expensive macOS runner. The @@ -286,3 +292,8 @@ jobs: generate_release_notes: true fail_on_unmatched_files: true files: ${{ steps.dmg.outputs.path }} + + monitor: + if: ${{ github.event_name == 'workflow_dispatch' && inputs.monitor_only }} + uses: ./.github/workflows/notary-monitor.yaml + secrets: inherit