# EAS Local Build → TestFlight Delivery Plan

NO BUILD/SUBMIT YET. Plan-first per no-shortcuts. Reviewed + greenlit before any execution.

## Context

EAS CI free minutes exhausted. Need to ship a production iOS build to TestFlight from this machine without burning more CI. App is on `1.0.1` (`expo.version`). Production bundle id `ai.nanostreet.app`. Apple Team `8GM434ZGG6`. ASC App ID `6763804521`.

The machine already has `/opt/homebrew/bin/fastlane` and a working `eas-cli` (currently `18.11.0`, recommended bump to ≥`19` but works). EAS `appVersionSource: "remote"` so build numbers come from Expo, NOT app.json — local builds still hit EAS for the next number.

---

## (1) Local build path

**Command:**
```bash
APP_ENV=production eas build \
  --profile production \
  --platform ios \
  --local \
  --non-interactive
```

**Notes / tweaks:**
- `--local` runs the entire native build on this Mac — consumes `eas-cli`'s local pipeline, NOT cloud build minutes.
- Implicitly auto-increments build number via EAS remote (`autoIncrement: true` in `eas.json` + `appVersionSource: "remote"` in `eas.json` cli config).
- Profile `production` has NO `distribution` field set in `eas.json`, which defaults to `"store"` → produces a TestFlight/App Store-eligible signed `.ipa`.
- Output artifact lands as `build-<timestamp>.ipa` in the cwd — same convention as the existing `.claude/worktrees/onboarding-flow-refactor/build-1778388578477.ipa` from earlier dev builds.
- `APP_ENV=production` prefix is **mandatory** — `app.config.js` reads it to swap bundle id from `ai.nanostreet.app.dev` → `ai.nanostreet.app` and pick prod icons. Without it, `app.config.js` defaults to `'development'` and the build will sign the wrong bundle id.
- Run from a clean tree on `main` (or a tagged release branch). Never from a worktree carrying unrelated changes.

**Sanity tweak to `eas.json` (recommended, not required):** add an explicit `"distribution": "store"` under `build.production` so future readers don't have to know the default.

---

## (2) Credentials state

EAS is the source of truth for production iOS signing. The previously-configured production cert + provisioning profile live in `EAS Credentials` — NOT in this machine's keychain.

**Inspect (interactive, run on the Mac, do NOT log output):**
```bash
eas credentials -p ios
# Pick: production profile → "Credentials JSON" → "Show distribution credentials" (or similar menu item)
```

This displays current cert, provisioning profile, and push key references. Confirm:
- Distribution Certificate exists, valid, not expiring soon.
- App Store Provisioning Profile exists, bundle id `ai.nanostreet.app`, valid, not expiring.

**Local build credential strategy:**
- `eas build --local` will, by default, fetch credentials from EAS Cloud and use them locally. No need to hand-export to keychain.
- If `eas credentials` reports any of these missing or expired, run `eas credentials -p ios` → `Manage credentials for project` → let EAS regenerate. Apple Developer Portal login required (interactive, app-specific password + 2FA).
- If user prefers fully-local credentials (no EAS round-trip), `credentialsSource: "local"` can be set on the production profile + `credentials.json` checked in next to `.gitignore`d cert/p12 files. **Not recommended** for now — adds complexity, EAS-managed is fine for a one-off TestFlight push.

---

## (3) App Store Connect submit

Three options. Recommendation: **(a) `eas submit`**, with **(b) `fastlane pilot`** as fallback.

### (a) `eas submit` — RECOMMENDED

```bash
eas submit \
  --platform ios \
  --path ./build-<timestamp>.ipa \
  --profile production \
  --non-interactive
```

- Uses `submit.production.ios` from `eas.json` — `appleTeamId: "8GM434ZGG6"`, `ascAppId: "6763804521"`.
- Wraps Apple's Transporter under the hood. Submit minutes are **separate from build minutes** in EAS billing; **EAS Submit is currently free** for paid plans and quota-counted only for free plans (Expo docs as of last check — verify in EAS dashboard if uncertain).
- Requires a one-time interactive login to Apple ID on first submit; after that EAS caches an App Store Connect API key. Already configured per `submit.production.ios` having `appleTeamId` set.
- On success, the `.ipa` lands in App Store Connect → TestFlight → Internal Testing pipeline. Build appears after Apple processes it (~10–30 min).

### (b) `fastlane pilot upload` — FALLBACK

