Привет, меня зовут Дмитрий, и я iOS разработчик в компании Triada. В этой статье я расскажу, как настроить CI/CD для вашего iOS приложения, и приведу пошаговую инструкцию, как сделать это правильно с первого раза – чтобы не пришлось переделывать.

Мы настроим CI/CD для iOS проекта с репозиторием на GitLab с использованием Fastlane. Сборки будем отправлять в TestFlight и в Firebase, если он у вас настроен. Полный код решения находится здесь.

Что нам потребуется: 

  1. 3 Gitlab репозитория:

    1. репозиторий с проектом, для которого мы настраиваем CI/CD (PROJECT repo).
      Предполагается, что на проекте настроен линтер, однако, его отсутствие не критично. Также для тестирования проекта будут использоваться Unit тесты.

    2. нужно создать 

      1. репозиторий для хранения сертификатов (CERTS repo)

      2. репозиторий с файлами CI/CD (Если вы работаете исключительно с одним проектом, то скрипты можно расположить и в репозитории с проектом. В рамках этой статьи будем считать, что вы работаете с несколькими проектами)

  2. MacOS машина, на которой будет работать CI/CD (CI/CD SERVER)

  3. Apple ID с доступом к проектам, от имени которого будет публиковаться приложение

  4. (Опционально) Firebase Service Account - для доступа к проектам. Авторизация CI/CD будет происходить от имени данного пользователя. Firebase здесь будет использоваться исключительно для предоставления сборок тестировщикам. 

  5. (Опционально) Gitlab (Premium or Ultimate) для использования Gitlab API запросов на отправку сообщений

  6. (Опционально) Discord сервер - стоит учесть, что на канале необходимы привилегии для создания вебхуков только в рамках настройки.

  7. (Опционально) Jira - так как в данном решении управление задачами осуществляется c помощью Jira, то потребуется аккаунт с доступом на чтение задач.

Нам понадобится два вспомогательных репозитория - один для безопасного хранения сертификатов, а второй для хранения скриптов CI/CD. В первой части статьи расскажу про то, как будет выглядеть процесс настройки CI/CD в целом, а во второй части подробно опишу каждый шаг: 

  1. Создание и настройка репозитория для хранения сертификатов

  2. Создание репозитория для скриптов CI/CD

  3. Настройка iOS проекта для работы с GitLab

  4. Настройка машины (хоста) для раннеров CI/CD

Введение

Рассмотрим следующий пайплайн:
При открытии мердж реквеста (MR) автоматически запускается сборка текущей ветки и прогон тестов. После их успешного завершения пайплайн ожидает ручного запуска следующего шага, чтобы разработчик мог при необходимости внести корректировки в код. На первом этапе пайплайна можно также прогнать линтер/форматтер. Если в MR вносятся правки, пайплайн запускается заново.

При запуске следующего этапа create_archive повышает версию приложения, генерирует .ipa-архив, а также release notes для Firebase. Затем этот архив будет отправлен в Firebase и TestFlight для тестирования.

В общем и целом, наш процесс CI/CD выглядит примерно так:

Выполнение начальных шагов пайплайна: выполнение сборки и запуск тестоРис 2. Выполнение заключительных шагов пайплайна: генерация архива и теплой в TestFlight и Firebase
Выполнение начальных шагов пайплайна: выполнение сборки и запуск тестов
Выполнение заключительных шагов пайплайна: генерация архива и теплой в TestFlight и Firebase
Выполнение заключительных шагов пайплайна: генерация архива и теплой в TestFlight и Firebase

Как я упомянул ранее, в нашей реализации на этапе сборки дополнительно выполняется проверка линтером файлов .swift, участвующих в MR, и если будут обнаружены какие-либо конфликты, соответствующие сообщения отправляются в MR. Хочу отметить, что даже если независимо от того, нашел ли линтер какие-либо проблемы или нет - пайплайн не блокируется. Если мы хотим, чтобы в MR на GitLab отображался статус проверки кода линтером, нам нужна подписка, иначе у нас не будет токена для API Gitlab.

Сообщения от линтера выглядят следующим образом:

Рис 3. Сообщения от линтера в MR
Рис 3. Сообщения от линтера в MR
Там, где возможно, линтер открывает тред в MR.

В текущей реализации нет гарантий, что для каждого конфликта литера будет заведен diff комментарий с указанием кода. Это связано с тем, что в Gitlab API, на мой взгляд, несколько неудобно организована отправка diff комментариев: необходимо указывать начальную и конечную позиции блоков кода как в старом файле, так и в новом, дополнительно предоставляя sha1 для файла. Но если у вас есть время поиграться с Gitlab API, можно написать еще несколько десятков строк кода и решить эту интересную задачку. Подробнее об Gitlab API можно почитать тут.

Шаги deploy_to_fb и deploy_to_tf отвечают за отправку архива приложения в Firebase и TestFlight соответственно. Для Firebase дополнительно установлено оповещение группы тестировщиков и прикрепляются release notes.

Несколько слов о Release notes

В случае нахождения задачи в Jira - будет предоставлен номер задачи и ссылка на нее. Если для задачи присутствует еще и эпик - он также предоставляется в подобном формате. На последней строке всегда присутствует номер версии и сборки.

Пример текущего исполнения release notes
Пример текущего исполнения release notes
Возможный пример исполнения release notes
Возможный пример исполнения release notes

Если же задача не будет найдена, в release notes будет представлена только информация о версии и номере сборки

Возможный пример исполнения release notes
Возможный пример исполнения release notes

Для архива, отправляющегося в TestFlight в рамках данного примера оповещения отключены.

Настройка CI/CD

Настало время приступить к настройке нашего процесса CI/CD

Настройка [CERTS repo] 

Прежде всего, нам нужен репозиторий для хранения сертификатов. Он будет использован для хранения сертификатов и поэтому не должен находиться в общем доступе.
Доступ к репозиторию необходимо оформить только определенному кругу лиц и серверу CI/CD, поэтому создаем репо в Gitlab и делаем его приватным.
Вуаля - вы замечательны. На этом настройка репозитория с сертификатами завершена. 

Настройка [CI/CD repo]

Мы создаем репозиторий и делаем его приватным. Файлы, перечисленные здесь, представлены только для справки. По окончанию настройки, репозиторий должен содержать следующие файлы, которые вы можете взять из репозитория.

Корень проекта репозитория со скриптами CI/CD
Корень проекта репозитория со скриптами CI/CD

где по пути fastlane/ располагается:

Содержимое каталога Fastlane
Содержимое каталога Fastlane

Обратите внимание на .gitlab-ci-template.yml - этот файл содержит необходимую информацию о нашем пайплайне и будет использоваться любым проектом с CI/CD. Как вы могли заметить, он довольно небольшой, и в нем не так много переменных - они должны быть объявлены позже в настройках CI/CD вашего проекта.

Вы можете скорректировать файл удобным для вас образом, например, добавить везде дополнительное условие на именование ветки, как это было сделано в шаге create_archive, либо убрать его вовсе:

Условие запускаcreate_archiveдля определенного эвента и имени ветки
Условие запускаcreate_archiveдля определенного эвента и имени ветки

Если вы не собираетесь внедрять Firebase в проект или использовать TestFlight для тестирования, удалите следующие строки:

Для Firebase:

Код задачи для деплоя сборки в Firebase
Код задачи для деплоя сборки в Firebase

Для TestFlight:

Код задачи для деплоя сборки в TestFlight
Код задачи для деплоя сборки в TestFlight

Настройка [PROJECT repo]

Допустим, у нас уже есть готовый проект с развернутым линтером, нам потребуется выполнить следующие шаги:

  1. Убедиться, что проект настроен корректно

  2. Завести несколько переменных

  3. Завести новый пайплайн

  4. Создать для проекта раннеры

  5. Настроить Appfile

1. Убеждаемся, что проект настроен корректно

Теперь проверим, что проект настроен корректно.

Настройка схем и таргетов

В проекте должны присутствовать кроме основного таргета еще таргет для тестирования с привязанной к нему схеме.
В данном гайде будет проект с 2 таргетами:

