Únor 25, 2022

Intro

CI/CD is something that's way too difficult to set up for what you need it to do. As a developer, you want to commit your changes, merge them and go have a beer... You don't want to manually increment versions, upload to TestFlight or distribute to your testers, handle code signing, expired certificates, invalid provisioning profiles, etc. One would think this is relatively simple to accomplish, however the process can be quite cumbersome. 

CI/CD is an often downplayed topic in the journey of software development, however having a good setup can speed up the development process significantly as well as save the developers quite a few grey hairs. When learning about this topic and browsing tutorials and articles, I noticed many of them were just a step-by-step process on how to setup a specific combination of cloud provider/platform/deployment target and lacked overall explanations of the process. This is something I'd like to rectify here. In this series we will discuss several setups, their pros and cons and attempt to give you insight into what's happening behind the scenes of what the senior developer set up before you. Whole CI/CD process is composed of several key components

  1. Cloud repository provider (Gitlab, Github, Bitbucket)
  2. Platform we're deploying for (iOS, Android)
  3. Deployment target (TestFlight, Firebase App Distribution)

Optionally there's fastlane integrated in the process, which is a set of tools, that makes code signing process much easier than if you'd have to do it manually. 

First setup we will discuss is the setup we're using internally.

Gitlab + Gitlab runners on physical Mac mini + fastlane + iOS => Deploying to TestFlight

Benefits of this setup are minimal issues with code signing, Gitlab runners being very intuitive to setup and not having to pay anything for computing time. One negative is having to have a dedicated physical machine to run the pipelines on.

Gitlab config file: 

In the root of your project, create a file .gitlab-ci.yml which is a configuration file for the pipelines you will run. The file can look something like this:

gitlab-ci

stages:

  •   - init
  •   - build
  •   - test

variables:

  LC_ALL: "en_US.UTF-8"

  LANG: "en_US.UTF-8"

  GIT_STRATEGY: clone

pr_check:

  stage: test

  tags:

    - ios

  script:

- bundle config set --local path 'vendor/bundle'  # Set path to bundle when it's not in the PATH variable on your machine

- bundle install                              # Install bundle

- bundle exec pod install --repo-update       # Install dependencies

- bundle exec fastlane tests                  # Execute tests job in fastlane, we'll discuss later

  artifacts:

