Publishing a Dala app to TestFlight (iOS)

Copy Markdown View Source

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 .ipa and 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 .so and .a files 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 .ipa you 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:

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 Dala app. After this, every release is just mix dala.releasemix 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 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 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 --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 dala_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 Dala 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 dala.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, dala_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 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:

  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 dala.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 dala.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. Dala 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 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_RELEASE so Erlang distribution
    • EPMD are dropped from the binary
  • Links the iOS device binary
  • Signs the .app with your distribution identity (no get-task-allow)
  • Packages as Payload/<App>.app zipped 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 with RELEASE_LIBBEAM=yes in OTP) — single archive containing the BEAM VM + all NIFs baked in, no .so files at all
  • Cross-compile third-party NIFs (e.g. exqlite for SQLite-backed apps) as .a for aarch64-apple-ios, link them statically into the main binary
  • Bundle only .beam bytecode 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.

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.