Files
native/docs/ios-release.md
T

6.8 KiB

iOS builds and TestFlight

The iOS app is built by .github/workflows/release-ios.yml on a GitHub-hosted macOS runner and uploaded straight to TestFlight. macOS runner minutes are free for this repository because it is public.

Unlike android/, which is committed and hand-maintained, ios/ is gitignored and regenerated by expo prebuild on every run. Never commit it, and never fix anything by editing it - native iOS changes belong in app.config.js or a config plugin, or they will be silently thrown away on the next build.

Without the secrets below the workflow still runs, but only as an unsigned compile check; no IPA is produced.

One-time Apple setup

You need an active Apple Developer Program membership ($99/yr). Everything here can be done from Windows - no Mac required.

1. Team ID

developer.apple.com → Account → Membership details. A 10-character string like A1B2C3D4E5.

2. Distribution certificate

Apple issues certificates against a certificate signing request. Keychain Access is the usual way to make one, but OpenSSL works and does not need a Mac:

openssl genrsa -out dist.key 2048
openssl req -new -key dist.key -out dist.csr \
  -subj "/emailAddress=you@bulwarkmail.org/CN=Bulwark Mail/C=DE"

Upload dist.csr at developer.apple.com → Certificates → + → Apple Distribution, download the resulting distribution.cer, then bundle the cert and its private key into a .p12:

openssl x509 -inform DER -in distribution.cer -out dist.pem
openssl pkcs12 -export -inkey dist.key -in dist.pem -out dist.p12 \
  -passout pass:CHOOSE_A_PASSWORD

On OpenSSL 3.x, add -legacy to that last command. OpenSSL 3 defaults to AES-256-CBC, which macOS security import refuses to read, and the workflow then fails at the import step. OpenSSL 1.x has no -legacy flag and already writes the format macOS wants - passing it there is an error. Check with openssl version first; Git Bash on Windows currently ships 1.1.1.

Keep dist.key somewhere safe - you cannot re-download it, and losing it means revoking the certificate and starting over.

3. App Store Connect API key

App Store Connect → Users and Access → Integrations → App Store Connect API → +, with the App Manager role. Download the .p8 (one download only) and note the Key ID and the Issuer ID shown above the key list.

The key authenticates the TestFlight upload.

4. App ID, then app record

These live on two different Apple sites and have to be done in this order.

developer.apple.com → Certificates, Identifiers & Profiles → Identifiers → + → App IDs → App. Explicit bundle ID org.bulwarkmail.mobile, matching ios.bundleIdentifier in app.config.js. Leave every capability off: push is not implemented on iOS, and sign-in uses the custom bulwarkmobile:// scheme rather than universal links, so no Associated Domains.

appstoreconnect.apple.com → Apps → + → New App. Platform iOS, any SKU. The bundle ID here is a dropdown populated from the identifiers above, which is why the App ID has to be registered first. The app name must be unique across the entire App Store.

The TestFlight upload is rejected if the app record does not exist - and that failure lands at the very end of a ~30 minute build.

5. App Store provisioning profile

developer.apple.com → Profiles → + → Distribution → App Store Connect. Pick the App ID from step 4 and the distribution certificate from step 2, name it Bulwark Mobile App Store, and download the .mobileprovision.

The build signs manually and this profile is not optional. Automatic signing would be less to maintain, but xcodebuild archive under automatic signing always signs with a development identity and leans on -exportArchive to re-sign for distribution afterwards. That needs a development certificate and at least one registered device - neither of which a CI-only TestFlight pipeline has any reason to own, and Apple will not issue a development profile without a device. Naming the App Store profile up front avoids the whole problem.

The profile expires when the certificate does (2027-08-01). Regenerate it here and update the secret when that happens.

Repository secrets

Settings → Secrets and variables → Actions:

Secret Value
APPLE_TEAM_ID The 10-character team ID from step 1
IOS_DIST_CERT_BASE64 dist.p12 from step 2, base64-encoded
IOS_DIST_CERT_PASSWORD The password you chose for dist.p12
APP_STORE_CONNECT_KEY_ID Key ID from step 3
APP_STORE_CONNECT_ISSUER_ID Issuer ID from step 3
APP_STORE_CONNECT_PRIVATE_KEY The .p8 from step 3, base64-encoded
IOS_PROVISIONING_PROFILE_BASE64 The .mobileprovision from step 5, base64-encoded

To base64-encode a file:

# PowerShell
[Convert]::ToBase64String([IO.File]::ReadAllBytes('dist.p12')) | Set-Clipboard
# bash
base64 -w0 dist.p12

Running a build

Publishing a GitHub release triggers the iOS workflow alongside the Android one. You can also run it by hand from the Actions tab, with an upload_to_testflight toggle if you only want the IPA as a workflow artifact.

The IPA is deliberately not attached to the GitHub release: unlike the APK, it is not installable outside TestFlight.

CFBundleVersion comes from the workflow run number via the IOS_BUILD_NUMBER env var. App Store Connect rejects a build number it has already seen for a given version, so never re-run a workflow expecting a re-upload to succeed - the run number advances on its own, so just start a new run.

After the upload

Processing takes roughly 5-30 minutes before the build shows up in TestFlight. Internal testers (up to 100, must be on your App Store Connect team) get it immediately. External testers (up to 10,000) need a Beta App Review, which takes about a day the first time and is usually waved through afterwards.

What does not work on iOS yet

  • Push notifications. The push path is a hand-written Android module talking to FCM (BulwarkFcmModule, BulwarkMessagingService). iOS needs an APNs key and an equivalent native module; until then push-notifications.ts short- circuits and the app polls like a device with push disabled.
  • Client certificates. BulwarkClientCertModule is Android-only. secureFetch falls back to plain fetch, so cert-authenticated servers will not connect.
  • In-app updates. Hidden on purpose - see supportsSideloadUpdates in src/lib/platform-capabilities.ts. Downloading and installing a binary is forbidden by App Store Review guideline 2.5.2, so iOS updates come through TestFlight instead. The Updates settings pane is removed from the list on iOS; the version and commit are still shown under About & Data.