DEV Community

Cover image for CI/CD in iOS Development
Fernando Martín Ortiz
Fernando Martín Ortiz

Posted on

CI/CD in iOS Development

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.

devopsios-001

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.

devopsios-002

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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."

image

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:

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
Enter fullscreen mode Exit fullscreen mode

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 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: 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). A step could be either an action 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:

devopsios-001-Page-1

Job composition:

devopsios-001-Page-1 (1)

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

image

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"
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)