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.