# Publishing a Dala app to TestFlight (iOS)

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](https://hexdocs.pm/dala/getting_started.html) 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](#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:

| 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 owns `beyondagronomy.com`)
- `ca.larocque.aircartmax` (Canadian convention, your name)
- `com.genericjam.somecoolapp` (your dev handle)

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

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

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

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

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

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

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

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

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

```xml
<!-- 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:

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

### 1.6 Create an App Store provisioning profile

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

> **Why we don't automate this either**: the same `xcodebuild
> -allowProvisioningUpdates` limitation. Once a profile exists, 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:

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

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

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

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

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

### 1.11 Configure `dala.exs`

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

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

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

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

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

If you want to see real-time progress:

```bash
mix 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.

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