Automating your iOS App Development Workflow: Continuous Deployment with GitHub Actions

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.

Environment Variables

First, we need to configure the following environment variables in your project's settings (Project > Settings > Security > Secrets and Environment Variables > actions):

KeyValue (Example)
APP_ID1234567
TEAM_IDA123456789
BUNDLE_IDcom.example.app
PROVISIONING_PROFILE_NAMEDistribution
SIMULATOR_DEVICE_TYPEiPhone-14
SIMULATOR_RUNTIMEiOS-16-2

Explanation:

  • APP_ID: The identifier that uniquely identifies the application.
    • Location: AppStore Connect > Apps > App > App Information > Apple ID
  • TEAM_ID: The identifier for the team enrolled in the Apple Developer Program.
    • Location: AppStore Connect > Edit Profile > TeamID
  • BUNDLE_ID: The identifier used by Apple to uniquely identify the application.
    • Location: AppStore Connect > Apps > App > App Information > Bundle Identifier
  • 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.

Secrets

Next, we create the following secrets in the project's settings:

KeyValue (Example)
API_KEY_BASE64XXXXXXXXXX
API_KEY_IDXXXXXXXXXX
API_KEY_ISSUER_IDXXXXXXXXXX
KEYCHAIN_PASSWORDXXXXXXXXXX
SIGNING_CERTIFICATE_BASE64XXXXXXXXXX
SIGNING_CERTIFICATE_PASSWORDXXXXXXXXXX
PROVISIONING_PROFILE_BASE64XXXXXXXXXX

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.
    • Location: App Store Connect > Users and Access > Keys
  • API_KEY_ISSUER_ID: The identifier of the issuer who created the authentication token.
    • Location: App Store Connect > Users and Access > Keys > Issuer Id
  • 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 

Deployment Workflow

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

Step 1: Checkout Repository

First, we use the checkout step to gain access to the source code:

- name: Checkout repository
  uses: actions/checkout@v3

Step 2: Install App Store Connect API Key

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.

Step 3: Install Signing Certificate

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"

Step 4: Install Provisioning Profile

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.

Step 5: Configure 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>

Step 6: Inject Build Number

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

Step 7: Build, Sign and Archive

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

Step 8: Export Archive

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

Step 9: Publish Archive

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

Step 10: Validate Build Artifact

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 }}

Step 11: Upload Build Artifact to AppStore Connect

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 }}

Step 12: Cleanup keychain and provisioning profile

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

Deploy to AppStore Connect Workflow

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

Conclusion

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.

References:

Happy Coding 🚀