diff --git a/.github/workflows/release-ios.yml b/.github/workflows/release-ios.yml index 2db9f414..33d903cc 100644 --- a/.github/workflows/release-ios.yml +++ b/.github/workflows/release-ios.yml @@ -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" < @@ -200,7 +222,12 @@ jobs: methodapp-store teamID${TEAM_ID} - signingStyleautomatic + signingStylemanual + signingCertificateApple Distribution + provisioningProfiles + + ${BUNDLE_ID}${PROFILE_NAME} + uploadSymbols destinationexport @@ -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) diff --git a/docs/ios-release.md b/docs/ios-release.md index 213b65f3..cf0c6b09 100644 --- a/docs/ios-release.md +++ b/docs/ios-release.md @@ -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: