Files
native/docs/ios-release.md
T

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

This one key does two jobs: it lets xcodebuild -allowProvisioningUpdates create and refresh the provisioning profile, which is why no .mobileprovision has to be stored anywhere, and it 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.

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

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.