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