Setting up GitHub Actions for iOS

· GitHub Actions to TestFlight - from zero to hero

Introduction

So, you’ve been building your app with Fastlane, maybe even running your unit tests like the responsible developer you are (right?), and now you’re thinking: “This is great, but I’m still doing this manually on my machine. What if I could automate this even further?”

Well, my friend, you’ve come to the right place! In this series, we’re going to take your iOS development automation to the next level by setting up GitHub Actions to automatically trigger TestFlight builds when we tag our branch with “testflight”. But before we get to the fun stuff, we need to lay the groundwork.

This is going to be the first post in our “GitHub Actions to TestFlight - from zero to hero” series, and just like our Fastlane series, we’re going to start from the beginning and work our way up.

Why GitHub Actions?

It’s free (mostly)

GitHub Actions gives you 2,000 minutes per month for free on public repositories, and 2,000 minutes per month for free on private repositories too. For most indie developers and small teams, this is more than enough to get started. And even if you go over, the pricing is pretty reasonable.

It’s integrated

Unlike other CI/CD solutions that you need to set up separately, GitHub Actions is built right into GitHub. This means no additional accounts to manage, no webhooks to configure, and no external services to worry about. It just works.

It’s powerful

GitHub Actions can do pretty much anything you can think of. Build your app, run your tests, deploy to TestFlight, send notifications to Slack, update your documentation, and even make coffee (okay, maybe not that last one, but you get the idea).

Pre-requisites

Before we dive in, let’s make sure we’ve got everything we need:

If you’re missing any of these, no worries! Go get them set up and come back. I’ll wait. ☕️

Understanding GitHub Actions

Before we start writing YAML files (yes, we’re going to write YAML, but I promise it won’t hurt), let’s understand what GitHub Actions actually is.

GitHub Actions is essentially a way to run code on GitHub’s servers whenever something happens in your repository. That “something” could be a push to main, a pull request, a new tag, or even something as simple as someone starring your repo.

The basic concepts

Workflows: These are the main files that define what should happen. They live in .github/workflows/ in your repository.

Jobs: Each workflow can have multiple jobs that run in parallel (or sequentially if you want).

Steps: Each job has multiple steps that run one after another.

Actions: These are pre-built pieces of functionality that you can use in your steps. Think of them as the npm packages of the CI/CD world.

Runners: These are the actual machines that run your code. GitHub provides Ubuntu, Windows, and macOS runners.

Setting up your first workflow

Let’s start with something simple. We’re going to create a workflow that builds your iOS app every time you push to the main branch. This is a great way to catch build errors early and make sure your app can always be built.

First, create the directory structure in your repository:

mkdir -p .github/workflows

Now, let’s create our first workflow file:

name: Build iOS App

# When should this workflow run?
on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]

jobs:
  build:
    # We need a macOS runner for iOS builds
    runs-on: macos-latest
    
    steps:
    # First, we check out our code
    - uses: actions/checkout@v4
    
    # Set up Xcode 16.2
    - name: Set up Xcode
      uses: maxim-lobanov/setup-xcode@v1
      with:
        xcode-version: "16.2.0"
    
    # Set up Ruby (for Fastlane)
    - name: Set up Ruby
      uses: ruby/setup-ruby@v1
      with:
        ruby-version: 3.1
        bundler-cache: true
    
    # Install dependencies
    - name: Install dependencies
      run: bundle install
    
    # Set up SSH key for Match repository access
    - name: Set up SSH key for Match
      run: |
        mkdir -p ~/.ssh
        echo "${{ secrets.MATCH_GIT_PRIVATE_KEY }}" > ~/.ssh/id_rsa
        chmod 600 ~/.ssh/id_rsa
        ssh-keyscan github.com >> ~/.ssh/known_hosts
        # Create SSH config for GitHub
        cat > ~/.ssh/config << EOF
        Host github.com
          HostName github.com
          User git
          IdentityFile ~/.ssh/id_rsa
          IdentitiesOnly yes
        EOF
        # Test SSH connection
        ssh -T git@github.com || true
    
    # Set up code signing with Match
    - name: Set up code signing
      run: bundle exec fastlane match appstore --readonly --app_identifier com.yourcompany.yourapp
      env:
        MATCH_REPOSITORY: ${{ secrets.MATCH_REPOSITORY }}
        MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
    
    # Build the app using Fastlane
    - name: Build app
      run: bundle exec fastlane build

Let’s break this down step by step:

The trigger

on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]

This tells GitHub Actions to run this workflow whenever someone pushes to the main branch, or when someone creates a pull request targeting the main branch. This is a great way to catch issues early!

The runner

runs-on: macos-latest

For iOS development, we need a macOS runner because Xcode only runs on macOS. GitHub provides macOS runners, but they’re a bit more expensive than Linux runners (but still within the free tier for most use cases).

The steps

