# Publishing a Mob app to TestFlight (iOS)

This is the full, step-by-step recipe for taking a Mob app from "runs on
my iPhone via `mix mob.deploy --native`" to "uploaded to App Store
Connect, ready for TestFlight beta testing".

It assumes you already have a working development setup — `mix mob.deploy`
runs your app on a connected iPhone. If you don't, work through
[Getting Started](https://hexdocs.pm/mob/getting_started.html) first.

> **Status (mob 0.5.12 / mob_dev 0.3.30):** End-to-end works. A real
> Mob app (Air Cart Maximizer) shipped through this exact pipeline on
> 2026-05-02. If you follow the steps below in order you should land a
> build in TestFlight on your first or second attempt.
>
> Apple's validator runs in **two separate stages** — an upload
> validator (catches obvious bundle problems) and a secondary scanner
> (runs after upload, emails you if it finds anything). Most of the
> troubleshooting at the bottom of this guide is for errors from the
> second stage. They typically don't show up until after `mix mob.publish`
> reports success. See [Two-stage validation](#part-3--two-stage-validation)
> for the model.

---

## Prerequisites

- macOS with Xcode (Xcode 16+ tested; Xcode 26 produces App Store-grade
  builds without quirks)
- An Apple Developer Program membership ($99/year) — TestFlight requires
  this; the free tier can sideload but not publish
- An iPhone you've successfully run the app on via `mix mob.deploy --native`
  (proves your dev signing works end-to-end)
- iOS 17.0 simulator deployment target. If your project was generated
  by `mix mob.new` from a mob_new before 0.1.30, your `ios/build.sh`
  may say `version-min=16.0` — bump it:

  ```bash
  sed -i '' \
    -e 's/version-min=16.0/version-min=17.0/g' \
    -e 's/arm64-apple-ios16.0-simulator/arm64-apple-ios17.0-simulator/g' \
    -e 's/--minimum-deployment-target 16.0/--minimum-deployment-target 17.0/g' \
    ios/build.sh
  ```

  iOS 17 was released September 2023; older targets fail because the
  framework's Swift code uses iOS 17+ APIs (modern `onChange(of:_:)`
  closure form).

You'll also create things in three Apple web portals during this guide:

| Portal | URL | What lives here |
|---|---|---|
| Apple Developer | https://developer.apple.com/account/resources/identifiers/list | App IDs, certificates, provisioning profiles |
| App Store Connect | https://appstoreconnect.apple.com/apps | App records, TestFlight, App Store API keys |
| App Store Connect API | https://appstoreconnect.apple.com/access/integrations/api | API keys for `altool` upload auth |

These are different portals serving different parts of the process.
Easy to confuse them — the bundle ID lives in the developer portal, the
app record (where the bundle ID is *attached* to a public-facing app)
lives in App Store Connect.

---

## Part 1 — One-time setup (per app)

You only do these once per Mob app. After this, every release is just
`mix mob.release` → `mix mob.publish`.

### 1.1 Pick a real bundle ID

The bundle ID generated by `mix mob.new` defaults to `com.example.<app>`
which Apple won't accept for App Store distribution. You need a
reverse-DNS identifier under a domain you control.

Examples:

- `com.beyondagronomy.aircartmax` (your team owns `beyondagronomy.com`)
- `ca.larocque.aircartmax` (Canadian convention, your name)
- `com.genericjam.somecoolapp` (your dev handle)

Bundle IDs are forever — once Apple registers it under your team you
can't transfer it cleanly. Pick something you'll be happy with in 5 years.

### 1.2 Update the bundle ID + display name in your project

**iOS** — edit `ios/Info.plist`:

```xml
<key>CFBundleIdentifier</key>
<string>com.beyondagronomy.aircartmax</string>

<!-- Optional but nice: how the app shows up on the home screen.
     Without this, iOS uses CFBundleName which is usually the technical
     PascalCase name. -->
<key>CFBundleDisplayName</key>
<string>Air Cart Maximizer</string>

<!-- Apple's convention: integer build number + semver public version.
     Bump CFBundleVersion every upload (even rebuilds of the same
     version). CFBundleShortVersionString is what users see. -->
<key>CFBundleVersion</key>
<string>1</string>
<key>CFBundleShortVersionString</key>
<string>1.0.0</string>
```

**Android** — edit `android/app/build.gradle`:

```gradle
defaultConfig {
    // The Kotlin `namespace` above can stay as the generator default
    // (`com.example.foo`). Renaming it would mean moving every .kt
    // file's package declaration. Android allows applicationId and
    // namespace to differ — only applicationId is user-visible.
    applicationId "com.beyondagronomy.aircartmax"
    versionCode 1
    versionName "1.0.0"
    ...
}
```

### 1.3 Keep usage strings in Info.plist (counterintuitive — read this)

Your first instinct will be to strip the `NSCameraUsageDescription`,
`NSMicrophoneUsageDescription`, etc. that `mix mob.new` scaffolds —
"my offline calculator doesn't use the camera, why declare it?"

**Don't strip them.** Apple's secondary validator (the post-upload
scanner that emails you) flags ITMS-90683 if any code in the bundle
references a sensitive-data API and the corresponding usage string is
missing. The Mob framework's NIFs in `mob_nif.m` reference all of these
APIs (camera, mic, location, photo library, motion) regardless of
whether your specific app calls them — Apple's scanner sees the API
references and demands the strings.

You have two valid paths:

**Path A — keep all the usage strings** (recommended, easiest)

Leave the strings as scaffolded. They only trigger user-visible
permission prompts when the API is actually *called* at runtime — and
your app never calls them, so users never see a prompt. From the App
Store reviewer's perspective the strings are framework-required boilerplate.

If your app legitimately doesn't use a capability, write an honest string
that says so — App Store reviewers appreciate the clarity:

```xml
<key>NSCameraUsageDescription</key>
<string>This app does not use the camera. The permission is declared
because of a framework dependency only.</string>
<key>NSMicrophoneUsageDescription</key>
<string>This app does not use the microphone. The permission is declared
because of a framework dependency only.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>This app does not use your location. The permission is declared
because of a framework dependency only.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>This app does not access your photo library. The permission is
declared because of a framework dependency only.</string>
<key>NSMotionUsageDescription</key>
<string>This app does not use motion sensors. The permission is declared
because of a framework dependency only.</string>
```

**Path B — strip strings AND opt out of the corresponding NIFs** (future)

The clean fix is to compile the unused capability NIFs out of release
builds via per-feature flags. mob doesn't currently expose this surface,
but it's planned. When that lands, this section will get a "Path C —
opt out per capability" subsection and Path A will become "if you don't
care about the strings."

For everything else — `UIBackgroundModes`, custom URL schemes,
`UIRequiredDeviceCapabilities` — strip what your app legitimately
doesn't need. Only the privacy usage strings are subject to the
ITMS-90683 weirdness.

### 1.4 Register the App ID at Apple

Apple's developer portal needs to know your bundle ID exists before
anything else (App Store Connect, distribution profiles, the API)
can reference it.

> **Why we don't automate this**: `mix mob.provision --distribution`
> uses `xcodebuild -allowProvisioningUpdates`, which manages existing
> profiles and certs but won't register a brand-new bundle ID for
> distribution. The Apple-blessed automation path goes through the App
> Store Connect API, which mob_dev hasn't yet integrated.

1. Go to https://developer.apple.com/account/resources/identifiers/list
2. Click `+` → **App IDs** → Continue → **App** → Continue
3. Fill in:
   - **Description**: a human-readable name (e.g. `Air Cart Maximizer`)
   - **Bundle ID**: select **Explicit**, paste your bundle ID exactly
     as it appears in `Info.plist`
   - **Capabilities**: leave at defaults unless your app needs special
     entitlements (push notifications, app groups, iCloud, etc.). Most
     Mob apps don't need any.
4. Click Continue → Register

The registration is instant. The bundle ID will now appear in
selectors throughout Apple's portals.

### 1.5 Create the Apple Distribution certificate

You need a distribution-signing cert in your Mac's keychain. The Apple
Development cert your team already has (used for `mix mob.deploy`) is
not the same thing — App Store builds need an Apple Distribution cert.

In Xcode:

1. Settings (`⌘,`) → Accounts
2. Click your team in the left list
3. Click **Manage Certificates...**
4. Click `+` in the bottom-left → **Apple Distribution**
5. Done

Xcode generates a CSR locally, uploads it, downloads the signed cert,
and installs it in your login keychain. One time only.

You can verify with:

```bash
security find-identity -v -p codesigning | grep "Apple Distribution"
```

### 1.6 Create an App Store provisioning profile

A provisioning profile binds a cert + an App ID + an entitlements set
into a signed blob your Mac can use to sign builds.

> **Why we don't automate this either**: the same `xcodebuild
> -allowProvisioningUpdates` limitation. Once a profile exists, mob_dev
> can fetch it; creating one for a brand-new App ID under manual
> signing is the gap.

1. Go to https://developer.apple.com/account/resources/profiles/list
2. Click `+`
3. Distribution → **App Store** → Continue
4. App ID: select your bundle ID from step 1.4 → Continue
5. Certificates: select the Apple Distribution cert from step 1.5 → Continue
6. Profile name: anything. Common conventions:
   - `<AppName> App Store` (e.g. `AirCartMax App Store`)
   - The Apple-default `iOS Team Store Provisioning Profile: <bundle_id>`
7. Generate → **Download**

`mix mob.provision --distribution` discovers profiles by parsing the
files in `~/Library/Developer/Xcode/UserData/Provisioning Profiles/`
and matching by bundle ID + team — so the profile name doesn't matter.

### 1.7 Install the profile

Double-click the downloaded `.mobileprovision` file. macOS installs it
into `~/Library/Developer/Xcode/UserData/Provisioning Profiles/` with
a UUID-based filename.

You can verify with:

```bash
ls ~/Library/Developer/Xcode/UserData/Provisioning\ Profiles/
```

### 1.8 Run `mix mob.provision --distribution`

```bash
cd path/to/your/app
mix mob.provision --distribution
```

This:

1. Reads bundle ID from `ios/Info.plist`
2. Auto-detects your team ID from your existing dev profile
3. Verifies the Apple Distribution cert is in keychain
4. **Discovers the App Store profile UUID** by scanning the local
   profiles directory for a non-dev/non-ad-hoc profile matching your
   bundle ID + team — works regardless of what you named the profile
5. Generates `ios/Provision.xcodeproj` (a minimal Xcode project) wired
   up with manual signing, the discovered profile UUID, and the Apple
   Distribution identity
6. Runs `xcodebuild archive` — proves the cert + profile + bundle ID
   form a valid signing chain end-to-end
7. Confirms the profile is in place

Output should end with:

```
✓ App Store provisioning profile ready
✓ Provisioning complete!
Next step: mix mob.release
```

### 1.9 Create the App Store Connect app record

The bundle ID + your developer account is one half of the picture. The
*app record* (where store metadata, TestFlight builds, and screenshots
live) is the other half — and it's in a different portal.

1. Go to https://appstoreconnect.apple.com/apps
2. Click `+` → **New App**
3. Fill in:
   - **Platforms**: iOS
   - **Name**: the public-facing name (60 char limit, must be unique
     across the entire App Store — try variants if it's taken)
   - **Primary Language**: pick whatever's appropriate
   - **Bundle ID**: pick from the dropdown — your bundle ID from step
     1.4 should appear here. If it doesn't, either step 1.4 didn't
     succeed or you need to wait a minute for Apple's portals to sync
   - **SKU**: anything you want. Doesn't need to match the bundle ID;
     a common convention is `<short-name>-001`. The SKU is for your
     internal tracking.
   - **User Access**: Full Access
4. Create

You don't need to fill in screenshots, descriptions, age ratings, etc.
yet — those are required for App Store *review*, not for TestFlight
beta testing.

### 1.10 Create an App Store Connect API key

The upload step (`mix mob.publish`) uses Apple's `altool` with an API
key for authentication — much smoother than older app-specific-password
flows.

1. Go to https://appstoreconnect.apple.com/access/integrations/api
2. Switch to the **Team Keys** tab (not Individual Keys)
3. Note the **Issuer ID** at the top of the page — copy it somewhere safe
4. Click `+`
5. Fill in:
   - **Name**: anything (e.g. `Mob CLI Upload`)
   - **Access**: **App Manager** (the minimum role that can upload builds)
6. **Generate**

Now Apple shows you a one-time download:

7. Click **Download API Key** — you get `AuthKey_<KEY_ID>.p8`. **This
   is the only chance to download it.** Apple does not store the
   private key. If you close this page without downloading, revoke the
   key and create a new one.
8. Note the **Key ID** (10 chars, also visible in the table after you
   download) — copy it somewhere safe
9. Move the file somewhere persistent and lock it down:

```bash
mkdir -p ~/.appstoreconnect
mv ~/Downloads/AuthKey_*.p8 ~/.appstoreconnect/
chmod 600 ~/.appstoreconnect/AuthKey_*.p8     # owner read/write only
```

### 1.11 Configure `mob.exs`

Add the API key block to your `mob.exs`:

```elixir
import Config

config :mob_dev,
  # ... your existing config ...

  app_store_connect: [
    key_id:    "ABC123XYZ4",                            # 10-char Key ID
    issuer_id: "69a6de76-aaaa-bbbb-cccc-1234567890ab",  # team Issuer ID
    key_path:  "~/.appstoreconnect/AuthKey_ABC123XYZ4.p8"
  ]
```

`mob.exs` is per-machine and should be in your `.gitignore` (the file
itself says so at the top). Don't commit the `.p8` either — treat it
like an SSH private key.

That's the one-time setup. Everything below is the per-release flow.

---

## Part 2 — Per-release flow

Once Part 1 is done, every subsequent release is **one command**:

```bash
mix mob.republish --ios     # bump build number, mob.release, mob.publish --ios
```

That wraps the three steps below. Use the wrapper for the common path;
drop down to the individual commands if you need to troubleshoot one
in isolation, or you've bumped the build number some other way and
just want to rebuild + upload.

### 2.1 (Optional) `mix mob.provision --distribution`

Idempotent — re-runs against existing artifacts and tells you if
anything's missing or stale. App Store profiles expire annually; this
is the command to refresh one. Skip it if you ran it recently and
nothing's changed. `mix mob.republish` does NOT re-run this — bake it
into your annual calendar.

### 2.2 `mix mob.republish --ios` (the wrapper)

What it does, exactly:

1. **Bump `CFBundleVersion`** — reads the current value from
   `ios/Info.plist`, integer-bumps by 1, writes back. Refuses if the
   current value isn't a clean integer (e.g. someone wrote `"1.0"` —
   that's `CFBundleShortVersionString`'s job, not `CFBundleVersion`'s).
2. **`mix mob.release`** — builds the `.ipa`. See section 2.3 for what
   happens here.
3. **`mix mob.publish --ios`** — uploads via `xcrun altool`. See
   section 2.4 for what happens here.

Flags:

- `--ios` — required. Mob is platform-agnostic; you must pick.
- `--android` — errors with "not yet implemented" (Android publish
  pipeline is on the roadmap).
- `--no-bump` — skip step 1 (Apple will reject the same build number,
  so this is mostly useful for testing the pipeline itself).
- `--verbose` — passes through to `mix mob.publish` to show altool's
  per-chunk upload progress.

### 2.3 `mix mob.release` (manual equivalent of step 2 of `mix mob.republish --ios`)

Builds a release-signed `.ipa` at `_build/mob_release/<App>.ipa`:

- Compiles BEAMs, strips them down to the apps your release actually
  uses, drops the unused OTP libs (megaco, runtime_tools, erl_interface,
  os_mon, wx, et, eunit, etc.) from the bundle
- Removes `.so`/`.a`/standalone executables from the bundle (Apple's
  one-Mach-O-per-`.app` policy) — the static archives are linked into
  the main binary instead
- Builds native sources with `-DMOB_RELEASE` to drop the Erlang
  distribution surface, EPMD, AND the test harness (whose synthetic
  touch NIFs use private UIKit selectors that App Store auto-rejects)
- Synthesizes the full set of `DT*` build-environment plist keys
  (`DTSDKName`, `DTSDKBuild`, `DTPlatformName`, `DTPlatformVersion`,
  `DTPlatformBuild`, `DTXcode`, `DTXcodeBuild`, `DTCompiler`,
  `BuildMachineOSBuild`) plus `MinimumOSVersion`, `UIDeviceFamily`,
  and `CFBundleSupportedPlatforms`
- Signs the `.app` with your distribution identity (no `get-task-allow`)
- Packages with `ditto -c -k --keepParent --norsrc --noextattr --noqtn`
  to preserve the `_CodeSignature/CodeResources` symlink and avoid
  `__MACOSX/`/`._<file>` AppleDouble pollution

The resulting `.ipa` is typically ~19.8 MB for a basic Mob app
(down from ~45 MB before the slim build was added). The full breakdown
of what gets stripped, why, and how to bisect a broken slim build is in
[`slim_release.md`](slim_release.md).

To validate a slim build runs on device _before_ the TestFlight
round-trip, use:

```bash
mix mob.deploy --slim   # dev build with the same strips applied
```

### 2.4 `mix mob.publish --ios` (manual equivalent of step 3 of `mix mob.republish --ios`)

Uploads `_build/mob_release/<App>.ipa` to App Store Connect via
`xcrun altool --upload-app` with API-key auth.

Platform flag is required (`--ios` or `--android`) — Mob refuses to
default to either side so it's obvious from the command which store
you're hitting. `--android` errors with "not yet implemented".

The upload is silent for several minutes — `altool` doesn't print
progress unless you pass `--verbose`. **This is normal**, not a hang.
To verify it's still alive:

```bash
ps aux | grep -E "altool|java" | grep -v grep
```

Should show altool + a child Java process (altool's actual upload
engine). CPU and TIME columns climbing means it's working.

If you want to see real-time progress:

```bash
mix mob.publish --ios --verbose
```

You'll likely see noise like:

```
[SSZipArchive] Set attributes failed for directory: ...Info.plist
[SSZipArchive] Error setting directory file modification date attribute
```

**Harmless** — Info.plist is a file, not a directory, and altool's
warning is bogus. Long-standing Apple-side noise.

Successful upload ends with:

```
UPLOAD SUCCEEDED with no errors
Delivery UUID: 6a1711f4-2f11-4023-9711-9ddcef583a73
✓ Upload accepted by App Store Connect
```

This is **not the same as "your build is in TestFlight"** — see Part 3
below.

### 2.5 If the wrapper isn't working — the manual three-step

If `mix mob.republish` fails partway and you need to recover, or you'd
rather run each step yourself, here's the long form. Each command is
exactly what `mix mob.republish --ios` runs internally:

```bash
# 1. Bump the build number (Apple rejects re-uploads of the same number).
#    Reads CFBundleVersion, integer-bumps by 1, writes back.
CURRENT=$(/usr/libexec/PlistBuddy -c "Print :CFBundleVersion" ios/Info.plist)
/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $((CURRENT + 1))" ios/Info.plist

# 2. Build the IPA.
mix mob.release

# 3. Upload to App Store Connect.
mix mob.publish --ios
```

The bump is a separate first step intentionally — the build number is
baked into the binary at compile time, not added later. If you skip
the bump and go straight to `mix mob.release`, the resulting `.ipa`
will have the SAME build number as the previous one and Apple will
reject the upload at validation time.

Common recovery scenarios:

- **`mix mob.release` failed** — fix the build error, then run `mix
  mob.release && mix mob.publish --ios`. Don't bump again — the bump
  already happened.
- **`mix mob.publish --ios` failed mid-upload** — the bump is
  "consumed" from Apple's POV (they've seen that build number now,
  even if upload didn't complete). Bump again then re-publish:
  `mix mob.republish --ios`.
- **You bumped manually and want to re-build with the new number** —
  `mix mob.republish --ios --no-bump` skips the bump step.

### 2.6 Add testers in TestFlight

App Store Connect → your app → **TestFlight** tab → **Internal Testing**
group → `+` to add testers by email.

Internal testers (up to 100, must be users on your App Store Connect
team) get the build immediately, no review.

External testers (up to 10,000, no team membership required) need a
one-time **Beta App Review** per major version (~24h typical), then get
the build via a public link or by email invite.

For the first round of TestFlight beta testing, internal is usually
the fastest path — you and a couple of trusted testers can be added as
admins on your App Store Connect team.

---

## Part 3 — Two-stage validation

**The single most non-obvious thing about App Store uploads.** Apple
runs your build through TWO completely separate validators, and
"upload succeeded" only means you cleared the first one.

```
mix mob.publish
   │
   ▼
┌─ Stage 1: Upload validator ──────────────────────────────┐
│ Runs while altool uploads. Catches obvious bundle        │
│ problems (missing required keys, wrong file structure,   │
│ disallowed content like .so/.a in the bundle, signature  │
│ issues). If it fails, altool exits non-zero and prints   │
│ the errors. mix mob.publish reports a failure.           │
└──────────────────────────────────────────────────────────┘
   │ "UPLOAD SUCCEEDED" → mix mob.publish exits 0
   ▼
┌─ Apple processes the build (5–15 min) ───────────────────┐
│ App Store Connect ingests the .ipa, generates assets,    │
│ runs the secondary validator against the ingested copy.  │
└──────────────────────────────────────────────────────────┘
   │
   ▼
┌─ Stage 2: Secondary scanner ─────────────────────────────┐
│ Static-analyses the binary for symbol references         │
│ (private API usage, missing usage strings for referenced │
│ APIs), checks DT* keys against an allow-list of accepted │
│ Xcode/SDK versions, etc. Issues are emailed to your      │
│ team's primary contact and visible in App Store Connect  │
│ → app → TestFlight tab → the build's "View Details"      │
│ link.                                                    │
└──────────────────────────────────────────────────────────┘
   │
   ▼
Build either appears in TestFlight as "Ready to Test" or shows
"Missing Compliance" / "Invalid Binary" with errors to fix.
```

**Practical consequence**: when `mix mob.publish` reports success, the
real test is what arrives in your inbox 5-15 minutes later. If you
don't see the build in the TestFlight tab after ~20 minutes, check
your email for an "App Store Connect" message titled "We noticed one
or more issues with a recent delivery". Those are stage-2 errors.

The pipeline mob_dev 0.3.30 ships clears all the stage-1 errors and
the common stage-2 errors (missing usage strings, missing
`CFBundleSupportedPlatforms`, missing DT* keys, missing
`UIDeviceFamily`) — your first stage-2 surprise will likely be
something app-specific, not framework-level.

The full list of stage-2 error codes Apple uses lives in the
[Troubleshooting](#troubleshooting) section below.

---

## Troubleshooting

### `xcodebuild: error: The flag -scheme is required when specifying -archivePath but not -exportArchive`

Xcode 16 tightened the rules: `-archivePath` paired with `-target` now
errors out. mob_dev 0.3.27+ uses `-scheme` for archive actions. If you
hit this on an older mob_dev, upgrade.

### `BuildProductsPath couldn't be opened`

Xcode 26's archive action needs its own DerivedData layout for
intermediate paths. Don't override `SYMROOT`/`OBJROOT` for the archive
action. mob_dev 0.3.27+ handles this.

### `MobProvision has conflicting provisioning settings. MobProvision is automatically signed for development, but a conflicting code signing identity Apple Distribution has been manually specified.`

You're on an older mob_dev that uses automatic signing for the Release
config. The wildcard Apple Development profile your team owns satisfies
your specific bundle, so automatic signing never enters distribution
mode — even with `archive` action. The fix is manual signing for Release
plus a discovered `PROVISIONING_PROFILE_SPECIFIER`. mob_dev 0.3.27+
does this.

### `No profile for team '<X>' matching '<profile name>' found`

`xcodebuild` is looking for a specific profile name and not finding it.
Two common causes:

- The App ID isn't registered yet at Apple Developer (step 1.4) — the
  profile can't exist until the App ID does
- An older mob_dev hardcoded the Apple-default profile name; if you
  named your profile something else, the lookup fails

mob_dev 0.3.27+ discovers profiles by UUID (parsing the local
profiles directory) so the profile name doesn't matter.

### `Distribution profile can't be auto-created for an unregistered App ID`

mob_dev's diagnostic for the "No profile for team..." case when the
App ID hasn't been registered. Walks you through step 1.4. Once the App
ID exists, re-run `mix mob.provision --distribution`.

### `[SSZipArchive] Set attributes failed for directory: ...Info.plist`

altool noise during IPA validation. Harmless — `Info.plist` is a file,
not a directory, and the warning is bogus. Long-standing altool issue
that Apple hasn't cleaned up.

### `mix mob.publish` appears to hang for several minutes

Not hung. altool is silent during upload unless `--verbose` is set.
Expect 2–10 minutes of no output for a 60–80MB IPA. Use the `ps aux`
check above to confirm it's still running.

### `Missing :app_store_connect in mob.exs`

You haven't done step 1.10/1.11. Get an API key and add the
`app_store_connect:` config block to `mob.exs`.

### `One-time download` warning was missed for the API key

If you closed the API key creation page without downloading the `.p8`,
the private key is gone — Apple doesn't store it. Go back to the API
key list, find the row, revoke it, create a fresh one. Costs nothing.

### Stage-2 email: `ITMS-90683 Missing purpose string in Info.plist`

You'll get an email from Apple titled "We noticed one or more issues
with a recent delivery". Body says:

```
The Info.plist file for the "<App>.app" bundle should contain a
NSCameraUsageDescription key with a user-facing purpose string
explaining clearly and completely why your app needs the data.
```

Apple's static analyser found a reference to a sensitive-data API
(camera, microphone, location, photos, motion, contacts, …) in your
binary and the matching `NS<X>UsageDescription` key isn't present in
Info.plist.

For Mob apps this almost always comes from the framework's NIFs in
`mob_nif.m`, not your app code. See [Section 1.3](#13-keep-usage-strings-in-infoplist-counterintuitive--read-this).
**Don't strip usage strings even when your app doesn't use the capability.**

The email lists ALL the missing strings, separated into "required to
fix" (will block the build from TestFlight) and "wanted to make you
aware of" (warnings — not blocking but worth fixing for App Store
review later).

Fix → bump `CFBundleVersion` → `mix mob.release` → `mix mob.publish`.

### Stage-2: error 90562 — `CFBundleSupportedPlatforms` missing

```
Invalid Bundle. Info.plist should specify CFBundleSupportedPlatforms
with an array containing a single platform.
```

mob_dev 0.3.30+ adds this defensively. Older versions don't —
upgrade.

### Stage-2: error 90534 — Unsupported SDK or Xcode version

```
Your app was built with an SDK or version of Xcode that isn't supported.
```

Apple cross-references `DTSDKBuild` + `DTXcodeBuild` in your bundle
Info.plist against an allow-list of accepted Xcode releases. Two ways
this hits:

1. Your `DT*` keys are missing or wrong. mob_dev 0.3.30+ synthesizes
   the full set; older versions don't — upgrade.
2. Your Xcode itself is too old (or a beta that hasn't been moved to
   the accepted list yet). Update Xcode to the current release.

### Stage-2: error 90102 — `UIDeviceFamily` missing

```
The UIDeviceFamily key must be present when requiring a MinimumOSVersion
of at least 3.2.
```

mob_dev 0.3.30+ adds this defensively (defaults to `[1]` =
iPhone-only). For universal apps that also support iPad, set
`UIDeviceFamily` explicitly in your `ios/Info.plist`:

```xml
<key>UIDeviceFamily</key>
<array>
    <integer>1</integer>
    <integer>2</integer>
</array>
```

### Stage-1: errors 90065 / 90507 / 90530 — Info.plist gaps

```
Invalid MinimumOSVersion.
Missing Info.plist value. A value for the key 'DTPlatformName' is required.
Missing Deployment Target.
```

mob_dev 0.3.30+ synthesizes these. Older versions don't.

### Stage-1: error 90071 — CodeResources not a symbolic link

```
The CodeResources file must be a symbolic link to _CodeSignature/CodeResources.
```

mob_dev 0.3.30+ uses `ditto` with the right flags; older versions
used `zip` which flattens the symlink.

### Stage-1: error 90171 — `.so` / `.a` / standalone binary in bundle

```
The "<App>.app/otp/lib/<otp_lib>/priv/lib/<thing>.so" binary file is not
permitted. Your app cannot contain standalone executables or libraries,
other than a valid CFBundleExecutable of supported bundles.
```

Apple's bundle policy: one Mach-O per `.app`. mob_dev 0.3.30+ strips
all `.so`/`.a`/standalone executables from the bundled OTP tree
(static archives are linked into the main binary; unused OTP libs
like megaco/runtime_tools/erl_interface are dropped entirely). Older
versions copy the OTP tree wholesale and trip this rule.

### Stage-1: error 50 — non-public selectors

```
The app references non-public selectors in Payload/<App>.app/<App>:
_addTouch:forDelayedDelivery:, _clearTouches, _hidEvent,
_initWithEvent:touches:, _setHIDEvent:, ...
```

Mob's test harness uses private UIKit selectors for synthetic touch
injection. mob 0.5.12+ wraps the harness in `#if !MOB_RELEASE` so it
compiles out of release builds. mob_dev 0.3.29+ defines `MOB_RELEASE`
when compiling `mob_nif.m` for release. Both upgrades together clear
this; either one alone won't.

### "I see no email but the build isn't in TestFlight after 20 minutes"

App Store Connect → your app → **Activity** tab. Pending or rejected
builds show up here with a status. "Invalid Binary" usually means a
stage-2 error and the email is on its way (or in spam). "Processing"
means Apple is still ingesting — wait another 10 minutes.

### "Build appears in TestFlight as `Missing Compliance`"

Click into the build → answer the encryption-export-compliance question
(most apps qualify for the standard exemption). Not blocking for
internal testers but blocks external testing.
