This is the full, step-by-step recipe for taking a Dala app from "runs on
my iPhone via mix dala.deploy --native" to "uploaded to App Store
Connect, ready for TestFlight beta testing".
It assumes you already have a working development setup — mix dala.deploy
runs your app on a connected iPhone. If you don't, work through
Getting Started first.
Important — current status (dala 0.3.27 / dala_dev 0.3.27)
The provisioning, release-build, and upload pipeline (
mix dala.provision --distribution,mix dala.release,mix dala.publish) all work. They produce a signed.ipaand hand it to App Store Connect.However, App Store Connect's automated validator currently rejects the resulting build because Dala bundles the OTP runtime tree (which includes
.soand.afiles Apple doesn't allow in App Store bundles) and uses the test-harness NIFs (which reference private UIKit selectors).See Known limitation: App Store validator rejects the bundle at the bottom of this guide for the full error breakdown and the framework work needed to clear it. Until that work lands, the path below gets you to the upload step but the build won't appear in TestFlight.
The provisioning + release flow IS complete and useful — it's the exact same path you'll use once the App Store validation work lands, and it produces a working
.ipayou can side-load via Xcode for ad-hoc testing today.
Prerequisites
- macOS with Xcode (any recent version; Xcode 16+ is what most of this has been tested against)
- 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 dala.deploy --native(proves your dev signing works end-to-end)
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 Dala app. After this, every release is just
mix dala.release → mix dala.publish.
1.1 Pick a real bundle ID
The bundle ID generated by mix dala.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 Strip unused permissions from Info.plist
mix dala.new scaffolds permission strings for camera, microphone, and
several other capabilities that are part of the framework's
device-capability surface. If your app doesn't actually use them,
remove them. Apple's review team will ask why an offline calculator
needs camera access; clean Info.plist sails through review unchallenged.
Common things to remove if your app doesn't use them:
<!-- Remove if your app doesn't use the camera -->
<key>NSCameraUsageDescription</key>
<string>...uses the camera for...</string>
<!-- Remove if your app doesn't record audio -->
<key>NSMicrophoneUsageDescription</key>
<string>...uses the microphone for...</string>
<!-- Remove if your app doesn't play audio in the background -->
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
<!-- Other usage descriptions — keep only what you actually use -->
<key>NSLocationWhenInUseUsageDescription</key>
<key>NSPhotoLibraryUsageDescription</key>
<key>NSContactsUsageDescription</key>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 dala.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 dala_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 Dala 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 dala.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, dala_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 dala.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 dala.provision --distribution
cd path/to/your/app
mix dala.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 dala.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 dala.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.
Dala 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 dala.exs
Add the API key block to your dala.exs:
import Config
config :dala_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"
]dala.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 three commands:
mix dala.provision --distribution # only when profile expires (annual)
mix dala.release # builds the .ipa
mix dala.publish # uploads to App Store Connect
2.1 mix dala.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.
2.2 mix dala.release
Builds a release-signed .ipa at _build/dala_release/<App>.ipa:
- Compiles BEAMs and copies them into the bundled OTP runtime
- Builds native sources with
-DDALA_RELEASEso Erlang distribution- EPMD are dropped from the binary
- Links the iOS device binary
- Signs the
.appwith your distribution identity (noget-task-allow) - Packages as
Payload/<App>.appzipped into<App>.ipa
Bump CFBundleVersion in ios/Info.plist between uploads — Apple
rejects re-uploads with the same build number. Keep
CFBundleShortVersionString (the public version) the same across
multiple builds of one release.
2.3 mix dala.publish
Uploads _build/dala_release/<App>.ipa to App Store Connect via
xcrun altool --upload-app with API-key auth.
The upload is silent for several minutes — altool doesn't print
progress unless you pass --verbose. 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 dala.publish --verbose
After upload Apple processes the build for 5–15 minutes before it appears in App Store Connect → your app → TestFlight tab.
2.4 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.
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. dala_dev 0.3.27+ uses -scheme for archive actions. If you
hit this on an older dala_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. dala_dev 0.3.27+ handles this.
DalaProvision has conflicting provisioning settings. DalaProvision is automatically signed for development, but a conflicting code signing identity Apple Distribution has been manually specified.
You're on an older dala_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. dala_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 dala_dev hardcoded the Apple-default profile name; if you named your profile something else, the lookup fails
dala_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
dala_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 dala.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 dala.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 dala.exs
You haven't done step 1.10/1.11. Get an API key and add the
app_store_connect: config block to dala.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.
Known limitation: App Store validator rejects the bundle
After mix dala.publish succeeds at uploading, Apple's automated
validator runs and (as of dala 0.3.27) rejects the build with ~17
errors across four categories:
Category 1 — Standalone binaries in the bundle (12 errors, code 90171)
The "<App>.app/otp/lib/<otp_lib>/priv/lib/<thing>.so" binary file is not permitted.
The "<App>.app/otp/lib/<otp_lib>/lib/<thing>.a" binary file is not permitted.Apple's policy: an iOS App Store bundle can only contain ONE Mach-O
executable (the main CFBundleExecutable). No .so, no .a, no
standalone CLI tools.
The current Dala release pipeline copies the entire OTP runtime tree
into the .app/otp/ subdirectory, which includes:
asn1rt_nif.so,dyntrace.so,trace_ip_drv.so,trace_file_drv.so,megaco_flex_scanner_drv*.so(loadable NIFs/drivers)libei.a,libei_st.a,sqlite3_nif.a(static libs)erl_call,memsup(standalone executables)
There's no entitlement that allows these. The fix is structural:
- Switch release builds to use a statically-linked
libbeam.a(built withRELEASE_LIBBEAM=yesin OTP) — single archive containing the BEAM VM + all NIFs baked in, no.sofiles at all - Cross-compile third-party NIFs (e.g.
exqlitefor SQLite-backed apps) as.aforaarch64-apple-ios, link them statically into the main binary - Bundle only
.beambytecode files (Apple is fine with bytecode in app resources)
The ~/code/beam-ios-test work in the same author's repos has proven
this approach works (boot ~64ms on M4 Pro, ~120-180ms estimated on real
A18 device, 3.5MB binary). Wiring it into dala's release pipeline is
the framework work needed to clear this category.
Category 2 — Test-harness uses private UIKit selectors (1 error, code 50)
The app references non-public selectors:
_addTouch:forDelayedDelivery:, _clearTouches, _hidEvent,
_initWithEvent:touches:, _setHIDEvent:, _setLocationInWindow:resetPrevious:, ...Dala's synthetic-touch injection NIFs (tap_xy, swipe_xy, type_text)
in dala/ios/dala_nif.m use private UIKit APIs to drive UI from
Erlang/Elixir for the agent test harness. App Store auto-scans
binaries for these selector strings and rejects.
The fix: wrap the test-harness NIF implementations in #if !DALA_RELEASE
so they compile out of release builds. The NIF stubs in dala_nif.erl
can stay (they'll just nif_error at runtime in release builds, which
is fine — the test harness isn't supposed to work in release mode
anyway).
Category 3 — Info.plist gaps (3 errors, codes 90065/90507/90530)
Invalid MinimumOSVersion. ... is ''.
Missing Info.plist value. A value for the key 'DTPlatformName' is required.
Missing Deployment Target.The Dala-template Info.plist doesn't set MinimumOSVersion or
DTPlatformName. Easy fix — add them. Both can be templated at
project-generation time since they don't change per-release.
Category 4 — CodeResources symlink (1 error, code 90071)
The CodeResources file must be a symbolic link to _CodeSignature/CodeResources.The IPA packaging step in dala.release uses standard zip which
doesn't preserve the symlink that codesign creates. Switching to
ditto -c -k --keepParent --sequesterRsrc preserves the symlink.
Tracking the App Store work
The above four issues are tracked separately and will be resolved in
upcoming dala releases. For now mix dala.release produces a valid
device-installable .ipa that you can side-load via Xcode (Window →
Devices and Simulators → drag the .ipa onto a connected device) for
ad-hoc testing.
When the framework work lands, this guide will be updated and the "known limitation" section above will move into a changelog note.