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.
This commit is contained in:
Jinwoo Hong 2026-06-15 21:38:00 -07:00 committed by GitHub
parent 97db443764
commit 6740c00351
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 173 additions and 0 deletions

View File

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

View File

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

6
mobile/Gemfile Normal file
View File

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

2
mobile/fastlane/Appfile Normal file
View File

@ -0,0 +1,2 @@
app_identifier(ENV["IOS_BUNDLE_IDENTIFIER"] || "com.stably.orca.mobile")
team_id(ENV["APPLE_TEAM_ID"])

57
mobile/fastlane/Fastfile Normal file
View File

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