Each step does one specific thing:

  1. Checkout the code: This downloads your repository’s code to the runner
  2. Set up Ruby: This installs Ruby and Bundler, which we need for Fastlane
  3. Install dependencies: This runs bundle install to install all the gems in your Gemfile
  4. Build the app: This runs your Fastlane build lane

Making it work with your project

Now, this workflow assumes you’ve got a Fastlane setup with a build lane, like we covered in our building with Fastlane post. If you don’t have that set up yet, you’ll need to do that first.

Your Fastfile should look something like this:

#!/usr/bin/ruby
# frozen_string_literal: true

fastlane_version '2.225.0'

default_platform :ios
platform :ios do

  desc "Build the app"
  lane :build do
    # Build the app (code signing should be done separately)
    gym(
      scheme: "YourAppScheme",
      configuration: "Release",
      export_method: "app-store",
      output_directory: "./build",
      export_options: {
        uploadBitcode: false,
        uploadSymbols: true,
        compileBitcode: false
      }
    )
  end

end

Make sure to replace "YourAppScheme" with the actual scheme name from your Xcode project.

Code Signing Explained

Notice how we handle code signing as a separate step in our GitHub Actions workflow, before the build step:

- name: Set up code signing
  run: bundle exec fastlane match appstore --readonly

This approach separates concerns:

This makes debugging easier - if signing fails, you know exactly where to look!

Setting up Fastlane Match

Before our workflow can sign apps in CI, we need to set up Fastlane Match. Match is a tool that stores your certificates and provisioning profiles in a secure Git repository, encrypted with a passphrase.

Why Fastlane Match?

Traditional code signing is a nightmare in CI environments:

Match solves all of this by:

Initial Match Setup

First, add Match to your Fastfile if it’s not already there:

desc "Sync certificates and profiles"
lane :certificates do
  match(
    type: "appstore",
    readonly: false  # Allow creating new certificates during setup
  )
end

Creating the certificates repository

  1. Create a new private Git repository for your certificates. This can be on GitHub, GitLab, or any other Git provider. Name it something like MyApp-certificates or ios-certificates.

  2. Initialize Match in your project directory:

bundle exec fastlane match init

When prompted:

Generating certificates

Now let’s generate the certificates and profiles:

# Generate App Store certificates and profiles
bundle exec fastlane match appstore

Match will:

Important: Make sure your Apple Developer account has admin access, or this step will fail.

Verify it worked

You should see something like this in your certificates repository:

certs/
  distribution/
    [TEAM_ID].cer
    [TEAM_ID].p12
profiles/
  appstore/
    AppStore_[BUNDLE_ID].mobileprovision

All files are encrypted and can only be decrypted with your passphrase.

Team setup

If you’re working with a team, each team member should run:

bundle exec fastlane match appstore --readonly

This downloads and installs the certificates locally without creating new ones.

Testing your workflow

Once you’ve committed and pushed these files to your repository, GitHub Actions will automatically pick them up and start running. You can see the status of your workflows by going to the “Actions” tab in your GitHub repository.

If everything goes well, you should see a green checkmark ✅. If something goes wrong, you’ll see a red X ❌, and you can click on it to see what went wrong.

Common issues and troubleshooting

”No Gemfile found”

This means you don’t have a Gemfile in your repository. Create one with:

source 'https://rubygems.org'

gem 'fastlane'

“Could not find scheme ‘YourAppScheme’”

Make sure the scheme name in your Fastfile matches exactly with the scheme name in your Xcode project. Scheme names are case-sensitive!

”Error cloning certificates git repo” / “terminal prompts disabled”

This is the error you’ll see if your Match repository is private and you haven’t set up SSH authentication:

fatal: could not read Username for 'https://github.com': terminal prompts disabled
Error cloning certificates repo, please make sure you have read access to the repository

Solution: Follow the SSH access setup section above to configure SSH authentication for your private certificates repository.

”Enter passphrase for /Users/runner/.ssh/match_key”

If you see this error, it means your SSH key was generated with a passphrase:

Agent pid 2195
Enter passphrase for /Users/runner/.ssh/match_key: 
Error: Process completed with exit code 1.

Solution: Regenerate your SSH key without a passphrase:

ssh-keygen -t ed25519 -C "github-actions-match" -f ~/.ssh/github_actions_match -N ""

The -N "" flag is crucial - it creates a key without a passphrase, which is required for automated CI/CD environments.

”Repository not found” / “Could not read from remote repository”

If you see this error when Match tries to clone your certificates repository:

ERROR: Repository not found.
fatal: Could not read from remote repository.
Please make sure you have the correct access rights and the repository exists.

This usually means the SSH key isn’t being used properly by Git. The updated workflow above uses an SSH config file which is more reliable than environment variables.

