top of page
Matt

How to create a Github Actions pipeline to deploy iOS app to TestFlight

Updated: Feb 19

In our previous blog post we wrote about the importance of automating tests and integrating that as part of a fully automated CICD pipeline. We shared a code snippet of the yml file for out Github Actions workflow. In this article we're going to demonstrate exactly how to achieve this. To view the code click here

The workflow does the following:

Pipeline Environment Variables

It's always good practice to capture reused variables as global pipeline variables

Code:

env:
  ARCHIVE_SCHEME: "Your Project Name (e.g. Falling Sky)"
  APP_NAME: "Your App Name (e.g. FallingSky)"
  BUNDLE_ID: "Your Bundle Id (e.g. com.ka.fallingsky)"

Clean the Workspace (suitable for self-hosted runners)

Code:

- name: Clean Workspace
        run: |
          rm -rf $GITHUB_WORKSPACE/* 
          rm -rf $RUNNER_TEMP/*

If you are running your own build agent then you will want to make sure that each build runs in perfect isolation from each other. All we're doing here is simply removing any files in the two folders the build agent uses as part of the build.


Checkout out the code

Code:

- name: Checkout
        uses: actions/checkout@v3

This uses the GitHub actions checkout step which will checkout the latest version of your code.


Setting the default scheme

Code:

- name: Set Default Scheme
        run: |
          scheme_list=$(xcodebuild -list -json | tr -d "\n")
          default=$(echo $scheme_list | ruby -e "require 'json'; puts JSON.parse(STDIN.gets)['project']['targets'][0]")
          echo $default | cat >default
          echo Using default scheme: $default

This step retrieves the list of schemes for the Xcode project, extracts the first target (which is assumed to be the default scheme), and then saves the default scheme's name into a file named "default" while also providing a log message indicating the default scheme being used. This is useful for future pipeline steps that rely on a specific Xcode scheme being set as the default.


Building and running automated tests

Code:

- name: Build for test
        env:
          scheme: ${{ 'default' }}
          platform: ${{ 'iOS Simulator' }}
        run: |
          # xcrun xctrace returns via stderr, not the expected stdout (see https://developer.apple.com/forums/thread/663959)
          device=`xcrun xctrace list devices 2>&1 | grep -oE 'iPhone.*?[^\(]+' | head -1 | awk '{$1=$1;print}' | sed -e "s/ Simulator$//"`
          if [ $scheme = default ]; then scheme=$(cat default); fi
          if [ "`ls -A | grep -i \\.xcworkspace\$`" ]; then filetype_parameter="workspace" && file_to_build="`ls -A | grep -i \\.xcworkspace\$`"; else filetype_parameter="project" && file_to_build="`ls -A | grep -i \\.xcodeproj\$`"; fi
          file_to_build=`echo $file_to_build | awk '{$1=$1;print}'`

          xcodebuild clean build-for-testing -scheme "$scheme" -"$filetype_parameter" "$file_to_build" -destination "platform=$platform,name=$device"

      - name: Test
        timeout-minutes: 30
        env:
          scheme: ${{ 'default' }}
          platform: ${{ 'iOS Simulator' }}
        run: |
          # xcrun xctrace returns via stderr, not the expected stdout (see https://developer.apple.com/forums/thread/663959)
          device=`xcrun xctrace list devices 2>&1 | grep -oE 'iPhone.*?[^\(]+' | head -1 | awk '{$1=$1;print}' | sed -e "s/ Simulator$//"`
          if [ $scheme = default ]; then scheme=$(cat default); fi
          if [ "`ls -A | grep -i \\.xcworkspace\$`" ]; then filetype_parameter="workspace" && file_to_build="`ls -A | grep -i \\.xcworkspace\$`"; else filetype_parameter="project" && file_to_build="`ls -A | grep -i \\.xcodeproj\$`"; fi
          file_to_build=`echo $file_to_build | awk '{$1=$1;print}'`
          xcodebuild test-without-building -scheme "$scheme" -"$filetype_parameter" "$file_to_build" -destination "platform=$platform,name=$device" -test-iterations 3 -retry-tests-on-failure

This is an important step that guarantees the pipeline stops if any tests fail. This is ideal for preventing regressions from going through into production.


Installing certificates and provisioning profiles

Code:

- name: Install the Apple certificate and provisioning profile
        env:
          BUILD_CERTIFICATE_BASE64: ${{ secrets.DIST_CERT_BASE64 }}
          P12_PASSWORD: ${{ secrets.DIST_CERT_P12_PASSWORD }}
          BUILD_PROVISION_PROFILE_BASE64: ${{ secrets.PROVISIONING_PROFILE_BASE64 }}
          KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
        run: |
          # create variables
          CERTIFICATE_PATH=$RUNNER_TEMP/build_certificate.p12
          PP_PATH=$RUNNER_TEMP/build_pp.mobileprovision
          KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db

          # import certificate and provisioning profile from secrets
          echo -n "$BUILD_CERTIFICATE_BASE64" | base64 --decode -o $CERTIFICATE_PATH
          echo -n "$BUILD_PROVISION_PROFILE_BASE64" | base64 --decode -o $PP_PATH

          # create temporary 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 certificate to keychain
          security import $CERTIFICATE_PATH -P "$P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
          security list-keychain -d user -s $KEYCHAIN_PATH

          # apply provisioning profile
          mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
          cp $PP_PATH ~/Library/MobileDevice/Provisioning\ Profiles

This is required for archiving the app ready for distributing to TestFlight and then onto the App Store.

There are a number of GitHub Repository level secrets required for this step. We'll detail how to do these below. See https://docs.github.com/en/actions/deployment/deploying-xcode-applications/installing-an-apple-certificate-on-macos-runners-for-xcode-development for more information.


DIST_CERT_BASE64 & DIST_CERT_P12_PASSWORD

This is the p12 build certificate stored as Base64 and the password used to sign it.

Once you've created the p12 certificate following the steps here: https://help.apple.com/xcode/mac/current/#/dev154b28f09 then you need to run the following to get the base64 text:

base64 -i BUILD_CERTIFICATE.p12 | pbcopy

then store this as well as the p12 password as repository secrets.


PROVISIONING_PROFILE_BASE64

This is the provisioning profile required for distribution. Once you've created the profile (you have to create a provisioning profile at https://developer.apple.com), then run the following to get the base64 test:

base64 -i PROVISIONING_PROFILE.mobileprovision | pbcopy

then store as a repository secret


KEYCHAIN_PASSWORD

A temporary keychain gets created with each run so this can be a randomly generated string.


Incrementing version numbers

Code:

- uses: yanamura/ios-bump-version@v1
        with:
          version: 1.1.0
          build-number: ${{ github.run_number }}

We use the GitHub run number as our "unique" part of the version. This handles updating the relevant files so that this becomes available in App Store Connect as a new build.


Building and archiving the app

Code:

- name: Build and archive app
        run: |
          if [ "`ls -A | grep -i \\.xcworkspace\$`" ]; then filetype_parameter="workspace" && file_to_build="`ls -A | grep -i \\.xcworkspace\$`"; else filetype_parameter="project" && file_to_build="`ls -A | grep -i \\.xcodeproj\$`"; fi
          file_to_build=`echo $file_to_build | awk '{$1=$1;print}'`
          xcodebuild archive -"$filetype_parameter" "$file_to_build" -scheme "$ARCHIVE_SCHEME" -archivePath "$APP_NAME" -configuration Release

Creating an IPA file

Code:

- name: Create IPA file
        env:
          EXPORT_OPTIONS_PLIST: ${{ secrets.EXPORT_OPTIONS_PLIST_BASE64 }}
        run: |
          EXPORT_OPTIONS_PLIST_PATH=$RUNNER_TEMP/ExportOptions.plist
          echo -n "$EXPORT_OPTIONS_PLIST" | base64 -d -o $EXPORT_OPTIONS_PLIST_PATH
          xcodebuild -exportArchive -archivePath $GITHUB_WORKSPACE/$APP_NAME.xcarchive -exportPath $RUNNER_TEMP/build -exportOptionsPlist $EXPORT_OPTIONS_PLIST_PATH
          mv "$RUNNER_TEMP/build/$ARCHIVE_SCHEME.ipa" $RUNNER_TEMP/build/$APP_NAME.ipa

This creates the IPA file ready for uploading to TestFlight.

It uses the EXPORT_OPTIONS_PLIST_BASE64 secret. This is the base64 of the ExportOptions.plist. You can create this file manually with many examples online. Run the following to capture the Base64 of the ExportOptions.plist file and store as a repository secret:

base64 -i ExportOptions.plist | pbcopy

Deploying to TestFlight

Code:

- name: Upload to TestFlight
        env:
          API_KEY_BASE64: ${{ secrets.APPSTORE_API_PRIVATE_KEY }}
        run: |
          mkdir -p ./private_keys
          echo -n "$API_KEY_BASE64" | base64 --decode -o "./private_keys/AuthKey_${{ secrets.APPSTORE_API_KEY_ID }}.p8"
          xcrun altool --validate-app -f ${{ runner.temp }}/build/${{ env.APP_NAME }}.ipa -t ios --apiKey ${{ secrets.APPSTORE_API_KEY_ID }} --apiIssuer ${{ secrets.APPSTORE_ISSUER_ID }}
          xcrun altool --upload-app -f ${{ runner.temp }}/build/${{ env.APP_NAME }}.ipa -t ios --apiKey ${{ secrets.APPSTORE_API_KEY_ID }} --apiIssuer ${{ secrets.APPSTORE_ISSUER_ID }}

This uploads the generated IPA file to TestFlight, once completed it takes a few moments for Apple to process but will become available on you device ready for testing.

This step use three secrets: APPSTORE_API_PRIVATE_KEY, APPSTORE_API_KEY_ID & APPSTORE_ISSUER_ID. This is all generated from an API Key created in App Store Connect. Download the key and capture the base64 of the .p8 file and save to APPSTORE_API_PRIVATE_KEY repository secret. The key id and issuer id is found on App Store Connect.


How to support


This content will always remain free, and if you find it valuable, please consider sharing it with others. Additionally, downloading our games and leaving honest reviews greatly supports us. Feel free to reach out with any questions or feedback, and we'll do our best to respond.


Download Falling Sky from the Apple App Store today: https://apps.apple.com/app/id6446787964


Here's the code of the whole yml file:

name: My Automated CI/CD Pipeline

on:
  push:
    branches: [ "main" ]
  workflow_dispatch:

env:
  ARCHIVE_SCHEME: "Your Project Name (e.g. Falling Sky)"
  APP_NAME: "Your App Name (e.g. FallingSky)"
  BUNDLE_ID: "Your Bundle Id (e.g. com.ka.fallingsky)"

jobs:
  build:
    name: Build and Test default scheme using any available iPhone simulator
    runs-on: self-hosted

    steps:
      - name: Clean Workspace
        run: |
          rm -rf $GITHUB_WORKSPACE/* 
          rm -rf $RUNNER_TEMP/*

      - name: Checkout
        uses: actions/checkout@v3

      - name: Set Default Scheme
        run: |
          scheme_list=$(xcodebuild -list -json | tr -d "\n")
          default=$(echo $scheme_list | ruby -e "require 'json'; puts JSON.parse(STDIN.gets)['project']['targets'][0]")
          echo $default | cat >default
          echo Using default scheme: $default

      - name: Build for test
        env:
          scheme: ${{ 'default' }}
          platform: ${{ 'iOS Simulator' }}
        run: |
          # xcrun xctrace returns via stderr, not the expected stdout (see https://developer.apple.com/forums/thread/663959)
          device=`xcrun xctrace list devices 2>&1 | grep -oE 'iPhone.*?[^\(]+' | head -1 | awk '{$1=$1;print}' | sed -e "s/ Simulator$//"`
          if [ $scheme = default ]; then scheme=$(cat default); fi
          if [ "`ls -A | grep -i \\.xcworkspace\$`" ]; then filetype_parameter="workspace" && file_to_build="`ls -A | grep -i \\.xcworkspace\$`"; else filetype_parameter="project" && file_to_build="`ls -A | grep -i \\.xcodeproj\$`"; fi
          file_to_build=`echo $file_to_build | awk '{$1=$1;print}'`

          xcodebuild clean build-for-testing -scheme "$scheme" -"$filetype_parameter" "$file_to_build" -destination "platform=$platform,name=$device"

      - name: Test
        timeout-minutes: 30
        env:
          scheme: ${{ 'default' }}
          platform: ${{ 'iOS Simulator' }}
        run: |
          # xcrun xctrace returns via stderr, not the expected stdout (see https://developer.apple.com/forums/thread/663959)
          device=`xcrun xctrace list devices 2>&1 | grep -oE 'iPhone.*?[^\(]+' | head -1 | awk '{$1=$1;print}' | sed -e "s/ Simulator$//"`
          if [ $scheme = default ]; then scheme=$(cat default); fi
          if [ "`ls -A | grep -i \\.xcworkspace\$`" ]; then filetype_parameter="workspace" && file_to_build="`ls -A | grep -i \\.xcworkspace\$`"; else filetype_parameter="project" && file_to_build="`ls -A | grep -i \\.xcodeproj\$`"; fi
          file_to_build=`echo $file_to_build | awk '{$1=$1;print}'`
          xcodebuild test-without-building -scheme "$scheme" -"$filetype_parameter" "$file_to_build" -destination "platform=$platform,name=$device" -test-iterations 3 -retry-tests-on-failure

      - name: Install the Apple certificate and provisioning profile
        env:
          BUILD_CERTIFICATE_BASE64: ${{ secrets.DIST_CERT_BASE64 }}
          P12_PASSWORD: ${{ secrets.DIST_CERT_P12_PASSWORD }}
          BUILD_PROVISION_PROFILE_BASE64: ${{ secrets.PROVISIONING_PROFILE_BASE64 }}
          KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
        run: |
          # create variables
          CERTIFICATE_PATH=$RUNNER_TEMP/build_certificate.p12
          PP_PATH=$RUNNER_TEMP/build_pp.mobileprovision
          KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db

          # import certificate and provisioning profile from secrets
          echo -n "$BUILD_CERTIFICATE_BASE64" | base64 --decode -o $CERTIFICATE_PATH
          echo -n "$BUILD_PROVISION_PROFILE_BASE64" | base64 --decode -o $PP_PATH

          # create temporary 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 certificate to keychain
          security import $CERTIFICATE_PATH -P "$P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
          security list-keychain -d user -s $KEYCHAIN_PATH

          # apply provisioning profile
          mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
          cp $PP_PATH ~/Library/MobileDevice/Provisioning\ Profiles

      - uses: yanamura/ios-bump-version@v1
        with:
          version: 1.1.0
          build-number: ${{ github.run_number }}

      - name: Build and archive app
        run: |
          if [ "`ls -A | grep -i \\.xcworkspace\$`" ]; then filetype_parameter="workspace" && file_to_build="`ls -A | grep -i \\.xcworkspace\$`"; else filetype_parameter="project" && file_to_build="`ls -A | grep -i \\.xcodeproj\$`"; fi
          file_to_build=`echo $file_to_build | awk '{$1=$1;print}'`
          xcodebuild archive -"$filetype_parameter" "$file_to_build" -scheme "$ARCHIVE_SCHEME" -archivePath "$APP_NAME" -configuration Release

      - name: Create IPA file
        env:
          EXPORT_OPTIONS_PLIST: ${{ secrets.EXPORT_OPTIONS_PLIST_BASE64 }}
        run: |
          EXPORT_OPTIONS_PLIST_PATH=$RUNNER_TEMP/ExportOptions.plist
          echo -n "$EXPORT_OPTIONS_PLIST" | base64 -d -o $EXPORT_OPTIONS_PLIST_PATH
          xcodebuild -exportArchive -archivePath $GITHUB_WORKSPACE/$APP_NAME.xcarchive -exportPath $RUNNER_TEMP/build -exportOptionsPlist $EXPORT_OPTIONS_PLIST_PATH
          mv "$RUNNER_TEMP/build/$ARCHIVE_SCHEME.ipa" $RUNNER_TEMP/build/$APP_NAME.ipa

      - name: Upload to TestFlight
        env:
          API_KEY_BASE64: ${{ secrets.APPSTORE_API_PRIVATE_KEY }}
        run: |
          mkdir -p ./private_keys

          echo -n "$API_KEY_BASE64" | base64 --decode -o "./private_keys/AuthKey_${{ secrets.APPSTORE_API_KEY_ID }}.p8"

          xcrun altool --validate-app -f ${{ runner.temp }}/build/${{ env.APP_NAME }}.ipa -t ios --apiKey ${{ secrets.APPSTORE_API_KEY_ID }} --apiIssuer ${{ secrets.APPSTORE_ISSUER_ID }}

          xcrun altool --upload-app -f ${{ runner.temp }}/build/${{ env.APP_NAME }}.ipa -t ios --apiKey ${{ secrets.APPSTORE_API_KEY_ID }} --apiIssuer ${{ secrets.APPSTORE_ISSUER_ID }}




1,266 views
bottom of page