Stratify
Mobile app

Mobile Release

docs/runbooks/mobile-release.md

Source updated 03. Aug. 2026

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 with ITMS-90725. Do not use eas build/eas submit. The Mobile · EAS stage in the root Jenkinsfile is defunct (tag-gated, never fired) — see docs/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.local must 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's env block 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 payload signalId)
  • First feed tap prompts push registration once (expo_push_tokens upsert)

TestFlight release (local fastlane)

cd apps/mobile
./scripts/testflight-local.sh

What it does:

  1. expo prebuild --platform ios + pod install.
  2. Signingfastlane 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").

  1. xcodebuild clean archive (raw, no PTY — gym needs a PTY and fails

headless) → xcodebuild -exportArchive with fastlane/ExportOptions.plist.

  1. Upload + distributefastlane 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)

  • signingcert + sigh against 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

  1. Sign in on device → Account → Push aktivieren (or open any signal from feed once).
  2. Publish a signal from ops/pilot (sandbox: STRATIFY_SANDBOX_MODE=1).
  3. The Hetzner cron drain-expo-push must run; verify a row in expo_push_tokens.

Parity checklist

SurfaceWebMobile
Feed / signals/feed/feed → tap → /signal/[id]
Billing/account/billing/account/billing → opens web URL
Mandate switch/account/mandate/account/mandate
Pushemail + in-appExpo 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.getInitialURL in root _layout.tsx.
  • Wrong billing URL: set EXPO_PUBLIC_APP_URL to 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, not gym. Don't switch back to gym.