Files

332 lines
14 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: 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 }}
NSE_PROFILE: ${{ secrets.IOS_NSE_PROVISIONING_PROFILE_BASE64 }}
run: |
if [ -n "$CERT" ] && [ -n "$ASC_KEY" ] && [ -n "$TEAM" ] && [ -n "$PROFILE" ]; then
if [ -z "$NSE_PROFILE" ]; then
# The notification service extension (plugins/ios-push) is its
# own App ID and needs its own App Store profile.
echo "::error::IOS_NSE_PROVISIONING_PROFILE_BASE64 is not set: the notification extension (org.bulwarkmail.mobile.NotificationService) can't be signed."
exit 1
fi
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: Install the provisioning profiles
if: steps.creds.outputs.signed == 'true'
id: profiles
env:
APP_PROFILE_BASE64: ${{ secrets.IOS_PROVISIONING_PROFILE_BASE64 }}
NSE_PROFILE_BASE64: ${{ secrets.IOS_NSE_PROVISIONING_PROFILE_BASE64 }}
run: |
# 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
# One App Store profile per target: the app and its notification
# service extension (plugins/ios-push).
for side in app nse; do
if [ "$side" = app ]; then DATA="$APP_PROFILE_BASE64"; else DATA="$NSE_PROFILE_BASE64"; fi
PROFILE="$RUNNER_TEMP/$side.mobileprovision"
echo "$DATA" | 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 -)
cp "$PROFILE" ~/Library/MobileDevice/Provisioning\ Profiles/"$UUID".mobileprovision
cp "$PROFILE" ~/Library/Developer/Xcode/UserData/Provisioning\ Profiles/"$UUID".mobileprovision
echo "${side}_uuid=$UUID" >> "$GITHUB_OUTPUT"
echo "${side}_name=$NAME" >> "$GITHUB_OUTPUT"
echo "${side}_bundle_id=$BUNDLE_ID" >> "$GITHUB_OUTPUT"
echo "$side: profile '$NAME' ($UUID) for $BUNDLE_ID, expires $EXPIRY"
done
- name: Generate the iOS project
env:
# plugins/ios-push names each target's profile: the command line
# can't, it would give every target the same one.
IOS_APP_PROFILE: ${{ steps.profiles.outputs.app_uuid }}
IOS_NSE_PROFILE: ${{ steps.profiles.outputs.nse_uuid }}
run: npx expo prebuild --platform ios --clean
- 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: 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"
# 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.profiles.outputs.app_name }}
BUNDLE_ID: ${{ steps.profiles.outputs.app_bundle_id }}
NSE_PROFILE_NAME: ${{ steps.profiles.outputs.nse_name }}
NSE_BUNDLE_ID: ${{ steps.profiles.outputs.nse_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>
<key>${NSE_BUNDLE_ID}</key><string>${NSE_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 }}"