From 100290b33c5c7cadffecf27261fd9e1effe71299 Mon Sep 17 00:00:00 2001 From: diffwireauto <330573775+diffwireauto@users.noreply.github.com> Date: Sat, 7 Nov 2026 03:42:23 -0500 Subject: [PATCH] feat(#1109): sign and notarize the macOS release with Developer ID New reusable macos-signed.yml, gated by the apple-signing Environment: imports the Developer ID Application identity into a temporary keychain, builds, signs every nested Mach-O and framework then the app with Hardened Runtime and its entitlements, notarizes with notarytool using the existing App Store Connect API key, staples, and checks the result with spctl before zipping. release-signed.yml's macos job now calls it, so an unsigned or unnotarized macOS app can never be published. A temporary push trigger on this branch proves the lane before a tag; it is removed before merge. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/macos-signed.yml | 172 +++++++++++++++++++++++++++ .github/workflows/release-signed.yml | 43 ++----- 2 files changed, 180 insertions(+), 35 deletions(-) create mode 100644 .github/workflows/macos-signed.yml diff --git a/.github/workflows/macos-signed.yml b/.github/workflows/macos-signed.yml new file mode 100644 index 00000000..2dcb0a3c --- /dev/null +++ b/.github/workflows/macos-signed.yml @@ -0,0 +1,172 @@ +name: macOS signed build + +# Builds the macOS app, signs it with the Developer ID Application identity, +# 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. +# +# - 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. +# - 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). + +on: + workflow_call: + # Proves the lane before a release tag (#1109). Removed before this branch + # merges. + push: + branches: + - feat/1109-macos-signing + +permissions: + contents: read + +jobs: + macos-signed: + runs-on: macos-latest + environment: apple-signing # required-reviewer gate; do not remove + steps: + - uses: actions/checkout@v4 + + - name: Check the signing secrets are present + 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 }} + 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 + if [ -z "${!name:-}" ]; then + echo "::error::Secret ${name} is empty or absent in this run." + missing=1 + fi + done + exit "${missing}" + + - name: Temporary keychain with the Developer ID identity + env: + MACOS_DEVID_P12_BASE64: ${{ secrets.MACOS_DEVID_P12_BASE64 }} + MACOS_DEVID_P12_PASSWORD: ${{ secrets.MACOS_DEVID_P12_PASSWORD }} + run: | + set -eu + umask 077 + KC="${RUNNER_TEMP}/signing.keychain-db" + KP="$(openssl rand -hex 16)" + echo "::add-mask::${KP}" + echo "KC=${KC}" >> "$GITHUB_ENV" + security create-keychain -p "${KP}" "${KC}" + security set-keychain-settings -lut 21600 "${KC}" + security unlock-keychain -p "${KP}" "${KC}" + P12="${RUNNER_TEMP}/devid.p12" + printf '%s' "${MACOS_DEVID_P12_BASE64}" | base64 --decode > "${P12}" + security import "${P12}" -k "${KC}" -P "${MACOS_DEVID_P12_PASSWORD}" \ + -T /usr/bin/codesign -T /usr/bin/security + rm -f "${P12}" + security set-key-partition-list -S apple-tool:,apple: -s -k "${KP}" "${KC}" > /dev/null + security list-keychains -d user -s "${KC}" $(security list-keychains -d user | tr -d '"') + IDENTITIES="$(security find-identity -v -p codesigning "${KC}")" + COUNT="$(printf '%s\n' "${IDENTITIES}" | grep -c '"Developer ID Application: ' || true)" + if [ "${COUNT}" != "1" ]; then + echo "::error::Expected one Developer ID Application identity in the keychain, found ${COUNT}." + exit 1 + fi + IDENTITY="$(printf '%s\n' "${IDENTITIES}" | sed -n 's/.*"\(Developer ID Application: .*\)".*/\1/p')" + echo "::add-mask::${IDENTITY}" + echo "IDENTITY=${IDENTITY}" >> "$GITHUB_ENV" + echo "Keychain ready with one Developer ID Application identity." + + - uses: subosito/flutter-action@v2 + with: + flutter-version: "3.44.1" + cache: true + - run: flutter pub get + - run: dart run build_runner build --delete-conflicting-outputs + - name: Write dart_defines.json + env: + GIPHY_API_KEY: ${{ secrets.GIPHY_API_KEY }} + run: jq -n --arg k "$GIPHY_API_KEY" '{GIPHY_API_KEY:$k}' > dart_defines.json + - name: Enable native assets (bundles sqlite3 + llamadart libs, #445) + run: flutter config --enable-native-assets + - run: flutter build macos --release --no-pub --dart-define-from-file=dart_defines.json + + # Inside-out: every nested Mach-O first, then each framework bundle, then + # the app with its entitlements. Hardened Runtime and a secure timestamp + # are what notarization requires. + - name: Sign with Developer ID + run: | + set -eu + APP=build/macos/Build/Products/Release/offband_meshcore.app + echo "APP=${APP}" >> "$GITHUB_ENV" + SIGN=(codesign --force --options runtime --timestamp --sign "${IDENTITY}" --keychain "${KC}") + while IFS= read -r -d '' f; do + if file -b "${f}" | grep -q 'Mach-O'; then + "${SIGN[@]}" "${f}" + fi + done < <(find "${APP}/Contents/Frameworks" -type f -print0) + while IFS= read -r -d '' fw; do + "${SIGN[@]}" "${fw}" + done < <(find "${APP}/Contents/Frameworks" -depth -name '*.framework' -type d -print0) + "${SIGN[@]}" --entitlements macos/Runner/Release.entitlements "${APP}" + codesign --verify --deep --strict --verbose=2 "${APP}" + lipo -archs "${APP}/Contents/MacOS/offband_meshcore" + + - name: Notarize and staple + 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 }} + 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}" + OUT="${RUNNER_TEMP}/notarize.json" + xcrun notarytool submit "${SUBMIT}" --key "${P8}" --key-id "${ASC_API_KEY_ID}" \ + --issuer "${ASC_API_ISSUER_ID}" --wait --timeout 30m --output-format json > "${OUT}" || true + 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 + exit 1 + fi + xcrun stapler staple "${APP}" + xcrun stapler validate "${APP}" + spctl --assess --type execute --verbose=4 "${APP}" + + # 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 + - uses: actions/upload-artifact@v4 + with: + name: release-macos + path: offband-macos.zip + if-no-files-found: error + retention-days: 7 + + - name: Remove the signing material + if: always() + run: | + set +e + if [ -n "${KC:-}" ]; then security delete-keychain "${KC}"; fi + rm -f dart_defines.json "${RUNNER_TEMP}/devid.p12" "${RUNNER_TEMP}/AuthKey.p8" diff --git a/.github/workflows/release-signed.yml b/.github/workflows/release-signed.yml index 2171883e..c8b947fd 100644 --- a/.github/workflows/release-signed.yml +++ b/.github/workflows/release-signed.yml @@ -1,7 +1,7 @@ name: Release (signed, all platforms) # Produces a COMPLETE, published release from a `v*` tag: signed Android APK + -# AAB, Windows, Linux, and macOS (unsigned until #1109), attached to a GitHub +# 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 # rc.2/rc.3 failure) impossible. # @@ -14,7 +14,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` job has its own Environment, `apple-signing`, in ios-signed.yml. +# `ios` and `macos` jobs use the `apple-signing` Environment, in +# ios-signed.yml and 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. # @@ -250,41 +251,13 @@ jobs: if-no-files-found: error retention-days: 7 - # Unsigned (ad-hoc) macOS build, distributed as a release asset (#1108). - # No Apple service is involved, so unlike `ios` it blocks the release like - # the other desktop jobs. Developer ID signing + notarization is #1109. + # 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. macos: needs: gate - runs-on: macos-latest - steps: - - uses: actions/checkout@v4 - - uses: subosito/flutter-action@v2 - with: - flutter-version: "3.44.1" - cache: true - - run: flutter pub get - - run: dart run build_runner build --delete-conflicting-outputs - - name: Write dart_defines.json - env: - GIPHY_API_KEY: ${{ secrets.GIPHY_API_KEY }} - run: jq -n --arg k "$GIPHY_API_KEY" '{GIPHY_API_KEY:$k}' > dart_defines.json - - name: Enable native assets (bundles sqlite3 + llamadart libs, #445) - run: flutter config --enable-native-assets - - run: flutter build macos --release --no-pub --dart-define-from-file=dart_defines.json - # ditto, not zip: it preserves the .app bundle's symlinks, which plain - # zip flattens into copies that break code-signing validation (#1108). - - name: Zip macOS app - run: | - set -eu - APP=build/macos/Build/Products/Release/offband_meshcore.app - lipo -archs "${APP}/Contents/MacOS/offband_meshcore" || true - 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 + uses: ./.github/workflows/macos-signed.yml + secrets: inherit # Uploads to App Store Connect (#1039). Deliberately not in `release`'s needs: # an Apple-side failure must not block the Android and desktop release.