Double-check:

  1. Repository exists: Make sure git@github.com:NoamEfergan/brewbuddy_certs actually exists
  2. Deploy key is added: Verify you added the public key to the repository’s deploy keys
  3. Write access enabled: Ensure “Allow write access” is checked for the deploy key
  4. Private key is correct: Verify the MATCH_GIT_PRIVATE_KEY secret contains the complete private key

”Permission denied (publickey)”

This means your SSH key isn’t properly configured:

  1. Check your deploy key: Make sure you added the public key to your certificates repository’s deploy keys
  2. Check write access: Ensure “Allow write access” is checked for the deploy key
  3. Check the private key secret: Verify MATCH_GIT_PRIVATE_KEY contains the complete private key (including -----BEGIN and -----END lines)

“No value found for ‘app_identifier’”

If you see this error after Match successfully clones and decrypts the certificates repo:

🔓  Successfully decrypted certificates repo
[!] No value found for 'app_identifier'

Solution: Add your app’s bundle identifier to the Match command:

- name: Set up code signing
  run: bundle exec fastlane match appstore --readonly --app_identifier com.yourcompany.yourapp

Replace com.yourcompany.yourapp with your actual bundle identifier (the same one you used when setting up Match initially).

“xcodebuild: error: Unable to find a destination matching the provided destination specifier”

This usually means there’s an issue with the simulator or device specification. For now, don’t worry about this - we’ll cover device selection in more detail in the next post.

Adding code signing secrets

Now that you have Fastlane Match set up, you need to add the secrets to your GitHub repository so the CI environment can access your certificates:

  1. Go to your repository on GitHub
  2. Settings → Secrets and variables → Actions
  3. Add these repository secrets:
    • MATCH_REPOSITORY: The URL to your certificates repository (e.g., https://github.com/yourteam/ios-certificates.git)
    • MATCH_PASSWORD: Your Match encryption passphrase
    • MATCH_GIT_PRIVATE_KEY: SSH private key for accessing your private certificates repository

⚠️ Security Note: These secrets are encrypted by GitHub and only accessible to your workflow. Never commit the repository URL, passphrase, or private keys to your code repository.

Setting up SSH access for private Match repository

Since your certificates repository is private (as it should be!), you need to set up SSH authentication:

1. Generate an SSH key pair

On your local machine, generate a new SSH key specifically for GitHub Actions:

ssh-keygen -t ed25519 -C "github-actions-match" -f ~/.ssh/github_actions_match -N ""

Important: The -N "" flag creates the key without a passphrase. This is necessary for automated CI/CD environments where there’s no interactive terminal to enter passphrases.

This creates two files:

2. Add the public key to your certificates repository

  1. Go to your certificates repository on GitHub
  2. Settings → Deploy keys
  3. Click “Add deploy key”
  4. Title: “GitHub Actions Match Access”
  5. Key: Copy the content of ~/.ssh/github_actions_match.pub
  6. ✅ Check “Allow write access” (Match needs to update the repository)
  7. Click “Add key”

3. Add the private key to your main repository secrets

  1. Go to your main app repository on GitHub
  2. Settings → Secrets and variables → Actions
  3. Add a new secret:
    • Name: MATCH_GIT_PRIVATE_KEY
    • Value: Copy the entire content of ~/.ssh/github_actions_match (the private key file)

4. Update your Match repository URL

In your Matchfile, use the SSH URL instead of HTTPS:

git_url("git@github.com:yourteam/ios-certificates.git")
storage_mode("git")
type("development") # The default type, can be overridden

Or if you prefer to keep the HTTPS URL in your Matchfile, you can override it in the workflow (shown below).

Testing your setup

To verify everything is working, you can test locally first:

# This should download and install certificates without prompting
MATCH_PASSWORD="your_passphrase" bundle exec fastlane match appstore --readonly

If this works locally, it will work in CI too!

Security considerations

Now that we’re dealing with certificates and sensitive information, security becomes crucial. GitHub provides a feature called “Secrets” where you can store sensitive information encrypted. Never commit certificates, private keys, or passphrases directly to your repository.

We’ll dive deeper into secure code signing practices in the upcoming posts, but the foundation is now in place!

What’s next?

This is just the beginning! We’ve set up a basic workflow that builds your app on every push. In the next post, we’re going to extend this to run your unit tests as well, and we’ll start getting into some more advanced GitHub Actions features like caching and matrix builds.

And after that? Well, we’ll get to the good stuff - automatically triggering TestFlight builds when you tag your branch with “testflight”. But we need to walk before we can run!

Conclusion

Setting up GitHub Actions for iOS might seem daunting at first, but it’s really just writing some YAML files and leveraging the Fastlane knowledge you already have. The beauty of this approach is that you’re not learning an entirely new tool - you’re just running your existing Fastlane scripts in the cloud.

Give this a try, and let me know how it goes! And if you run into any issues, don’t worry - we’ve all been there. The GitHub Actions logs are usually pretty helpful in figuring out what went wrong.

Next up: running your unit tests with GitHub Actions! 🚀