Таргеты проекта, для которого разворачиваем CI/CD
Таргеты проекта, для которого разворачиваем CI/CD

Как ранее было упомянуто, таргет для тестирования в нашем случае отвечает за unit тесты. Однако, если у Вас появляется желание или необходимость развернуть и UI тесты, дополнительно заводится таргет и схема под него.

Для работы CI/CD с нашей версией проекта необходимо создать как минимум первые 2 схемы:
1. Схема, с которой будет собираться проект
2. Схема, с которой будут проходить тесты
3. Схема, в которой ведется разработка
Таким образом, мы имеем 3 рабочих схемы, стоит убедиться, что для всех них стоит галочка на shared, в противном случае - схема видна будет только вам.

Схемы проекта, для которого разворачивается CI/CD
Схемы проекта, для которого разворачивается CI/CD

Интеграция с Firebase

Мы подразумеваем, что проект уже привязан к Firebase, поэтому смело пропускаем эту секцию, если все готово или Firebase использовать не планируется.

Шаги по настройке интеграции с Firebase.

Для начала переходим на страницу Firebase.
Если вы еще не создали проект Firebase, нажмите “Add project”.
После создания проекта, ассоциируем его с нашим iOS проектом. Для этого нажимаем “Add app” внутри проекта и выбираем iOS проект.
На данном этапе отобразится 5 шагов, самым главным для нас является “Apple bundle ID” - его берем из настроек проекта в Xcode.
Скачайте GoogleService-Info.plist и добавьте его в корень репозитория с iOS проектом. После завершения всех указанных шагов проект готов к работе.

Добавим возможность выкладывать сборки в Firebase.

Переходим в "App Distribution" для добавления тестовых групп
Переходим в "App Distribution" для добавления тестовых групп

Перейдите во вкладку “Release & Monitor” и выберете “App Distribution”. В открывшемся окне, нажмите “Get started”. 

Настройка почти завершена.

Перейдите в таб “Testers & Groups” и добавьте группу для теста “Add group” (при желании можете добавить в нее себя).


На этом настройка тестовых групп завершена, скопируйте название группы - оно понадобится при настройке GitLab CI/CD

Теперь, перейдите в настройки проекта:

Переходим в "Project settings"
Переходим в "Project settings"

В табе “General” располагается информация о проекте и привязанных к нему приложениях. Нас интересует секция “Your apps” - в ней можно найти информацию о проекте и при необходимости ее скорректировать.
Отмечу, что Bundle ID менять для приложения крайне не рекомендую.  При необходимости провести данную процедуру - стоит привязать новое приложение.

Скопируйте значение “App ID” - оно потребуется далее при настройке.

2. Заводим переменные для проекта

Для корректной работы CI/CD потребуется в рамках проекта на Gitlab завести 3 переменные.
"SETTINGS" → "CI/CD" → "Variables" → "Expand" → "Add variable"

Настройки проекта, где планируется развернуть CI/CD. Добавляем переменные CI/CD
Настройки проекта, где планируется развернуть CI/CD. Добавляем переменные CI/CD

Переменная

Описание

Пример

PROJECT_NAME

Имя проекта

FastlaneProject

PROJECT_REPO_URL

Ссылка на репозиторий для использования git clone

https://gitlab.com/XXXXXX/YYYYYY.git вместо XXXXXX и YYYYYY подставьте ваши значения

XCWORKSPACE

Eсли установлены поды, то необходимо предоставить имя .xcworkspace файла с расширением

FastlaneProject.xcworkspace

Как итог, должно получиться подобное представление:

Переменные CI/CD в проекте
Переменные CI/CD в проекте

Устанавливать Masked не обязательно, это свойство определяет лишь для скрытия адреса в логах

3. Заводим пайплайн

Создаем /.gitlab-ci.yml в корне проекта и копируем в него следующие строки:

include:
- project: 'cicdXXXXXX/cicd'
  file: '/.gitlab-ci-template.yml'

Данный файл ссылается на .gitlab-ci-template.yml из проекта cicdXXXXXX/cicd, который в моем случае используется как [CI/CD repo], не забудьте заменить его на имя вашего проекта для CI/CD.

4. Настроим раннеры для проекта

В Gitlab проекте переходим в “Settings” -> “CI/CD” и открываем секцию “Runners

Настройки проекта, где планируется развернуть CI/CD. Добавляем раннеры
Настройки проекта, где планируется развернуть CI/CD. Добавляем раннеры

Пока мы не можем найти нужные нам раннеры с тегами - создадим их, нажмем на “New project runner”. Необходимо указать теги для раннера.
В качестве названия раннеров можно, но не обязательно, использовать следующую нотацию для упрощения их идентификации: PROJECT_NAME/JOB_NAME/NUMBER.

Добавление Gitlab раннера в проект
Добавление Gitlab раннера в проект

После нажатия Create runner будет предложено выполнить несколько команд в среде, где планируется запускать раннер - для корректной настройки этот шаг пропускать нельзя.
Выполнять команды будем на [CI/CD SERVER].
Во время регистрации раннеров важно не запускать команду под sudo - в дальнейшем это может привести к некорректной работе Раннера. В качестве executor выбираем shell.
По итогу будут зарегистрированы раннеры, файл настройки можно найти тут:

➜  ~ ls -ltrh ~/.gitlab-runner/config.toml
-rw-------@ 1 User  staff   900B May  4 19:55 /Users/CICDUser/.gitlab-runner/config.toml

Внутри config.toml мы можем увидеть записи, связанные с каждым созданным раннером, следующего вида:

concurrent = 1
check_interval = 0
 
[[runners]]
  name = "Runner_name"
  limit = 1
  id = XXXXXXXX
  url = "https://gitlab.com/"
  token = "xxxx-XXXXXXXXXXXXXXXXXXXX"
  executor = "shell"
  [runners.custom_build_dir]
    enabled = true
  [runners.cache]
    [runners.cache.s3]
    [runners.cache.gcs]
    [runners.cache.azure]

Пример заполнения:

[[runners]] 
name = "FastlaneProject/create_archive/1"
  limit = 1
  url = "https://gitlab.com"
  id = 36354822
  token = "XXXXXXXXXXXXX"
  token_obtained_at = 2024-05-07T14:48:23Z
  token_expires_at = 0001-01-01T00:00:00Z
  executor = "shell"
  builds_dir = "/Users/CICDUser/YYYYYY/ZZZZZZZ/cicd/builds"
  cache_dir = "/Users/CICDUser/YYYYYY/ZZZZZZZ/cicd/cache"
  [runners.custom_build_dir]
    enabled = true

Здесь стоит также отметить несколько параметров, а именно:

Параметр

Описание

concurrent = 1

ограничение на количество одновременно работающих раннеров

limit = 1

ограничение для раннера на количество одновременно работающих задач

builds_dir = "/Users/CICDUser/YYYYYY/ZZZZZZZ/cicd/builds"

путь, по которому будут выполняться задачи раннера. Обратите внимание, что ему предшествует директория cicd - это директория, где будет настроен весь процесс CI/CD

cache_dir = "/Users/CICDUser/YYYYYY/ZZZZZZZ/cicd/cache"

кэш раннера

5. Настройка Appfile

Для выполнения этого шага необходимо сперва донастроить [CI/CD SERVER].
Как получить и настроить Appfile будет указано ниже в секции “Настройка [CI/CD SERVER]".
Настроенный Appfile необходимо поместить в Secure Files [PROJECT repo]

Настройка [CI/CD SERVER]

1. Настройка Gitlab раннеров на локальной машинке (сервере)

Для настройки раннеров на локальной машине можно пользоваться официальной документацией GitLab. Переводим командную оболочку на bash, так как корректную работу на zsh GitLab не гарантирует. Проверяем текущий shell:

echo $SHELL

Eсли результат отличен от /bin/bash, то меняем следующей командой и перезапускаем терминал:

chsh -s /bin/bash

Если brew не установлен, то ставим:

/bin/bash -c "$(curl "https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh")"

Устанавливаем rbenv согласно шагам, описанным в описании к репо: инструкция по настройке rbenv
Ставим rbenv, чтобы использовать его вместо системного ruby:

