Subscribe to Jacob’s Tech Tavern for free to get ludicrously in-depth articles on iOS, Swift, tech, & indie projects in your inbox every two weeks.
Paid subscribers unlock Quick Hacks, my advanced tips series, and enjoy exclusive early access to my long-form articles.
Continuous Integration (CI) is one of the great pillars of senior developerdom.
Opportunities to run the gauntlet of setting up effective build, test, & deploy pipelines is commonplace if you live and breathe greenfield projects, but what if you work on a mature project by day?
If you’ve not set up CI pipelines before, it puts you at a disadvantage as an engineer, since this automation is table stakes for any new project. Today, I’ll show you how to set up CI on your side projects. For free!
Part I: Fastlane
Part II: App Store Connect
Part III: GitHub Actions
I’m implementing CI for Bev, my trusty open-sauce project, but follow my lead to get the same results for your own codebases.
Part I: Fastlane
Fastlane is an open-source set of Ruby scripts which automate builds and deployments. They are essentially user-friendly wrappers over xcodebuild
or Gradle
commands which help you automate standard workflows.
To set Fastlane up, just read the damn docs.
For once, I am going to resist turning this article into a 6,000 word behemoth.
Let’s set up two really simple lanes for testing (to run on each PR) and deployment (to run each time we merge to main).
fastlane test
Our test lane, specced out in the Fastfile, could not be more straightforward:
desc "Run tests on each PR"
lane :test do
scan(device: "iPhone 15 Pro",
scheme: "BevTests")
end
scan runs the tests for a given scheme in our app — here, we’ve set up a test plan which includes a comprehensive unit & UI testing suite. We can run fastlane test
locally, and rapidly see our first success.
fastlane deploy
The deployment lane is a little more complex, since we need to manage certificates, archive our app, and submit to App Store Connect. Here’s a slightly simplified form:
desc "Deploy app to App Store Connect"
lane :deploy do
match
gym
api_key = app_store_connect_api_key()
deliver(api_key: api_key)
end
Working backwards…
deliver uploads our signed
.ipa
file to App Store Connect.app_store_connect_api_key loads our API token in a form we can use with other Fastlane commands.
gym builds the app’s Release variant and packages up our
.ipa
.match installs certificates and provisioning profiles.
If this isn’t your first rodeo, you won’t be surprised to hear that this didn’t just work first time — we had to actually go through a substantial amount of setup work.
match
is the first major difficulty spike in our continuous integration.
fastlane match
The match
command helps to automate and unify the creation, storage, and management of code-signing certificates and profiles for your team. More importantly, it gives your CI machine the ability to sign releases without needing to store credentials on Git.
This is a major reasons that senior devs like to gatekeep CI: match
is both mandatory to understand and a little scary to get to grips with.
If you suffer from impostor syndrome, you are welcome to look at the number of failed builds in my repo.
To set up, run fastlane match init
and follow the step-by-step guide in the CLI to securely store your certificates and provisioning profiles.
Personally I favour the Google Cloud storage mode, since (1) the secret is pretty simple to manage, (2) it has a generous free tier, (3) most mobile devs will already have a Google cloud account via Firebase, and (4) it’s pretty easy to set up a project.
Google Cloud Tips
If you choose Google cloud storage, the steps in the CLI are slightly unclear, so I thought I’d give some extra guidance:
The
gc_keys.json
file created byfastlane match init
contains aclient_email
, e.g.jacobsapps@jacobsapps.iam.gserviceaccount.com
.In the Google Cloud Storage bucket that contains your keys, give admin access to this email address .
For fastlane match
to work, the gc_keys.json
needs to live in your project folder, but it’s critical to ensure keys are kept out of source control, so make sure to .gitignore
it immediately. We will be adding them as secrets to our CI system shortly.
Now, we can run fastlane match appstore
, enter your App Store Connect credentials, and set up the distribution profiles on the key store. fastlane match
generates certificates and profiles, and stores them in the Google Cloud keystore we’ve just set up.
The Full Deployment Pipeline
Now we have our code signing set up, we can flesh out the rest of our deployment pipeline:
lane :deploy do
match(readonly: true)
api_key = app_store_connect_api_key(
key_id: "V4D62Q8UQB",
issuer_id: "69a6de92-2bb4-47e3-e053-5b8c7c11a4d1",
key_content: $APP_STORE_CONNECT_API_KEY_KEY,
is_key_content_base64: true
)
increment_build_number({
build_number: latest_testflight_build_number(api_key: api_key) + 1
})
gym(export_options: "./fastlane/ExportOptions.plist")
deliver(
api_key: api_key,
force: true,
skip_screenshots: true,
precheck_include_in_app_purchases: false
)
end
This has a few improvements from the simplified version above.
match
is running inreadonly
mode, recommended for CI systems as it won’t attempt to create new certificates and profiles.api_key
fetches our App Store Connect API key so we can automate the deployment step — we’ll explain everything here in the next section.increment_build_number
simply increases the build number automatically so we don’t need to keep track of it, fetching the latest from TestFlight.gym
is now fetching export options from a local file that tellxcodebuild
how to package the app.deliver
is overriding a few defaults to make our upload faster.
Now that our local pipeline is in place, and works locally, we can start pulling together our infrastructure: the App Store, and the GitHub Actions runner.
Part II: App Store Connect
To ensure we can actually deploy our app, we need to go to App Store Connect. If your app isn’t already here, add it now.
Creating our App Store Connect API key
Head to Users and Access to set up an App Store Connect API key — we’ll need this later when we start deploying!
Download the file as a .p8
and put this file somewhere secure.
Files are hard and strings are easy. Therefore, I like to base-64-encode the key for use in Fastlane — use this terminal command from inside the folder containing the key:
cat AuthKey_A4D72Q2UQC.p8 | base64
We can take this encoded API key, along with the key_id
and issuer_id
listed on the App Store Connect Users and Access page, and add them to our Fastlane script:
api_key = app_store_connect_api_key(
key_id: "V4D62Q8UQB",
issuer_id: "69a6de92-2bb4-47e3-e053-5b8c7c11a4d1",
key_content: $APP_STORE_CONNECT_API_KEY_KEY,
is_key_content_base64: true
)
What’s APP_STORE_CONNECT_API_KEY_KEY
in key_content
you might ask?
This is our next secret.
Since this API key can submit apps to our account, we need it on lockdown, and far from source control. Note the base-64 encoded key somewhere safe for now; we’ll deal with secrets in Section III when we set up GitHub Actions.
To test locally, you can go ahead and run fastlane deploy
with this base-64 encoded API key hardcoded, to make sure you can run the lane correctly and see the uploaded app on App Store Connect — just make sure not to commit the fastfile
with the API key.
While GitHub Actions allows you to give any name to your secrets, you need to use the exact name
$APP_STORE_CONNECT_API_KEY_KEY
to reference the environment variable of a base-64 encoded .p8 secret. Delve into the Fastlane repo issues for more context on this.
App-Specific Password
There are some Fastlane services for which the API key isn’t sufficient.
To cover all your bases, you can also generate an App-Specific Password for Fastlane services, which authenticate your automation as your own App Store Connect account.
Go to appleid.apple.com and choose Sign-In and Security, then select App-Specific Passwords and add one.
Again, note this password somewhere safe until we add it to our GitHub Actions secrets shortly.
Now we’re ready for prime time.
Part III: GitHub Actions
We’ve got our Fastlane scripts set up nicely. We’ve set our app up on App Store Connect and created our Google Cloud keystore. Finally, we can set up the final piece of our automation infrastructure: GitHub Actions.
Securely Storing Secrets
Let’s get rid of our hardcoded secret first, and place the base-64 encoded $APP_STORE_CONNECT_API_KEY_KEY
in GitHub Actions, under Settings → Secrets and variables → Actions → Repository Secrets.
When running on GitHub Actions, your workflow file (more on this below) which runs your Fastlane scripts will be able to access secrets as an environment property.
APP_STORE_CONNECT_API_KEY_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY_KEY }}
Let’s do the same for the Google Cloud Storage keys — add gc_keys.json
as a secret, ensuring it’s accessible to your CI while safely out of source control.
If you lose this, it’s no problem — run fastlane match
again to recreate your keys, certificates, and profiles.
All in all, we will usually end up with 4 secrets:
The base-64 encoded App Store Connect API Key
Your Apple App-Specific Password
The Google Cloud Storage Bucket access keys
Your Fastlane session credentials*
*This final piece allows your CI to run without requiring 2FA, since App Store Connect’s 2FA is laughably broken.
If you’re running your CI pipeline, the 2FA service will send you a code, but you can’t enter it into the CI session. You need to run it manually and locally to authenticate again, but won’t be sent another code for 8 hours!
fastlane spaceauth
allows you to create an authentication session without risking being sent an unusable 2FA code. Since this is beyond the scope of this tutorial, check out authentication in the Fastlane docs for more details.
Now, with all our secrets happily stored, we can begin.
Self-hosted runners
Frustratingly, cloud-hosted MacOS runners on GitHub Actions cost 10x as much per minute as Linux runners. While public repos are granted 200 minutes of Mac runner time a month, this can be spenny for private or particularly-active repos.
But I promised you that your CI would cost $0.
You can set up a local machine — even your standard development laptop! — as a self-hosted runner. This is a fairly straightforward process, documented well by GitHub. FYI, it’s risky to run self-hosted runners on public repos.
Personally, I like to petition for a Mac Mini at most of my companies.
Once you’re all set up, you can set the runs-on
property in your workflow file to self-hosted
, to point at your machine (or whatever tags you choose). Finally, kick off the listener by executing the action-runner/run.sh
shell script.
Test workflow
Our automation scripts live in the .github/workflows
folder of our repo. The format is good-ol’-fashioned .yaml
, defining when and where we run what.
name: Test
on:
pull_request:
branches: [ main ]
jobs:
test:
runs-on: self-hosted
timeout-minutes: 10
steps:
- uses: actions/checkout@v3
- uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: 15.2
- name: Run Unit Tests
run: fastlane test
The on:
parameter defines when to trigger the workflow: in this instance, we want to run our test suite whenever a PR is made into the main
branch.
The jobs:
parameter shows just one job we want to run, the test
suite.
As mentioned above, the runs-on:
parameter allows you to choose what machine (or machines) to run the pipeline on. self-hosted
tells it to use our local devices, but we can also put tags here, or even request a cloud-hosted runner by putting macos-13
(or any other version number).
Disclaimer: the cloud-hosted mac runners are pretty flakey. Check out my AppCircle comparison for a more in-depth explainer of the difficulty of GitHub Actions cloud-hosted runners.
Now that we know the when (the on:
trigger), and the where (runs-on:
), we can finally define the what. The steps:
defines what to execute on our test pipeline:
actions/checkout
is a standard built-in action for checking out our source code repository.setup-xcode
is a necessary evil which ensures the correct version of Xcode and its associated build toolchains are used. When running locally, you need to have the defined version installed.fastlane test
finally runs our Fastlane script.
Now that we have everything set up in the workflow, we can create a PR and see it run in the Actions tab on our source repo.
With this test workflow set up, we know whether we’ve created a test-breaking regression every time we create a pull request.
But we can go even further: In your repository Settings, in Branches, you can set up a Branch protection rule. You can enforce a rule which ensures that the test
action passes before merging into main
.
Now that your PR workflow is on point, we can face the final boss: the deployment workflow.
Deploy workflow
It’s likely that running the single Fastlane command in the test
workflow worked out just fine, especially if you used a self-hosted runner.
The deploy
workflow is a different beast entirely.
The surface area of things which can go wrong between provisioning, authentication, and build steps, is far greater. This is magnified further if you masochistically opt for a cloud-hosted runner, and need to deal with versioning everything on top.
This is the last major difficulty spike. Persevering through this pain is what separates the wheat from the chaff.
Seniors were never gatekeeping CI to be jerks.
We wanted to spare you the pain.
Our final, battle-tested deployment workflow is complete:
name: Deploy
env:
FASTLANE_SESSION: ${{ secrets.FASTLANE_SESSION }}
GOOGLE_CLOUD_KEYS: ${{ secrets.GOOGLE_CLOUD_KEYS }}
APP_STORE_CONNECT_API_KEY_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY_KEY }}
FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD: ${{ secrets.FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD }}
on:
push:
branches: [ main ]
jobs:
deploy:
runs-on: self-hosted
timeout-minutes: 10
steps:
- uses: actions/checkout@v3
- uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: 15.2
- name: Create gc_Keys
run: echo $GOOGLE_CLOUD_KEYS >> gc_keys.json
- name: App Store Deploy
run: fastlane deploy
This workflow looks fairly similar, with many of the same steps:
We set up
env:
and grab all our secrets, which are accessible as environment variables in the workflow. This makes them visible to our Fastlane script.The trigger is now
on: push
tomain
, meaning it runs when your PR from the above workflow gets merged in.We similarly
checkout
the repository andsetup-xcode
.Next, we run a very simple script to write the
GOOGLE_CLOUD_KEYS
secret to a local.json
file accessible by the Fastlane script.Finally, we run
fastlane deploy
to toggle the Fastlane script we set up earlier.
I’ll reiterate again: self-hosted runners (i.e., your own machines) tend to work pretty well. Cloud-hosted runners on GitHub Actions can often give you unfathomable errors, especially when using the latest OS and SDKs (SwiftData stopped it dead in its tracks).
I’ve skimmed over the pain here, but you’re welcome to take a look at my many attempts to get the cloud-hosted runner working. Honestly, self-hosted is much easier!
But, like I said before, this tutorial is about setting up your CI workflows for $0, and I’ve delivered that.
After much toil and trouble, our deploy workflow is working nicely on the self-hosted runner!
If you’ve been around the block a few times, you may be compelled to ask: “Jacob, why are you simply writing
fastlane deploy
instead ofbundle exec fastlane deploy
?”To which I respond; I invite you to get Ruby versioning behaving nicely on GitHub Actions, then we can talk.
Conclusion
Setting up Continuous Integration pipelines to test, build, and deploy your projects can be daunting if you’re not familiar, but needn’t be painful.
With Fastlane and GitHub Actions, you can create a sandbox in which to practice these automation techniques at a cost of exactly zero dollars. I hope you learned something, and if you’ve not set up CI before, strongly suggest you give it a go with your side project!
It ain’t glamorous, but it’s honest work.