fix: sign iOS archives manually with an App Store provisioning profile

This commit is contained in:
Linus Rath
2026-08-01 13:47:49 +02:00
parent 4eeda34d95
commit dfa278f7a8
2 changed files with 71 additions and 32 deletions
+52 -29
View File
@@ -65,8 +65,9 @@ jobs:
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" ]; then
if [ -n "$CERT" ] && [ -n "$ASC_KEY" ] && [ -n "$TEAM" ] && [ -n "$PROFILE" ]; then
echo "signed=true" >> "$GITHUB_OUTPUT"
else
echo "signed=false" >> "$GITHUB_OUTPUT"
@@ -132,43 +133,64 @@ jobs:
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.
# 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:
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.
#
# CODE_SIGN_IDENTITY has to be forced: the Expo iOS template hardcodes
# "Apple Development", and automatic signing honours it even when
# archiving Release. Xcode then asks Apple for a *development* profile,
# which needs a registered device, and fails with "Your team has no
# devices from which to generate a provisioning profile" - misleading,
# because a TestFlight build never needs one.
# 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" \
-allowProvisioningUpdates \
-authenticationKeyPath ~/private_keys/AuthKey_${ASC_KEY_ID}.p8 \
-authenticationKeyID "$ASC_KEY_ID" \
-authenticationKeyIssuerID "$ASC_ISSUER_ID" \
CODE_SIGN_STYLE=Automatic \
CODE_SIGN_STYLE=Manual \
DEVELOPMENT_TEAM="$TEAM_ID" \
CODE_SIGN_IDENTITY="Apple Distribution" \
PROVISIONING_PROFILE_SPECIFIER=""
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
@@ -189,9 +211,9 @@ jobs:
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 }}
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"?>
@@ -200,7 +222,12 @@ jobs:
<dict>
<key>method</key><string>app-store</string>
<key>teamID</key><string>${TEAM_ID}</string>
<key>signingStyle</key><string>automatic</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>
@@ -210,11 +237,7 @@ jobs:
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"
-exportOptionsPlist "$RUNNER_TEMP/ExportOptions.plist"
VERSION=$(cat VERSION | tr -d '[:space:]')
SRC=$(ls "$RUNNER_TEMP"/export/*.ipa | head -1)
+19 -3
View File
@@ -58,9 +58,7 @@ App Store Connect → Users and Access → Integrations → App Store Connect AP
**+**, 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.
The key authenticates the TestFlight upload.
### 4. App ID, then app record
@@ -80,6 +78,23 @@ 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.
### 5. App Store provisioning profile
developer.apple.com → Profiles → **+** → Distribution → **App Store Connect**.
Pick the App ID from step 4 and the distribution certificate from step 2, name it
`Bulwark Mobile App Store`, and download the `.mobileprovision`.
The build signs **manually** and this profile is not optional. Automatic signing
would be less to maintain, but `xcodebuild archive` under automatic signing always
signs with a *development* identity and leans on `-exportArchive` to re-sign for
distribution afterwards. That needs a development certificate and at least one
registered device - neither of which a CI-only TestFlight pipeline has any reason
to own, and Apple will not issue a development profile without a device. Naming
the App Store profile up front avoids the whole problem.
The profile expires when the certificate does (2027-08-01). Regenerate it here and
update the secret when that happens.
## Repository secrets
Settings → Secrets and variables → Actions:
@@ -92,6 +107,7 @@ Settings → Secrets and variables → Actions:
| `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 |
| `IOS_PROVISIONING_PROFILE_BASE64` | The `.mobileprovision` from step 5, base64-encoded |
To base64-encode a file: