Ú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
- Cloud repository provider (Gitlab, Github, Bitbucket)
- Platform we're deploying for (iOS, Android)
- 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:
- FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD (Need to generate on the Apple account page)
- MATCH_PASSWORD (Password to be used to decode repository with certificates by fastlane match)
- 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.