brew install rbenv gitlab-runner 
brew services start gitlab-runner

Добавим rbenv в профайл:

echo 'if which rbenv > /dev/null; then eval "$(rbenv init -)"; fi' >> ~/.bash_profile
source ~/.bash_profile

проверяем версии ruby:

rbenv install -l 

И ставим актуальную версию ruby, на момент написания статьи - это версия 3.3.1:

rbenv install 3.3.1
rbenv global 3.3.1 

Корректируем .bashrc файл, добавив в него следующие строки. Не забудьте заменить заглушку CICDUser актуальным пользователем:

export PATH="/bin:/usr/bin:/usr/local/bin"
export LANG=en_US.UTF-8
export LANGUAGE=en_US.UTF-8
export LC_ALL=en_US.UTF-8
eval "$(rbenv init -)"
 
PATH=$PATH:/Users/CICDUser/bin:/usr/local/homebrew
PATH=$PATH:/Users/CICDUser/.rbenv/shims/
export PATH

Если не установлен Xcode - ставим его.
Для удобства работы с JSON файлами ставим утилиту jq (инструкция по настройке jq).

brew install jq

Выполняем установку gitlab runner согласно пункту 3 из Настройка [PROJECT repo]

2. Fastlane

Устанавливаем fastlane:

brew install fastlane

Теперь переходим по пути, где будет выполняться вся магия CI/CD - место для репозитория [CI/CD repo]. Мы его уже указывали в настройках раннеров - это родительская директория для кэша и билда раннеров:
"/Users/XXXXXX/YYYYYY/ZZZZZZZ/cicd/". Тут делаем клон репозитория [CI/CD repo] и разворачиваем fastlane (инструкция по установке fastlane).

cd /Users/CICDUser/YYYYYY/ZZZZZZZ/cicd/
git clone https://gitlab.com/AAAAAA/BBBBBB.git
...
fastlane init
...

Так как в данном репозитории нет еще проектов, получим следующее предупреждение:

[✔] ?
[✔] Looking for iOS and Android projects in current directory...
[13:51:43]: Created new folder './fastlane'.
[13:51:43]: No iOS or Android projects were found in directory '/Users/WWWW/XXXX/YYYY/ZZZZ'
[13:51:43]: Make sure to `cd` into the directory containing your iOS or Android app
[13:51:43]: Alternatively, would you like to manually setup a fastlane config in the current directory instead? (y/n)

Соглашаемся со всем. По итогу получаем следующие файлы:

MBP-Workstation:ZZZZ cicd$ ls -la
total 24
drwxr-xr-x@  5 CICDUser  staff   160 May 14 13:51 .
drwxr-xr-x@ 17 CICDUser  staff   544 May 14 13:51 ..
-rw-r--r--@  1 CICDUser  staff    46 May 14 13:51 Gemfile
-rw-r--r--@  1 CICDUser  staff  5992 May 14 13:51 Gemfile.lock
drwxr-xr-x@  4 CICDUser  staff   128 May 14 13:51 fastlane
MBP-Workstation:ZZZZ cicd$ ls -la fastlane/
total 16
drwxr-xr-x@ 4 CICDUser  staff  128 May 14 13:51 .
drwxr-xr-x@ 5 CICDUser  staff  160 May 14 13:51 ..
-rw-r--r--@ 1 CICDUser  staff  242 May 14 13:51 Appfile
-rw-r--r--@ 1 CICDUser  staff  598 May 14 13:51 Fastfile

GEMFILE

Ставим ruby gems и вместе с ним dotenv (инструкция по настройке dotenv). Это в дальнейшем упростит нам настройку fastlane. Открываем Gemfile и добавляем следующие строки:

gem "dotenv"
gem "fastlane"

можно использовать следующие команды:

gem install bundler
gem install dotenv

После этого выполняем:

bundle install
Bundle complete! 3 Gemfile dependencies, 92 gems now installed.
Use `bundle info [gemname]` to see where a bundled gem is installed.

для того, чтобы fastlane работал с Firebase выполним следующую команду:

fastlane add_plugin firebase_app_distribution

Таким образом, будет установлен плагин, позволяющий работать с Firebase CLI
Для того, чтобы подтянулся нужный проект - достаточно скачать Firebase CLI и залогиниться в соответствующий аккаунт:

curl -sL https://firebase.tools | bash
firebase login

Appfile

Данный файл специфичен для каждого проекта.
Детальную инструкцию по полям Appfile можно найти тут: инструкция по заполнению Appfile.

Для локального проекта мы ограничимся заполнением всего трех полей: app_identifier, app_id, team_id, itc_team_id. После заполнения - прикрепляем этот файл как Secure File в [PROJECT repo]: "SETTINGS" → "CI/CD" → "Secure Files" → "Expand" → "Upload File".
По итогу, Appfile будет располагаться в Gitlab проекте. В дальнейшем данный файл будет скачиваться и использоваться CI/CD в рамках пайплайна

Настройки CI/CD проекта, добавление Appfile как Secure File в проект
Настройки CI/CD проекта, добавление Appfile как Secure File в проект

Fastfile

Сердцем нашего CI/CD является Fastfile - именно здесь будет осуществляться вся логика работы CI/CD.
Перепишем дефолтный Fastfile по пути fastlane/Fastfile следующим содержанием:

Содержимое Fastfile

Так как файл довольно объемный, его можно взять из репозитория.
Здесь он представлен для ознакомления

# This file contains the fastlane.tools configuration
# You can find the documentation at https://docs.fastlane.tools
#
# For a list of all available actions, check out
#
#     https://docs.fastlane.tools/actions
#
# For a list of all available plugins, check out
#
#     https://docs.fastlane.tools/plugins/available-plugins
#

# Uncomment the line if you want fastlane to automatically update itself
# update_fastlane
require 'fileutils'

default_platform(:ios)
xcode_select "/Applications/Xcode.app"

