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:
- A GitHub repository with your iOS project (obviously!)
- Xcode project with a working build configuration
- Basic familiarity with Fastlane (we’ve covered this in our previous series)
- An Apple Developer account (for later posts when we actually deploy to TestFlight)
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:
- Checkout the code: This downloads your repository’s code to the runner
- Set up Ruby: This installs Ruby and Bundler, which we need for Fastlane
- Install dependencies: This runs
bundle install
to install all the gems in your Gemfile - 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:
- Code signing setup happens first, downloading certificates and provisioning profiles
- Building happens second, using the certificates that are now installed
--readonly
ensures we only download existing certificates, not create new ones
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:
- Manually exporting certificates and profiles
- Managing expiration dates
- Keeping multiple machines in sync
- Security risks of storing certificates in CI systems
Match solves all of this by:
- ✅ Storing everything in an encrypted Git repository
- ✅ Automatically syncing certificates across team members and CI
- ✅ Handling certificate renewal
- ✅ Using a single source of truth for all code signing
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
-
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
orios-certificates
. -
Initialize Match in your project directory:
bundle exec fastlane match init
When prompted:
- Enter the URL of your certificates repository
- Choose a strong passphrase (you’ll need this later)
Generating certificates
Now let’s generate the certificates and profiles:
# Generate App Store certificates and profiles
bundle exec fastlane match appstore
Match will:
- Create or download certificates from Apple Developer Portal
- Create provisioning profiles for your app
- Encrypt everything with your passphrase
- Store it in your certificates repository
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:
- Repository exists: Make sure
git@github.com:NoamEfergan/brewbuddy_certs
actually exists - Deploy key is added: Verify you added the public key to the repository’s deploy keys
- Write access enabled: Ensure “Allow write access” is checked for the deploy key
- 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:
- Check your deploy key: Make sure you added the public key to your certificates repository’s deploy keys
- Check write access: Ensure “Allow write access” is checked for the deploy key
- 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:
- Go to your repository on GitHub
- Settings → Secrets and variables → Actions
- 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 passphraseMATCH_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:
~/.ssh/github_actions_match
(private key)~/.ssh/github_actions_match.pub
(public key)
2. Add the public key to your certificates repository
- Go to your certificates repository on GitHub
- Settings → Deploy keys
- Click “Add deploy key”
- Title: “GitHub Actions Match Access”
- Key: Copy the content of
~/.ssh/github_actions_match.pub
- ✅ Check “Allow write access” (Match needs to update the repository)
- Click “Add key”
3. Add the private key to your main repository secrets
- Go to your main app repository on GitHub
- Settings → Secrets and variables → Actions
- Add a new secret:
- Name:
MATCH_GIT_PRIVATE_KEY
- Value: Copy the entire content of
~/.ssh/github_actions_match
(the private key file)
- Name:
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! 🚀