From f040a48e2fc8c32c4ef0139a9fe1c8141b046689 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 1 Aug 2026 13:29:15 +0200 Subject: [PATCH] feat: iOS TestFlight release pipeline --- .github/workflows/release-ios.yml | 227 ++++++++++++++++++++++++++++++ README.md | 5 +- VERSION | 2 +- app.config.js | 27 ++++ docs/ios-release.md | 143 +++++++++++++++++++ package-lock.json | 4 +- package.json | 2 +- src/lib/platform-capabilities.ts | 16 +++ src/screens/SettingsScreen.tsx | 10 +- src/stores/updates-store.ts | 7 + 10 files changed, 436 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/release-ios.yml create mode 100644 docs/ios-release.md create mode 100644 src/lib/platform-capabilities.ts diff --git a/.github/workflows/release-ios.yml b/.github/workflows/release-ios.yml new file mode 100644 index 00000000..f0ad12f7 --- /dev/null +++ b/.github/workflows/release-ios.yml @@ -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" < + + + + methodapp-store + teamID${TEAM_ID} + signingStyleautomatic + uploadSymbols + destinationexport + + + 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." diff --git a/README.md b/README.md index 55a3edf9..5a260531 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/VERSION b/VERSION index 7b300677..8893a8e1 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.54 +0.1.55 diff --git a/app.config.js b/app.config.js index 63f70d13..e228b2b0 100644 --- a/app.config.js +++ b/app.config.js @@ -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, }, ], ], diff --git a/docs/ios-release.md b/docs/ios-release.md new file mode 100644 index 00000000..213b65f3 --- /dev/null +++ b/docs/ios-release.md @@ -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. diff --git a/package-lock.json b/package-lock.json index 11eb7ad8..7270a9ee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 959a1687..6f69f027 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bulwark-mobile", - "version": "0.1.54", + "version": "0.1.55", "license": "AGPL-3.0-only", "main": "index.ts", "scripts": { diff --git a/src/lib/platform-capabilities.ts b/src/lib/platform-capabilities.ts new file mode 100644 index 00000000..785786c6 --- /dev/null +++ b/src/lib/platform-capabilities.ts @@ -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'; diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index b5941e68..c37be7e4 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -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>> = { 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); } diff --git a/src/stores/updates-store.ts b/src/stores/updates-store.ts index 8df649ca..22ddf67e 100644 --- a/src/stores/updates-store.ts +++ b/src/stores/updates-store.ts @@ -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((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((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((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';