paths:

   - ./*.ipa                                   # Produced artifacts (binary app)

  only:

- merge_requests                              # Execute job on every merge request

auto_increment:

  stage: init

  script:

- git remote set-url --push origin git@git.inventi.cz:$CI_PROJECT_PATH  # Set origin using the $CI_PROJECT_PATH variable provided by gitlab environment

- git checkout $CI_COMMIT_REF_NAME                                  # Checkout last commit

- xcrun agvtool new-version -all $CI_PIPELINE_ID                    # Use Xcode tools to set the build number to pipeline token ID (we use this because it's always increasing number)

- git add .                                                                                                              # Add everything to git

- git commit -m "Version bump"                                                                           # Commit with the "Version bump" message

- git push -o ci.skip                                                                                     # Push with the parameter -o ci.skip, which tells Gitlab not to execute any jobs on this push

  tags:

- ios

  only:

- stage

build_qa_internal:

  stage: build

  tags:

    - ios

  script:

- bundle config set --local path 'vendor/bundle'

- bundle install

- bundle exec pod install --repo-update

- bundle exec fastlane qainternal

  artifacts:

paths:

   - ./*.ipa

  only:

- stage

In this file you can see three jobs defined: pr_check, auto_increment, build_qa_internal.

The jobs have several parts:

stage: Determines the order in which the jobs run, as defined at the top of the file

script: The actual commands to be executed

tags: Tag for the gitlab runner (we'll explain in more detail later)

only: Gitlab event(s) that trigger job execution

You may notice the commands use variables such as $CI_PIPELINE_ID... these are provided by Gitlab environment and we'll discuss them later on.

And that's your Gitlab pipeline configuration taken care of. Let's take a look at how to set up the gitlab runner.

Gitlab runners

Gitlabs makes our CI/CD process pretty easy because of its Gitlab Runners. Gitlab runner is similar to Jenkins or TeamCity build systems, but much easier. It is an instance of build software that can run command accoring to ".gitlab-ci.yml" file in root of your project, which we set up above. 

Gitlab already provides a comprehensive tutorial on how to install the runner https://docs.gitlab.com/runner/install/ , so here we will discuss the setup afterwards.

Action steps:

CI/CD variables are needed to be setup. Go to repository menu > Settings > CI/CD > Variables.

Set the following variables:

  1. FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD (Need to generate on the Apple account page)
  2. MATCH_PASSWORD (Password to be used to decode repository with certificates by fastlane match)
  3. MATCH_KEYCHAIN_PASSWORD (Password for the keychain on your machine)

When you set up your gitlab runner, make sure it has the tags that you've used in your .gitlab-ci.yml file

Fastlane

Fastlane... the (black) magic of code signing. We all know that code signing sucks. Having to generate certificates, install provisioning profiles, manually distribute app to TestFlight... Time consuming and mind-numbing process. Fastlane doesn't take all the pain away, but it takes care of plenty of things. The key config file can be found in {PROJECT_ROOT}/fastlane/Fastfile and may look something like this:

Fastfile
# Fastlane Constants
default_platform :ios
fastlane_version "2.125.0"
fastlane_require "spaceship"
 
APPLE_ID = "{SomeNumber}"                       	# From Apple Developer portal. e.g. APP_ID = "123456789"
XCODEPROJ = "{YourProject.xcodeproj}"         	# e.g. XCODEPROJ = "project.xcodeproj"
XCODEWORKSPACE = "{YourWorkspace.xcworkspace}"	# e.g. XCODEWORKSPACE = "project.xcworkspace"
TARGET = "{YourTarget}"                       	# e.g. TARGET = "Develop"
TEAM_ID = "{Apple Team ID}"                   	# From Apple Developer portal. e.g. TEAM_ID = "1234567FF89"
APP_SCHEME = "{YourScheme}"                   	# e.g. SCHEME = "Dev"
APP_BUNDLE_ID = "{your.bundle.identifier}"    	# e.g. APP_BUNDLE_ID = "com.team.project"
APP_CONFIGURATION = "{ConfigurationOptions}". 	# e.g. APP_CONFIGURATION = "Development"
 
MATCH_CERTIFICATES_REPO = "git@git.mycompany.com:mobile/my-project-certificates.git" # Path to the git repository containing certificates repository
BETA_APP_FEEDBACK_EMAIL = "myself@company.com"
CONTACT_FIRST_NAME = "John"
CONTACT_LAST_NAME = "Doe"
CONTACT_PHONE = "+420123456789"
 
ENV["FASTLANE_ITC_TEAM_NAME"] = "My company s.r.o." # From Apple Developer portal
ENV["SPACESHIP_2FA_SMS_DEFAULT_PHONE_NUMBER"] = "+420123456789"
 
 
platform :ios do
 
  ######################### SETUP ###########################################
 
 
  desc "Get certificates"
  lane :get_certs do
    match(type: "development", readonly: true)
	match(type: "appstore", readonly: true)
  end
 
  ######################### TESTS ##########################################
 
  desc "Runs tests"
  lane :tests do
  run_tests(workspace: XCODEWORKSPACE,
        	devices: ["iPhone 11"],
       	 scheme: DEV_SCHEME,
        	clean: true)
  end
 
  ############################################
  desc "Push a new build for the client to the Testflight"
  ############################################
 
  lane :qainternal do
 
	delete_keychain(name: "ci-qai.keychain")
 
	create_keychain(
  	name: "ci-qai.keychain",
  	password: ENV["MATCH_PASSWORD"],
  	default_keychain: false,
  	unlock: true,
  	timeout: 7200,
  	lock_when_sleeps: false
	)
 
	match(
      app_identifier: APP_BUNDLE_ID,
  	git_url: MATCH_CERTIFICATES_REPO,
  	type: "appstore",
  	team_id: TEAM_ID,
  	keychain_name: "ci-qai.keychain",
  	keychain_password: ENV["MATCH_PASSWORD"],
  	force: true
	)
 
    ipa = gym(
	  scheme: APP_SCHEME,
  	configuration: APP_CONFIGURATION,
  	include_bitcode: false
	)
 
	upload_to_testflight(
  	app_identifier: APP_BUNDLE_ID,
  	apple_id: APPLE_ID,
  	distribute_external: false,
  	beta_app_feedback_email: BETA_APP_FEEDBACK_EMAIL,
  	demo_account_required: false,
  	skip_waiting_for_build_processing: true,
  	beta_app_review_info: {
    	contact_email: BETA_APP_FEEDBACK_EMAIL,
    	contact_first_name: CONTACT_FIRST_NAME,
    	contact_last_name: CONTACT_LAST_NAME,
    	contact_phone: CONTACT_PHONE
 	})
 
	refresh_dsyms
 
  end
 
 
  ############################################
  desc "Refresh dsyms"
  ############################################
 
  lane :refresh_dsyms do
	download_dsyms
	upload_symbols_to_crashlytics
 
  end
 
  ############################# POST ACTIONS ##############################
 
  after_all do |lane|
	clean_build_artifacts
  end
 
  error do |lane, exception|
	clean_build_artifacts
  end
 
end

The fastlane works on having defined "lanes" which you execute from the command line. At the top of the file we've defined our variables that are used by fastlane commands and we also use environment variables such as ENV["MATCH_PASSWORD"] which are the Gitlab environment variables we've set up earlier. 

Let's break down the qa_internal lane:

1. delete_keychain: We delete the keychain we want to use in case it exists so as not to interfere with our next steps

2. create_keychain: We create keychain to store our certificates

3. match: Very important step. Downloads and decrypts the repository which holds our certificates and provisioning profiles. Installs them afterwards. No more manual code signing!

4. ipa = gym: Gym actually builds the archive. Equivalent of going to Xcode > Product > Archive. The resulting spa file is stored in the ipa variable, which fastlane then uploads to Testflight

5. upload_to_testflight: Takes the generated archive and uploads to TestFlight

6. refresh_dsyms (Optional): Calls lane that downloads dsyms and uploads them to crashlytics. If you're not using Crashlytics, I highly recommend you start. 

Closing notes

Tying all of this together, you should now just be able to merge your changes and leave to have a beer! All distribution and version bumping is taken care of automatically.