feat: iOS TestFlight release pipeline

This commit is contained in:
Linus Rath
2026-08-01 13:29:15 +02:00
parent c29cbcd549
commit f040a48e2f
10 changed files with 436 additions and 7 deletions
+227
View File
@@ -0,0 +1,227 @@
name: Release iOS (TestFlight)
# The iOS project is NOT committed - unlike android/, which is hand-maintained,
# ios/ is gitignored and generated fresh by `expo prebuild` on every run. Keep
# any native iOS change in app.config.js (or a config plugin), never in ios/.
on:
release:
types: [published]
workflow_dispatch:
inputs:
upload_to_testflight:
description: "Upload the build to TestFlight (off = build the IPA only)"
type: boolean
required: false
default: true
concurrency:
group: release-ios-${{ github.ref }}
cancel-in-progress: false
jobs:
build:
runs-on: macos-latest
timeout-minutes: 90
permissions:
contents: write
env:
EXPO_NO_TELEMETRY: "1"
# App Store Connect refuses a CFBundleVersion it has already seen for this
# marketing version, so every run needs a fresh, increasing number.
IOS_BUILD_NUMBER: ${{ github.run_number }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- name: Show toolchain versions
run: |
xcodebuild -version
pod --version
- name: Cache CocoaPods
uses: actions/cache@v4
with:
path: |
~/Library/Caches/CocoaPods
~/.cocoapods/repos
key: pods-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
restore-keys: pods-${{ runner.os }}-
- name: Install JS dependencies
run: npm ci
- name: Generate the iOS project
run: npx expo prebuild --platform ios --clean
- name: Check for signing credentials
id: creds
env:
CERT: ${{ secrets.IOS_DIST_CERT_BASE64 }}
ASC_KEY: ${{ secrets.APP_STORE_CONNECT_PRIVATE_KEY }}
TEAM: ${{ secrets.APPLE_TEAM_ID }}
run: |
if [ -n "$CERT" ] && [ -n "$ASC_KEY" ] && [ -n "$TEAM" ]; then
echo "signed=true" >> "$GITHUB_OUTPUT"
else
echo "signed=false" >> "$GITHUB_OUTPUT"
echo "::notice::Signing secrets are not configured - compiling without signing. No IPA will be produced."
fi
- name: Resolve workspace and scheme
id: xcode
run: |
WORKSPACE=$(ls -d ios/*.xcworkspace | head -1)
SCHEME=$(basename "$WORKSPACE" .xcworkspace)
echo "workspace=$WORKSPACE" >> "$GITHUB_OUTPUT"
echo "scheme=$SCHEME" >> "$GITHUB_OUTPUT"
echo "Using workspace $WORKSPACE, scheme $SCHEME"
# ── Unsigned path: a compile check only, so the workflow is still useful
# on forks and before the Apple secrets exist.
- name: Build without signing
if: steps.creds.outputs.signed != 'true'
run: |
xcodebuild build \
-workspace "${{ steps.xcode.outputs.workspace }}" \
-scheme "${{ steps.xcode.outputs.scheme }}" \
-configuration Release \
-destination 'generic/platform=iOS' \
CODE_SIGNING_ALLOWED=NO \
CODE_SIGNING_REQUIRED=NO \
CODE_SIGN_IDENTITY=""
# ── Signed path ────────────────────────────────────────────
- name: Import the distribution certificate
if: steps.creds.outputs.signed == 'true'
env:
CERT_BASE64: ${{ secrets.IOS_DIST_CERT_BASE64 }}
CERT_PASSWORD: ${{ secrets.IOS_DIST_CERT_PASSWORD }}
run: |
KEYCHAIN="$RUNNER_TEMP/build.keychain-db"
# Ephemeral runner, ephemeral keychain - this password protects nothing
# beyond the life of the job.
KEYCHAIN_PASSWORD=$(uuidgen)
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
security set-keychain-settings -lut 21600 "$KEYCHAIN"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
echo "$CERT_BASE64" | base64 -d > "$RUNNER_TEMP/dist.p12"
security import "$RUNNER_TEMP/dist.p12" -k "$KEYCHAIN" -P "$CERT_PASSWORD" \
-T /usr/bin/codesign -T /usr/bin/security -f pkcs12
rm -f "$RUNNER_TEMP/dist.p12"
# Without this, codesign triggers an interactive keychain prompt that
# nothing can answer on a headless runner.
security set-key-partition-list -S apple-tool:,apple:,codesign: \
-s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN" > /dev/null
# Put our keychain first in the search list, keeping the login one so
# Xcode can still reach Apple's intermediate certificates.
security list-keychains -d user -s "$KEYCHAIN" $(security list-keychains -d user | sed 's/"//g')
security find-identity -v -p codesigning "$KEYCHAIN"
- name: Install the App Store Connect API key
if: steps.creds.outputs.signed == 'true'
env:
ASC_KEY_BASE64: ${{ secrets.APP_STORE_CONNECT_PRIVATE_KEY }}
ASC_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }}
run: |
# Both xcodebuild -allowProvisioningUpdates and altool look the key up
# by ID in this directory.
mkdir -p ~/private_keys
echo "$ASC_KEY_BASE64" | base64 -d > ~/private_keys/AuthKey_${ASC_KEY_ID}.p8
chmod 600 ~/private_keys/AuthKey_${ASC_KEY_ID}.p8
- name: Archive
if: steps.creds.outputs.signed == 'true'
env:
ASC_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }}
ASC_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
# -allowProvisioningUpdates lets Xcode create/refresh the App Store
# provisioning profile through the API key, so no .mobileprovision has
# to be stored as a secret.
xcodebuild archive \
-workspace "${{ steps.xcode.outputs.workspace }}" \
-scheme "${{ steps.xcode.outputs.scheme }}" \
-configuration Release \
-destination 'generic/platform=iOS' \
-archivePath "$RUNNER_TEMP/Bulwark.xcarchive" \
-allowProvisioningUpdates \
-authenticationKeyPath ~/private_keys/AuthKey_${ASC_KEY_ID}.p8 \
-authenticationKeyID "$ASC_KEY_ID" \
-authenticationKeyIssuerID "$ASC_ISSUER_ID" \
CODE_SIGN_STYLE=Automatic \
DEVELOPMENT_TEAM="$TEAM_ID"
- name: Export the IPA
if: steps.creds.outputs.signed == 'true'
id: export
env:
ASC_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }}
ASC_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
cat > "$RUNNER_TEMP/ExportOptions.plist" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key><string>app-store</string>
<key>teamID</key><string>${TEAM_ID}</string>
<key>signingStyle</key><string>automatic</string>
<key>uploadSymbols</key><true/>
<key>destination</key><string>export</string>
</dict>
</plist>
PLIST
xcodebuild -exportArchive \
-archivePath "$RUNNER_TEMP/Bulwark.xcarchive" \
-exportPath "$RUNNER_TEMP/export" \
-exportOptionsPlist "$RUNNER_TEMP/ExportOptions.plist" \
-allowProvisioningUpdates \
-authenticationKeyPath ~/private_keys/AuthKey_${ASC_KEY_ID}.p8 \
-authenticationKeyID "$ASC_KEY_ID" \
-authenticationKeyIssuerID "$ASC_ISSUER_ID"
VERSION=$(cat VERSION | tr -d '[:space:]')
SRC=$(ls "$RUNNER_TEMP"/export/*.ipa | head -1)
DEST="bulwark-mobile-${VERSION}-${GITHUB_SHA::7}.ipa"
cp "$SRC" "$DEST"
echo "path=$DEST" >> "$GITHUB_OUTPUT"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- uses: actions/upload-artifact@v4
if: steps.creds.outputs.signed == 'true'
with:
name: ${{ steps.export.outputs.path }}
path: ${{ steps.export.outputs.path }}
if-no-files-found: error
- name: Upload to TestFlight
# The IPA is not attached to the GitHub release: without an Apple device
# provisioned for it, it is not installable, and TestFlight is the only
# distribution channel for it.
if: >-
steps.creds.outputs.signed == 'true' &&
(github.event_name == 'release' || inputs.upload_to_testflight)
env:
ASC_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }}
ASC_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
run: |
IPA="${{ steps.export.outputs.path }}"
# Validate first - it catches missing Info.plist keys and entitlement
# mistakes in seconds, instead of via a rejection email 20 minutes later.
xcrun altool --validate-app --type ios --file "$IPA" \
--apiKey "$ASC_KEY_ID" --apiIssuer "$ASC_ISSUER_ID"
xcrun altool --upload-app --type ios --file "$IPA" \
--apiKey "$ASC_KEY_ID" --apiIssuer "$ASC_ISSUER_ID"
echo "::notice::Uploaded build ${IOS_BUILD_NUMBER} to App Store Connect. Processing usually takes 5-30 minutes before it appears in TestFlight."
+3 -2
View File
@@ -26,10 +26,10 @@ React Native (Expo SDK 54) client for [Bulwark Webmail](https://github.com/bulwa
## What's missing or rough
- iOS build is untested
- iOS builds, but push notifications and client certificates are Android-only so far
- Filters & rules, S/MIME, plugins, themes, file storage - UI stubs only
- Calendar editing is partial; contacts editing is basic
- No Play Store distribution yet (sideload APK from Releases)
- No Play Store distribution yet (sideload APK from Releases); iOS ships via TestFlight
## Run locally
@@ -41,6 +41,7 @@ npx expo start
Then press `a` for Android, `i` for iOS, or scan the QR with Expo Go.
For a release APK build see [.github/workflows/release-android.yml](.github/workflows/release-android.yml).
For iOS builds and TestFlight distribution see [docs/ios-release.md](docs/ios-release.md).
## License
+1 -1
View File
@@ -1 +1 @@
0.1.54
0.1.55
+27
View File
@@ -16,6 +16,12 @@ if (!COMMIT) {
}
COMMIT = COMMIT.slice(0, 7);
// App Store Connect rejects a build whose CFBundleVersion it has already seen
// for this CFBundleShortVersionString, so this has to advance on every upload
// even when VERSION does not. CI passes the workflow run number; local builds
// fall back to 1 (never uploaded).
const IOS_BUILD_NUMBER = process.env.IOS_BUILD_NUMBER || '1';
module.exports = {
expo: {
name: 'Bulwark Mobile',
@@ -33,6 +39,14 @@ module.exports = {
},
ios: {
supportsTablet: true,
bundleIdentifier: 'org.bulwarkmail.mobile',
buildNumber: IOS_BUILD_NUMBER,
config: {
// The app only speaks HTTPS/TLS and uses platform crypto, which is
// exempt. Declaring it here skips the manual export-compliance
// questionnaire that otherwise blocks every TestFlight build.
usesNonExemptEncryption: false,
},
},
android: {
adaptiveIcon: {
@@ -55,6 +69,19 @@ module.exports = {
{
cameraPermission:
'Bulwark Mail uses the camera to scan sign-in QR codes shown in webmail.',
// QR scanning never records audio; leaving the mic entry in would be
// an unexplained permission in App Review.
microphonePermission: false,
},
],
[
'expo-image-picker',
{
photosPermission:
'Bulwark Mail needs access to your photos so you can attach them to emails and set contact photos.',
// Only launchImageLibraryAsync is used - no in-app capture.
cameraPermission: false,
microphonePermission: false,
},
],
],
+143
View File
@@ -0,0 +1,143 @@
# iOS builds and TestFlight
The iOS app is built by [.github/workflows/release-ios.yml](../.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:
```bash
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`:
```bash
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](../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
# PowerShell
[Convert]::ToBase64String([IO.File]::ReadAllBytes('dist.p12')) | Set-Clipboard
```
```bash
# 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](../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.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "bulwark-mobile",
"version": "0.1.54",
"version": "0.1.55",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bulwark-mobile",
"version": "0.1.54",
"version": "0.1.55",
"license": "AGPL-3.0-only",
"dependencies": {
"@react-native-async-storage/async-storage": "2.2.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bulwark-mobile",
"version": "0.1.54",
"version": "0.1.55",
"license": "AGPL-3.0-only",
"main": "index.ts",
"scripts": {
+16
View File
@@ -0,0 +1,16 @@
// Single source of truth for "this feature only exists on some platforms", so
// the UI, the stores and the native bridges all agree on what is reachable.
import { Platform } from 'react-native';
/**
* In-app sideload updates: download the release APK from GitHub and hand it to
* the system package installer (see `lib/install-update`).
*
* Android only. App Store Review guideline 2.5.2 forbids an iOS app from
* downloading and installing executable code, and there is no iOS equivalent of
* the installer intent anyway - iOS builds are updated through TestFlight or
* the App Store. The whole updater surface (banner, settings pane, launch-time
* check) is hidden when this is false.
*/
export const supportsSideloadUpdates = Platform.OS === 'android';
+9 -1
View File
@@ -38,6 +38,7 @@ import { LayoutSettings } from '../components/settings/LayoutSettings';
import { DownloadsSettings } from '../components/settings/DownloadsSettings';
import { useLocaleStore } from '../stores/locale-store';
import { useHasCalendar, useHasContacts, useHasFiles } from '../lib/capabilities';
import { supportsSideloadUpdates } from '../lib/platform-capabilities';
type Tab =
| 'account' | 'language' | 'notifications'
@@ -138,11 +139,18 @@ const TAB_COMPONENTS: Partial<Record<Tab, React.ComponentType<any>>> = {
updates: UpdatesSettings,
};
// Tabs whose feature does not exist on this platform never appear in the list.
// The "Updates" pane drives the sideload installer, which is Android-only - the
// current version and build are still shown under "About & Data".
const AVAILABLE_TABS: TabDef[] = TABS.filter(
(t) => t.id !== 'updates' || supportsSideloadUpdates,
);
function groupTabs() {
return GROUP_ORDER.map(group => ({
group,
label: GROUP_LABELS[group],
items: TABS.filter(t => t.group === group),
items: AVAILABLE_TABS.filter(t => t.group === group),
})).filter(g => g.items.length > 0);
}
+7
View File
@@ -4,6 +4,7 @@ import Constants from 'expo-constants';
import { fetchLatestRelease, type LatestRelease } from '../api/updates';
import { isNewer } from '../lib/version-compare';
import { downloadAndInstallApk, type InstallProgress } from '../lib/install-update';
import { supportsSideloadUpdates } from '../lib/platform-capabilities';
const STORAGE_KEY = 'webmail:updates:v1';
const MIN_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000;
@@ -80,6 +81,10 @@ export const useUpdatesStore = create<UpdatesState>((set, get) => ({
},
checkNow: async (opts) => {
// On platforms without sideloading there is nothing actionable to report,
// so skip the GitHub round-trip entirely rather than caching a release the
// user can never install.
if (!supportsSideloadUpdates) return;
const s = get();
if (s.checking) return;
const now = Date.now();
@@ -104,6 +109,7 @@ export const useUpdatesStore = create<UpdatesState>((set, get) => ({
},
installLatest: async () => {
if (!supportsSideloadUpdates) return;
const s = get();
if (s.installing || !s.cachedLatest?.apkAsset) return;
set({ installing: true, error: null, installProgress: { phase: 'downloading', progress: 0 } });
@@ -142,6 +148,7 @@ export const useUpdatesStore = create<UpdatesState>((set, get) => ({
currentVersion: () => Constants.expoConfig?.version ?? '0.0.0',
hasUpdate: () => {
if (!supportsSideloadUpdates) return false;
const s = get();
if (!s.cachedLatest) return false;
const current = Constants.expoConfig?.version ?? '0.0.0';