How to set up Gitlab CI/CD with Fastlane for iOS-project on a Mac mini

8 min readJun 28, 2023

Hello, everyone! I’m Yaroslav Fomenko, Doubletapp iOS-developer. Since the end of May, my department colleague and I have been working on implementation, improvement, and scaling of CI/CD for our projects. In this article, we want to share a guide on preparing the Xcode project and setting up runners, scripts, and configs, as well as explain how CI/CD helps us.

To learn how and why we decided to use a Mac mini for CI/CD, click here.

How CI/CD helps us

After deploying automation on the first project, tasks started to reach testing faster.

Now we have 1 task = 1 build. We decided not to merge tasks in dev until testing passes the task. This allows us to be more flexible when it’s time to release but not all tasks have been tested.

This saves time on routine tasks:

  1. No need for a developer responsible for deploying builds for testing.
  2. The builds are given a name.
  3. Information about the build version is added to the board and the status changes.

The likelihood of getting invalid code into dev approaches zero.

CI/CD implementation for a project

Technologies used:

  • CI/CD works on a Mac mini (2018) with a 3.2 GHz 6-core Intel Core i7 processor, 16GB 2667MHz DDR4, macOS Monterey 12.4
  • Gitlab Runner
  • Fastlane 2.208.0
  • Xcode 13.4 and Xcode Command Line Tools
  • rbenv and Ruby 2.6.8. We recommend using this dependency manager, not RVM, as tasks unexpectedly start failing with RVM.
  • Python 3.10
  • Youtrack API
  • Discord

Project preparation

Most of our projects have dependencies through CocoaPods and use Rx and Firebase.

All builds that are made automatically are uploaded to the corporate App Store Connect account for internal testing. Therefore, to start with, we create a CI/CD configuration, specify the bundleID, set the required account, and check that all capabilities are working and the schemes have a Shared checkmark.

If Firebase is used, we create an application object in it with the required ID and generate keys in Connect for pushes (and other keys that your backend needs), which should be added to Firebase. And don’t forget to download Google plist from Firebase and add it to the project, as well as change your script, which selects the required file during application compilation.

Create tests targets if necessary

Setting up runners

You can learn how to install a Gitlab runner here.

For further actions, you will need access to the repository at a Maintainer level or higher.

During registration, you will need to enter:

  1. URL
  2. token
  3. runner name
  4. runner name
  5. executor

After installation, we will register runners in the terminal using the command “gitlab runner register”, using the token that can be found in the repository: Setting-CI/CD-Runners. Also in this tab, it is necessary to disable Shared Runners so that unnecessary runners do not take our job.

We need several shared runners that we can use on multiple projects, and one specific to deploy with a limit of one.

NOTE: It is important not to run the command with sudo during runner registration, as this will lead to incorrect runner operation in the future.

For the names of specific runners, we suggest using the following scheme: ProjectName/jobName/number. For shared runners: jobName/number.

For tags: job:jobName (for example, job:build). The specified tags will be used in the yml file to assign work to the runner only when the tags match (if tag checking is enabled on the runner).

We choose the shell as the executor because we perform actions directly on macOS. Now our runner is registered and displayed in Specific runners. We need to make them shared by clicking on the pencil icon and changing the locking status for the project.

If we leave our runners like this, when we try to run multiple tasks at the same time, they will overwrite each other and crash. To fix this, add the line “concurrent = 4” (or any other number) to the beginning of ~/.gitlab-runner/config.toml

Also, add “limit = 2” to each runner and allow a custom directory for the build.

The difference between “concurrent” and “limit” is that “concurrent” denotes the total number of tasks that can be executed on all runners, while limit restricts the number of tasks on a specific runner.

Setting up the CI stage

For this stage, we need Xcode and xcpretty (gem install xcpretty) for logging.

CI contains 2 stages: build, which checks the build, and test, which runs the test file.

We also want these stages only to be executed when we open a merge request. And if multiple pipelines are launched simultaneously, each job should be executed in its own folder.

We also use Cococapods for dependencies.

Here’s the script that meets our requirements:



- if: $CI_PIPELINE_SOURCE == 'merge_request_event'

when: always

- when: never


- build

- test


LC_ALL: "en_US.UTF-8"

LANG: "en_US.UTF-8"

.general: &general_config


- pod install


stage: build

<<: *general_config


- job:build


- xcodebuild clean -workspace project-ios.xcworkspace -scheme "Scheme debug" | xcpretty

- xcodebuild build -workspace project-ios.xcworkspace -scheme "Scheme debug" -destination 'platform=iOS Simulator,name=iPhone 13,OS=15.5' | xcpretty -s

- './scripts/ "$CI_MERGE_REQUEST_TITLE" "Review"'


stage: test

<<: *general_config


- job:test


- xcodebuild clean -workspace project-ios.xcworkspace -scheme " Scheme debug" | xcpretty

- xcodebuild test -workspace project-ios.xcworkspace -scheme ProjectTests -destination 'platform=iOS Simulator,name=iPhone 13,OS=15.5' | xcpretty -s

LC_ALL and LANG are needed to avoid encoding conflicts.

.general: &general_config is an object that is later referred to with a link, and the code is put in that place.

In “xcodebuild clean”, “build”, and “test”, we simply substitute the name of the necessary workspace/project and device.

As mentioned earlier, whether the runner takes the job depends on the tags.

