CI/CD in iOS Development
Tags: ios, ci, fastlane, githubactions
Published: 2021-03-14 13:09:59

# 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](https://en.wikipedia.org/wiki/Continuous_integration): *"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](https://www.raywenderlich.com/960290-ios-unit-testing-and-ui-testing-tutorial), unit or UI tests, but more generally your unit tests, whenever you create a [pull request](https://docs.github.com/en/github/collaborating-with-issues-and-pull-requests/about-pull-requests) to a certain branch.

![devopsios-001](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/snix5aqcs07y1nll2usu.png)

In practice, whenever we create a pull request to a [protected branch](https://docs.github.com/en/github/administering-a-repository/about-protected-branches#:~:text=You%20can%20enable%20branch%20restrictions,push%20to%20the%20protected%20branch.) 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**](https://www.redhat.com/en/topics/devops/what-cicd-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](https://en.wikipedia.org/wiki/Continuous_delivery): *"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](https://developer.apple.com/testflight/). That is Continuous Delivery. Of course we can extend it, and do something else when it is merged into `main`, for instance.

![devopsios-002](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/rf5wmizr5to5wygksno1.png)

# 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](https://fastlane.tools/) 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](https://developer.apple.com/library/archive/technotes/tn2339/_index.html) cli, are also possible to do by writing a straightforward [ruby](https://www.ruby-lang.org/en/)-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.

```ruby
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](https://docs.fastlane.tools/actions/).
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:

```ruby
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](https://codesigning.guide/). According to that guide, *"you can use a separate private Git repo to sync your profiles across multiple machines."*

![image](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/9qybeys3m1or829re4bw.png)
 
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](https://docs.fastlane.tools/actions/match/#is-this-secure) 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`](https://docs.fastlane.tools/actions/match/): signs the project.
- [`increment_build_number`](https://docs.fastlane.tools/actions/increment_build_number/): increments the build number.
- [`commit_version_bump`](https://docs.fastlane.tools/actions/commit_version_bump/): creates a commit for the incremented version number.
- [`pilot`](https://docs.fastlane.tools/actions/testflight/): 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:

```ruby
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](https://github.com/features/actions) is not the only CI server available for us as iOS developers, but it's popular. I have to mention [Bitrise](https://www.bitrise.io/) as the best alternative I've used in the past, and also [CircleCI](https://circleci.com/), 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**](https://github.com/actions/runner): It's the application that actually runs your `jobs`.
- [**Workflow**](https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions): A `workflow` is a set of `jobs` 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**](https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions#jobs): 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**](https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions#jobsjob_idsteps): The steps are just that, steps. They are the components on our `job` that actually do the job (no pun intended). A `step` could be either an `action` or a bash script.
- [**Action**](https://docs.github.com/en/actions/learn-github-actions/finding-and-customizing-actions): 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](https://github.com/marketplace/actions/checkout), [installing certificates](https://github.com/marketplace/actions/import-code-signing-certificates) (which we won't do because we're using `match`), or some other things.

The workflow overview:

![devopsios-001-Page-1](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/18niiz6p9sxri855gskq.png)

Job composition:

![devopsios-001-Page-1 (1)](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/hkmy33i9ysww4h8fnnk9.png)

### YAML

Each `workflow` in Github Actions is described in a [YAML](https://en.wikipedia.org/wiki/YAML) file called [Workflow file](https://resources.github.com/whitepapers/GitHub-Actions-Cheat-sheet/).
The syntax is straightforward, but I have to admit I need to have the [syntax reference](https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions) 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

![image](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/2eynszkzinw9grrfpjg1.png)
 
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`](https://bundler.io/) 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`):

```yaml
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`):

```yaml
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](https://joshuamdeguzman.com/continuous-delivery-for-flutter-using-fastlane-and-github-actions-pt-3-ios/). 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](https://docs.fastlane.tools/actions/app_store_connect_api_key/).

# 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).

```ruby
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](https://joshuamdeguzman.com/continuous-delivery-for-flutter-using-fastlane-and-github-actions-pt-3-ios/). 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](http://shashikantjagtap.net/ios-code-signing-1-getting-started/).
And, of course, practice and patience, a lot of patience.