In agile software development, continuous deployment is key to collect user feedback leading to more reliable and successful iOS apps. Still, deploying to AppStore Connect is challenging due to managing signing certificates, provisioning profiles, and build numbers. In this article, we'll explore how to automate this process, allowing you to release your apps with a single button press.
First, we need to configure the following environment variables in your project's settings (Project
> Settings
> Security
> Secrets and Environment Variables
> actions
):
Key | Value (Example) |
---|---|
APP_ID | 1234567 |
TEAM_ID | A123456789 |
BUNDLE_ID | com.example.app |
PROVISIONING_PROFILE_NAME | Distribution |
SIMULATOR_DEVICE_TYPE | iPhone-14 |
SIMULATOR_RUNTIME | iOS-16-2 |
Explanation:
APP_ID
: The identifier that uniquely identifies the application.
TEAM_ID
: The identifier for the team enrolled in the Apple Developer Program.
BUNDLE_ID
: The identifier used by Apple to uniquely identify the application.
PROVISIONING_PROFILE_NAME
: The name of the provisioning profile.SIMULATOR_DEVICE_TYPE
: The simulator device used to run tests in the workflow.SIMULATOR_RUNTIME
: The Runtime version of the iOS simulator.Next, we create the following secrets in the project's settings:
Key | Value (Example) |
---|---|
API_KEY_BASE64 | XXXXXXXXXX |
API_KEY_ID | XXXXXXXXXX |
API_KEY_ISSUER_ID | XXXXXXXXXX |
KEYCHAIN_PASSWORD | XXXXXXXXXX |
SIGNING_CERTIFICATE_BASE64 | XXXXXXXXXX |
SIGNING_CERTIFICATE_PASSWORD | XXXXXXXXXX |
PROVISIONING_PROFILE_BASE64 | XXXXXXXXXX |
Github secrets store sensitive information in the project's repository and provide them as encrypted workflow configuration variables to the workflows, ensuring that their values are hidden from the web interface and can only be updated, not seen, once stored.
Explanation:
API_KEY_BASE64
: The private key to authorize against the AppStore Connect API encoded in base64 format.API_KEY_ID
: The key's Id.
API_KEY_ISSUER_ID
: The identifier of the issuer who created the authentication token.
KEYCHAIN_PASSWORD
: The password used to unlock the keychain.SIGNING_CERTIFICATE_BASE64
: The signing certificate encoded in base64 format.SIGNING_CERTIFICATE_PASSWORD
: The password for your Apple signing certificate.PROVISIONING_PROFILE_BASE64
: The provisioning profile encoded in base64 format.You can use the following commands to encode secrets in base64 format and copy it to the pasteboard:
# API_KEY_BASE64
openssl base64 -in AuthKey_{KEY_ID}.p8 | pbcopy
# SIGNING_CERTIFICATE_BASE64
openssl base64 -in {SIGNING_CERTIFICATE_NAME}.p12 | pbcopy
# PROVISIONING_PROFILE_BASE64
openssl base64 -in {PROVISIONING_PROFILE_NAME}.mobileprovision | pbcopy
Having specified all secrets and configuration variables, we can create a dedicated workflow that automates deployment to AppStore Connect. This way, we can distribute the application to TestFlight and get feedback from internal- and external testers.
We start with the following blueprint:
name: Deploy to App Store Connect
on:
workflow_dispatch:
jobs:
archive-and-deploy:
runs-on: macos-latest
steps:
...
Note that only the manual trigger via the web interface is included to better control when deployment is made. Still, it is possible to enable automatic deployment whenever the main
branch is updated:
on:
push:
branches:
- main
First, we use the checkout step to gain access to the source code:
- name: Checkout repository
uses: actions/checkout@v3
Next, we need to install the private key to the agent such that it can communicate with AppStore Connect:
mkdir ~/.private_keys
echo -n "$API_KEY_BASE64" | base64 --decode --output ~/.private_AuthKey_${{ secrets.API_KEY_ID }}.p8
echo "After saving:"
ls ~/.private_keys
The API Key is decoded from the base64-encoded secret and stored in the current directory.
Next, the signing certificate is decoded and stored in the app-signing
keychain. We unlock the keychain to have access to the signing certificate when archiving the application:
SIGNING_CERTIFICATE_PATH=$RUNNER_TEMP/signing_certificate.p12
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
# Read Signing Certificate
echo -n "$SIGNING_CERTIFICATE_BASE64" | base64 --decode -o "$SIGNING_CERTIFICATE_PATH"
# Create Keychain
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
# Import Signing Certificate to Keychain
security import "$SIGNING_CERTIFICATE_PATH" -P "$SIGNING_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH"
security list-keychain -d user -s "$KEYCHAIN_PATH"
Similarly, the provising profile is decoded and stored in the agent's library directory(~/Library/MobileDevice/Provisioning\ Profiles
):
PROVISIONING_PROFILE_PATH=$RUNNER_TEMP/provisioning_profile.mobileprovision
# Read Provisioning Profile
echo -n "$PROVISIONING_PROFILE_BASE64" | base64 --decode -o "$PROVISIONING_PROFILE_PATH"
# Import Provisioning Profile
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
cp $PROVISIONING_PROFILE_PATH ~/Library/MobileDevice/Provisioning\ Profiles
The provisioning profiles specify the devices the application is allowed to run. In addition, they ensure that the app is from a trusted source and has not been tampered with.
exportOptions.plist
Next, we inject the provisioning profile's name, the Team- and Bundle-ID into the exportOptions.plist
that is used by xcodebuild
when distributing the archive. The injection is done using the sed
command with which we can replace the placeholders {{Placeholder}}
with their corresponding values:
- name: Configure exportOptions.plist
env:
TEAM_ID: ${{ vars.TEAM_ID }}
BUNDLE_ID: ${{ vars.BUNDLE_ID }}
PROVISIONING_PROFILE_NAME: ${{ vars.PROVISIONING_PROFILE_NAME }}
run: |
sed -i '' "s/{{TEAM_ID}}/$TEAM_ID/g" exportOptions.plist
sed -i '' "s/{{BUNDLE_ID}}/$BUNDLE_ID/g" exportOptions.plist
sed -i '' "s/{{PROVISIONING_PROFILE_NAME}}/$PROVISIONING_PROFILE_NAME/g" exportOptions.plist
This way, we can customize the export process and specify the distribution method as well as the provisioning profile, used when code signing the app.
<?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>uploadSymbols</key>
<true/>
<key>signingStyle</key>
<string>manual</string>
<key>provisioningProfiles</key>
<dict>
<key>{{BUNDLE_ID}}</key>
<string>{{PROVISIONING_PROFILE_NAME}}</string>
</dict>
</dict>
</plist>
Now that we have the signing certificate, provisioning profile and API Key in place, we need to determine the build number.
Each buildnmber submitted to AppStore Connect is required to be strictly greater than the maximum known build number of all builds ever submitted. Since manually keeping track of build numbers is tedious, we utitlize the workflow's built-in counter, i.e., github.run_number
that is incremented on every build. This way, we only need to specify the marketing version that is shown in the AppStore:
buildNumber=${{ github.run_number }}
echo "Current build number: $buildNumber"
agvtool new-version -all $buildNumber
Having setup the environment, we can archive the application as an xcarchive
. Note that we use manual signing with the provising profile that we imported in an earlier step:
set -o pipefail && xcodebuild clean archive \
-scheme "App" \
-archivePath $RUNNER_TEMP/App.xcarchive \
-sdk iphoneos \
-configuration Release \
-destination generic/platform=iOS \
CODE_SIGN_STYLE=Manual \
PROVISIONING_PROFILE_SPECIFIER=Distribution | xcpretty
As soon as the archive is built, we can export the iOS AppStore Package (.ipa
) considering the exportOptions
.
ARTIFACT_FILEPATH=$RUNNER_TEMP/App.ipa
set -o pipefail && xcodebuild -exportArchive \
-archivePath $RUNNER_TEMP/App.xcarchive \
-exportOptionsPlist exportOptions.plist \
-exportPath $RUNNER_TEMP | xcpretty
An iOS AppStore Package (.ipa
) is technically identical to a zip
file and can be extracted by renaming it's file extension. That's why it makes sence to publish it as a workflow artifact, such that we can access to the package and verify whether all ressources are properly bundled:
- name: Publish App.ipa file
uses: actions/upload-artifact@v3
with:
name: App.ipa
path: ${{ runner.temp }}/App.ipa
Before uploading the application package to AppStore Connect, we use the altool
command to validate it. In case the AppStore will not accept the package the validation will fail. E.g., we might have missed adding an App Icon which is required by the store:
xcrun altool --validate-app \
-f ${{ runner.temp }}/App.ipa \
-t ios \
--apiKey ${{ secrets.API_KEY_ID }} \
--apiIssuer ${{ secrets.API_KEY_ISSUER_ID }}
In case the validation succeeded, we can upload the package via the AppStore Connect API.
xcrun altool --upload-app \
-f ${{ runner.temp }}/App.ipa \
-t ios \
--apiKey ${{ secrets.API_KEY_ID }} \
--apiIssuer ${{ secrets.API_KEY_ISSUER_ID }}
Even though Github's own runners always ensure that we start with a clean environment it is best practive to clean up certificates that are no longer needed. In case we would use a self hosted runner, these artifacts could otherwiese remain and cause unintended side-effects on subsequent builds.
security delete-keychain $RUNNER_TEMP/app-signing.keychain-db
rm ~/Library/MobileDevice/Provisioning\ Profiles/provisioning_profile.mobileprovision
Finally, we obtain the following worklow that is stored as deploy.yml
in the .github/workflows
directory:
name: Deploy to AppStore Connect
on:
workflow_dispatch:
jobs:
archive-and-deploy:
runs-on: macos-latest
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Install App Store Connect Api Key
env:
API_KEY_BASE64: ${{ secrets.API_KEY_BASE64 }}
run: |
mkdir ~/.private_keys
echo -n "$API_KEY_BASE64" | base64 --decode --output ~/.private_keys/AuthKey_${{ secrets.API_KEY_ID }}.p8
echo "After saving:"
ls ~/.private_keys
- name: Install Signing Certificate
env:
SIGNING_CERTIFICATE_BASE64: ${{ secrets.SIGNING_CERTIFICATE_BASE64 }}
SIGNING_CERTIFICATE_PASSWORD: ${{ secrets.SIGNING_CERTIFICATE_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: |
SIGNING_CERTIFICATE_PATH=$RUNNER_TEMP/signing_certificate.p12
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
# Read Signing Certificate
echo -n "$SIGNING_CERTIFICATE_BASE64" | base64 --decode -o "$SIGNING_CERTIFICATE_PATH"
# Create Keychain
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
# Import Signing Certificate to Keychain
security import "$SIGNING_CERTIFICATE_PATH" -P "$SIGNING_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH"
security list-keychain -d user -s "$KEYCHAIN_PATH"
- name: Install Provisioning Profile
env:
PROVISIONING_PROFILE_BASE64: ${{ secrets.PROVISIONING_PROFILE_BASE64 }}
run: |
PROVISIONING_PROFILE_PATH=$RUNNER_TEMP/provisioning_profile.mobileprovision
# Read Provisioning Profile
echo -n "$PROVISIONING_PROFILE_BASE64" | base64 --decode -o "$PROVISIONING_PROFILE_PATH"
# Import Provisioning Profile
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
cp $PROVISIONING_PROFILE_PATH ~/Library/MobileDevice/Provisioning\ Profiles
- name: Inject Build Number
run: |
buildNumber=${{ github.run_number }}
echo "Current build number: $buildNumber"
agvtool new-version -all $buildNumber
sed -i "" "s/CFBundleVersion/$buildNumber/" exportOptions.plist
- name: Configure exportOptions.plist
env:
TEAM_ID: ${{ vars.TEAM_ID }}
BUNDLE_ID: ${{ vars.BUNDLE_ID }}
PROVISIONING_PROFILE_NAME: ${{ vars.PROVISIONING_PROFILE_NAME }}
run: |
sed -i '' "s/{{TEAM_ID}}/$TEAM_ID/g" exportOptions.plist
sed -i '' "s/{{BUNDLE_ID}}/$BUNDLE_ID/g" exportOptions.plist
sed -i '' "s/{{PROVISIONING_PROFILE_NAME}}/$PROVISIONING_PROFILE_NAME/g" exportOptions.plist
- name: Build, Sign and Archive
run: |
set -o pipefail && xcodebuild clean archive \
-scheme "App" \
-archivePath $RUNNER_TEMP/App.xcarchive \
-sdk iphoneos \
-configuration Release \
-destination generic/platform=iOS \
CODE_SIGN_STYLE=Manual \
PROVISIONING_PROFILE_SPECIFIER=Distribution | xcpretty
- name: Export archive
run: |
ARTIFACT_FILEPATH=$RUNNER_TEMP/App.ipa
set -o pipefail && xcodebuild -exportArchive \
-archivePath $RUNNER_TEMP/App.xcarchive \
-exportOptionsPlist exportOptions.plist \
-exportPath $RUNNER_TEMP | xcpretty
- name: Publish App.ipa file
uses: actions/upload-artifact@v3
with:
name: App.ipa
path: ${{ runner.temp }}/App.ipa
- name: Validate Build Artifact
run: |
xcrun altool --validate-app \
-f ${{ runner.temp }}/App.ipa \
-t ios \
--apiKey ${{ secrets.API_KEY_ID }} \
--apiIssuer ${{ secrets.API_KEY_ISSUER_ID }}
- name: Upload Build Artifact to TestFlight
run: |
xcrun altool --upload-app \
-f ${{ runner.temp }}/App.ipa \
-t ios \
--apiKey ${{ secrets.API_KEY_ID }} \
--apiIssuer ${{ secrets.API_KEY_ISSUER_ID }}
- name: Clean up keychain and provisioning profile
if: ${{ always() }}
run: |
security delete-keychain $RUNNER_TEMP/app-signing.keychain-db
rm ~/Library/MobileDevice/Provisioning\ Profiles/provisioning_profile.mobileprovision
In this article, we went through the necessary steps to automate deployment of an iOS application via Github Actions. Having setup the dedicated workflow, we can release the app upon the press of a button and rather focus on building features while getting valuable feedback from testers and users.
Happy Coding 🚀