```bash
export FASTLANE_USER="<apple-id-email>"
export FASTLANE_APP_SPECIFIC_PASSWORD="<app-specific-password>"  # NOT regular Apple ID password
fastlane pilot upload \
  --ipa ./build-<timestamp>.ipa \
  --apple_id 6763804521 \
  --team_id 8GM434ZGG6 \
  --skip_waiting_for_build_processing true \
  --changelog "<release notes — see (5)>"
```

- Requires app-specific password from https://appleid.apple.com/account/manage → "App-Specific Passwords".
- `--apple_id` here is the ASC App ID (numeric), not email — fastlane convention.
- Use only if (a) fails or if user wants direct Transporter without going through EAS.

### (c) `xcrun altool --upload-app` — DIRECT/EMERGENCY

```bash
xcrun altool --upload-app \
  -f ./build-<timestamp>.ipa \
  -t ios \
  --apiKey <key-id> \
  --apiIssuer <issuer-id>
```

- `altool` is **deprecated** in favor of `xcrun notarytool` for notarization, but App Store upload via `altool` still works.
- Requires App Store Connect API key (`.p8` file in `~/.appstoreconnect/private_keys/`) — generate at App Store Connect → Users and Access → Keys → "+".
- No release notes attached at upload — must edit in App Store Connect after.
- Use only if both (a) and (b) fail. Most opaque error reporting of the three.

**Recommendation rationale:** `eas submit` re-uses the existing EAS-stored Apple credentials, no new secrets to wire up, single command, attaches changelog automatically.

---

## (4) Version + build number bump

**Marketing version (`expo.version`):** static in `app.json`, currently `1.0.1`. Bump manually in `app.json` for new release tags. Also bump `runtimeVersion` indirectly — `runtimeVersion: { policy: "appVersion" }` ties OTA channel to `expo.version`, so a new marketing version means a fresh runtime channel and OTA bundles must be re-published before that build's users get OTA.

**Build number (`CFBundleVersion`):** managed by EAS remote. `eas.json` has `cli.appVersionSource: "remote"` + `build.production.autoIncrement: true`. Each `eas build --profile production` (cloud OR local) hits EAS for the next build number and bakes it into the .ipa during the build. Do NOT set `expo.ios.buildNumber` in `app.json` — leave it null/absent so EAS-remote wins.

**Inspecting the next build number BEFORE building:**
```bash
eas build:version:get --profile production --platform ios
```

**Manually setting (only needed for hotfix scenarios):**
```bash
eas build:version:set --profile production --platform ios --version <N>
```

**For the upcoming TestFlight delivery:** if this is a 1.0.1 patch (no marketing version bump), only the build number auto-increments — leave `expo.version` alone. If we want 1.0.2, bump `app.json` `expo.version` to `1.0.2`, commit, then build.

---

## (5) Release notes

TestFlight surfaces a "What to Test" / changelog field per build.

**Where to maintain:**
- No `CHANGELOG.md` in this repo today. Recommend adopting one as `docs/RELEASES.md` or `CHANGELOG.md` (root) for human-readable history.
- For the upcoming push, draft inline at submit time. `eas submit --profile production --message "<notes>"` does NOT support inline message — the notes are managed in App Store Connect after the build is processed, OR via fastlane:
  ```bash
  fastlane pilot upload --changelog "<notes>" ...
  ```

