From d49ebdf93577becc1ee147d3f995453b1fe5a10b Mon Sep 17 00:00:00 2001 From: jwinterm Date: Mon, 26 Oct 2026 13:00:34 -0400 Subject: [PATCH] ci(macos): build Developer ID and Mac App Store lanes, signed by xcodebuild The macOS lane has never gone green. It built with the project's Release signing, which was still Cake's (automatic Apple Development, team 32J6BB6VUS), and failed at "No profiles for 'com.suchsoftware...'". Past that it would have re-signed the finished app by hand, shipping keychain-access-groups as a literal "$(AppIdentifierPrefix)" with no profile to authorise it, and read notary secrets that do not exist. Now, on the shape Vegan IQ proved for its store build: - Runner's Release config signs manually under D8PL9F7X33 and takes its profile name from HASH_BAGS_MACOS_PROFILE, so only the app carries one. - scripts/macos/build_signed.sh decodes a profile, picks the keychain identity it was issued for (check_macos_profile.py, from vegan-IQ), and signs inside xcodebuild; it refuses an app whose entitlements keep an unexpanded variable or that embeds no profile. - developer-id lane: hardened runtime, notarize, staple, spctl assess, zip to the release. app-store lane: productbuild with the installer identity, pkgutil check, altool upload to App Store Connect. - The first step refuses a selected lane missing any of its secrets and names each one. The API key lives under the job's temp dir, not the runner home. Not run: no Developer ID or Mac App Store signing material is in the repository's secrets yet, so the guard is the only part exercised. --- .github/workflows/build-macos.yml | 361 ++++++++++++++----------- macos/Runner.xcodeproj/project.pbxproj | 10 +- scripts/macos/build_signed.sh | 74 +++++ scripts/macos/check_macos_profile.py | 113 ++++++++ 4 files changed, 400 insertions(+), 158 deletions(-) create mode 100755 scripts/macos/build_signed.sh create mode 100755 scripts/macos/check_macos_profile.py diff --git a/.github/workflows/build-macos.yml b/.github/workflows/build-macos.yml index c88e24842..a0556c08a 100644 --- a/.github/workflows/build-macos.yml +++ b/.github/workflows/build-macos.yml @@ -1,28 +1,25 @@ -# Hash Bags macOS build: signed with Developer ID, notarized, stapled. +# Hash Bags macOS builds, two lanes from one configured tree. # -# This is DIRECT distribution, not the Mac App Store, so it needs a -# "Developer ID Application" certificate. That is a different certificate from -# the "Apple Distribution" one build-ios-testflight.yml uses, and the two are -# not interchangeable: a build signed with the App Store cert is rejected by -# Gatekeeper when a user downloads it from a web page. Notarization, on the -# other hand, reuses the App Store Connect API key that repository already has. +# developer-id Direct download: a zip of the .app signed with Developer ID +# Application, notarized and stapled, published to the release. +# app-store Mac App Store: a .pkg whose .app is signed with Apple +# Distribution and whose installer is signed with 3rd Party Mac +# Developer Installer, uploaded to App Store Connect. # -# Secrets, all optional. Absent ones do not fail the build; they downgrade it, -# loudly, and the preflight below prints exactly which stage you will lose. -# APPLE_CERTIFICATE base64 of the Developer ID Application .p12 -# APPLE_CERTIFICATE_PASSWORD password set when the .p12 was exported -# APPLE_SIGNING_IDENTITY "Developer ID Application: Such Software LLC (D8PL9F7X33)" -# APPLE_TEAM_ID D8PL9F7X33 -# APPLE_KEYCHAIN_PASSWORD any value; scopes a throwaway keychain -# APPLE_API_KEY_P8_BASE64 base64 of AuthKey_.p8 (notarization) -# APPLE_API_KEY key id (notarization) -# APPLE_API_ISSUER issuer UUID (notarization) -# GH_RELEASE_TOKEN publishes the zip to the GitHub release +# Each lane signs inside xcodebuild against its own provisioning profile +# (scripts/macos/build_signed.sh). The approach and every failure it avoids are +# in docs infra/macos-app-store-release.md, from Vegan IQ's first store build. # -# The same names smirk-monorepo uses, deliberately: both repositories live under -# the Builds owner, so an organisation-level secret serves both and a reader -# comparing the two workflows is not left wondering whether the difference means -# something. +# Secrets. A selected lane with any of its own missing refuses in the first +# step, naming each one, before forty minutes of prerequisites. +# both lanes APPLE_KEYCHAIN_PASSWORD, APPLE_KEY_ID, APPLE_ISSUER_ID, +# APPLE_API_KEY_P8_BASE64 (notarytool and altool) +# developer-id MACOS_DEVELOPER_ID_CERT_P12_BASE64, MACOS_DEVELOPER_ID_CERT_P12_PASSWORD, +# MACOS_DEVELOPER_ID_PROFILE_BASE64 (a "Developer ID" profile) +# app-store APPLE_DIST_CERT_P12_BASE64, APPLE_DIST_CERT_P12_PASSWORD (shared +# with iOS), MACOS_INSTALLER_CERT_P12_BASE64, +# MACOS_INSTALLER_CERT_P12_PASSWORD, MACOS_APP_STORE_PROFILE_BASE64 +# optional GH_RELEASE_TOKEN publishes the Developer ID zip to the release name: Hash Bags macOS build @@ -32,6 +29,18 @@ on: - 'hash-bags-*' workflow_dispatch: inputs: + lanes: + description: Which macOS builds to produce + type: choice + options: [both, developer-id, app-store] + default: both + app_store_upload: + description: >- + Upload the App Store package to App Store Connect. A build number + already uploaded is refused by Apple, so a rebuild of the same + version sets this false. + type: boolean + default: true release_tag: description: >- Existing release tag to publish to (e.g. hash-bags-1.0.3). Leave empty @@ -56,6 +65,10 @@ jobs: timeout-minutes: 90 env: APP_MACOS_TYPE: cakewallet + # A tag push builds both lanes and uploads; a dispatch says what it wants. + LANES: ${{ github.event.inputs.lanes || 'both' }} + APP_STORE_UPLOAD: ${{ github.event.inputs.app_store_upload || 'true' }} + KEYCHAIN: ${{ github.workspace }}/../hashbags-macos.keychain-db LANG: en_US.UTF-8 LC_ALL: en_US.UTF-8 @@ -64,40 +77,47 @@ jobs: with: fetch-depth: 1 - # Cheap guards first: this reports what the run can actually produce - # before spending forty minutes finding out. Lengths only, never values. - - name: Preflight — which signing stages are available + # Cheap guards first: a selected lane without its signing material refuses + # here, in seconds, naming every missing secret. Presence only, never values. + - name: Require each selected lane's signing material env: - APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} - APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} - APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + APPLE_KEYCHAIN_PASSWORD: ${{ secrets.APPLE_KEYCHAIN_PASSWORD }} + APPLE_KEY_ID: ${{ secrets.APPLE_KEY_ID }} + APPLE_ISSUER_ID: ${{ secrets.APPLE_ISSUER_ID }} APPLE_API_KEY_P8_BASE64: ${{ secrets.APPLE_API_KEY_P8_BASE64 }} - APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} - APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} - GH_TOKEN: ${{ secrets.GH_RELEASE_TOKEN }} + MACOS_DEVELOPER_ID_CERT_P12_BASE64: ${{ secrets.MACOS_DEVELOPER_ID_CERT_P12_BASE64 }} + MACOS_DEVELOPER_ID_CERT_P12_PASSWORD: ${{ secrets.MACOS_DEVELOPER_ID_CERT_P12_PASSWORD }} + MACOS_DEVELOPER_ID_PROFILE_BASE64: ${{ secrets.MACOS_DEVELOPER_ID_PROFILE_BASE64 }} + APPLE_DIST_CERT_P12_BASE64: ${{ secrets.APPLE_DIST_CERT_P12_BASE64 }} + APPLE_DIST_CERT_P12_PASSWORD: ${{ secrets.APPLE_DIST_CERT_P12_PASSWORD }} + MACOS_INSTALLER_CERT_P12_BASE64: ${{ secrets.MACOS_INSTALLER_CERT_P12_BASE64 }} + MACOS_INSTALLER_CERT_P12_PASSWORD: ${{ secrets.MACOS_INSTALLER_CERT_P12_PASSWORD }} + MACOS_APP_STORE_PROFILE_BASE64: ${{ secrets.MACOS_APP_STORE_PROFILE_BASE64 }} run: | set -euo pipefail - have() { [ -n "${1:-}" ] && echo "present (${#1} chars)" || echo "ABSENT"; } - echo "APPLE_CERTIFICATE : $(have "${APPLE_CERTIFICATE:-}")" - echo "APPLE_CERTIFICATE_PASSWORD: $(have "${APPLE_CERTIFICATE_PASSWORD:-}")" - echo "APPLE_SIGNING_IDENTITY : $(have "${APPLE_SIGNING_IDENTITY:-}")" - echo "APPLE_API_KEY_P8_BASE64 : $(have "${APPLE_API_KEY_P8_BASE64:-}")" - echo "APPLE_API_KEY : $(have "${APPLE_API_KEY:-}")" - echo "APPLE_API_ISSUER : $(have "${APPLE_API_ISSUER:-}")" - echo "GH_RELEASE_TOKEN : $(have "${GH_TOKEN:-}")" - echo - if [ -n "${APPLE_CERTIFICATE:-}" ] && [ -n "${APPLE_SIGNING_IDENTITY:-}" ]; then - echo "VERDICT: will code-sign with Developer ID" - else - echo "VERDICT: NO Developer ID cert — the .app will be ad-hoc signed and" - echo " Gatekeeper will refuse it on any machine but this one." + case "$LANES" in both|developer-id|app-store) ;; *) + echo "FATAL: lanes must be both, developer-id or app-store, not '$LANES'"; exit 64 ;; esac + need="APPLE_KEYCHAIN_PASSWORD APPLE_KEY_ID APPLE_ISSUER_ID APPLE_API_KEY_P8_BASE64" + if [ "$LANES" != app-store ]; then + need="$need MACOS_DEVELOPER_ID_CERT_P12_BASE64 MACOS_DEVELOPER_ID_CERT_P12_PASSWORD MACOS_DEVELOPER_ID_PROFILE_BASE64" fi - if [ -n "${APPLE_API_KEY_P8_BASE64:-}" ] && [ -n "${APPLE_API_KEY:-}" ] && [ -n "${APPLE_API_ISSUER:-}" ]; then - echo "VERDICT: will notarize and staple" - else - echo "VERDICT: NO notarization credentials — users get the unidentified" - echo " developer warning even with a valid Developer ID signature." + if [ "$LANES" != developer-id ]; then + need="$need APPLE_DIST_CERT_P12_BASE64 APPLE_DIST_CERT_P12_PASSWORD MACOS_INSTALLER_CERT_P12_BASE64 MACOS_INSTALLER_CERT_P12_PASSWORD MACOS_APP_STORE_PROFILE_BASE64" fi + missing="" + for v in $need; do + eval "val=\${$v:-}" + [ -n "$val" ] || missing="$missing $v" + done + if [ -n "$missing" ]; then + echo "FATAL: lanes=$LANES needs these repository secrets, which are not set:" + for v in $missing; do echo " $v"; done + echo "The certificates and profiles they hold, and how to export them, are in" + echo "docs infra/apple-desktop-signing.md. Run one lane with the lanes input" + echo "while the other's material is still being gathered." + exit 1 + fi + echo "lanes=$LANES: signing material present; App Store upload=$APP_STORE_UPLOAD" - name: Check Flutter 3.41.9 id: flutter_check @@ -306,124 +326,156 @@ jobs: - name: Compile SVG assets (res/pictures/*.svg → assets/new-ui/*.svg.vec) run: ./compile_graphics.sh - - name: Build macOS release - run: | - set -euo pipefail - flutter build macos --release - ls -la build/macos/Build/Products/Release/ + # ---- Configure the Xcode project, then build each lane ---------------- + - name: Configure the macOS project + run: flutter build macos --release --config-only - # ---- Developer ID signing --------------------------------------------- - - name: Import the Developer ID certificate - id: cert + # One throwaway keychain for both lanes, never the login keychain: this is + # a shared Mac. Each tool is granted only the keys it uses: codesign the + # application certificates, productbuild the installer one. + - name: Prepare the signing keychain, profiles and API key env: - APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} - APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} APPLE_KEYCHAIN_PASSWORD: ${{ secrets.APPLE_KEYCHAIN_PASSWORD }} - run: | - set -euo pipefail - if [ -z "${APPLE_CERTIFICATE:-}" ]; then - echo "no Developer ID certificate; skipping import" - echo "imported=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - KEYCHAIN_PASSWORD="${APPLE_KEYCHAIN_PASSWORD:-hashbags-ci}" - KEYCHAIN_PATH="$RUNNER_TEMP/hashbags-macos.keychain-db" - # A dedicated throwaway keychain, never the login keychain: this runner - # is a shared Mac and a build should not be able to read, or leave - # anything in, the operator's own keys. - security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" - security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" - security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" - security list-keychains -d user -s "$KEYCHAIN_PATH" $(security list-keychains -d user | sed 's/"//g') - P12="$RUNNER_TEMP/developer_id.p12" - echo "$APPLE_CERTIFICATE" | openssl base64 -d -A > "$P12" - security import "$P12" -k "$KEYCHAIN_PATH" -P "${APPLE_CERTIFICATE_PASSWORD:-}" \ - -T /usr/bin/codesign -T /usr/bin/security - security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" - rm -f "$P12" - security find-identity -v -p codesigning "$KEYCHAIN_PATH" - echo "keychain=$KEYCHAIN_PATH" >> "$GITHUB_OUTPUT" - echo "imported=true" >> "$GITHUB_OUTPUT" - - - name: Codesign the app - if: steps.cert.outputs.imported == 'true' - env: - APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} - run: | - set -euo pipefail - APP=$(ls -d build/macos/Build/Products/Release/*.app | head -1) - echo "signing $APP" - # --deep is deliberately NOT used: Apple deprecated it and it skips - # nested code it does not recognise. Sign inside-out by hand so every - # dylib is covered and a failure names the file it could not sign. - find "$APP/Contents" -type f \( -name '*.dylib' -o -name '*.so' \) -print0 | - while IFS= read -r -d '' lib; do - codesign --force --timestamp --options runtime \ - --sign "$APPLE_SIGNING_IDENTITY" "$lib" - done - find "$APP/Contents/Frameworks" -maxdepth 1 -name '*.framework' -print0 2>/dev/null | - while IFS= read -r -d '' fw; do - codesign --force --timestamp --options runtime \ - --sign "$APPLE_SIGNING_IDENTITY" "$fw" - done - # Hardened runtime is what notarization requires; entitlements come - # from the templated Release.entitlements written by app_config.sh. - codesign --force --timestamp --options runtime \ - --entitlements macos/Runner/Release.entitlements \ - --sign "$APPLE_SIGNING_IDENTITY" "$APP" - codesign --verify --deep --strict --verbose=2 "$APP" - - # ---- Notarization ------------------------------------------------------ - - name: Notarize and staple - id: notarize - if: steps.cert.outputs.imported == 'true' - env: + APPLE_KEY_ID: ${{ secrets.APPLE_KEY_ID }} APPLE_API_KEY_P8_BASE64: ${{ secrets.APPLE_API_KEY_P8_BASE64 }} - APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} - APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + MACOS_DEVELOPER_ID_CERT_P12_BASE64: ${{ secrets.MACOS_DEVELOPER_ID_CERT_P12_BASE64 }} + MACOS_DEVELOPER_ID_CERT_P12_PASSWORD: ${{ secrets.MACOS_DEVELOPER_ID_CERT_P12_PASSWORD }} + MACOS_DEVELOPER_ID_PROFILE_BASE64: ${{ secrets.MACOS_DEVELOPER_ID_PROFILE_BASE64 }} + APPLE_DIST_CERT_P12_BASE64: ${{ secrets.APPLE_DIST_CERT_P12_BASE64 }} + APPLE_DIST_CERT_P12_PASSWORD: ${{ secrets.APPLE_DIST_CERT_P12_PASSWORD }} + MACOS_INSTALLER_CERT_P12_BASE64: ${{ secrets.MACOS_INSTALLER_CERT_P12_BASE64 }} + MACOS_INSTALLER_CERT_P12_PASSWORD: ${{ secrets.MACOS_INSTALLER_CERT_P12_PASSWORD }} + MACOS_APP_STORE_PROFILE_BASE64: ${{ secrets.MACOS_APP_STORE_PROFILE_BASE64 }} run: | set -euo pipefail - if [ -z "${APPLE_API_KEY_P8_BASE64:-}" ] || [ -z "${APPLE_API_KEY:-}" ] || [ -z "${APPLE_API_ISSUER:-}" ]; then - echo "no notarization credentials; the build stays signed but un-notarized" - echo "notarized=false" >> "$GITHUB_OUTPUT" - exit 0 + umask 077 + S="$RUNNER_TEMP/macos-signing" + rm -rf "$S"; mkdir -p "$S/private_keys" + # A job killed before cleanup leaves the keychain behind, and + # create-keychain then refuses every later run until someone deletes it. + security delete-keychain "$KEYCHAIN" 2>/dev/null || true + security create-keychain -p "$APPLE_KEYCHAIN_PASSWORD" "$KEYCHAIN" + security set-keychain-settings -lut 21600 "$KEYCHAIN" + security unlock-keychain -p "$APPLE_KEYCHAIN_PASSWORD" "$KEYCHAIN" + import() { # + printf '%s' "$1" | base64 --decode > "$S/cert.p12" + security import "$S/cert.p12" -k "$KEYCHAIN" -P "$2" -T "$3" + rm -f "$S/cert.p12" + } + if [ "$LANES" != app-store ]; then + import "$MACOS_DEVELOPER_ID_CERT_P12_BASE64" "$MACOS_DEVELOPER_ID_CERT_P12_PASSWORD" /usr/bin/codesign + printf '%s' "$MACOS_DEVELOPER_ID_PROFILE_BASE64" | base64 --decode > "$S/developer-id.provisionprofile" fi - APP=$(ls -d build/macos/Build/Products/Release/*.app | head -1) - KEY="$RUNNER_TEMP/AuthKey.p8" - echo "$APPLE_API_KEY_P8_BASE64" | openssl base64 -d -A > "$KEY" + if [ "$LANES" != developer-id ]; then + import "$APPLE_DIST_CERT_P12_BASE64" "$APPLE_DIST_CERT_P12_PASSWORD" /usr/bin/codesign + import "$MACOS_INSTALLER_CERT_P12_BASE64" "$MACOS_INSTALLER_CERT_P12_PASSWORD" /usr/bin/productbuild + printf '%s' "$MACOS_APP_STORE_PROFILE_BASE64" | base64 --decode > "$S/app-store.provisionprofile" + fi + security set-key-partition-list -S apple-tool:,apple: -s -k "$APPLE_KEYCHAIN_PASSWORD" "$KEYCHAIN" >/dev/null + # Prepend rather than replace, so the runner keeps every keychain it had. + EXISTING="$(security list-keychains -d user | sed -e 's/^[[:space:]]*"//' -e 's/"$//')" + # shellcheck disable=SC2086 + security list-keychains -d user -s "$KEYCHAIN" $EXISTING + security find-identity -v "$KEYCHAIN" | sed -n 's/.*"\(.*\)".*/identity: \1/p' + # Under the job's temp dir, not the runner home: altool finds it through + # API_PRIVATE_KEYS_DIR, and nothing is left on the shared disk. + printf '%s' "$APPLE_API_KEY_P8_BASE64" | base64 --decode > "$S/private_keys/AuthKey_${APPLE_KEY_ID}.p8" + + - name: Build the Developer ID app + id: devid + if: env.LANES != 'app-store' + run: | + set -euo pipefail + APP="$(scripts/macos/build_signed.sh "$RUNNER_TEMP/macos-signing/developer-id.provisionprofile" \ + "$KEYCHAIN" build/macos-developer-id ENABLE_HARDENED_RUNTIME=YES | tail -1)" + echo "app=$APP" >> "$GITHUB_OUTPUT" + + - name: Notarize and staple the Developer ID app + if: env.LANES != 'app-store' + env: + APP: ${{ steps.devid.outputs.app }} + APPLE_KEY_ID: ${{ secrets.APPLE_KEY_ID }} + APPLE_ISSUER_ID: ${{ secrets.APPLE_ISSUER_ID }} + run: | + set -euo pipefail ZIP="$RUNNER_TEMP/notarize.zip" - # ditto, not zip: it preserves the bundle's symlinks and extended - # attributes, and a plain zip of a .app arrives at Apple as something - # it will not accept. + # ditto, not zip: it keeps the bundle's symlinks and extended attributes. ditto -c -k --keepParent "$APP" "$ZIP" xcrun notarytool submit "$ZIP" \ - --key "$KEY" --key-id "$APPLE_API_KEY" --issuer "$APPLE_API_ISSUER" \ + --key "$RUNNER_TEMP/macos-signing/private_keys/AuthKey_${APPLE_KEY_ID}.p8" \ + --key-id "$APPLE_KEY_ID" --issuer "$APPLE_ISSUER_ID" \ --wait --timeout 45m - rm -f "$KEY" "$ZIP" - # Stapling writes the ticket into the bundle so Gatekeeper clears it - # without a network round trip, which is what a user on a bad - # connection or behind a firewall actually needs. + rm -f "$ZIP" xcrun stapler staple "$APP" xcrun stapler validate "$APP" - echo "notarized=true" >> "$GITHUB_OUTPUT" + # What a user's Mac will decide on first launch, asked of this one. + spctl --assess --type execute --verbose=2 "$APP" - - name: Package the app - id: package + - name: Build the App Store package + id: mas + if: env.LANES != 'developer-id' run: | set -euo pipefail - APP=$(ls -d build/macos/Build/Products/Release/*.app | head -1) - VERSION=$(awk -F': ' '/^version:/ {print $2; exit}' pubspec_description.yaml | cut -d+ -f1) - OUT="HashBags-macos-${VERSION}.zip" - ditto -c -k --keepParent "$APP" "$OUT" - shasum -a 256 "$OUT" | tee "$OUT.sha256" - ls -la "$OUT" - echo "file=$OUT" >> "$GITHUB_OUTPUT" + APP="$(scripts/macos/build_signed.sh "$RUNNER_TEMP/macos-signing/app-store.provisionprofile" \ + "$KEYCHAIN" build/macos-app-store | tail -1)" + # Matched by name: a hardcoded hash would break on the annual renewal. + # -p codesigning is absent on purpose; an installer identity is not one. + INSTALLER_ID="$(security find-identity -v "$KEYCHAIN" | + sed -n 's/.*"\(3rd Party Mac Developer Installer:[^"]*\)".*/\1/p' | head -1)" + [ -n "$INSTALLER_ID" ] || { + echo "FATAL: no 3rd Party Mac Developer Installer identity in the keychain." + echo "MACOS_INSTALLER_CERT_P12_BASE64 imported, so it most likely holds an" + echo "application certificate; its CN should read Installer." + exit 1; } + mkdir -p dist + PKG="dist/app-store.pkg" + productbuild --component "$APP" /Applications \ + --sign "$INSTALLER_ID" --keychain "$KEYCHAIN" "$PKG" + pkgutil --check-signature "$PKG" + echo "pkg=$PKG" >> "$GITHUB_OUTPUT" + + - name: Upload the App Store package to App Store Connect + if: env.LANES != 'developer-id' && env.APP_STORE_UPLOAD == 'true' + env: + PKG: ${{ steps.mas.outputs.pkg }} + APPLE_KEY_ID: ${{ secrets.APPLE_KEY_ID }} + APPLE_ISSUER_ID: ${{ secrets.APPLE_ISSUER_ID }} + run: | + set -euo pipefail + API_PRIVATE_KEYS_DIR="$RUNNER_TEMP/macos-signing/private_keys" \ + xcrun altool --upload-app --type macos --file "$PKG" \ + --apiKey "$APPLE_KEY_ID" --apiIssuer "$APPLE_ISSUER_ID" + + - name: Name the outputs after the version they carry + id: package + env: + DEVID_APP: ${{ steps.devid.outputs.app }} + MAS_PKG: ${{ steps.mas.outputs.pkg }} + run: | + set -euo pipefail + FULL=$(awk '/^version:/ {print $2; exit}' pubspec_description.yaml) + VER="${FULL%%+*}"; BUILD="${FULL#*+}" + if [ -z "$VER" ] || [ "$BUILD" = "$FULL" ]; then + echo "pubspec_description.yaml version '$FULL' is not +; refusing to guess a name" >&2 + exit 1 + fi + BASE="hash-bags-${VER}-build${BUILD}-macos" + mkdir -p dist + if [ -n "${DEVID_APP:-}" ]; then + ditto -c -k --keepParent "$DEVID_APP" "dist/${BASE}.zip" + echo "zip=dist/${BASE}.zip" >> "$GITHUB_OUTPUT" + fi + if [ -n "${MAS_PKG:-}" ]; then + mv "$MAS_PKG" "dist/${BASE}-app-store.pkg" + fi + (cd dist && shasum -a 256 hash-bags-* | tee SHA256SUMS) + echo "base=$BASE" >> "$GITHUB_OUTPUT" - name: Upload artifact uses: actions/upload-artifact@v3 with: - name: hash-wallet-macos-${{ github.sha }} - path: ${{ steps.package.outputs.file }} + name: ${{ steps.package.outputs.base }}-${{ github.sha }} + path: dist/ retention-days: 14 - name: Resolve the release tag @@ -438,12 +490,13 @@ jobs: echo "tag=$TAG" >> "$GITHUB_OUTPUT" if [ -n "$TAG" ]; then echo "publishing to $TAG"; else echo "no tag; build only"; fi + # Only the Developer ID zip is a download; the App Store package goes to Apple. - name: Publish to the GitHub release - if: steps.reltag.outputs.tag != '' + if: steps.reltag.outputs.tag != '' && steps.package.outputs.zip != '' env: GH_TOKEN: ${{ secrets.GH_RELEASE_TOKEN }} TAG: ${{ steps.reltag.outputs.tag }} - FILE: ${{ steps.package.outputs.file }} + FILE: ${{ steps.package.outputs.zip }} run: | set -euo pipefail if [ -z "${GH_TOKEN:-}" ]; then @@ -469,6 +522,8 @@ jobs: "https://uploads.github.com/repos/$REPO/releases/$ID/assets?name=$NAME" echo "uploaded $NAME to the GitHub release $TAG" - - name: Clean up the keychain - if: always() && steps.cert.outputs.imported == 'true' - run: security delete-keychain "${{ steps.cert.outputs.keychain }}" || true + - name: Clean up the keychain, profiles and API key + if: always() + run: | + security delete-keychain "$KEYCHAIN" 2>/dev/null || true + rm -rf "$RUNNER_TEMP/macos-signing" diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index 5d956146f..9818deb2f 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -450,7 +450,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 10; - DEVELOPMENT_TEAM = 32J6BB6VUS; + DEVELOPMENT_TEAM = D8PL9F7X33; INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = "Hash Bags"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.finance"; @@ -585,7 +585,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 10; - DEVELOPMENT_TEAM = 32J6BB6VUS; + DEVELOPMENT_TEAM = D8PL9F7X33; INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = "Hash Bags"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.finance"; @@ -611,10 +611,10 @@ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; - CODE_SIGN_STYLE = Automatic; + CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 10; - DEVELOPMENT_TEAM = 32J6BB6VUS; + DEVELOPMENT_TEAM = D8PL9F7X33; INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = "Hash Bags"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.finance"; @@ -624,7 +624,7 @@ ); MACOSX_DEPLOYMENT_TARGET = 12; MARKETING_VERSION = 1.0.1; - PROVISIONING_PROFILE_SPECIFIER = ""; + PROVISIONING_PROFILE_SPECIFIER = "$(HASH_BAGS_MACOS_PROFILE)"; SWIFT_VERSION = 5.0; }; name = Release; diff --git a/scripts/macos/build_signed.sh b/scripts/macos/build_signed.sh new file mode 100755 index 000000000..74675b350 --- /dev/null +++ b/scripts/macos/build_signed.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# Build and sign Hash Bags for macOS against one provisioning profile. +# +# Usage: build_signed.sh [setting=value ...] +# Run from the repository root after `flutter build macos --release --config-only`. +# Prints the built .app's path as the last line of stdout; build output goes to stderr. +# +# Signing happens inside xcodebuild, never afterwards. Xcode then expands the +# entitlements' $(AppIdentifierPrefix), embeds the profile, and signs every nested +# dylib and framework with the same identity. Re-signing a finished .app by hand +# did none of that: the keychain group shipped as a literal "$(AppIdentifierPrefix)" +# string and the app had no profile to authorise it. +# +# The profile decides the identity. check_macos_profile.py reads the certificates it +# was issued for and picks the one the keychain holds, so a Developer ID profile +# cannot be paired with the App Store certificate or the other way round. +set -euo pipefail + +[ $# -ge 3 ] || { echo "usage: $0 [setting=value ...]" >&2; exit 64; } +profile=$1 keychain=$2 derived=$3 +shift 3 + +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT + +security cms -D -i "$profile" > "$work/profile.plist" +name="$(/usr/libexec/PlistBuddy -c 'Print :Name' "$work/profile.plist")" +uuid="$(/usr/libexec/PlistBuddy -c 'Print :UUID' "$work/profile.plist")" +team="$(/usr/libexec/PlistBuddy -c 'Print :TeamIdentifier:0' "$work/profile.plist")" +echo "profile: $name ($uuid), team $team" >&2 + +# Xcode 16 reads UserData; older Xcode reads MobileDevice. Both, by UUID, which is +# what Xcode matches on after resolving the specifier. +for dir in "$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles" \ + "$HOME/Library/MobileDevice/Provisioning Profiles"; do + mkdir -p "$dir" + cp "$profile" "$dir/$uuid.provisionprofile" +done + +security find-identity -v -p codesigning "$keychain" > "$work/identities.txt" +identity="$(python3 scripts/macos/check_macos_profile.py \ + --profile "$work/profile.plist" --identities "$work/identities.txt" --expect-name "$name")" +if [ -z "$identity" ]; then + # The checker declines only when the profile lists no readable certificate. With + # two lanes in one keychain, guessing could sign with the other lane's identity. + echo "FATAL: profile '$name' names no certificate this build can match; refusing to guess." >&2 + exit 1 +fi +echo "signing identity: $identity" >&2 + +# The profile specifier is a Runner-only setting (project.pbxproj reads it from +# HASH_BAGS_MACOS_PROFILE), so the pods never see a profile they would refuse. +xcodebuild -workspace macos/Runner.xcworkspace -scheme Runner \ + -configuration Release -derivedDataPath "$derived" \ + CODE_SIGN_IDENTITY="$identity" \ + DEVELOPMENT_TEAM="$team" \ + HASH_BAGS_MACOS_PROFILE="$name" \ + OTHER_CODE_SIGN_FLAGS="--keychain $keychain --timestamp" \ + "$@" build >&2 + +app="$(find "$derived/Build/Products/Release" -maxdepth 1 -name '*.app' | head -1)" +[ -n "$app" ] || { echo "FATAL: xcodebuild produced no .app under $derived" >&2; exit 1; } +codesign --verify --deep --strict --verbose=2 "$app" >&2 + +codesign -d --entitlements - --xml "$app" > "$work/entitlements.plist" 2>/dev/null +if grep -q '\$(' "$work/entitlements.plist"; then + echo "FATAL: the signed entitlements still hold an unexpanded build variable:" >&2 + grep '\$(' "$work/entitlements.plist" >&2 + exit 1 +fi +[ -f "$app/Contents/embedded.provisionprofile" ] || { + echo "FATAL: $app carries no embedded provisioning profile" >&2; exit 1; } + +echo "$app" diff --git a/scripts/macos/check_macos_profile.py b/scripts/macos/check_macos_profile.py new file mode 100755 index 000000000..db7e6ea1d --- /dev/null +++ b/scripts/macos/check_macos_profile.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +"""Choose the signing identity a provisioning profile will actually accept. + +Copied from vegan-IQ (scripts/check_macos_profile.py), where it carried the first +Mac App Store build through; Hash Bags uses it for both the Developer ID and the +App Store lanes. + +The profile is the constraint, not the certificate. A Mac App Store profile is +built around specific certificates, and signing with any other one fails late, +in a message that blames the certificate for being absent rather than the +profile for having been built without it. So read the profile first and pick +the keychain identity it endorses. + +Prints the chosen identity on stdout. Diagnostics go to stderr so the caller +can capture stdout directly. + +Exits 0 when the profile cannot be parsed or lists no certificates: those are +different failures, and xcodebuild is entitled to be the one that reports +them. In that case nothing is printed and the caller falls back. +""" + +import argparse +import plistlib +import re +import subprocess +import sys + +IDENTITY_RE = re.compile(r'"([^"]+)"') +# "subject=UID=X, CN=Apple Distribution: Org (TEAM), OU=..." -> the CN value +CN_RE = re.compile(r"CN\s*=\s*([^,/]+)") + + +def log(message): + print(message, file=sys.stderr) + + +def profile_common_names(profile): + names = [] + for der in profile.get("DeveloperCertificates", []): + result = subprocess.run( + ["openssl", "x509", "-inform", "DER", "-noout", "-subject"], + input=der, + capture_output=True, + ) + if result.returncode != 0: + continue + subject = result.stdout.decode("utf-8", "replace").strip() + match = CN_RE.search(subject) + if match: + names.append(match.group(1).strip()) + return names + + +def keychain_identities(path): + with open(path) as handle: + return [ + match.group(1) + for line in handle + for match in [IDENTITY_RE.search(line)] + if match + ] + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--profile", required=True) + parser.add_argument("--identities", required=True, help="security find-identity output") + parser.add_argument("--expect-name", required=True) + args = parser.parse_args() + + try: + with open(args.profile, "rb") as handle: + profile = plistlib.load(handle) + except Exception as exc: + log("check_macos_profile: cannot parse profile (%s); leaving it to xcodebuild" % exc) + return 0 + + name = profile.get("Name", "") + if name != args.expect_name: + log("FATAL: provisioning profile name mismatch.") + log(" the secret contains: %s" % (name or "")) + log(" Runner.xcodeproj asks for: %s" % args.expect_name) + log("Rename it in the portal, or change PROVISIONING_PROFILE_SPECIFIER.") + return 1 + + wanted = profile_common_names(profile) + if not wanted: + log("check_macos_profile: profile lists no certificates; leaving it to xcodebuild") + return 0 + + available = keychain_identities(args.identities) + for candidate in wanted: + if candidate in available: + log("profile %r endorses %r, which is in the keychain" % (name, candidate)) + print(candidate) + return 0 + + log("FATAL: no keychain identity matches this provisioning profile.") + log(" profile %r was built around:" % name) + for candidate in wanted: + log(" %s" % candidate) + log(" the keychain holds:") + for identity in available: + log(" %s" % identity) + log("") + log("Either put one of the profile's certificates in this lane's certificate") + log("secret, or regenerate the profile in the portal around a certificate") + log("listed above and update this lane's profile secret.") + return 1 + + +if __name__ == "__main__": + sys.exit(main())