name: Release iOS (TestFlight) # The iOS project is NOT committed - unlike android/, which is hand-maintained, # ios/ is gitignored and generated fresh by `expo prebuild` on every run. Keep # any native iOS change in app.config.js (or a config plugin), never in ios/. on: release: types: [published] workflow_dispatch: inputs: upload_to_testflight: description: "Upload the build to TestFlight (off = build the IPA only)" type: boolean required: false default: true concurrency: group: release-ios-${{ github.ref }} cancel-in-progress: false jobs: build: runs-on: macos-latest timeout-minutes: 90 permissions: contents: write outputs: version: ${{ steps.export.outputs.version }} uploaded: ${{ steps.upload.outputs.done }} env: EXPO_NO_TELEMETRY: "1" # App Store Connect refuses a CFBundleVersion it has already seen for this # marketing version, so every run needs a fresh, increasing number. IOS_BUILD_NUMBER: ${{ github.run_number }} steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 cache: npm - name: Show toolchain versions run: | xcodebuild -version pod --version - name: Cache CocoaPods uses: actions/cache@v4 with: path: | ~/Library/Caches/CocoaPods ~/.cocoapods/repos key: pods-${{ runner.os }}-${{ hashFiles('package-lock.json') }} restore-keys: pods-${{ runner.os }}- - name: Install JS dependencies run: npm ci - name: Check for signing credentials id: creds env: CERT: ${{ secrets.IOS_DIST_CERT_BASE64 }} ASC_KEY: ${{ secrets.APP_STORE_CONNECT_PRIVATE_KEY }} TEAM: ${{ secrets.APPLE_TEAM_ID }} PROFILE: ${{ secrets.IOS_PROVISIONING_PROFILE_BASE64 }} NSE_PROFILE: ${{ secrets.IOS_NSE_PROVISIONING_PROFILE_BASE64 }} run: | if [ -n "$CERT" ] && [ -n "$ASC_KEY" ] && [ -n "$TEAM" ] && [ -n "$PROFILE" ]; then if [ -z "$NSE_PROFILE" ]; then # The notification service extension (plugins/ios-push) is its # own App ID and needs its own App Store profile. echo "::error::IOS_NSE_PROVISIONING_PROFILE_BASE64 is not set: the notification extension (org.bulwarkmail.mobile.NotificationService) can't be signed." exit 1 fi echo "signed=true" >> "$GITHUB_OUTPUT" else echo "signed=false" >> "$GITHUB_OUTPUT" echo "::notice::Signing secrets are not configured - compiling without signing. No IPA will be produced." fi - name: Install the provisioning profiles if: steps.creds.outputs.signed == 'true' id: profiles env: APP_PROFILE_BASE64: ${{ secrets.IOS_PROVISIONING_PROFILE_BASE64 }} NSE_PROFILE_BASE64: ${{ secrets.IOS_NSE_PROVISIONING_PROFILE_BASE64 }} run: | # Xcode moved the profile directory in 16; write both so this does not # silently stop working on a runner image bump. mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles mkdir -p ~/Library/Developer/Xcode/UserData/Provisioning\ Profiles # One App Store profile per target: the app and its notification # service extension (plugins/ios-push). for side in app nse; do if [ "$side" = app ]; then DATA="$APP_PROFILE_BASE64"; else DATA="$NSE_PROFILE_BASE64"; fi PROFILE="$RUNNER_TEMP/$side.mobileprovision" echo "$DATA" | base64 -d > "$PROFILE" # A .mobileprovision is a CMS-signed plist, so it has to be decoded # before anything inside it can be read. PLIST=$(security cms -D -i "$PROFILE") UUID=$(echo "$PLIST" | plutil -extract UUID raw -) NAME=$(echo "$PLIST" | plutil -extract Name raw -) # application-identifier is "TEAMID.bundle.id" - deriving the bundle # ID from the profile keeps ExportOptions honest if app.config.js # changes. APP_ID=$(echo "$PLIST" | plutil -extract Entitlements.application-identifier raw -) BUNDLE_ID="${APP_ID#*.}" EXPIRY=$(echo "$PLIST" | plutil -extract ExpirationDate raw -) cp "$PROFILE" ~/Library/MobileDevice/Provisioning\ Profiles/"$UUID".mobileprovision cp "$PROFILE" ~/Library/Developer/Xcode/UserData/Provisioning\ Profiles/"$UUID".mobileprovision echo "${side}_uuid=$UUID" >> "$GITHUB_OUTPUT" echo "${side}_name=$NAME" >> "$GITHUB_OUTPUT" echo "${side}_bundle_id=$BUNDLE_ID" >> "$GITHUB_OUTPUT" echo "$side: profile '$NAME' ($UUID) for $BUNDLE_ID, expires $EXPIRY" done - name: Generate the iOS project env: # plugins/ios-push names each target's profile: the command line # can't, it would give every target the same one. IOS_APP_PROFILE: ${{ steps.profiles.outputs.app_uuid }} IOS_NSE_PROFILE: ${{ steps.profiles.outputs.nse_uuid }} run: npx expo prebuild --platform ios --clean - name: Resolve workspace and scheme id: xcode run: | WORKSPACE=$(ls -d ios/*.xcworkspace | head -1) SCHEME=$(basename "$WORKSPACE" .xcworkspace) echo "workspace=$WORKSPACE" >> "$GITHUB_OUTPUT" echo "scheme=$SCHEME" >> "$GITHUB_OUTPUT" echo "Using workspace $WORKSPACE, scheme $SCHEME" # ── Unsigned path: a compile check only, so the workflow is still useful # on forks and before the Apple secrets exist. - name: Build without signing if: steps.creds.outputs.signed != 'true' run: | xcodebuild build \ -workspace "${{ steps.xcode.outputs.workspace }}" \ -scheme "${{ steps.xcode.outputs.scheme }}" \ -configuration Release \ -destination 'generic/platform=iOS' \ CODE_SIGNING_ALLOWED=NO \ CODE_SIGNING_REQUIRED=NO \ CODE_SIGN_IDENTITY="" # ── Signed path ──────────────────────────────────────────── - name: Import the distribution certificate if: steps.creds.outputs.signed == 'true' env: CERT_BASE64: ${{ secrets.IOS_DIST_CERT_BASE64 }} CERT_PASSWORD: ${{ secrets.IOS_DIST_CERT_PASSWORD }} run: | KEYCHAIN="$RUNNER_TEMP/build.keychain-db" # Ephemeral runner, ephemeral keychain - this password protects nothing # beyond the life of the job. KEYCHAIN_PASSWORD=$(uuidgen) security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN" security set-keychain-settings -lut 21600 "$KEYCHAIN" security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN" echo "$CERT_BASE64" | base64 -d > "$RUNNER_TEMP/dist.p12" security import "$RUNNER_TEMP/dist.p12" -k "$KEYCHAIN" -P "$CERT_PASSWORD" \ -T /usr/bin/codesign -T /usr/bin/security -f pkcs12 rm -f "$RUNNER_TEMP/dist.p12" # Without this, codesign triggers an interactive keychain prompt that # nothing can answer on a headless runner. security set-key-partition-list -S apple-tool:,apple:,codesign: \ -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN" > /dev/null # Put our keychain first in the search list, keeping the login one so # Xcode can still reach Apple's intermediate certificates. security list-keychains -d user -s "$KEYCHAIN" $(security list-keychains -d user | sed 's/"//g') security find-identity -v -p codesigning "$KEYCHAIN" - name: Install the App Store Connect API key if: steps.creds.outputs.signed == 'true' env: ASC_KEY_BASE64: ${{ secrets.APP_STORE_CONNECT_PRIVATE_KEY }} ASC_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }} run: | # altool looks the key up by ID in this directory. mkdir -p ~/private_keys echo "$ASC_KEY_BASE64" | base64 -d > ~/private_keys/AuthKey_${ASC_KEY_ID}.p8 chmod 600 ~/private_keys/AuthKey_${ASC_KEY_ID}.p8 - name: Archive if: steps.creds.outputs.signed == 'true' env: TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} run: | # Manual signing, deliberately. Automatic signing always signs the # archive with a *development* identity and relies on -exportArchive to # re-sign it for distribution afterwards - which needs a development # certificate and a registered device that a CI-only TestFlight # pipeline has no reason to own. Naming the App Store profile up front # skips that dance entirely. xcodebuild archive \ -workspace "${{ steps.xcode.outputs.workspace }}" \ -scheme "${{ steps.xcode.outputs.scheme }}" \ -configuration Release \ -destination 'generic/platform=iOS' \ -archivePath "$RUNNER_TEMP/Bulwark.xcarchive" \ CODE_SIGN_STYLE=Manual \ DEVELOPMENT_TEAM="$TEAM_ID" \ CODE_SIGN_IDENTITY="Apple Distribution" # Signing failures report a symptom ("no profiles found") rather than the # setting that caused it, and a re-run costs ~30 minutes. Dump the # resolved values instead of guessing twice. - name: Dump signing settings if: failure() && steps.creds.outputs.signed == 'true' continue-on-error: true run: | echo "── identities in the keychain ──" security find-identity -v -p codesigning || true echo "── resolved build settings ──" xcodebuild -workspace "${{ steps.xcode.outputs.workspace }}" \ -scheme "${{ steps.xcode.outputs.scheme }}" \ -configuration Release -showBuildSettings 2>/dev/null \ | grep -Ei 'CODE_SIGN|PROVISIONING|DEVELOPMENT_TEAM|PRODUCT_BUNDLE_ID' | sort -u || true - name: Export the IPA if: steps.creds.outputs.signed == 'true' id: export env: TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} PROFILE_NAME: ${{ steps.profiles.outputs.app_name }} BUNDLE_ID: ${{ steps.profiles.outputs.app_bundle_id }} NSE_PROFILE_NAME: ${{ steps.profiles.outputs.nse_name }} NSE_BUNDLE_ID: ${{ steps.profiles.outputs.nse_bundle_id }} run: | cat > "$RUNNER_TEMP/ExportOptions.plist" < methodapp-store teamID${TEAM_ID} signingStylemanual signingCertificateApple Distribution provisioningProfiles ${BUNDLE_ID}${PROFILE_NAME} ${NSE_BUNDLE_ID}${NSE_PROFILE_NAME} uploadSymbols destinationexport PLIST xcodebuild -exportArchive \ -archivePath "$RUNNER_TEMP/Bulwark.xcarchive" \ -exportPath "$RUNNER_TEMP/export" \ -exportOptionsPlist "$RUNNER_TEMP/ExportOptions.plist" VERSION=$(cat VERSION | tr -d '[:space:]') SRC=$(ls "$RUNNER_TEMP"/export/*.ipa | head -1) DEST="bulwark-mobile-${VERSION}-${GITHUB_SHA::7}.ipa" cp "$SRC" "$DEST" echo "path=$DEST" >> "$GITHUB_OUTPUT" echo "version=$VERSION" >> "$GITHUB_OUTPUT" - uses: actions/upload-artifact@v4 if: steps.creds.outputs.signed == 'true' with: name: ${{ steps.export.outputs.path }} path: ${{ steps.export.outputs.path }} if-no-files-found: error - name: Upload to TestFlight id: upload # The IPA is not attached to the GitHub release: without an Apple device # provisioned for it, it is not installable, and TestFlight is the only # distribution channel for it. if: >- steps.creds.outputs.signed == 'true' && (github.event_name == 'release' || inputs.upload_to_testflight) env: ASC_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }} ASC_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }} run: | IPA="${{ steps.export.outputs.path }}" # Validate first - it catches missing Info.plist keys and entitlement # mistakes in seconds, instead of via a rejection email 20 minutes later. xcrun altool --validate-app --type ios --file "$IPA" \ --apiKey "$ASC_KEY_ID" --apiIssuer "$ASC_ISSUER_ID" xcrun altool --upload-app --type ios --file "$IPA" \ --apiKey "$ASC_KEY_ID" --apiIssuer "$ASC_ISSUER_ID" echo "done=true" >> "$GITHUB_OUTPUT" echo "::notice::Uploaded build ${IOS_BUILD_NUMBER} to App Store Connect. Processing usually takes 5-30 minutes before it appears in TestFlight." # Puts the upload on the public TestFlight link (README): waits for App Store # Connect to process it, adds it to the external group and submits it for # beta review. On Linux, because most of this job is waiting. public-testflight: needs: build if: needs.build.outputs.uploaded == 'true' runs-on: ubuntu-latest timeout-minutes: 75 steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 - name: Install the App Store Connect API key env: ASC_KEY_BASE64: ${{ secrets.APP_STORE_CONNECT_PRIVATE_KEY }} ASC_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }} run: | mkdir -p ~/private_keys echo "$ASC_KEY_BASE64" | base64 -d > ~/private_keys/AuthKey_${ASC_KEY_ID}.p8 chmod 600 ~/private_keys/AuthKey_${ASC_KEY_ID}.p8 - name: Add the build to the public group and submit it for beta review env: ASC_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }} ASC_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }} run: node scripts/testflight-public.mjs "${{ needs.build.outputs.version }}" "${{ github.run_number }}"