**Recommended format for the next TestFlight build (PR #301 cycle):**
```
v1.0.1 build <N>
- New pre-register questionnaire flow + SettingUp loader
- SmartTips EBIT inline link + ExplainChip overlay during Beginner tour
- Lottie celebration timing fixes (no first-mount blank, holds on visible frame)
- Notification permission no longer fires during pre-register
- Tour now uses mocked data exclusively (no backend leakage)
- IncomeStatement tabs scroll into view during Beginner tour Step 2
```

If using `eas submit`, the release notes step is a follow-up in App Store Connect web (or via `fastlane deliver` separately). If using `fastlane pilot`, pass `--changelog` and skip the web step.

---

## (6) Safety checklist (pre-delivery)

Run in order, **STOP if any fails**:

1. **Tree state:** `git status` clean, on `main` (or `release/<version>` tag branch). No untracked source files. `.agent-status/` and `build-*.ipa` ignored.
2. **Up to date:** `git pull --ff-only origin main` — abort if non-FF.
3. **Gates:** `rtk pnpm lint --fix && rtk pnpm typecheck && rtk pnpm test --ci --forceExit`. All green.
4. **Bundle id:** confirm `APP_ENV=production` is set when invoking `eas build`. Sanity check by inspecting any prior production `.ipa` if available — extract `Info.plist` and verify `CFBundleIdentifier == ai.nanostreet.app`.
5. **No secrets in bundle:** `rtk grep -rn "EXPO_PUBLIC_" src | grep -iE "secret|key|password|token"` — confirm only public keys (`EXPO_PUBLIC_SUPABASE_ANON_KEY`, `EXPO_PUBLIC_API_BASE_URL`, etc.) are referenced. The `EXPO_PUBLIC_` prefix is intentional — these get inlined into the JS bundle. Anything sensitive must NOT have that prefix.
6. **Production env vars set:** verify `eas env:list --environment production --non-interactive` shows expected entries (Supabase URL, anon key, API base URL, Google iOS URL scheme, Sentry/analytics keys if any). Local `.env.local` does NOT apply to EAS builds — EAS reads from its own env per profile.
7. **Credentials valid:** see (2). Cert + profile not expiring within 30 days.
8. **Production OTA channel:** confirm `eas channel:view production` shows the latest production OTA group is the same `runtimeVersion` as the build we're about to submit. Fresh marketing-version bumps need a fresh OTA publish to that channel BEFORE the .ipa goes live; otherwise users land on the bundled JS only.
9. **Tagging:** create the release tag locally (`git tag v1.0.1-build<N>` or `v1.0.2`) but DO NOT push the tag yet — pushing the tag triggers `deploy-production.yml` OTA workflow on CI which is fine, but timing matters: push tag AFTER the .ipa has uploaded to TestFlight so users on that runtime version get the matching OTA.

---

## (7) Step-by-step runbook

**Confirmation gates marked [GATE] — coor pings user before each gate.**

```bash
# 0. PREP — runs in main repo on main branch
cd /Users/fathoni/Documents/Project/BlockDev/nano-street/mobile
git status                                 # MUST be clean
git pull --ff-only origin main             # MUST be fast-forward
rtk pnpm lint --fix
rtk pnpm typecheck
rtk pnpm test --ci --forceExit
# [GATE 1] — coor confirms gates green + tree state clean

# 1. CREDENTIALS — interactive, user runs
eas whoami                                 # confirm logged in to expo account
eas credentials -p ios                     # interactive: confirm production cert + profile valid
# [GATE 2] — user confirms credentials inspected

# 2. ENV / VERSION SANITY
eas env:list --environment production --non-interactive
eas build:version:get --profile production --platform ios
# Note the next build number that will be assigned (N)
# Decide: bump expo.version in app.json? If yes, edit + commit FIRST
# [GATE 3] — coor confirms env list looks right + decides version strategy

# 3. LOCAL BUILD
APP_ENV=production eas build \
  --profile production \
  --platform ios \
  --local \
  --non-interactive
# Output: build-<timestamp>.ipa in cwd
ls -lh build-*.ipa
# [GATE 4] — coor confirms .ipa landed + size sanity

# 4. INSPECT THE .ipa BEFORE UPLOAD (optional but recommended)
mkdir -p /tmp/ipa-inspect && cd /tmp/ipa-inspect
unzip -o /path/to/build-<timestamp>.ipa
plutil -p Payload/*.app/Info.plist | grep -E 'CFBundleIdentifier|CFBundleShortVersionString|CFBundleVersion'
# Verify: CFBundleIdentifier=ai.nanostreet.app, ShortVersion=1.0.1 (or new), CFBundleVersion=N
cd -
# [GATE 5] — coor confirms metadata correct

# 5. SUBMIT TO TESTFLIGHT
eas submit \
  --platform ios \
  --path ./build-<timestamp>.ipa \
  --profile production \
  --non-interactive
# Watch for "Submission successful" + ASC link in output
# [GATE 6] — coor confirms submit accepted, build appears in ASC TestFlight processing

# 6. RELEASE NOTES
# Open https://appstoreconnect.apple.com → My Apps → NanoStreet → TestFlight → iOS Builds
# Pick the new build (~10–30 min after submit)
# Edit "What to Test" with the changelog from (5)
# [GATE 7] — coor confirms notes attached

# 7. OTA + TAG (optional, only if marketing version changed)
# Edit app.json: expo.version (e.g. 1.0.1 → 1.0.2)
# git commit -am "release: v1.0.2"
# git tag v1.0.2
# git push origin main --tags     # triggers deploy-production.yml OTA workflow on CI
# [GATE 8] — coor confirms OTA workflow green on Actions tab

# 8. INTERNAL/EXTERNAL TESTERS
# In ASC TestFlight: assign build to internal testers immediately for smoke
# Once smoke passes, assign to external test groups
```

---

## Followup work (out of scope for this delivery)

- Adopt `CHANGELOG.md` at repo root with one entry per release tag.
- Add a script `scripts/release.sh` that wraps gates 0–5 and prompts at each gate — turns this runbook into a single command.
- Document `EAS_LOCAL_BUILD_CACHE` env var if local build is slow on first run (`pod install` cache).
- Investigate why CI free minutes are exhausted — long-term move toward EAS paid plan or self-hosted runners (`actions/runner` on a dedicated Mac).

---

## Open questions for user

1. Is this for `1.0.1` (just bump build number) or a new marketing version (`1.0.2`)?
2. Internal-testers-only push, or assign to external testers in same flow?
3. Tag the release commit on `main` (and trigger the production OTA workflow) NOW, or AFTER the build is approved + on TestFlight?
4. Run `eas submit` (recommendation) or fall back to `fastlane pilot` for any reason (e.g. EAS submit minutes also exhausted)?

---

## Addendum — USER-LOCKED PICKS (2026-05-10)

### Locked

- **Marketing version:** STAY `1.0.1`. Do NOT bump.
- **Build number:** explicitly `6` (not auto-assigned by EAS remote).
- **Cloud minutes:** ZERO budget. Build minutes exhausted, treat submit minutes as exhausted too unless verified otherwise.

### State of EAS remote version tracker (verified just now)

`eas build:version:get --profile production --platform ios` returns: **"No remote versions are configured for this project."** That is, the remote tracker has never been initialized for production iOS. autoIncrement on first build would start at "1", NOT "6" — so leaving autoIncrement on does NOT give us 6.

### Static-buildNumber path (zero cloud cost)

To pin CFBundleVersion to exactly `6` without any cloud build/queue activity:

1. **One-time init the remote tracker:**
   ```bash
   eas build:version:set --profile production --platform ios --version 6
   ```
   This is a metadata-only API call to EAS — does NOT consume build/submit minutes. Sets the remote tracker so future builds pull `6` (or autoIncrement to `7+` if re-enabled later).

2. **Disable autoIncrement on production profile** so subsequent builds don't bump past `6`:
   - Edit `eas.json`:
     ```diff
       "production": {
         "channel": "production",
     -   "autoIncrement": true,
         "env": { "APP_ENV": "production" }
       }
     ```
   - Commit + push (or stage as a local-only edit revertable post-build).

3. **Build (local, zero cloud minutes):**
   ```bash
   APP_ENV=production eas build --profile production --platform ios --local --non-interactive
   ```
   `--local` runs the entire native compile on this Mac. The `eas-cli` queries EAS for the build number (`6`) — that read is metadata, free. No cloud build container spins up.

4. **Post-build:** revert the `autoIncrement: true` deletion if we want CI workflows (`deploy-production.yml`) to keep auto-incrementing in the future. Or leave it off and manually bump for every release. User decides post-delivery.

### Submit path (verify free quota first)

`eas submit --profile production --platform ios` orchestration is **separate billing from build minutes** (per Expo docs as of last check). On paid plans, EAS Submit is typically free; on free plans, it shares a smaller quota. mfnano account is the project owner — quota state visible in https://expo.dev/accounts/mfnano/settings/billing.

**If submit quota is also drained, fallback path:**

```bash
# Apple ID app-specific password from https://appleid.apple.com → App-Specific Passwords
export FASTLANE_USER="<apple-id-email>"
export FASTLANE_APP_SPECIFIC_PASSWORD="<app-specific-password>"
fastlane pilot upload \
  --ipa ./build-<timestamp>.ipa \
  --apple_id 6763804521 \
  --team_id 8GM434ZGG6 \
  --skip_waiting_for_build_processing true \
  --changelog "<release notes>"
```

`fastlane pilot` is 100% local — it talks to App Store Connect API directly via the Apple ID + app-specific password, no Expo intermediary. Zero EAS quota.

### Free-cost surfaces (verified)

- `eas whoami` — free
- `eas account:view` — free
- `eas credentials -p ios` (interactive inspect, no regenerate) — free
- `eas build:version:get` — free (metadata read)
- `eas build:version:set --version 6` — free (metadata write)
- `eas env:list --environment production` — free
- `eas channel:view production` — free
- `eas update:list --branch production` — free
- `eas build --local` — local CPU cost only, NO cloud minutes
- `eas submit` — separate quota (verify before relying)

### Final locked runbook (zero-build-cloud-minutes path)

```bash
# 0. PREP
cd /Users/fathoni/Documents/Project/BlockDev/nano-street/mobile
git status                                          # MUST be clean
git pull --ff-only origin main
rtk pnpm lint --fix && rtk pnpm typecheck && rtk pnpm test --ci --forceExit
# [GATE 1] gates green

# 1. INSPECT CREDENTIALS (free)
eas credentials -p ios
# pick: production profile → confirm cert + provisioning profile valid, not expiring
# [GATE 2] credentials valid

# 2. INIT REMOTE BUILD NUMBER TO 6 (free, metadata-only)
eas build:version:set --profile production --platform ios --version 6
eas build:version:get --profile production --platform ios   # verify shows 6
# [GATE 3] remote = 6

# 3. DISABLE AUTOINCREMENT TEMPORARILY
# Edit eas.json: remove "autoIncrement": true from build.production
# (do NOT commit this edit yet — keeps local-only until post-delivery)
# [GATE 4] eas.json patched

# 4. ENV / SECRETS SANITY (free)
eas env:list --environment production --non-interactive
# Confirm: SUPABASE keys, API base URL, Google iOS URL scheme, etc.
# [GATE 5] env list correct

# 5. LOCAL BUILD (zero cloud minutes)
APP_ENV=production eas build --profile production --platform ios --local --non-interactive
# Watch for "Build artifact" path. Lands as build-<timestamp>.ipa
ls -lh build-*.ipa
# [GATE 6] .ipa exists

# 6. INSPECT THE .ipa
mkdir -p /tmp/ipa-inspect && cd /tmp/ipa-inspect
unzip -o /Users/fathoni/Documents/Project/BlockDev/nano-street/mobile/build-<timestamp>.ipa
plutil -p Payload/*.app/Info.plist | grep -E 'CFBundleIdentifier|CFBundleShortVersionString|CFBundleVersion'
# Expected: ai.nanostreet.app, 1.0.1, 6
cd -
# [GATE 7] metadata correct

# 7. SUBMIT — VERIFY QUOTA FIRST
# Open https://expo.dev/accounts/mfnano/settings/billing → Submit minutes status
# IF submit quota available:
eas submit --platform ios --path ./build-<timestamp>.ipa --profile production --non-interactive
# IF submit quota also drained, fallback to fastlane:
# (interactive — user provides FASTLANE_USER + FASTLANE_APP_SPECIFIC_PASSWORD when prompted)
# [GATE 8] submit accepted, build appears in ASC processing pipeline

# 8. RELEASE NOTES (in App Store Connect web)
# Wait ~10–30 min for Apple to process the build
# Open ASC → My Apps → NanoStreet → TestFlight → iOS Builds → pick build 6
# Edit "What to Test" with PR #301 changelog (see (5) above)
# [GATE 9] notes attached

# 9. INTERNAL TESTERS
# In ASC TestFlight: assign build to internal testers immediately
# User handles external/group distribution — no automated step

# 10. POST-DELIVERY CLEANUP
# Either restore autoIncrement: true in eas.json (commit + push)
# Or leave it off and manually bump per release going forward
# [GATE 10] eas.json final state agreed
```

### Caveats

- The `expo.runtimeVersion: { policy: "appVersion" }` ties OTA channel to `expo.version=1.0.1`. Build 6 will run the same OTA channel as builds 1–5 (if any landed before). Make sure the latest production OTA published to `production` channel is compatible with build 6's bundled native modules — verify with `eas channel:view production`.
- ASC requires CFBundleVersion strictly greater than the highest already in TestFlight for the same `1.0.1` version string. If ASC currently has build 5 or lower under 1.0.1, build 6 is accepted. If ASC has 6 or higher already, Apple rejects with `ITMS-90062` or similar — must bump to 7+.
- If `eas build --local` fails on `pod install` or signing, the failure is local — no cloud charge for the failed attempt. Re-run after fixing.

---

## CI Mirror — what production CI actually does, and the local equivalent

### Existing CI workflow inventory

**`.github/workflows/deploy-production.yml`** is the canonical production iOS native-build + submit pipeline. Two jobs:

1. **`ota-update`** (auto-fires on `push: tags: ['v*']`)
   - Reuses `_eas-update.yml`.
   - Publishes a JS-only OTA bundle to channel `production`, message `"Release <tag>"`.
   - This is JUST OTA — it does NOT build a native .ipa.

2. **`native-build`** (manual via `workflow_dispatch` with `build_native: true`)
   - On `ubuntu-latest`. Matrix: `[ios, android]`.
   - Setup: Node 20 + pnpm 4 + cache + `pnpm install --frozen-lockfile`.
   - `expo/expo-github-action@v8` with `eas-version: ^18.0.0` + `EXPO_TOKEN` secret for auth.
   - Build step: `eas build --platform ios --profile production --non-interactive`
   - Submit step: `eas submit --platform ios --profile production --non-interactive`
   - **NO `--wait --json --id`** chaining — submit picks the latest matching build for the profile automatically.

**`.github/workflows/deploy-staging.yml`** is more elaborate: it captures `eas build --json` output, extracts `build_id`, and passes `--id $BUILD_ID` to submit. **Production CI does NOT do this** — relies on submit auto-picking latest. Both approaches work; staging chose tighter coupling, prod chose simplicity. The local runbook here matches the production-CI simplicity.

**`.github/workflows/_eas-update.yml`** is OTA-only:
- Pulls all `EXPO_PUBLIC_*` from GitHub repo `vars` (not `secrets`) and exports to `$GITHUB_ENV`.
- Validates that every key in `.env.example` is set.
- `eas update --branch <name> --message <msg> --non-interactive`.

### Canonical production-iOS native-build sequence (CI)

```bash
# (after Node/pnpm setup + pnpm install + expo/expo-github-action auth)
eas build --platform ios --profile production --non-interactive
eas submit --platform ios --profile production --non-interactive
```

Two commands. That's it. Build minutes consumed; submit minutes consumed. No tagging here — tagging happens BEFORE this workflow fires (push tag triggers `ota-update`; `native-build` is dispatched separately).

### Local equivalent (zero-cloud-build-minutes path)

```bash
# pre: cd to repo, git pull --ff-only, gates green, eas credentials inspected
# pre: eas build:version:set --profile production --platform ios --version 6
# pre: edit eas.json — remove autoIncrement: true from build.production

APP_ENV=production eas build --platform ios --profile production --local --non-interactive
eas submit --platform ios --path ./build-<timestamp>.ipa --profile production --non-interactive
```

Two commands. Same shape as CI. Only differences:
1. **`--local` flag on build** — runs the iOS native compile on this Mac instead of EAS Cloud. Zero build minutes.
2. **`--path ./build-<timestamp>.ipa` on submit** — local builds don't auto-link to a build ID in EAS, so we point submit at the artifact path directly. CI's submit auto-discovers the latest build via the EAS API; local submit doesn't have that link unless we use `--id` (which would require uploading the local build to EAS first, defeating the point).
3. **`APP_ENV=production`** prefix — CI sets this implicitly via the `production` profile's `env: { APP_ENV: production }` in `eas.json`. The cloud builder reads the profile env. **Local builds also read the profile env**, so technically `APP_ENV=production` may be redundant — but setting it explicitly defends against any environment leak from the local shell. Keep it.

### Where local DIVERGES from CI (and why each divergence is safe)

| Divergence | CI | Local | Why local is safe |
|---|---|---|---|
| Build runner | `ubuntu-latest` cloud | This Mac | iOS native build prefers macOS anyway (Xcode + Pod). CI on Ubuntu produces iOS via EAS cloud Mac runners — same Xcode toolchain. Local Mac runs the SAME Xcode toolchain end-to-end. |
| Build flag | (cloud) | `--local` | `--local` skips the cloud queue + uses local `eas-cli`'s pinned Xcode/SDK config from `eas.json`. Output `.ipa` byte-equivalent shape (signed by EAS-fetched cert + profile). |
| Build number | `autoIncrement: true` from EAS remote | Pre-set remote to 6, autoIncrement OFF | User locked picks: explicit 6, no auto-bump for this delivery. Reverted post-delivery. |
| Submit flag | (no `--path`, auto-discovers cloud build) | `--path ./build-<timestamp>.ipa` | Local artifact has no EAS build-id; `--path` is the documented way to submit a locally-built `.ipa`. |
| Env vars in build | `expo/expo-github-action@v8` injects `EXPO_TOKEN`; no other env on build step (EAS server env per profile is read by the cloud builder) | Local `eas-cli` already auth'd via `eas whoami`; EAS server env per profile is read locally same way | Identical resolution path — `eas build` regardless of `--local` reads `eas.json` env block + EAS-server env for the profile. Local also has access to `.env.local`, but that's NOT picked up by `eas build` unless explicitly piped — same isolation as CI. |
| Submit auth | `expo/expo-github-action@v8` injects `EXPO_TOKEN` | Local `eas-cli` already auth'd via `eas whoami` | EAS submit uses cached App Store Connect API key linked to the EAS account. Same key whether invoked from CI or local. |
| OTA bundle publish | Triggered by tag push (`ota-update` job in `deploy-production.yml` → `_eas-update.yml`) | Manual / deferred | OTA + native are independent steps. We can publish OTA via `eas update --branch production --message "..." --non-interactive` AFTER the native build is on TestFlight, or skip OTA entirely if no JS changes since the last production OTA group. |

### Net delta: CI → Local

The local pipeline is a **strict subset** of CI:
- Same `eas.json` profiles
- Same `expo.version` / runtime version policy
- Same EAS remote credential fetch
- Same submit endpoint
- Just two flags swapped (`--local` on build, `--path` on submit)

No commands invented from scratch. Everything else (gates, version inspection, credential check, post-submit ASC notes) is preflight/verification — not part of the canonical CI build+submit pair.

### CI cost breakdown (so we know what we're avoiding)

- `eas build --profile production --platform ios` (cloud) → consumes Mac/iOS build minutes from EAS quota. **This is what's exhausted.** Local `--local` skips it entirely.
- `eas submit --profile production --platform ios` (cloud) → consumes Submit minutes from a SEPARATE EAS quota. Verify before relying — see "Submit path" addendum above. If exhausted, fall back to `fastlane pilot upload`.
- `eas update --branch production` (cloud) → consumes Update minutes (or is free on paid plans, depending). Tiny — just bundle compile + upload. Not a concern for a single delivery.

The local plan above explicitly avoids the build-minute drain, treats submit-minutes as suspect (verify first), and defers OTA update unless needed.

---

## Addendum 3 — Preview/staging delivery (NOT production)

User locked picks (2026-05-10):
- **Preview-bundle delivery, NOT production.** Push the .ipa to TestFlight under the preview bundle id, not under `ai.nanostreet.app`.
- **Internal + external testers same flow.** Group assignment via App Store Connect post-upload.
- **autoIncrement: true STAYS** on whichever profile we use. Current remote = 5, autoIncrement → 6 naturally. No `eas build:version:set` override needed.

### Profile selection — BLOCKER and resolution

**`preview` profile in `eas.json`:**
```json
"preview": {
  "distribution": "internal",     ← AD-HOC, NOT TestFlight compatible
  "channel": "preview",
  "env": { "APP_ENV": "preview" },
  "ios": { "credentialsSource": "remote" }
}
```
- `distribution: "internal"` produces an ad-hoc-signed `.ipa`. Apple rejects ad-hoc IPAs at the Transporter / TestFlight upload stage.
- `submit.preview` is **NOT DEFINED** in `eas.json` — no Apple Team / ASC App ID configured for this profile.

**`staging` profile in `eas.json`:**
```json
"staging": {
  "distribution": "store",        ← STORE, TestFlight compatible
  "channel": "staging",
  "autoIncrement": true,
  "env": { "APP_ENV": "staging" }
}
```
- `distribution: "store"` produces an App Store-signed `.ipa`, eligible for TestFlight upload.
- `autoIncrement: true` already on (matches user's "stay on, don't override" pick).
- `submit.staging` IS defined: `appleTeamId: "8GM434ZGG6"`, `ascAppId: "6763981289"`.

**Bundle id mapping (`app.config.js`):**
- `APP_ENV=preview` → bundle id `ai.nanostreet.app.preview` + preview icons
- `APP_ENV=staging` → bundle id `ai.nanostreet.app.preview` + preview icons (commented as "staging shares the preview bundle ID")

**Both profiles produce the same bundle id**, so functionally identical app to install. The only differences: staging is TestFlight-eligible (store distribution) and has a submit config; preview is ad-hoc only and has no submit config.

### Resolution: use `staging` profile

User asked to deliver on "preview profile / bundle id". The **bundle** they want is `ai.nanostreet.app.preview` — the **profile** that delivers that bundle to TestFlight is `staging`. Using `preview` profile blocks at upload (ad-hoc IPA rejected by Apple). Using `staging` profile produces the same bundle but with store distribution + submit config.

**Surface this to user** before executing. If user explicitly wants the `preview` profile (e.g. for ad-hoc device install via TestFlight-bypass), this whole runbook is wrong target. If user wants TestFlight, switch profile to `staging`.

### ASC App IDs — staging vs production

- `submit.production.ios.ascAppId: "6763804521"` — ASC App for production bundle `ai.nanostreet.app`.
- `submit.staging.ios.ascAppId: "6763981289"` — ASC App for staging/preview bundle `ai.nanostreet.app.preview`.

Two separate ASC Apps. TestFlight builds, internal/external groups, and review submissions are managed independently. The user's existing TestFlight configuration for the preview bundle lives under ASC App `6763981289`, so this delivery lands there.

### Final locked runbook (preview-bundle, staging-profile path)

```bash
# 0. PREP
cd /Users/fathoni/Documents/Project/BlockDev/nano-street/mobile
git status                                          # MUST be clean
git pull --ff-only origin main
rtk pnpm lint --fix && rtk pnpm typecheck && rtk pnpm test --ci --forceExit
# [GATE 1] gates green

# 1. INSPECT CREDENTIALS for staging (free)
eas credentials -p ios
# pick: staging profile → confirm cert + provisioning profile valid for ai.nanostreet.app.preview
# [GATE 2] credentials valid

# 2. VERIFY REMOTE BUILD NUMBER (free, metadata-only)
eas build:version:get --profile staging --platform ios
# Expected: 5 (autoIncrement → 6 on next build)
# [GATE 3] remote = 5

# 3. ENV / SECRETS SANITY (free)
eas env:list --environment preview --non-interactive
# Note: staging profile env block sets APP_ENV=staging, but the EAS-server env "environment"
# attached to the staging profile may share the preview EAS env. Verify the right env-set is loaded
# at build time — should include EXPO_PUBLIC_API_BASE_URL, EXPO_PUBLIC_SUPABASE_*, etc.
# [GATE 4] env list correct

# 4. LOCAL BUILD (zero cloud minutes, autoIncrement to 6)
APP_ENV=staging eas build --platform ios --profile staging --local --non-interactive
# Watch for "Build artifact" path. Lands as build-<timestamp>.ipa
ls -lh build-*.ipa
# [GATE 5] .ipa exists

# 5. INSPECT THE .ipa
mkdir -p /tmp/ipa-inspect && cd /tmp/ipa-inspect
unzip -o /Users/fathoni/Documents/Project/BlockDev/nano-street/mobile/build-<timestamp>.ipa
plutil -p Payload/*.app/Info.plist | grep -E 'CFBundleIdentifier|CFBundleShortVersionString|CFBundleVersion'
# Expected: ai.nanostreet.app.preview, 1.0.1, 6
cd -
# [GATE 6] metadata correct: bundle id .preview + version 1.0.1 + build 6

# 6. SUBMIT TO TESTFLIGHT (preview-bundle ASC App)
# VERIFY submit quota first at https://expo.dev/accounts/mfnano/settings/billing
eas submit --platform ios --path ./build-<timestamp>.ipa --profile staging --non-interactive
# Build lands in ASC App 6763981289 → TestFlight processing
# [GATE 7] submit accepted

# 7. RELEASE NOTES + GROUPS in App Store Connect (manual)
# Wait ~10–30 min for Apple to process the build
# Open https://appstoreconnect.apple.com → My Apps → NanoStreet (preview) → TestFlight → iOS Builds → build 6
# - Edit "What to Test" with PR #301 changelog
# - Assign to Internal Testing groups (immediate)
# - Assign to External Testing groups (requires Beta App Review pass — may take 24h)
# [GATE 8] notes attached + groups assigned

# 8. NO production OTA, NO production tag — this is preview channel only
# If staging OTA bundle needs to refresh in parallel:
# eas update --branch staging --message "PR #301 device-verify build" --non-interactive
# Otherwise skip — the .ipa carries its own JS bundle.
```

### What this delivery does NOT touch

- Production bundle `ai.nanostreet.app` — untouched.
- Production OTA channel — untouched.
- `git tag v*` push — NOT issued. `deploy-production.yml` does NOT fire.
- `eas.json` `autoIncrement` — UNCHANGED. Stays `true` on staging + production profiles per user pick.

### Open questions (final)

1. **Confirm profile switch:** OK to use `staging` profile to deliver the preview bundle? Same bundle id (`ai.nanostreet.app.preview`), same icons, but store distribution + submit config so TestFlight accepts it. Alternative: keep `preview` profile but it requires ad-hoc UDID-list device install (TestFlight bypass) — likely not what user wants.
2. **External testers timing:** assign to external groups in this same flow (triggers Beta App Review on first external assignment for this version, ~24h delay), or hold for a follow-up?
3. **Staging OTA refresh:** publish a fresh `eas update --branch staging` after the .ipa lands, or skip (the .ipa carries its own JS, OTA refresh only matters for over-the-air updates to already-installed builds)?
