Introduction
Developing an iOS app is much more than just coding it. Apart from the far obvious activities like product design, product discovery, marketing, sales, etc. which generally don't concern to us (unless we are indie devs), there are other activities that are more related to iOS development that aren't just writing Swift code. Those include certificates management, app distribution, beta testing, CI/CD pipeline configuration, managing dependencies and some others. These activities are very important for us to know, as iOS developers, in order to "level up" in our profession. Now that you know how to write iOS apps, let's talk about CI/CD.
Disclaimer: This article won't go deeper through a detailed step-by-step example. Expect a conceptual introduction to concepts related to CI/CD in iOS development and links along the path, if you are trying to implement a pipeline for your project.
In English, please
CI: Continuous Integration
According to the Wikipedia: "In software engineering, continuous integration is the practice of merging all developers' working copies to a shared mainline several times a day. "
In practice, that means merging your team's code to a shared Git repo several times a day. But you can tell me, "but Fer, we already do that. How should we develop a software project without merging my changes along with my teammates'?", and that's true. However, which is the degree of confidence with which you do that? Are you sure you aren't breaking anything when you do a merge?
Here's when Continuous Integration (CI from here onwards) comes into play.
Let's bring some confidence to the process. The first step will be to execute your tests, unit or UI tests, but more generally your unit tests, whenever you create a pull request to a certain branch.
In practice, whenever we create a pull request to a protected branch such as main
(ex-master
) or develop
, we use a machine that is called CI Server to run some checks on the code you are trying to merge. Those checks could be any kind of process that will tell us whether the code is ready to be merged or if it would break anything. Tests are almost always included into those checks, and generally also does linting, for example.
The steps that are run on a CI server (the "checks"), are called a Continuous Integration Pipeline (CI Pipeline).
You create a pull request, the Git provider (Github, for instance) starts a pipeline in a CI server, the CI server runs the pipeline and finally reports its results. In your pull request, you'll see if the tests passed, the code coverage, or the number of lint warnings in your code.
CD: Continuous Delivery
Continuous Delivery is deeply related to Continuous Integration, that's why we usually talk about CI/CD as a single term. In reality, they mean different things, but they work side by side.
Let's leverage Wikipedia again. According to Wikipedia: "Continuous delivery is a software engineering approach in which teams produce software in short cycles, ensuring that the software can be reliably released at any time and, when releasing the software, doing so manually. It aims at building, testing, and releasing software with greater speed and frequency."
Let's explain this with an example. So you have your CI pipeline that runs whenever you are attempting to merge something into develop
. So you create a PR, the CI pipeline runs and great! the tests passed! You merge your code into develop
. You know now beta testers have to test it. That's when CD enters into play.
We'll use our beloved CI Server again, and will create a new pipeline whenever we merge something into this branch. Whenever we merge into develop
, we'll increment the build number, archive the app to generate an IPA, and send the IPA to TestFlight. That is Continuous Delivery. Of course we can extend it, and do something else when it is merged into main
, for instance.
The toolbox
These are fantastic techniques, whose value is much more evident as your team grows. I've worked in a company with ~100 iOS developers working all in the same app, releasing versions weekly, and we literally couldn't have been able to do it without CI/CD, and a strict discipline and strong mindset and culture from the entire company.
Now, let's talk about the tools we can use to implement CI/CD.
Fastlane
Fastlane deserves my deepest respect. The app automation toolset by excellence, life is better since it came to existence.
So, what exactly means "app automation toolset"? According to their headline, it is "The easiest way to build and release mobile apps. Fastlane handles tedious tasks so you don’t have to."
In practice, this means that things you would do using Xcode, or leveraging the complex xcodebuild cli, are also possible to do by writing a straightforward ruby-based Fastlane script. Fastlane comes with several tools for different purposes, but let's first start by explaining how a Fastlane script, or Fastfile
, is structured.
Lanes
Fastlane calls lanes to the different commands or entry points you define for your automation script. You could have a lane for testing your app for instance, or another lane for incrementing your app's build number, then archiving your app and upload it to TestFlight, etc. You could also combine lanes together in your Fastlane script, a lane could call another lane even sending arguments to it.
desc "Run tests and lint"
lane :ci_develop do
# Some code here...
end
Tools
As I've said, Fastlane is a set of tools for different purposes. They are called actions and you can explore them here.
I'll mention three of the most important actions I use daily.
Scan
The easiest way to run tests of your iOS and Mac app
Like we have discussed in the CI section, we run tests in our project before merging it to a shared branch like develop
or main
. Scan does it in a very simple way. Adding it to your Fastfile is as simple as this:
desc "Run tests and lint"
lane :ci_develop do
scan(
workspace: "Example.xcworkspace",
devices: ["iPhone 11"]
)
end
If you now run fastlane ci_develop
it will immediately run the tests for your app.
Match
Easily sync your certificates and profiles across your team
This tool is amazing, folks. Really. Incredible tool. So, what is Match
? It's the implementation of the Codesigning Guide. According to that guide, "you can use a separate private Git repo to sync your profiles across multiple machines."
We create a private Github repository (we could also use another container like Amazon S3), give Match
the url for that repository and then specify some arguments on how exactly would we like to have our app signed. Match
also asks us for a passphrase that is used for creating the certificates. An important thing to keep in mind is that creating new certificates using Match
usually means that we need to revoke our existing certificates. In terms of how secure it is to have our very private and sensible certificates hosted on Github, this reading would be useful. TL;DR: yes, it is secure enough.
Gym
gym builds and packages iOS apps for you. It takes care of all the heavy lifting and makes it super easy to generate a signed ipa or app file
Creates an ipa file for our iOS app. It's commonly used in combination to other Fastlane actions:
-
Match
: signs the project. -
increment_build_number
: increments the build number. -
commit_version_bump
: creates a commit for the incremented version number. -
pilot
: uploads the ipa to TestFlight.
This is the core of our CD pipeline.
An example Script
Integrating all of this in a Fastfile
is as simple as this:
fastlane_version "2.173.0"
default_platform :ios
platform :ios do
desc "Upload to Testflight"
lane :alpha do
match(
type: "appstore",
app_identifier: [
"com.fmo.example-ios",
"com.fmo.example-ios.app-push-notifications"
],
readonly: true
)
cocoapods(
clean_install: true,
podfile: "./Podfile"
)
gym(
configuration: "Release",
clean: true,
scheme: "example-ios",
export_method: "app-store",
output_directory: "./build"
)
pilot(
changelog: "New functionality",
distribute_external: true,
groups: ["beta_testers"],
ipa: "./build/example-ios.ipa",
skip_waiting_for_build_processing: true,
beta_app_review_info: {
contact_email: "ortizfernandomartin@gmail.com",
contact_first_name: "Fernando",
contact_last_name: "Ortiz",
contact_phone: "123456789",
demo_account_name: "ortizfernandomartin@gmail.com",
demo_account_password: "123456"
}
)
increment_build_number
commit_version_bump(
force: true,
xcodeproj: "example-ios.xcodeproj",
ignore: /example-iosTests/
)
push_to_git_remote(
remote_branch: "main",
force: true
)
end
desc "Run the Test scheme"
lane :test do
cocoapods(
clean_install: true,
podfile: "./Podfile"
)
scan(
scheme: "example-ios",
devices: ["iPhone 11"],
output_types: "junit",
derived_data_path: "./fastlane/derivedData",
)
end
end
Simple, huh? We are now capable of running fastlane test
to install Cocoapods and run tests, and fastlane alpha
to build an ipa and upload it to TestFlight.
Github Actions
The real CI server. I've not mastered it yet, I have to be honest. However, I could configure it in order to run our Fastlane actions on it. It was hard, I won't deny, but extremely rewarding in terms of new knowledge acquired and time saved because of the CI/CD benefits I've described above.
Note that Github Actions is not the only CI server available for us as iOS developers, but it's popular. I have to mention Bitrise as the best alternative I've used in the past, and also CircleCI, which is also popular.
So, what is Github Actions? It's a CI server that is integrated into the same Github repository that you are currently using. You define a workflow
, which actually is a pipeline, where you define also the branch and the event that will activate that workflow
.
Concepts
There are some concepts that are essential to understand how Github Actions works and how you can start using it:
-
Runner: It's the application that actually runs your
jobs
. -
Workflow: A
workflow
is a set ofjobs
that will run together as a CI/CD pipeline. They are fired on specified branches when an event (like push or pull request) happens. -
Job: A set of
steps
. Jobs run in parallel by default, unless dependencies between them are specified. As far as I know, this is more related for when you have microservices or something like that, where (generally in a web project), you can parallelize your work and be more efficient. We will be using just one job per workflow. -
Step: The steps are just that, steps. They are the components on our
job
that actually do the job (no pun intended). Astep
could be either anaction
or a bash script. -
Action: They are predefined blocks of functionality that you can add as a step into your job, in order to do something like cloning a repo, installing certificates (which we won't do because we're using
match
), or some other things.
The workflow overview:
Job composition:
YAML
Each workflow
in Github Actions is described in a YAML file called Workflow file.
The syntax is straightforward, but I have to admit I need to have the syntax reference open side by side with my text editor in order to write it effectively.
Github Secrets and env
I don't want to write much more about Github Actions because it would take many many articles to cover all of what Github Actions (especially in combination with Fastlane) could bring to us in terms of functionality and benefits.
However, I need to mention Github Secrets. If you go to a repo of yours, to the Settings tab, and finally to the Secrets section, you'll find options to add Environment secrets
What are those Secrets? Well, in Github's words: "Secrets are environment variables that are encrypted. Anyone with collaborator access to this repository can use these secrets for Actions."
So, we enter sensible information there, like the match
certicates secrets and finally make it visible to your actions using env
.
A sample file
Let's suppose you have your Fastlane correctly set, the next step would be to add bundler
to your project in case you haven't, and add a line to install Fastlane, and possibly another one to install Cocoapods. Now, let's define two workflow files. Both of them needs to be located in a folder called workflows
, in another folder called .github
.
So, for instance, let's define a workflow that will be executed whenever a pull request is sent to main
(.github/workflows/pull-request.workflow.yml
):
name: "build and test"
on:
pull_request:
branches: ['main']
jobs:
build_and_test:
runs-on: macOS-latest
timeout-minutes: 60
env:
XC_VERSION: ${{ '12.4' }}
steps:
- name: Select latest Xcode
run: "sudo xcode-select -s /Applications/Xcode_$XC_VERSION.app"
- uses: actions/checkout@v2
- name: "Install system dependencies"
run: |
gem install bundler
bundle install
- name: "Run tests"
run: "bundle exec fastlane test"
Pretty straightforward. The env
vars we define there are then accessible to every step.
And another one for when we actually merge into main
(.github/workflows/merge-main.workflow.yml
):
name: "build and distribute"
on:
push:
branches: ['main']
jobs:
build_and_distribute:
runs-on: macOS-latest
timeout-minutes: 60
env:
XC_VERSION: ${{ '12.4' }}
APP_STORE_CONNECT_API_KEY_CONTENT: ${{ secrets.APP_STORE_CONNECT_API_KEY_CONTENT }}
FASTLANE_PASSWORD: ${{ secrets.FASTLANE_PASSWORD }}
FASTLANE_USER: ${{ secrets.FASTLANE_USER }}
GH_AUTH_TOKEN: ${{ secrets.GH_AUTH_TOKEN }}
TEMP_KEYCHAIN_PASSWORD: ${{ secrets.TEMP_KEYCHAIN_PASSWORD }}
TEMP_KEYCHAIN_USER: ${{ secrets.TEMP_KEYCHAIN_USER }}
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
steps:
- name: Select latest Xcode
run: "sudo xcode-select -s /Applications/Xcode_$XC_VERSION.app"
- uses: actions/checkout@v2
- name: "Install system dependencies"
run: |
gem install bundler
bundle install
- name: "Build and distribute"
run: "bundle exec fastlane alpha"
We are defining some important env variables based on secrets. The keychain vars are important because we need to create a temp keychain and send it to match
as explained in this tutorial. The sample applies to the MATCH_PASSWORD
.
The APP_STORE_CONNECT_API_KEY_CONTENT
is really curious and important. As Apple is forcing accounts to enable 2FA, using an api key is the only way we could continue using Fastlane in a CI environment. For more information, see this action.
A final Fastfile
I was about to stop editing this post at this point, but I simply can't without giving to you a Fastfile
with an alpha
action that is the nearest to what worked for me (after several days of working on it).
fastlane_version "2.173.0"
default_platform :ios
# ENV Variables
APP_STORE_CONNECT_API_KEY_CONTENT = ENV["APP_STORE_CONNECT_API_KEY_CONTENT"]
GH_AUTH_TOKEN = ENV["GH_AUTH_TOKEN"]
TEMP_KEYCHAIN_PASSWORD = ENV["TEMP_KEYCHAIN_PASSWORD"]
TEMP_KEYCHAIN_USER = ENV["TEMP_KEYCHAIN_USER"]
def delete_temp_keychain(name)
delete_keychain(
name: name
) if File.exist? File.expand_path("~/Library/Keychains/#{name}-db")
end
def create_temp_keychain(name, password)
create_keychain(
name: name,
password: password,
unlock: false,
timeout: false
)
end
def ensure_temp_keychain(name, password)
delete_temp_keychain(name)
create_temp_keychain(name, password)
end
platform :ios do
desc "Upload to Testflight"
lane :alpha do
app_store_connect_api_key(
key_id: "faf3n2fn1lkn",
issuer_id: "kfnkdnla-asda-1231-1224-asfqwnlk1n",
key_content: APP_STORE_CONNECT_API_KEY_CONTENT,
duration: 1200,
in_house: false
)
keychain_name = TEMP_KEYCHAIN_USER
keychain_password = TEMP_KEYCHAIN_PASSWORD
ensure_temp_keychain(keychain_name, keychain_password)
match(
type: "appstore",
app_identifier: [
"com.fmo.example-ios",
"com.fmo.example-ios.app-push-notifications"
],
git_basic_authorization: Base64.strict_encode64(GH_AUTH_TOKEN),
readonly: true,
keychain_name: keychain_name,
keychain_password: keychain_password
)
cocoapods(
clean_install: true,
podfile: "./Podfile"
)
gym(
configuration: "Release",
clean: true,
scheme: "example-ios",
export_method: "app-store",
output_directory: "./build"
)
pilot(
changelog: "New functionality",
distribute_external: true,
groups: ["beta_testers"],
ipa: "./build/example-ios.ipa",
skip_waiting_for_build_processing: true,
beta_app_review_info: {
contact_email: "ortizfernandomartin@gmail.com",
contact_first_name: "Fernando",
contact_last_name: "Ortiz",
contact_phone: "123456789",
demo_account_name: "ortizfernandomartin@gmail.com",
demo_account_password: "123456"
}
)
increment_build_number
commit_version_bump(
force: true,
xcodeproj: "example-ios.xcodeproj",
ignore: /example-iosTests/
)
push_to_git_remote(
remote_branch: "main",
force: true
)
delete_temp_keychain(keychain_name)
end
end
Where to go next
This has been a quick overview of CI/CD in iOS development, highlighting Fastlane and Github Actions as the two main tools to setup them. Now, there is some knowledge I haven't covered. And more important: I haven't provided a step-by-step complete tutorial on all of this. I highly recommend you this tutorial. I know it's Flutter based, but it will help you A LOT. Also, having deep knowledge on codesigning in iOS will be useful for you, because all of this is not as simple as it can sound, and things can go horribly wrong, especially with app codesigning. Regarding that, I recommend this BRILLIANT article series.
And, of course, practice and patience, a lot of patience.
Top comments (0)