Mobile — build & release (local fastlane)
iOS ships via a local fastlane pipeline driven by apps/mobile/scripts/testflight-local.sh — raw xcodebuild archive/export + fastlane upload to TestFlight. No cloud build service.
EAS is deprecated. EAS Build fails on upload withITMS-90725. Do not useeas build/eas submit. TheMobile · EASstage in the rootJenkinsfileis defunct (tag-gated, never fired) — seedocs/jenkins.md.
Expo SDK: 52 on `main`; 56 on the unmerged `feature/sdk-56` branch.
Phase A: internal TestFlight (iOS). Billing stays on web (/account/billing).
Prerequisites
- Xcode 26.5 (iOS 26 SDK) installed and selected (
xcode-select). - fastlane + CocoaPods on PATH.
- App Store Connect API key in macOS Keychain (account
fyt):
ASC_KEY_ID, ASC_ISSUER_ID. The .p8 lives at ~/.appstoreconnect/private_keys/AuthKey_<ASC_KEY_ID>.p8. The script exports these and derives ASC_KEY_PATH.
- App identity (see
apps/mobile/fastlane/Appfile): - Bundle ID
co.waitwhat.stratify - Apple Developer team
69D9FYX67C - ASC App ID
6775941027 - Mobile env (
EXPO_PUBLIC_*):EXPO_PUBLIC_SUPABASE_URL,
EXPO_PUBLIC_SUPABASE_ANON_KEY, EXPO_PUBLIC_APP_URL (https://app.stratifyinvest.com), EXPO_PUBLIC_PROJECT_ID for Expo push tokens.
- Sign-in uses a 6-digit email code (
verifyOtp), not a link — no deep link is
involved, and stratify:// redirect URLs are not part of the auth path.
apps/mobile/.env.localmust exist before building: the TestFlight script hard-
fails without EXPO_PUBLIC_SUPABASE_URL/_ANON_KEY rather than shipping a bundle that crashes on launch. It is gitignored — copy it from the main checkout when building from a worktree.
- `.env.local` beats `eas.json`. EAS is dead, so
eas.json'senvblock is
read by nothing in this pipeline; .env.local is the only source the local build sees. Because it is gitignored it drifts silently — it survived both the anon-key rotation and the move off ventures.wait-what.co, and a stale copy ships a bundle that gets 401 from Kong on every request while building and uploading perfectly. Check it against eas.json before every release:
curl -s -o /dev/null -w '%{http_code}\n' \
-H "apikey: $(grep '^EXPO_PUBLIC_SUPABASE_ANON_KEY=' apps/mobile/.env.local | cut -d= -f2-)" \
"$(grep '^EXPO_PUBLIC_SUPABASE_URL=' apps/mobile/.env.local | cut -d= -f2-)/rest/v1/pilots?select=id&limit=1"
# 200 = good. 401 = stale key or wrong host; the build would be dead on arrival.Local dev
pnpm --filter @stratify/mobile install
pnpm --filter @stratify/mobile start- Scheme:
stratify:// - Deep link signal:
stratify://signal/<uuid>(also notification payloadsignalId) - First feed tap prompts push registration once (
expo_push_tokensupsert)
TestFlight release (local fastlane)
cd apps/mobile
./scripts/testflight-local.shWhat it does:
expo prebuild --platform ios+pod install.- Signing —
fastlane signing(Fastfile lane): ensures an Apple
Distribution cert (fastlane/certs) + App Store provisioning profile (fastlane/profiles/AppStore_co.waitwhat.stratify.mobileprovision) via the ASC API key. Skipped if cert + profile already exist locally (resilient to transient ASC TLS "unexpected eof").
xcodebuild clean archive(raw, no PTY —gymneeds a PTY and fails
headless) → xcodebuild -exportArchive with fastlane/ExportOptions.plist.
- Upload + distribute —
fastlane upload ipa:<built.ipa>(Fastfile lane →
upload_to_testflight with distribute_external: true). Waits for Apple processing, then submits + distributes to external groups. Retries ONLY on transient SSL — a post-upload failure fails fast (re-upload would collide on the same buildNumber).
Assets: apps/mobile/assets/{icon,splash,adaptive-icon}.png must exist before build (prebuild fails otherwise).
Fastfile lanes (apps/mobile/fastlane/Fastfile)
signing—cert+sighagainst the ASC API key (cert →fastlane/certs,
profile → fastlane/profiles).
upload— `upload_to_testflight(distribute_external: true, groups:, changelog:,
skip_submission: false, skip_waiting_for_build_processing: false). Auto-distributes to external testers. **Prereqs:** the group(s) in TESTFLIGHT_GROUPS (default External Testers) must already exist in App Store Connect, and the first external build of a version goes through Apple **Beta App Review** (needs Test Information + a demo account, since the app is sign-in-gated). Internal testers still get every build automatically. Notes via TESTFLIGHT_CHANGELOG`.
Both read ASC_KEY_ID / ASC_ISSUER_ID / ASC_KEY_PATH from the environment (populated from Keychain by the script).
Push test
- Sign in on device → Account → Push aktivieren (or open any signal from feed once).
- Publish a signal from ops/pilot (sandbox:
STRATIFY_SANDBOX_MODE=1). - The Hetzner cron
drain-expo-pushmust run; verify a row inexpo_push_tokens.
Parity checklist
| Surface | Web | Mobile |
|---|---|---|
| Feed / signals | /feed | /feed → tap → /signal/[id] |
| Billing | /account/billing | /account/billing → opens web URL |
| Mandate switch | /account/mandate | /account/mandate |
| Push | email + in-app | Expo push + deep link |
Recovery
- No push: check
EXPO_PUBLIC_PROJECT_ID, iOS entitlements,drain-expo-push(Hetzner cron) logs. - Deep link cold start:
getLastNotificationResponseAsync+Linking.getInitialURLin root_layout.tsx. - Wrong billing URL: set
EXPO_PUBLIC_APP_URLto the production origin
(https://app.stratifyinvest.com); lib/app-url.ts appends the path to it as-is.
- Signing fails (ASC SSL eof): re-run
./scripts/testflight-local.sh; the
signing lane retries once and is skipped entirely once cert + profile exist locally.
- `gym`/PTY error: expected — the script uses raw
xcodebuild, notgym. Don't switch back togym.