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.publishreports 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.newfrom a mob_new before 0.1.30, yourios/build.shmay sayversion-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.shiOS 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 ownsbeyondagronomy.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 --distributionusesxcodebuild -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.
- Go to https://developer.apple.com/account/resources/identifiers/list
- Click
+→ App IDs → Continue → App → Continue - 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.
- Description: a human-readable name (e.g.
- 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:
- Settings (
⌘,) → Accounts - Click your team in the left list
- Click Manage Certificates...
- Click
+in the bottom-left → Apple Distribution - 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 -allowProvisioningUpdateslimitation. Once a profile exists, mob_dev can fetch it; creating one for a brand-new App ID under manual signing is the gap.
- Go to https://developer.apple.com/account/resources/profiles/list
- Click
+ - Distribution → App Store → Continue
- App ID: select your bundle ID from step 1.4 → Continue
- Certificates: select the Apple Distribution cert from step 1.5 → Continue
- Profile name: anything. Common conventions:
<AppName> App Store(e.g.AirCartMax App Store)- The Apple-default
iOS Team Store Provisioning Profile: <bundle_id>
- 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:
- Reads bundle ID from
ios/Info.plist - Auto-detects your team ID from your existing dev profile
- Verifies the Apple Distribution cert is in keychain
- 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
- Generates
ios/Provision.xcodeproj(a minimal Xcode project) wired up with manual signing, the discovered profile UUID, and the Apple Distribution identity - Runs
xcodebuild archive— proves the cert + profile + bundle ID form a valid signing chain end-to-end - Confirms the profile is in place
Output should end with:
✓ App Store provisioning profile ready
✓ Provisioning complete!
Next step: mix mob.release1.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.
- Go to https://appstoreconnect.apple.com/apps
- Click
+→ New App - 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
- 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.
- Go to https://appstoreconnect.apple.com/access/integrations/api
- Switch to the Team Keys tab (not Individual Keys)
- Note the Issuer ID at the top of the page — copy it somewhere safe
- Click
+ - Fill in:
- Name: anything (e.g.
Mob CLI Upload) - Access: App Manager (the minimum role that can upload builds)
- Name: anything (e.g.
- Generate
Now Apple shows you a one-time download:
- 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. - Note the Key ID (10 chars, also visible in the table after you download) — copy it somewhere safe
- 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:
- Bump
CFBundleVersion— reads the current value fromios/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'sCFBundleShortVersionString's job, notCFBundleVersion's). mix mob.release— builds the.ipa. See section 2.3 for what happens here.mix mob.publish --ios— uploads viaxcrun 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 tomix mob.publishto 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-.apppolicy) — the static archives are linked into the main binary instead - Builds native sources with
-DMOB_RELEASEto 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) plusMinimumOSVersion,UIDeviceFamily, andCFBundleSupportedPlatforms - Signs the
.appwith your distribution identity (noget-task-allow) - Packages with
ditto -c -k --keepParent --norsrc --noextattr --noqtnto preserve the_CodeSignature/CodeResourcessymlink 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 attributeHarmless — 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 ConnectThis 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.releasefailed — fix the build error, then runmix mob.release && mix mob.publish --ios. Don't bump again — the bump already happened.mix mob.publish --iosfailed 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-bumpskips 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 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 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:
- Your
DT*keys are missing or wrong. mob_dev 0.3.30+ synthesizes the full set; older versions don't — upgrade. - 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.
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.