name: Mobile iOS Release # Release iOS independently so App Store review cannot block Android. on: push: tags: - 'mobile-ios-v*' workflow_dispatch: inputs: bump_patch_version: description: 'Use the first open iOS patch version after the checked-in version, skipping versions already closed on the App Store.' required: false default: false type: boolean release_version: description: 'Optional exact iOS marketing version override, e.g. 0.0.15' required: false type: string testflight_changelog: description: 'Optional TestFlight external tester changelog' required: false type: string permissions: contents: read jobs: ios-build: # Xcode 27.1's iOS SDK enables iPhone Duo resizing; Expo SDK 55 requires Swift 6 syntax. runs-on: xcode-27 # Archive + upload is ~30–40m when healthy; 90m leaves margin for setup. timeout-minutes: 90 env: # Freeze Gemfile.lock in both jobs; installs ~25min apart must not resolve different fastlane versions. BUNDLE_FROZEN: 'true' outputs: release_version: ${{ steps.release_metadata.outputs.version }} build_number: ${{ steps.release_metadata.outputs.build_number }} defaults: run: working-directory: mobile steps: - name: Checkout uses: actions/checkout@v6 - name: Select Xcode uses: maxim-lobanov/setup-xcode@v1 with: # iOS 27.1 SDK: iPhone Duo resizing and reserved (fold/camera) regions. xcode-version: '27.1' - name: Setup Node.js uses: actions/setup-node@v6 with: node-version: 24 check-latest: true - name: Setup pnpm uses: pnpm/setup@v2 with: install: false - name: Install dependencies run: pnpm install --frozen-lockfile - name: Setup Ruby and fastlane uses: ruby/setup-ruby@v1 with: ruby-version: '3.3' bundler-cache: true working-directory: mobile - name: Resolve release version and build number env: ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} ASC_API_KEY_P8: ${{ secrets.ASC_API_KEY_P8 }} MOBILE_IOS_RELEASE_VERSION: ${{ github.event.inputs.release_version }} MOBILE_IOS_BUMP_PATCH_VERSION: ${{ github.event.inputs.bump_patch_version }} FASTLANE_SKIP_UPDATE_CHECK: '1' FASTLANE_HIDE_CHANGELOG: '1' run: bundle exec fastlane ios prepare_release_version version:"$MOBILE_IOS_RELEASE_VERSION" bump_patch:"$MOBILE_IOS_BUMP_PATCH_VERSION" - name: Capture release metadata id: release_metadata run: node -e 'const fs = require("node:fs"); const { expo } = require("./app.json"); fs.appendFileSync(process.env.GITHUB_OUTPUT, `version=${expo.version}\nbuild_number=${expo.ios.buildNumber}\n`)' - name: Expo prebuild env: ORCA_IOS_APS_ENVIRONMENT: production run: npx expo prebuild --platform ios --no-install - name: Install CocoaPods working-directory: mobile/ios run: bundle exec pod install # Import exported distribution .p12 into ephemeral runner keychain: provisioning updates/API key cannot recreate its private key. - name: Import distribution certificate env: IOS_DIST_CERT_P12: ${{ secrets.IOS_DIST_CERT_P12 }} IOS_DIST_CERT_PASSWORD: ${{ secrets.IOS_DIST_CERT_PASSWORD }} run: | set -euo pipefail KEYCHAIN_PATH="$RUNNER_TEMP/orca-signing.keychain-db" # Random per-run keychain password; never persisted. KEYCHAIN_PASSWORD="$(openssl rand -base64 24)" CERT_PATH="$RUNNER_TEMP/orca-dist-cert.p12" echo "$IOS_DIST_CERT_P12" | base64 --decode > "$CERT_PATH" 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 import "$CERT_PATH" -P "$IOS_DIST_CERT_PASSWORD" \ -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH" # Allow codesign/xcodebuild to use the key without an interactive prompt. security set-key-partition-list -S apple-tool:,apple: \ -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" >/dev/null # Put our keychain in the search list so xcodebuild can find the identity. security list-keychains -d user -s "$KEYCHAIN_PATH" login.keychain-db rm -f "$CERT_PATH" - name: Build and upload to TestFlight env: ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} ASC_API_KEY_P8: ${{ secrets.ASC_API_KEY_P8 }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} # Keep fastlane non-interactive and quiet about analytics in CI. FASTLANE_SKIP_UPDATE_CHECK: '1' FASTLANE_HIDE_CHANGELOG: '1' # Metro's release transform of the 3.6 MB Mermaid module needs >2 GB heap; the runner's default cap sits at that edge. NODE_OPTIONS: --max-old-space-size=4096 run: bundle exec fastlane ios build_and_upload - name: Upload .ipa artifact if: always() uses: actions/upload-artifact@v7 with: name: orca-mobile-ipa path: mobile/build/*.ipa if-no-files-found: ignore ios-distribute: name: Distribute to external TestFlight testers needs: ios-build runs-on: ubuntu-latest # Fastlane's processing wait fails after 20m; this outer bound leaves setup # margin without allowing App Store Connect polling to hang for hours. timeout-minutes: 30 env: # Same fastlane as ios-build, or fail before touching App Store Connect. BUNDLE_FROZEN: 'true' defaults: run: working-directory: mobile steps: - name: Checkout uses: actions/checkout@v6 - name: Setup Ruby and fastlane uses: ruby/setup-ruby@v1 with: ruby-version: '3.3' bundler-cache: true working-directory: mobile - name: Wait for processing and distribute to peeps env: ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} ASC_API_KEY_P8: ${{ secrets.ASC_API_KEY_P8 }} TESTFLIGHT_CHANGELOG: ${{ github.event.inputs.testflight_changelog }} MOBILE_IOS_RELEASE_VERSION: ${{ needs.ios-build.outputs.release_version }} MOBILE_IOS_BUILD_NUMBER: ${{ needs.ios-build.outputs.build_number }} FASTLANE_SKIP_UPDATE_CHECK: '1' FASTLANE_HIDE_CHANGELOG: '1' run: | if ! bundle exec fastlane ios distribute_testflight \ version:"$MOBILE_IOS_RELEASE_VERSION" \ build_number:"$MOBILE_IOS_BUILD_NUMBER"; then echo "::error::TestFlight $MOBILE_IOS_RELEASE_VERSION ($MOBILE_IOS_BUILD_NUMBER) was uploaded but not distributed to peeps. If App Store Connect is still processing it, rerun only this failed job after the build becomes Ready; otherwise inspect the Fastlane error above." exit 1 fi