At our company, we use youTrack. The main statuses for us as developers are “Open” → “In Progress” → “Review” → “Can be Tested”. So after “build”, move the card with the same name as the PR review title using ‘./scripts/ “$CI_MERGE_REQUEST_TITLE” “Review”’.

This script can already be used, but it doesn’t do the most important thing — deployment.

Setting up the CD stage

We use Fastlane for deployment.

Before writing any scripts, install Fastlane using the command “gem install fastlane”.

In the terminal, we navigate to our project folder and initialize Fastlane using “fastlane init”. This command will request data from the App Store Connect account (you need an account that can create applications and upload builds). The command will also create all necessary files and an application object in Connect if it hasn’t been created yet.

After creation, a Fastlane folder will appear, which will contain a fastfile (a file with executable scripts) and an Appfile, which contains information about the bundle Id, account, and team.

We want Fastlane to create necessary certificates, archive our project, and upload the .ipa file to Connect. We also recommend using an App Store Connect API Key for authorization of requests (owner account permissions required). However, we do not recommend storing it directly in the project folder. It’s better to use variables in Gitlab for this purpose.

Also, we will need the “versioning” plugin to get the version number from the project (“fastlane add_plugin versioning” in the project folder).

platform :ios do
desc "Push a new beta build to TestFlight"
before_all do
key_id: "KEY_ID",
issuer_id: "issuer_ID",
key_filepath: "fastlane/AuthKey_KEYID.p8"
lane :upload do |options|
version = get_version_number_from_plist(
target: "TargetName",
plist_build_setting_support: true,
build = latest_testflight_build_number(version: version, initial_build_number: 0) + 1
build_number: build.to_s,
target: " TargetName ",
scheme: "Project debug",
configuration: "CICD",
skip_package_dependencies_resolution: true
changelog: options[:task_name],
app_version: version,
build_number: build.to_s
sh("../scripts/ '#{options[:task_name]};#{version}-#{build}' append")

Let’s look at the script: before executing the lane, we set the key, and in the lane named “upload”, we wait for the passed variables.

We get the version from the project, check the last build from Connect, increment and set this value to the project in the desired configuration.

We sign and generate the necessary certificates using “cert” and “sigh”. We archive the project to the same folder to avoid cluttering the Mac with builds. We set “skip_package_dependencies_resolution” if SPM is not used, and then upload the build with the specified data, skipping the build processing to save time at this stage.

sh("../scripts/ '#{options[:task_name]};#{version}-#{build}' append") is used to save build and task data to a file in a separate repository to add this information to the build later. “task_name” is the name of the variable that must be passed to the script.

lane :distribute do |options|

var = sh("../scripts/ '#{options[:task_name]}' read")

splitted = var.split("\n").last()

version = splitted.split("-").first()

build = splitted.split("-").last()


app_platform: "ios",

distribute_only: true,

app_version: version,

build_number: build,

localized_build_info: {

"default": {whats_new: options[:task_name]},

"ru": {whats_new: options[:task_name]},

"en-GB": {whats_new: options[:task_name]},

"en-US": {whats_new: options[:task_name]}


sh("../scripts/ '#{options[:task_name]}' remove")

sh("../scripts/ '#{options[:task_name]}' 'Можно тестировать' '#{version}(#{build})'")

The file also contains the lane: distribute, which will wait for the build processing to complete. For this, using the script, we read the necessary data from the file, and then in “pilot”, we specify “distribute_only”: “true” so as not to upload anything, but to perform the distribution of the build. In “localized_build_info”, we pass the same description so that everything is displayed correctly on iPhones with different languages. Then the scripts remove the task information from the file and move the card to "Can be tested."

Splitting the addition of a description into different lanes allows us to be more flexible if something goes wrong during operation.

We start the “deploy” stage manually when the task has passed review. Execute the “distribute” stage only if the previous stage is successful.

Now let’s add the “deploy” and “distribute” stages to our yml file


- build

- test

- deploy

- distribute


stage: deploy

<<: *general_config


- job:deploy


- fastlane upload task_name:"$CI_MERGE_REQUEST_TITLE"


- if: $CI_PIPELINE_SOURCE == 'merge_request_event'

when: manual


stage: distribute


- job:distribute


- fastlane distribute task_name:"$CI_MERGE_REQUEST_TITLE"

needs: ["testflight_build"]

when: on_success

task_name: "$CI_MERGE_REQUEST_TITLE" is exactly the variable that our script expects in the “options” array.

Now all we have to do is push our changes to Gitlab 🎉

Integration with Discord

Our team communicates and collaborates on Discord, so the icing on the cake is the easy integration with Discord that is simple to set up. To do this, go to Settings — Integrations — Webhooks — New Webhook on the desired server. Copy its URL and go to the repository. Settings — Integrations — Discord Notification. Check the necessary boxes and paste the copied URL. Test, save, and start waiting for notifications.


After following the steps in the guide, you should have:

  1. three common runners and one specific
  2. a YML file with four stages: build, test, deploy, and distribute
  3. a Fastlane file that performs deploy and distribute in Testflight
  4. integration with Discord

And you will also save a lot of time on deployment.

If you have experience to share or questions, we are waiting for you in the comments.

If you feel like you need more information and want to know about other configuration options, feel free to read an article where we compared ways to set up CI/CD.