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 <noreply@anthropic.com>
pull/1138/head
diffwireauto 6 days ago committed by Strycher
parent c296e258ea
commit 100290b33c

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

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

Loading…
Cancel
Save

Powered by TurnKey Linux.