Publishing a Mob app to TestFlight (iOS)

Copy Markdown View Source

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

    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:

PortalURLWhat lives here
Apple Developerhttps://developer.apple.com/account/resources/identifiers/listApp IDs, certificates, provisioning profiles
App Store Connecthttps://appstoreconnect.apple.com/appsApp records, TestFlight, App Store API keys
App Store Connect APIhttps://appstoreconnect.apple.com/access/integrations/apiAPI 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.releasemix 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:

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

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:

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

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:

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

1.8 Run mix mob.provision --distribution

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:

  1. 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.
  2. Note the Key ID (10 chars, also visible in the table after you download) — copy it somewhere safe
  3. Move the file somewhere persistent and lock it down:
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:

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:

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.

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

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:

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:

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:

# 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 numbermix 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 (515 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 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. 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 CFBundleVersionmix mob.releasemix 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:

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

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.