platform :ios do
  desc "Build step"
  desc "### Example:"
  desc "```\n[bundler exec] fastlane build_before_tests [--env FASTLANE_ENVIRONMENT]\n```"
  lane :build_before_tests do |options|
    # Checks if all ENV variables were defined in the .env.PROJECT_NAME file
    verify_env_variables(['CICD_CLONE_PATH', 'BUILD_SCHEME', 'CICD_LOGS_HOME', 'CICD_DERIVED_DATA_PATH', 'DESTINATION', 'CICD_BUILD_RESULTS_PATH'])

    # Perform linting
    perform_linting()

    # Define project location based on Xcode project or workspace
    project_location = ENV['CICD_CLONE_PATH'] + (ENV['XCWORKSPACE'].empty? ? ENV['XCODEPROJ'] : ENV['XCWORKSPACE'])
    puts "Project location:\t #{ project_location}"

    # Prepare build with fastlane scan
    scan(
      project: ENV['XCWORKSPACE'].empty? ? project_location : nil,
      workspace: ENV['XCWORKSPACE'].empty? ? nil : project_location,
      scheme: ENV['BUILD_SCHEME'],
      configuration: "Release",
      buildlog_path: ENV['CICD_LOGS_HOME'],
      derived_data_path: ENV['CICD_DERIVED_DATA_PATH'],
      destination: ENV['DESTINATION'],
      code_coverage: true,
      output_directory: ENV['CICD_BUILD_RESULTS_PATH'],
      skip_build: false,
      build_for_testing: true,
      clean: false
    )
  end


  desc "Prepare tests for project"
  desc "### Examples:"
  desc "```\n[bundler exec] fastlane run_unit_tests [--env FASTLANE_ENVIRONMENT]\n```"
  lane :run_unit_tests do |options|
    # Checks if all ENV variables were defined in the .env.PROJECT_NAME file
    verify_env_variables(['CICD_CLONE_PATH', 'TEST_SCHEME', 'CICD_LOGS_HOME', 'CICD_DERIVED_DATA_PATH', 'DESTINATION', 'CICD_TEST_RESULTS_PATH'])

    # Define project location based on Xcode project or workspace
    project_location = ENV['CICD_CLONE_PATH'] + (ENV['XCWORKSPACE'].empty? ? ENV['XCODEPROJ'] : ENV['XCWORKSPACE'])
    puts "Project location:\t #{ project_location}"

    # Perform test with fastlane scan
    scan(
      project: ENV['XCWORKSPACE'].empty? ? project_location : nil,
      workspace: ENV['XCWORKSPACE'].empty? ? nil : project_location,
      scheme: ENV['TEST_SCHEME'],
      configuration: "Debug",
      buildlog_path: ENV['CICD_LOGS_HOME'],
      derived_data_path: ENV['CICD_DERIVED_DATA_PATH'],
      destination: ENV['DESTINATION'], 
      test_without_building: false,
      output_directory: ENV['CICD_TEST_RESULTS_PATH'],
      clean: false,
      include_simulator_logs: false
     )
  end


  desc "Prepare IPA archive"
  desc "The lane to run by developers or CI/CD"
  desc "### Examples:"
  desc "```\n[bundler exec] fastlane build_archive type:\"development\" export_method:\"development\" [--env FASTLANE_ENVIRONMENT]\n```"
  desc "```\n[bundler exec] fastlane build_archive type:\"adhoc\" export_method:\"ad-hoc\" [--env FASTLANE_ENVIRONMENT]\n```"
  desc "### Options:"
  desc " * **`type`**: must be: [\"appstore\", \"adhoc\", \"development\", \"enterprise\", \"developer_id\", \"mac_installer_distribution\", \"developer_id_installer\"]"
  desc " * **`export_method`**: export_method must be: [\"app-store\", \"validation\", \"ad-hoc\", \"package\", \"enterprise\", \"development\", \"developer-id\", \"mac-application\"]"
  lane :build_archive do |options|
    # Checks if all ENV variables were defined in the .env.PROJECT_NAME file
    verify_env_variables(['CICD_ARCHIVES_LOCATION', 'CICD_RELEASE_NOTES_FILE_PATH', 'CICD_CLONE_PATH', 'XCODEPROJ', 'BUILD_SCHEME', 'PROJECT_NAME', 'CICD_IPA_ARCHIVE_NAME', 'APP_BUNDLE_ID', 'APP_CERTIFICATES_STORE'])

    # Prepare paths for archive and release notes
    archive_location = ENV['CICD_ARCHIVES_LOCATION']
    release_notes_path = ENV['CICD_RELEASE_NOTES_FILE_PATH']

    if File.exist?(release_notes_path)
      sh "cat /dev/null > #{release_notes_path}"
    else
      FileUtils.mkdir_p(archive_location)
      FileUtils.touch(release_notes_path)
    end 

    # Sync code signing
    sync_code_signing(
          type: options[:type],
          app_identifier: ENV['APP_BUNDLE_ID'],
          readonly: true,
          git_url: ENV['APP_CERTIFICATES_STORE']
    )
    
    # Preparing release notes
    current_branch_name = git_current_branch(ENV['CICD_CLONE_PATH'])
    ticket_number = current_branch_name.match(/(?:\/)([A-Z]+-\d+)/)[1]
    prepare_release_notes(
      ticket_number: "#{ticket_number}"
    )
    parsed_version, parsed_build = parse_version_and_build("#{release_notes_path}")
    new_build = parsed_build.to_i
    new_version = parsed_version.to_i

    # Incrementing build number for archive
    increment_build_number(
      xcodeproj: ENV['CICD_CLONE_PATH'] + ENV['XCODEPROJ'],
      build_number: "#{new_build}"
    )

    # Define project location based on Xcode project or workspace
    project_location = ENV['CICD_CLONE_PATH'] + (ENV['XCWORKSPACE'].empty? ? ENV['XCODEPROJ'] : ENV['XCWORKSPACE'])
    puts "Project location:\t #{ project_location}"
     
    # Determine configuration
    configuration = options[:type] == "appstore" ? "Release" : "Debug"
 
    # Preparing ipa archive with fastlane gym
    gym(
      project: ENV['XCWORKSPACE'].empty? ? project_location : nil,
      workspace: ENV['XCWORKSPACE'].empty? ? nil : project_location,
      scheme: ENV['BUILD_SCHEME'],
      configuration: configuration,
      clean: true,
      output_directory: archive_location,
      output_name: ENV['CICD_IPA_ARCHIVE_NAME'],
      export_method: options[:export_method],
      skip_package_dependencies_resolution: true
    )
  end
  

  desc "Send IPA archive to Testflight"
  desc "### Examples:"
  desc "```\n[bundler exec] fastlane deploy_tf skip_submission:true [--env FASTLANE_ENVIRONMENT]\n```"
  desc "### Options:"
  desc " * **`skip_submission`**: skip the distributing action of pilot and only upload the ipa file true|false(by default)"
  lane :deploy_tf do |options|
    # Checks if all ENV variables were defined in the .env.PROJECT_NAME file
    verify_env_variables(['CICD_ARCHIVES_LOCATION', 'PROJECT_NAME', 'CICD_IPA_FULL_PATH', 'APP_BUNDLE_ID', 'APP_CERTIFICATES_STORE'])

    # Sync code signing
    sync_code_signing(
      type: "development",
      app_identifier: ENV['APP_BUNDLE_ID'],
      readonly: true,
      git_url: ENV['APP_CERTIFICATES_STORE']
    )
    
    # Get credentials for App Store Connect
    apiKey = app_store_connect_api_key(
      is_key_content_base64: true,
      duration: 1200,
      in_house: false # if it is enterprise or not
    )
    
    # Send .ipa archive to the Testflight silently
    testflight(
      app_identifier: options[:appIdentifier],
      skip_waiting_for_build_processing: options[:skip_submission],
      skip_submission: options[:skip_submission],
      ipa: ENV['CICD_IPA_FULL_PATH'],
      api_key: apiKey,
      changelog: ""
    )
  end


  desc "Send Archive to Firebase" 
  desc "### Examples:"
  desc "```\n[bundler exec] fastlane deploy_firebase [--env FASTLANE_ENVIRONMENT]\n```"
  desc "### Options:"
  desc " * **`fb_groups`**: testers groups created in firebase concole app distribution tab"
  desc " * **`fb_release_notes`**: release notes for the specified project archive"
  lane :deploy_firebase do |options|
    # Checks if all ENV variables were defined in the .env.PROJECT_NAME file
    verify_env_variables(['FB_APP_KEY', 'FB_TEST_GROUPS', 'CICD_RELEASE_NOTES_FILE_PATH', 'CICD_IPA_FULL_PATH'])

    # Push ipa archive with specified release notes to the Firebase App Distribution with notification to the specified test groups
    release = firebase_app_distribution(
      app: ENV['FB_APP_KEY'],
      testers: ENV['FB_TEST_GROUPS'],
      release_notes_file: ENV['CICD_RELEASE_NOTES_FILE_PATH'],
      ipa_path: ENV['CICD_IPA_FULL_PATH']
    )
  end


  desc "Get certificates for specified project"
  desc "### Examples:"
  desc "```\n[bundler exec] fastlane certificates [--env FASTLANE_ENVIRONMENT]\n```"
  lane :certificates do |options|
    # Checks if all ENV variables were defined in the .env.PROJECT_NAME file
    verify_env_variables(['APP_BUNDLE_ID', 'APP_CERTIFICATES_STORE'])

    sync_code_signing(
      type: "development",
      app_identifier: ENV['APP_BUNDLE_ID'],
      force_for_new_devices: true,
      git_url: ENV['APP_CERTIFICATES_STORE'],
      readonly: true
    )
    sync_code_signing(
      type: "adhoc",
      app_identifier: ENV['APP_BUNDLE_ID'],
      force_for_new_devices: true,
      git_url: ENV['APP_CERTIFICATES_STORE'],
      readonly: true
    )
    sync_code_signing(
      type: "appstore",
      app_identifier: ENV['APP_BUNDLE_ID'],
      git_url: ENV['APP_CERTIFICATES_STORE'],
      readonly: true
    )
  end


  desc "Generate new certificates for specified project"
  desc "### Examples:"
  desc "```\n[bundler exec] fastlane generate_new_certificates [--env FASTLANE_ENVIRONMENT]\n```"
  lane :generate_new_certificates do |options|
    # Checks if all ENV variables were defined in the .env.PROJECT_NAME file
    verify_env_variables(['APP_BUNDLE_ID', 'APP_CERTIFICATES_STORE'])

    sync_code_signing(
      type: "development",
      app_identifier: ENV['APP_BUNDLE_ID'],
      git_url: ENV['APP_CERTIFICATES_STORE'],
      force_for_new_devices: true,
      readonly: false
    )
    sync_code_signing(
      type: "adhoc",
      app_identifier: ENV['APP_BUNDLE_ID'],
      git_url: ENV['APP_CERTIFICATES_STORE'],
      force_for_new_devices: true,
      readonly: false
    )
    sync_code_signing(
      type: "appstore",
      app_identifier: ENV['APP_BUNDLE_ID'],
      git_url: ENV['APP_CERTIFICATES_STORE'],
      force_for_new_devices: true,
      readonly: false
    )  
  end


  desc "Lint step"
  desc "### Examples:"
  desc "```\n[bundler exec] fastlane perform_linting [--env FASTLANE_ENVIRONMENT]\n```"
  lane :perform_linting do |options|
    # Checks if all ENV variables were defined in the .env.PROJECT_NAME file
    verify_env_variables(['CICD_CLONE_PATH', 'CICD_LINTER_LOCK_FILE', 'CICD_LINTER_RESULTS_FILE'])
  
    # Frist three variables - just for readability
    cicd_clone_path = ENV['CICD_CLONE_PATH']
    linter_lock_file = ENV['CICD_LINTER_LOCK_FILE']
    linter_result_file = ENV['CICD_LINTER_RESULTS_FILE']
    previous_merge_commit = git_last_merge_commit(cicd_clone_path)
    current_commit = git_current_commit(cicd_clone_path)
    current_branch_name = git_current_branch(cicd_clone_path)

    puts "Previous merge commit hash:\t #{previous_merge_commit}"
    puts "Last commit hash:\t\t #{current_commit}"
    puts "Swiftlint lock file:\t  #{linter_lock_file}"
    
    # Reading installed lock or prepareing linter internal files 
    linter_commit = read_linter_commit(linter_lock_file)

    if linter_commit == current_commit
      puts "Linting was already performed for the current commit. Skipping lint steps."
    else
      files_to_lint = git_diff_swift_files(previous_merge_commit, current_commit, cicd_clone_path)
      puts "Files to lint: #{files_to_lint}"

      if files_to_lint.empty?
        puts "No swift files changed. Skipping lint"
      else 
        lint_swift_files(files_to_lint, linter_result_file, cicd_clone_path, current_branch_name)
      end

      File.open(linter_lock_file, "w") { |file| file.write(current_commit) }
    end
  end
  

  desc "Send message to Gitlab"
  desc "### Examples:"
  desc "```\n[bundler exec] fastlane send_message commit_ref:\"BRANCH_NAME\" file_to_comment:\"FILEPATH\" line_to_comment:\"\"  gitlab_message:\"MESSAGE_TO_POST\" [--env FASTLANE_ENVIRONMENT]\n```"
  desc "### Options:"
  desc "* **`commit_ref`**: current brnach name. This will be used to detect opened MR"
  desc "* **`gitlab_message`**: message to post in the new MR thread"
  desc "* **`file_to_comment`**: file to add comment to. Note that all subpaths of the project should be included, providing just file name is not sufficient."
  desc "* **`line_to_comment`**: line in the file that should be comented"
  lane :send_message do |options|
    # Checks if all ENV variables were defined in the .env.PROJECT_NAME file
    verify_env_variables(['GIT_PROJECT_ID', 'CI_API_TOKEN'])

    merge_request_iid = get_merge_request_iid(options[:commit_ref])
    if merge_request_iid
      json_data = prepare_json_data(options, merge_request_iid)
      post_comment_to_merge_request(options, merge_request_iid, json_data)
    else
      UI.error("Failed to retrieve merge request IID")
    end
  end


  desc "Get project information from JIRA"
  desc "### Examples:"
  desc "```\n[bundler exec] fastlane get_jira_info ticket_number:\"TICKET_NUMBER\" [--env FASTLANE_ENVIRONMENT]\n```"
  desc "### Options:"
  desc " * **`ticket_number`**: ticket number usually corresponds to branch_name in project"
  lane :get_jira_info do |options|
    # Checks if all ENV variables were defined in the .env.PROJECT_NAME file
    verify_env_variables(['CICD_RELEASE_NOTES_FILE_PATH', 'JIRA_API_KEY','JIRA_HOST_NAME', 'JIRA_TICKET_URL'])

    # Gather ticket related data
    ticket_summary = execute_jira_api_get_request("/issue/#{options[:ticket_number]}/?fields=summary")
    if ticket_summary.include?('errorMessages') || ticket_summary.include?('Not Found')
      puts "Couldn't find relevant ticket information in JIRA. Skipping all steps."
    else
      ticket_link="#{ENV['JIRA_TICKET_URL']}/#{options[:ticket_number]}"
      ticket_notes="Ticket:\t#{options[:ticket_number]}\t#{ticket_link}\n"

      # Gather Epic related information
      editmeta_response = execute_jira_api_get_request("/issue/#{options[:ticket_number]}/editmeta")
      epic_custom_field_id = `echo '#{editmeta_response}' | jq -r '.fields | to_entries[] | select(.value.name == "Epic Link") | .value.fieldId' | tr -d '\n'`
      epic_ticket_response = execute_jira_api_get_request("/issue/#{options[:ticket_number]}?fields=#{epic_custom_field_id}")
      epic_ticket_number = `echo '#{epic_ticket_response}' | jq -r '.fields.#{epic_custom_field_id}' | tr -d '\n'`
      if epic_ticket_number && !epic_ticket_number.strip.empty? && epic_ticket_number != "null"
        epic_ticket_link="#{ENV['JIRA_TICKET_URL']}/#{epic_ticket_number}"
        ticket_notes+="Epic:\t#{epic_ticket_number}\t#{epic_ticket_link}\n"
      end
        
      # Write ticket information to release notes file
      File.open(ENV['CICD_RELEASE_NOTES_FILE_PATH'], "a") { |file| file.puts ticket_notes }
    end
  end
  
  desc "Saves version and build info to the release notes"
  desc "### Examples:"
  desc "```\n[bundler exec] fastlane prepare_release_notes ticket_number:\"TICKET_NUMBER\"  [--env FASTLANE_ENVIRONMENT]\n```"
  desc "### Options:"
  desc " * **`ticket_number`**: ticket number usually corresponds to branch_name in project"
  lane :prepare_release_notes do |options|
    # Checks if all ENV variables were defined in the .env.PROJECT_NAME file
    verify_env_variables(['CICD_CLONE_PATH', 'XCODEPROJ'])

    # Gathering current version and build of the project
    project_location = ENV['CICD_CLONE_PATH'] + ENV['XCODEPROJ']
    version_number = get_version_number(xcodeproj: project_location)
    build_number = latest_testflight_build_number()
    puts "Local Version Number:\t\t#{version_number}"
    puts "Current Testflight Build Number:\t#{build_number}"

    # Adding ticket relevant information to the release notes
    if options[:ticket_number]
      ticket_number = options[:ticket_number]&.upcase
      puts "Ticket number: #{ticket_number}"
      get_jira_info(ticket_number: ticket_number)
    end

    # Adding version info to the release notes
    build_number += 1
    new_build_info = "Version and build: #{version_number}.#{build_number}"
    File.open(ENV['CICD_RELEASE_NOTES_FILE_PATH'], "a") do |file|
      file.puts "" if file.size > 0
      file.puts new_build_info
    end
  end

  # Methods

  # "Method to get last merge commit"
  def git_last_merge_commit(clone_path)
    `cd #{clone_path} && git log --merges --oneline --format="%H" | head -n1 | tr -d '\n'`
  end

  # "Method to get current commit"
  def git_current_commit(clone_path)
    `cd #{clone_path} && git rev-parse HEAD | tr -d '\n'`
  end

  # "Method to get current branch"
  def git_current_branch(clone_path)
    `cd #{clone_path} && git branch --show-current | tr -d '\n'`
  end

  # "Method to get changed files"
  def git_diff_swift_files(previous_commit, current_commit, clone_path)
  `cd #{clone_path} && git diff #{previous_commit} #{current_commit} --name-only | grep .swift`.split("\n")
  end

  # "Method to read current lock or create linter lock and result files if no lock deteted"
  desc "this prevents on running linter on already processed iteration of pipeline"
  def read_linter_commit(lock_file)
    if File.exist?(lock_file)
      File.read(lock_file).strip
    else
      FileUtils.mkdir_p(File.dirname(lock_file))
      FileUtils.touch(lock_file)
      FileUtils.touch(ENV['CICD_LINTER_RESULTS_FILE'])
      nil
    end
  end

  # "Method to lint specified file"
  def lint_swift_files(files_to_lint, result_file, clone_path, current_branch_name)
    files_to_lint.each do |file|
      swiftlint(
        mode: :lint,
        output_file: result_file,
        config_file: "#{clone_path}.swiftlint.yml",
        files: ["#{clone_path}#{file}"],
        raise_if_swiftlint_error: false,
        ignore_exit_status: true
      )

      parse_linter_results(ENV['CICD_LINTER_RESULTS_FILE'], file, current_branch_name)
    end
  end

  # "Method to parse swiftlint result file"
  def parse_linter_results(result_file, filename, current_branch_name)
    File.open(result_file, "r") do |file|
      file.each_line do |line|
        parsed_data = parse_line(line.chomp)
        if parsed_data
          prepared_string = "**`#{filename}`**  \nSwiftlint #{parsed_data[:issue_level]} at line `#{parsed_data[:line_number]}`  \nlinter rule violated: `#{parsed_data[:rule_name]}`  \n#{parsed_data[:issue_long_description]}"
          send_message(
            commit_ref: "#{current_branch_name}",
            gitlab_message: "#{prepared_string}",
            file_to_comment: "#{filename}",
            line_to_comment: parsed_data[:line_number]
          )
        else
          puts "Failed to parse line."
        end
      end
    end
  end

  # "Method to parse swiftlint result line"
  def parse_line(line)
    pattern = /^(.*\/)*(.+):(\d+):(\d+): (\w+): (.+): (.+) \((\w+)\)$/
    match = line.match(pattern)

    if match
      full_filename = line["#{ENV['CICD_CLONE_PATH']}".length..-1].split(':')[0]
      puts full_filename
      filename = full_filename.sub(/^#{Regexp.escape("#{ENV['CICD_CLONE_PATH']}")}/, '')
      line_number = match[3]
      column_number = match[4]
      issue_level = match[5]
      issue_short_description = match[6]
      issue_long_description = match[7]
      rule_name = match[8]

      return {
        filename: filename,
        line_number: line_number.to_i,
        column_number: column_number.to_i,
        issue_level: issue_level,
        issue_short_description: issue_short_description,
        issue_long_description: issue_long_description,
        rule_name: rule_name
      }
    else
      return nil
    end
  end

  # "Method to parse release notes"
  def parse_version_and_build(file_path)
    version_line = File.readlines(file_path).find { |line| line.start_with?('Version and build:') }
    if version_line
      version_build = version_line.split(':').last.strip
      version, build = version_build.split('.').first(2).join('.'), version_build.split('.').last
      return version, build
    else
      put "Version line not found in file"
    end
  end

  # "Method to get MR id related to the branch"
  def get_merge_request_iid(commit_ref)
    response = execute_gitlab_api_get_request("/merge_requests?scope=all&state=opened&source_branch=#{commit_ref}")
    merge_request_iid = JSON.parse(response).first['iid'] if response && !response.empty?
    merge_request_iid
  end

  #"Method to prepare data for MR comment in Gitlab"
  def prepare_json_data(options, merge_request_iid)
    merge_request_info = execute_gitlab_api_get_request("/projects/#{ENV['GIT_PROJECT_ID']}/merge_requests/#{merge_request_iid}")
    json_data = JSON.parse(merge_request_info)
    diff_refs = json_data['diff_refs']
    base_sha = diff_refs['base_sha']
    start_sha = diff_refs['start_sha']
    head_sha = diff_refs['head_sha']

    characters_to_escape = ['"', "'", '\\', '$', '(', ')', '\\\\']
    escaped_gitlab_message = options[:gitlab_message].gsub(/(#{characters_to_escape.map { |c| Regexp.escape(c) }.join('|')})/, '\\\\\1').gsub("\n", "\\n")
    escaped_gitlab_filepath = options[:file_to_comment].gsub(/(#{characters_to_escape.map { |c| Regexp.escape(c) }.join('|')})/, '\\\\\1')

    "{\"body\": \"#{escaped_gitlab_message}\", \"position\": {\"base_sha\":\"#{base_sha}\", \"start_sha\":\"#{start_sha}\", \"head_sha\": \"#{head_sha}\", \"new_path\": \"#{escaped_gitlab_filepath}\", \"position_type\": \"text\", \"new_line\": #{options[:line_to_comment]}}}"
  end

  # "Method to send comment to Gitlab MR"
  def post_comment_to_merge_request(options, merge_request_iid, json_data)
    response = execute_gitlab_api_post_request("/projects/#{ENV['GIT_PROJECT_ID']}/merge_requests/#{merge_request_iid}/discussions?body=comment", json_data)
    status_code = response.strip.to_i
    if (200..299).include?(status_code)
      UI.success("Diff comment posted successfully")
    else
      UI.error("Failed to apply comment to a code block. Status code: #{status_code}")
      UI.message("Trying to send a simple note to...")
      note_comment_response = execute_gitlab_api_post_request("/projects/#{ENV['GIT_PROJECT_ID']}/merge_requests/#{merge_request_iid}/notes", json_data)
      note_comment_status_code = note_comment_response.strip.to_i
      UI.error("Issue with sending messages to the MR request. Status code: #{note_comment_status_code}") unless (200..299).include?(note_comment_status_code)
    end
  end

  # "Method to execute GET request with Gitlab API"
  def execute_gitlab_api_get_request(endpoint)
    url = "#{ENV['FASTLANE_GITLAB_API_URL']}#{endpoint}"
    `curl -s --request GET --header 'PRIVATE-TOKEN: #{ENV['CI_API_TOKEN']}' '#{url}'`
  end

  # "Method to execute POST request with Gitlab API"
  def execute_gitlab_api_post_request(endpoint, json_data)
    url = "#{ENV['FASTLANE_GITLAB_API_URL']}#{endpoint}"
    `curl -s -o /dev/null --request POST --header "PRIVATE-TOKEN: #{ENV['CI_API_TOKEN']}" --header "Content-Type: application/json" --data '#{json_data}' -w "%{http_code}\n" '#{url}'`
  end
 
  # "Method to execute GET request with JIRA API"
  def execute_jira_api_get_request(endpoint)
    url = "#{ENV['JIRA_HOST_NAME']}#{endpoint}"
    `curl -s --header 'Authorization: Bearer #{ENV['JIRA_API_KEY']}' '#{url}'`
  end

  # "Method to check if ENV variable was defined"
  def verify_env_variables(variables)
    variables.each do |var|
      unless ENV[var]
        puts "Environment variable #{var} is not defined."
        error_message = "Please make sure that #{var} was properly defined in your environment."
        UI.user_error!(error_message)
      end
    end
  end
end


В Fastfile предоставлены lane для работы с пайпланом, а также было решено не выносить из этого же файла сопутствующие методы. У каждого lane присутствует описание работы, способ его вызова и используемые переменные. Дополнительно со всей документацией  можно ознакомиться в README.md в директории проекта fastlane.
Переменные окружения ENV['XXXXX'] - определяются в env файле по пути fastlane/.env.YYYYYYYYY, где  YYYYYYYYY = ['default','PROJECT_NAME'].
PROJECT_NAME - берется из переменных CI/CD [PROJECT repo], default - env по умолчанию.
options[:file_to_comment] - переменные, передаваемые lane извне
Все lane должны быть вызваны с указанием env, соответствующего проекту, например:

bundler exec fastlane iOS build_before_tests --env YourProject # YourProject - имя вашего проекта, ему заведен соответсвующий env .env.YourProject

3. Заведение env для проекта 

fastlane/.env.default 

.env.default - дефолтный env, который используется Fastlane, если мы выполняем команды без указания env. Ниже прикрепил .env.default, необходимый для корректной работы нашего CI/CD:

MATCH_PASSWORD="XXXXXXXXXXXX"
MATCH_KEYCHAIN_PASSWORD="YYYYYYYYYY"

CICD_HOME_DIR="/Users/CICDUser/YYYYYY/ZZZZZZ/cicd/builds"

DESTINATION="platform=iOS Simulator,name=iPhone 15,OS=17.2"

APP_STORE_CONNECT_API_KEY_KEY_ID="XXXXXXXX"
APP_STORE_CONNECT_API_KEY_ISSUER_ID="XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX"
APP_STORE_CONNECT_API_KEY_KEY="XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"

# Fastlane env
FASTLANE_XCODEBUILD_SETTINGS_TIMEOUT=15
FASTLANE_XCODEBUILD_SETTINGS_RETRIES=6
FASTLANE_GITLAB_API_URL="https://gitlab.com/api/v4"

Далее я предоставлю информацию о том, где можно получить данные для указанных параметров:

Параметр

Описание

MATCH_PASSWORD

Пароль чтобы можно было извлечь сертификаты

MATCH_KEYCHAIN_PASSWORD

Пароль к локальному keychain

CICD_HOME_DIR

Путь, по которому работают раннеры. В данном случае, может возникнуть небольшая коллизия. Почему переменная указана как CICD_HOME_DIR, а на самом деле приведен путь для билдов раннеров. Все потому что хоть сердцем CI/CD является Fastfile, однако его руками являются раннеры.

Вся работа выполняется в этой директории, мы могли указать другой путь, однако он все равно должен быть связан с рабочей директорией раннеров.

Данный путь при желании можно сменить, но в таком случае перепроверьте все взаимосвязи

DESTINATION

Симуляторы iOS на которых будут собираться сборки и выполняться тестирование. Указанный симулятор должен обязательно быть установлен.

APP_STORE_CONNECT_API_KEY_KEY_ID

API KEY ID. Данное значение можно получить из App Store Connect секция "Users and Access" , таб "Integrations" подтаб "App Store Connect API"

APP_STORE_CONNECT_API_KEY_ISSUER_ID

API KEY ISSUER ID. Данное значение можно получить из App Store Connect секция "Users and Access", таб "Integrations" подтаб "App Store Connect API"

APP_STORE_CONNECT_API_KEY_KEY

API KEY. Для получения данного значения выполните следующую команду над ключом, скаченным из App Store:

'openssl base64 < path/to/key.p8 | tr -d '\n' | pbcopy'

FASTLANE_XCODEBUILD_SETTINGS_TIMEOUT

Таймаут для попыток выполнения билда

FASTLANE_XCODEBUILD_SETTINGS_RETRIES

Ограничение на количество попыток выполнения билда

FASTLANE_GITLAB_API_URL

Gitlab  API

fastlane/.env.PROJECT_NAME

.env.PROJECT_NAME - это env файл нашего проекта, в нем будут предоставлены все необходимые переменные для работы CI/CD.

# Certificate store
APP_CERTIFICATES_STORE="https://gitlab.com/XXXXXX/YYYYYY.git"
APP_BUNDLE_ID="XXXXXXXXXXXXXXXXXXXXXXXXX"

# Xcode Project specific variables
XCODEPROJ="XXXXXXXXXX.xcodeproj"
XCWORKSPACE="XXXXXXXXXX.xcworkspace"
BUILD_SCHEME="YYYYYYYYYY"
TEST_SCHEME="ZZZZZZZZZ"
PROJECT_NAME="XXXXXXXXXX"

# Firebase variables
FB_APP_KEY="1:XXXXXXXXXXXX:ios:YYYYYYYYYYYY"
FB_TEST_GROUPS="TEST_GROUP_NAME"

# Runner folders
CICD_ARTIFACTS_HOME="$CICD_HOME_DIR/$PROJECT_NAME/artifacts"
CICD_CLONE_PATH="$CICD_HOME_DIR/clones/$PROJECT_NAME/"
CICD_LINTER_HOME="$CICD_ARTIFACTS_HOME/swiftlint"
CICD_LOGS_HOME="$CICD_ARTIFACTS_HOME/logs"
CICD_ARCHIVES_LOCATION="$CICD_ARTIFACTS_HOME/archives/"
CICD_RELEASE_NOTES_FILE_PATH="$CICD_ARCHIVES_LOCATION/release_notes"
CICD_DERIVED_DATA_PATH="$CICD_ARTIFACTS_HOME/derived_data/"
CICD_BUILD_RESULTS_PATH="$CICD_ARTIFACTS_HOME/build_results/$(date '+%Y-%m-%d_%H:%M:%S')/"
CICD_TEST_RESULTS_PATH="$CICD_ARTIFACTS_HOME/test_results/$(date '+%Y-%m-%d_%H:%M:%S')/"
CICD_LINTER_RESULTS_FILE="$CICD_LINTER_HOME/swiftlint_results"
CICD_LINTER_LOCK_FILE="$CICD_LINTER_HOME/swiftlint.lock"
CICD_IPA_ARCHIVE_NAME="$PROJECT_NAME.ipa"
CICD_IPA_FULL_PATH="$CICD_ARCHIVES_LOCATION/$CICD_IPA_ARCHIVE_NAME"

# Gitlab variables
GIT_PROJECT_ID=XXXXXXXX
# Message Agent for GitLab (Only for Premium or Ultimate Gitlab plans)
CI_API_TOKEN="xxxxx-xxxxxxxxxxxxxxxxxxxx"

# JIRA variables
JIRA_URL="https://jira.ZZZZZZ.ru"
JIRA_TICKET_URL="$JIRA_URL/browse"
JIRA_HOST_NAME="$JIRA_URL/rest/api/latest"
JIRA_SEARCH_URL="$JIRA_HOST_NAME/search"
JIRA_API_KEY="yyyyyyyyyyyyyyyyyyyy"

Ниже предоставлю информацию, где можно получить данные для параметров выше:

Параметр

Описание

APP_CERTIFICATES_STORE

Ссылка на репозиторий, где будут храниться все сертификаты. связанные с проектом. Репозитория заводился в шаге "Настройка [CERTS repo]"

APP_BUNDLE_ID

Bundle ID для проекта [PROJECT repo]

XCODEPROJ

Имя Xcodeproj файла с расширением

XCWORKSPACE

Имя Xcworkspace файла с расширением

BUILD_SCHEME

Имя схемы, в рамках которой будет проводить сборка

TEST_SCHEME

Имя схемы, в рамках которой будет проводиться Unit тестирование

PROJECT_NAME

Имя проекта, будет использоваться для создания директорий на сервере CI/CD, а также для обращения к env файлу

FB_APP_KEY

APP KEY от проекта в Firebase. Можем значение извлечь из "Firebase Console" -> "Project overview" -> "Project Settings" -> секция "Your apps" -> "App ID"

FB_TEST_GROUPS

Группы тестирования, заведенные через Firebase Console. Можем извлечь из  "Firebase Console" -> Project Shortcuts "App Distribution" → "Testers & Groups" → Tester groups"

GIT_PROJECT_ID

ID проекта в Gitlab репозитории. Можем значение извлечь из настроек проекта "Settings" -> "General", в секции "Naming, topics, avatar" значение "Project ID"

CI_API_TOKEN

Данный токен доступен только обладателем Premium или Ultimate подписки Gitlab. Может быть создан через настройки проекта "Settings" -> "Access Tokens" -> нажать "Add new token". Обратите внимание, что имя токена будет отображаться в комментариях Gitlab MR

JIRA_URL

URL проекта в JIRA

JIRA_API_KEY

Токен пользователя из под которого будет идти обращение к JIRA API. Можно создать через профиль пользователя → "Персональные токены доступа" → "Создать токен".

После настройки этих двух файлов, считаем, что с настройкой CI/CD - закончено, остается лишь кислая вишенка на торте - добавить интеграцию с Discord.
Почему кислая? Потому что мы настроем оповещения на падения сборок

Интеграция с Discord

Переходим на наш Discord сервер, где предполагается будут находиться все заинтересованные в проекте. Для настройки интеграции необходимо только завести Webhook. Для этого необходим доступ администратора к каналу. 

Клацаем "Edit Channel"

Discord сервер. Выбираем необходимый канал и переходим в его настройки
Discord сервер. Выбираем необходимый канал и переходим в его настройки

Нажимаем "New Webhook" и просто копируем его URL в появившимся поле нового элемента:

Создание Webhook на канал в Discord сервере
Создание Webhook на канал в Discord сервере
Discord сервер, настройки Webhook, копируем Webhook URL
Discord сервер, настройки Webhook, копируем Webhook URL

В проекте [PROJECT repo] переходим в "Settings" -> "Integrations" -> "Discord Notifications". Заполняем следующие поля:

После этого, проект полностью подготовлен к запуску с использованием Gitlab CI/CD + Firebase + Fastlane + Jira и оповещением в Discord. Подводя итог, при условии правильной настройки CI/CD-сервера единственные шаги, необходимые для применения этого процесса CI/CD к новому проекту, следующие:

  1. Создайте пустой частный репозиторий для его сертификатов.

  2. Добавьте в свой проект.gitlab-ci.yml с ссылкой на .gitlab-ci-template.yml.

  3. Добавьте переменные в проект GitLab.

  4. Заполните переменные в .env.NewProject и соответствующем Appfile.

На все действия уйдет не больше часа

Надеюсь, что это руководство будет Вам полезным. Приятного вам кодинга!
Отдельное спасибо авторам следующих статей и моим коллегам.

Полезные ссылки
https://firebase.google.com/docs/app-distribution/authenticate-service-account?platform=ios
https://medium.com/google-cloud/gitlab-and-workload-identity-federation-on-google-cloud-a0795091e404
https://docs.gitlab.com/ee/user/project/integrations/discord_notifications.html
https://medium.com/@sky.tienyu/how-to-deploy-firebase-in-gitlab-ci-using-a-service-account-key-b2a459b63db9
https://about.gitlab.com/blog/2020/03/16/gitlab-ci-cd-with-firebase/
https://www.andrewhoog.com/post/how-to-export-ad-hoc-ios-ipa-xcode/#export-ipa
https://www.andrewhoog.com/post/how-to-build-an-ios-app-archive-via-command-line/

Комментарии (10)


  1. house2008
    15.06.2024 10:47
    +1

    Мы недавно перешли на

    concurrent = 2

    так как много ресурсов простаивает без дела когда только одна джоба разрешена.


    1. OoopsItsME Автор
      15.06.2024 10:47

      Согласен, если ресурсы системы позволяют и это не приводит к замедлению общего выполнения, то можно спокойно увеличить на тех решениях, где собирается один проект.
      Если же проектов несколько, то есть вероятность, что джобы будут друг другу мешать.
      Например, если secure files одного проекта будут перезаписаны другим. Справиться с этим не трудно, но все-таки стоит учитывать.


      1. house2008
        15.06.2024 10:47
        +1

        Параллельные джобы запускаются в разных папках на CI, например project1/0, project1/1 если запущенны 2 параллельные джобы одного проекта.


  1. hw_store
    15.06.2024 10:47
    +1

    C ума сойти, кажется, впервые за последнюю пару-тройку лет лет я вижу запятую между частями сложносочинённого предложения, которым открывается статья на хабре! Супер.


  1. petro_64
    15.06.2024 10:47

    А что делать, если есть несколько проектов, и у них конфликтующие зависимости? Ведь их нельзя тогда пускать на один и тот же физический раннер. Есть ли у Apple технология управления окружениями, типа docker? Или предполагается что тогда надо покупать ещё один мак :-)


    1. OoopsItsME Автор
      15.06.2024 10:47
      +1

      Интересный вопрос, можете привести пример такого конфликта?


      1. petro_64
        15.06.2024 10:47

        Да, конечно, из недавнего: есть десяток проектов и несколько раннеров. Один из проектов где-то в глубине своиз 3rd-party скриптов обновляет pip до свежей версии на раннере, в итоге часть других проектов (которые не используют venv) ломаются, потому что он не разрешает больше ставить пакеты без venv. Происходит это, естественно, в пятницу вечером.

        Мы используем Jenkins, и у него есть разнообразные плагины, например он может запускать VM как раннер по запросу (в т.ч. из темплейта), но с приходом чипов M все гипервизоры типа VmWare превратились в тыкву, и теперь все команды делят bare-metal ноды, со всеми вытекающими приколами. И вот всё думаю, как бы их изолировать по-красивому. В Linux сборках такой проблемы нет - мы давно уже принудительно заставляем использовать докер, не даём прав на запись куда не нужно, не даём запускать :latest теги и т.п. Конечно есть ещё 999 других способов поломать сборку, но они хотя бы не мешают друг другу.

        Разумеется, можно попросить разработчиков уважать друг друга, делать всё правилньо, думать об изоляции и общем использовании, но когда много разных проектов, 3rd-party, legacy и прочее - тут это не так работает, к сожалению.


        1. house2008
          15.06.2024 10:47

          Попробуйте как-то через bash переменные для проблемного проекта выставить чтобы pip обновлялся и смотрелся не в системный, а для этого проекта отдельный путь или что-то подобное. Например, у нас постоянно были глюки с руби, что подхватывалась не та версия, в итоге мы просто на старте джобы зафорсили нужную версию выставив ее вначале поиска для bash

          before_script:
            - export PATH="/opt/homebrew/opt/ruby@3.1/bin:$PATH"


          1. petro_64
            15.06.2024 10:47

            Да, спасибо, мы примерно так и поправили. Но вопрос у меня риторический, я написал об этом в последнем абзаце: хотелось бы предотвратить подобные вещи, а не с горящей пятой точкой чинить в пятницу вечером, когда у кого-то из команд в Pull-request, который открыли вдруг оказалось что-то типа в CI:

            export TPM_DIR=".cache"
            ...
            rm -rf $HOME/$TMP_DIR

            , который потом ещё пару раз перезапустили потому что «пайплайн почему-то зафейлился» или PR обновили пару раз (нашли опечатки в комментариях), который в итоге ещё на нескольких машинах побывал из-за этого.

            Причем важнее даже изолировать команды/проекты друг от друга, одно дело когда твоя сборка поломалась из-за свежих изменений, а совсем другое - когда есть вероятность что она вдруг не работает из-за какого-то рандомного чела из другого здания.

            Пока что, видимо - предполагается что надо покупать свои маки на команду или проект, что сложно назвать нормальным решением. Меня не покидает ощущение, что ну не может быть всё так плохо у Apple с автоматизацией - должно быть какое-то нормальное корпоративное/промышленное/фирменное решение.


    1. house2008
      15.06.2024 10:47
      +1

      У нас на CI (одна машина и всего один ранер) три проекта, параллельные джобы, переиспользование запущенных стимуляторов для тестов и куча другой магии для ускорения сборок и тестов. Пока не сталкивались с конфликтами между проектами)
      Были конфликты в gem версиях, один проект один cocoapods/fastlane использовал, другой другие версии, но мы привязали гемы к каждому проекту через

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

      Xcode версия также легко переключается на лету на каждой джобе через https://docs.fastlane.tools/actions/xcodes/. Мы уже несколько джоб настроили на Xcode 16 beta и iOS 18 beta, и наши тесты выявили баг в SFSafariViewController которые перестал работать.

      Но некоторые тулы приходится держать глобальными (Carthage, Crowdin) так как другой возможности нет.