TestFlight + Actions Header Image

TL;DR: The YAML is here if you want to skip to the good part.

I’ve just started a new iPhone project in my personal time, and I thought it’d be nice to get a CD pipeline setup. The idea is to get each PR merge out to my victims friends who are kindly testing my buggy code via TestFlight. I thought this would be pretty straightforward (it has been with Azure Pipelines in the past!), but for added fun I thought I’d do it with GitHub Actions. Easy, right?

Turns out… not so much! 😅

After a week and many hours cursing, I created a workflow that nailed it. 🎉 In the interest of giving back to the community, I’ve documented the steps (and the YAML workflow I came up with) so you too can follow along and join in on the sweet DevOps goodness. The workflow makes a new build ready for release, increments the version number, uses the last PR merge commit message as the test release notes, and sends it off for distribution to a group we nominate.

Whilst my solution is for Xamarin.iOS, this should cover 95% of what you need to for any release procedure that needs to build an .ipa package and release it via TestFlight.

How’d I do this

You’re going to need a few things:

  1. An Apple Developer Account with a paid Developer Program subscription
  2. An app registration setup in App Store Connect with at least one build that has been released externally already (that is, to an external testing group that you have defined)
  3. Access to a macOS device to generate distribution certificates with private keys, if you don’t have one already
  4. A GitHub repo - public or private, it’ll still work!

What you won’t need is a non-2FA Apple ID like most iOS CI approaches require, so that’s a nice bonus!

Prerequisite #1 - Distribution Certificate & Provisioning Profile

You will need to generate a .p12 Distribution Certificate and a App Store Distribution Provisioning Profile using the Developer Portal. There are plenty of guides on how to get a p12 export going - I used this guide to do that. Note down the passphrase you use to export the certificate - you’ll need it later.

Once you have the certificate export, you’ll need to export it as base64 for later.

openssl base64 -in Certificates.p12 -out Certificates.txt

If you haven’t got an App Store Distribution Profile, make one now, and ensure your distribution certificate is included. You won’t need to download it though.

Prerequisite #2 - App Store Connect API Key

Next you will need to generate an App Store Connect API key with the App Manager access role, if you don’t have one already. You can do that from the Users and Access - Keys section of App Store Connect.

Once made, you should make a note of the Issuer ID, Key ID and download the private key using the Download API Key link. You’ll need all three of these things later. If you run into trouble, the Apple docs on generating API keys are a good place to start.

Prerequisite #3 - App Specific Password

In order to not get 2FA prompts when our release is happening, we’ll need an app-specific password to upload builds to App Store Connect. You can set one up via Apple Account Management - and like the others, note it down for later.

Prerequisite #4 - App ‘Apple ID’

Last but not least, you’ll need your app’s Apple ID. No, not your own Apple ID in email form - a numerical number that identifies your application inside App Store Connect. Navigate to the Apps section in App Store Connect and select App Information on the left - you’ll find the Apple Id under the General Information pane. That’s the last thing you need!

Building the GitHub Action Workflow

As of time of writing, the App Connect API is a bit flaky. It has this habit of returning 403 2-7 times in a row before actually letting you in, even though your credentials are fine. I had to hack around this - forgive the clunkiness!

Phew. Now that we’ve collected all the required parts, you can start on the action itself. Firstly head to your repo’s Secrets section in GitHub, and add the following secrets in:

Name Value
APPCONNECT_API_ISSUER Issuer ID from #2
APPCONNECT_API_KEY_ID Key ID from #2
APPCONNECT_API_KEY_PRIVATE Contents of the .p8 file downloaded in #2
APPLE_ID ID from #4
DIST_CERT_BASE64 Contents of the .txt file generated in #1
DIST_CERT_P12_PASSWORD Passphrase from #1
FASTLANE_APP_PASSWORD The app-specific password in #3
FASTLANE_USERNAME The email associated with the Apple Account you’ve been using

To top it all off, create a workflow file in your repo that lives in .github/workflows/testflight.yaml. I called it testflight - you can call it whatever you like.

The workflow in words is: On push to master, perform the following on the latest macOS agent:

  1. Checkout code with full history
  2. Make a temporary keychain to hold the distribution certificate
  3. Build a bearer token for the Connect API
  4. ‘Prime’ the API (see note above)
  5. Import the right provisioning profile
  6. Bump our build versions to use the build number
  7. Do the actual build. NB: This is Xamarin.iOS for me - replace with your build tool of choice
  8. Record the built .ipa as a build artefact in case we need it in the future
  9. Generate release notes from our PR merge history
  10. Push the build to TestFlight
  11. Instruct TestFlight to release the build to our chosen external group

Step 11 assumes you have not set export compliance in your PList and you intend to nominate that you use non-exempt encryption. Read more here if you’re unsure.

Show me the YAML

Here it is, in all it’s glory. You should change the following to your own values:

  • bundle-id
  • GROUP_NAME

Here be dragons! 🐉🐉🐉 Be warned there’s a wide array of language use going on.

And that’s it! 🎉 Some of the above is pretty darn clunky, particularly the sprinkling of Python and Bash, but it works quite well. I’d like GitHub Actions to support more tasks out of the box like Azure Pipelines does, and overall the experience of the Actions Marketplace left a bit to be desired - but that’s for another post!

If you’d like more understanding of how the App Store Connect API works, I’d recommend checking out the API docs - it does more than just poking TestFlight! And lastly, any suggestions on how to improve the workflow would be more than welcome. ❤

Happy automating! 🚀