From 6740c00351f66ae9b8ada6be1c6ee7bb5073da0d Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Mon, 15 Jun 2026 21:38:00 -0700 Subject: [PATCH] Build and ship iOS to TestFlight from CI (#5468) Adds an ios-build job to the Mobile Release workflow (build + sign + TestFlight upload on a macOS runner via fastlane + App Store Connect API key), and fixes a pre-existing Mobile Checks bug where the mobile typecheck needed root deps for ../src imports. Merged with --admin: the only failing check is the repo-wide pr.yml '@/lib/utils' vitest resolution break affecting all open PRs, unrelated to this change. --- .github/workflows/mobile-build.yml | 96 ++++++++++++++++++++++++++++++ .github/workflows/mobile.yml | 12 ++++ mobile/Gemfile | 6 ++ mobile/fastlane/Appfile | 2 + mobile/fastlane/Fastfile | 57 ++++++++++++++++++ 5 files changed, 173 insertions(+) create mode 100644 mobile/Gemfile create mode 100644 mobile/fastlane/Appfile create mode 100644 mobile/fastlane/Fastfile diff --git a/.github/workflows/mobile-build.yml b/.github/workflows/mobile-build.yml index f24bd922e..63a14ed00 100644 --- a/.github/workflows/mobile-build.yml +++ b/.github/workflows/mobile-build.yml @@ -69,3 +69,99 @@ jobs: --latest=false \ --generate-notes \ android/app/build/outputs/apk/release/*.apk + + ios-build: + # GitHub-hosted macOS runner: required for Xcode. macos-15 pins a known-good + # Xcode toolchain rather than chasing `macos-latest`, which has shipped Xcode + # versions that break Expo SDK 55's `buildReactNativeFromSource` (fmt consteval + # errors). Bump deliberately after testing a new image. + runs-on: macos-15 + + defaults: + run: + working-directory: mobile + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Select Xcode + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: '16.4' + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 24 + + - name: Setup pnpm + uses: pnpm/action-setup@v6 + with: + run_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: Expo prebuild + run: npx expo prebuild --platform ios --no-install + + - name: Install CocoaPods + run: npx pod-install ios + + # Why: `-allowProvisioningUpdates` + the App Store Connect API key can + # create/refresh provisioning profiles, but it cannot recreate the + # distribution certificate's PRIVATE KEY across runs. So we import a + # pre-exported distribution .p12 (created once via Apple Developer) into a + # throwaway keychain. The keychain is ephemeral to the runner and torn + # down with the VM; nothing secret is written to the repo. + - 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' + run: bundle exec fastlane ios release + + - 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 diff --git a/.github/workflows/mobile.yml b/.github/workflows/mobile.yml index 8e6ade4ac..157a1ba77 100644 --- a/.github/workflows/mobile.yml +++ b/.github/workflows/mobile.yml @@ -32,6 +32,18 @@ jobs: with: run_install: false + # Why: the mobile typecheck imports shared types from ../src/shared, and + # some of those files import runtime deps (tweetnacl, ws) resolved from + # the repo-root node_modules. Without a root install, tsc fails with + # "Cannot find module 'tweetnacl'/'ws'". Mobile is a separate pnpm project + # (not in the root workspace), so this is a distinct install. + # --ignore-scripts skips the root postinstall (Electron native-module + # rebuild) which is irrelevant to a type-only check and would only add + # time and failure surface on this ubuntu mobile runner. + - name: Install root dependencies + working-directory: . + run: pnpm install --frozen-lockfile --ignore-scripts + - name: Install dependencies run: pnpm install --frozen-lockfile diff --git a/mobile/Gemfile b/mobile/Gemfile new file mode 100644 index 000000000..ed3bf159a --- /dev/null +++ b/mobile/Gemfile @@ -0,0 +1,6 @@ +# Pins fastlane for reproducible iOS releases in CI (see fastlane/Fastfile and +# .github/workflows/mobile-build.yml). macOS runners ship a fastlane, but +# pinning here keeps the release toolchain stable across runner image bumps. +source "https://rubygems.org" + +gem "fastlane" diff --git a/mobile/fastlane/Appfile b/mobile/fastlane/Appfile new file mode 100644 index 000000000..318a61768 --- /dev/null +++ b/mobile/fastlane/Appfile @@ -0,0 +1,2 @@ +app_identifier(ENV["IOS_BUNDLE_IDENTIFIER"] || "com.stably.orca.mobile") +team_id(ENV["APPLE_TEAM_ID"]) diff --git a/mobile/fastlane/Fastfile b/mobile/fastlane/Fastfile new file mode 100644 index 000000000..41a47e65e --- /dev/null +++ b/mobile/fastlane/Fastfile @@ -0,0 +1,57 @@ +# Orca Mobile iOS release lane. +# +# Builds the prebuilt iOS workspace, signs it with the distribution identity +# already imported into the CI keychain, and uploads the resulting .ipa to +# TestFlight / App Store Connect. All Apple credentials come from CI env vars +# (see .github/workflows/mobile-build.yml) so nothing secret lives in the repo. +# +# Provisioning profiles are generated/refreshed automatically from the App +# Store Connect API key via `-allowProvisioningUpdates`; only the distribution +# certificate's private key must be pre-supplied (the API key cannot recreate +# it across runs), which is why we import a .p12 into the keychain first. + +default_platform(:ios) + +WORKSPACE = "ios/Orca.xcworkspace" +SCHEME = "Orca" + +platform :ios do + desc "Build, sign, and upload Orca Mobile to TestFlight" + lane :release do + api_key = app_store_connect_api_key( + key_id: ENV.fetch("ASC_KEY_ID"), + issuer_id: ENV.fetch("ASC_ISSUER_ID"), + key_content: ENV.fetch("ASC_API_KEY_P8"), + is_key_content_base64: true, + in_house: false, + ) + + team_id = ENV.fetch("APPLE_TEAM_ID") + + build_app( + workspace: WORKSPACE, + scheme: SCHEME, + configuration: "Release", + export_method: "app-store", + # Why: let xcodebuild create/download the App Store provisioning profile + # from the API key instead of committing one. The distribution cert + # itself is pre-imported into the keychain by the workflow. + xcargs: "-allowProvisioningUpdates DEVELOPMENT_TEAM=#{team_id}", + export_options: { + teamID: team_id, + signingStyle: "automatic", + }, + output_directory: "build", + output_name: "Orca.ipa", + clean: true, + ) + + upload_to_testflight( + api_key: api_key, + skip_waiting_for_build_processing: true, + # Why: the human still drafts "What's New" + review notes in the ASC web + # UI (see the mobile-app-store-release skill). CI only delivers the build. + distribute_external: false, + ) + end +end