Files
native/.github/workflows/release-ios.yml
Linus Rath 76180b332d ci: put every iOS release on the public TestFlight link
Uploads only reached internal testers. The public link's "external" group
still served 0.1.66, because nothing added later builds to it or
submitted them for Beta App Review.

A new `public-testflight` job runs after the upload on a Linux runner.
It waits for App Store Connect to process the build, adds it to the
external group, sets "What to Test" to the GitHub release and submits
it for review (`scripts/testflight-public.mjs`, safe to re-run). The
README links the TestFlight beta and the latest APK.
2026-10-02 16:33:07 +02:00

310 lines
13 KiB
YAML

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
outputs:
version: ${{ steps.export.outputs.version }}
uploaded: ${{ steps.upload.outputs.done }}
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 }}
PROFILE: ${{ secrets.IOS_PROVISIONING_PROFILE_BASE64 }}
run: |
if [ -n "$CERT" ] && [ -n "$ASC_KEY" ] && [ -n "$TEAM" ] && [ -n "$PROFILE" ]; 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: |
# altool looks 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: Install the provisioning profile
if: steps.creds.outputs.signed == 'true'
id: profile
env:
PROFILE_BASE64: ${{ secrets.IOS_PROVISIONING_PROFILE_BASE64 }}
run: |
PROFILE="$RUNNER_TEMP/profile.mobileprovision"
echo "$PROFILE_BASE64" | base64 -d > "$PROFILE"
# A .mobileprovision is a CMS-signed plist, so it has to be decoded
# before anything inside it can be read.
PLIST=$(security cms -D -i "$PROFILE")
UUID=$(echo "$PLIST" | plutil -extract UUID raw -)
NAME=$(echo "$PLIST" | plutil -extract Name raw -)
# application-identifier is "TEAMID.bundle.id" - deriving the bundle ID
# from the profile keeps ExportOptions honest if app.config.js changes.
APP_ID=$(echo "$PLIST" | plutil -extract Entitlements.application-identifier raw -)
BUNDLE_ID="${APP_ID#*.}"
EXPIRY=$(echo "$PLIST" | plutil -extract ExpirationDate raw -)
# Xcode moved the profile directory in 16; write both so this does not
# silently stop working on a runner image bump.
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
mkdir -p ~/Library/Developer/Xcode/UserData/Provisioning\ Profiles
cp "$PROFILE" ~/Library/MobileDevice/Provisioning\ Profiles/"$UUID".mobileprovision
cp "$PROFILE" ~/Library/Developer/Xcode/UserData/Provisioning\ Profiles/"$UUID".mobileprovision
echo "uuid=$UUID" >> "$GITHUB_OUTPUT"
echo "name=$NAME" >> "$GITHUB_OUTPUT"
echo "bundle_id=$BUNDLE_ID" >> "$GITHUB_OUTPUT"
echo "Profile '$NAME' ($UUID) for $BUNDLE_ID, expires $EXPIRY"
- name: Archive
if: steps.creds.outputs.signed == 'true'
env:
TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
# Manual signing, deliberately. Automatic signing always signs the
# archive with a *development* identity and relies on -exportArchive to
# re-sign it for distribution afterwards - which needs a development
# certificate and a registered device that a CI-only TestFlight
# pipeline has no reason to own. Naming the App Store profile up front
# skips that dance entirely.
xcodebuild archive \
-workspace "${{ steps.xcode.outputs.workspace }}" \
-scheme "${{ steps.xcode.outputs.scheme }}" \
-configuration Release \
-destination 'generic/platform=iOS' \
-archivePath "$RUNNER_TEMP/Bulwark.xcarchive" \
CODE_SIGN_STYLE=Manual \
DEVELOPMENT_TEAM="$TEAM_ID" \
CODE_SIGN_IDENTITY="Apple Distribution" \
PROVISIONING_PROFILE_SPECIFIER="${{ steps.profile.outputs.uuid }}"
# Signing failures report a symptom ("no profiles found") rather than the
# setting that caused it, and a re-run costs ~30 minutes. Dump the
# resolved values instead of guessing twice.
- name: Dump signing settings
if: failure() && steps.creds.outputs.signed == 'true'
continue-on-error: true
run: |
echo "── identities in the keychain ──"
security find-identity -v -p codesigning || true
echo "── resolved build settings ──"
xcodebuild -workspace "${{ steps.xcode.outputs.workspace }}" \
-scheme "${{ steps.xcode.outputs.scheme }}" \
-configuration Release -showBuildSettings 2>/dev/null \
| grep -Ei 'CODE_SIGN|PROVISIONING|DEVELOPMENT_TEAM|PRODUCT_BUNDLE_ID' | sort -u || true
- name: Export the IPA
if: steps.creds.outputs.signed == 'true'
id: export
env:
TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
PROFILE_NAME: ${{ steps.profile.outputs.name }}
BUNDLE_ID: ${{ steps.profile.outputs.bundle_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>manual</string>
<key>signingCertificate</key><string>Apple Distribution</string>
<key>provisioningProfiles</key>
<dict>
<key>${BUNDLE_ID}</key><string>${PROFILE_NAME}</string>
</dict>
<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"
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
id: upload
# 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 "done=true" >> "$GITHUB_OUTPUT"
echo "::notice::Uploaded build ${IOS_BUILD_NUMBER} to App Store Connect. Processing usually takes 5-30 minutes before it appears in TestFlight."
# Puts the upload on the public TestFlight link (README): waits for App Store
# Connect to process it, adds it to the external group and submits it for
# beta review. On Linux, because most of this job is waiting.
public-testflight:
needs: build
if: needs.build.outputs.uploaded == 'true'
runs-on: ubuntu-latest
timeout-minutes: 75
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Install the App Store Connect API key
env:
ASC_KEY_BASE64: ${{ secrets.APP_STORE_CONNECT_PRIVATE_KEY }}
ASC_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }}
run: |
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: Add the build to the public group and submit it for beta review
env:
ASC_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }}
ASC_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
run: node scripts/testflight-public.mjs "${{ needs.build.outputs.version }}" "${{ github.run_number }}"