Привет, меня зовут Дмитрий, и я iOS разработчик в компании Triada. В этой статье я расскажу, как настроить CI/CD для вашего iOS приложения, и приведу пошаговую инструкцию, как сделать это правильно с первого раза – чтобы не пришлось переделывать.
Мы настроим CI/CD для iOS проекта с репозиторием на GitLab с использованием Fastlane. Сборки будем отправлять в TestFlight и в Firebase, если он у вас настроен. Полный код решения находится здесь.
Что нам потребуется:
-
3 Gitlab репозитория:
репозиторий с проектом, для которого мы настраиваем CI/CD (PROJECT repo).
Предполагается, что на проекте настроен линтер, однако, его отсутствие не критично. Также для тестирования проекта будут использоваться Unit тесты.-
нужно создать
репозиторий для хранения сертификатов (CERTS repo)
репозиторий с файлами CI/CD (Если вы работаете исключительно с одним проектом, то скрипты можно расположить и в репозитории с проектом. В рамках этой статьи будем считать, что вы работаете с несколькими проектами)
MacOS машина, на которой будет работать CI/CD (CI/CD SERVER).
Apple ID с доступом к проектам, от имени которого будет публиковаться приложение
(Опционально) Firebase Service Account - для доступа к проектам. Авторизация CI/CD будет происходить от имени данного пользователя. Firebase здесь будет использоваться исключительно для предоставления сборок тестировщикам.
(Опционально) Gitlab (Premium or Ultimate) для использования Gitlab API запросов на отправку сообщений
(Опционально) Discord сервер - стоит учесть, что на канале необходимы привилегии для создания вебхуков только в рамках настройки.
(Опционально) Jira - так как в данном решении управление задачами осуществляется c помощью Jira, то потребуется аккаунт с доступом на чтение задач.
Нам понадобится два вспомогательных репозитория - один для безопасного хранения сертификатов, а второй для хранения скриптов CI/CD. В первой части статьи расскажу про то, как будет выглядеть процесс настройки CI/CD в целом, а во второй части подробно опишу каждый шаг:
Создание и настройка репозитория для хранения сертификатов
Создание репозитория для скриптов CI/CD
Настройка iOS проекта для работы с GitLab
Настройка машины (хоста) для раннеров CI/CD
Введение
Рассмотрим следующий пайплайн:
При открытии мердж реквеста (MR) автоматически запускается сборка текущей ветки и прогон тестов. После их успешного завершения пайплайн ожидает ручного запуска следующего шага, чтобы разработчик мог при необходимости внести корректировки в код. На первом этапе пайплайна можно также прогнать линтер/форматтер. Если в MR вносятся правки, пайплайн запускается заново.
При запуске следующего этапа create_archive
повышает версию приложения, генерирует .ipa-архив, а также release notes для Firebase. Затем этот архив будет отправлен в Firebase и TestFlight для тестирования.
В общем и целом, наш процесс CI/CD выглядит примерно так:
Как я упомянул ранее, в нашей реализации на этапе сборки дополнительно выполняется проверка линтером файлов .swift, участвующих в MR, и если будут обнаружены какие-либо конфликты, соответствующие сообщения отправляются в MR. Хочу отметить, что даже если независимо от того, нашел ли линтер какие-либо проблемы или нет - пайплайн не блокируется. Если мы хотим, чтобы в MR на GitLab отображался статус проверки кода линтером, нам нужна подписка, иначе у нас не будет токена для API Gitlab.
Сообщения от линтера выглядят следующим образом:
Там, где возможно, линтер открывает тред в 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 будет представлена только информация о версии и номере сборки
Для архива, отправляющегося в TestFlight в рамках данного примера оповещения отключены.
Настройка CI/CD
Настало время приступить к настройке нашего процесса CI/CD
Настройка [CERTS repo]
Прежде всего, нам нужен репозиторий для хранения сертификатов. Он будет использован для хранения сертификатов и поэтому не должен находиться в общем доступе.
Доступ к репозиторию необходимо оформить только определенному кругу лиц и серверу CI/CD, поэтому создаем репо в Gitlab и делаем его приватным.
Вуаля - вы замечательны. На этом настройка репозитория с сертификатами завершена.
Настройка [CI/CD repo]
Мы создаем репозиторий и делаем его приватным. Файлы, перечисленные здесь, представлены только для справки. По окончанию настройки, репозиторий должен содержать следующие файлы, которые вы можете взять из репозитория.
где по пути fastlane/
располагается:
Обратите внимание на .gitlab-ci-template.yml
- этот файл содержит необходимую информацию о нашем пайплайне и будет использоваться любым проектом с CI/CD. Как вы могли заметить, он довольно небольшой, и в нем не так много переменных - они должны быть объявлены позже в настройках CI/CD вашего проекта.
Вы можете скорректировать файл удобным для вас образом, например, добавить везде дополнительное условие на именование ветки, как это было сделано в шаге create_archive
, либо убрать его вовсе:
Если вы не собираетесь внедрять Firebase в проект или использовать TestFlight для тестирования, удалите следующие строки:
Для Firebase:
Для TestFlight:
Настройка [PROJECT repo]
Допустим, у нас уже есть готовый проект с развернутым линтером, нам потребуется выполнить следующие шаги:
Убедиться, что проект настроен корректно
Завести несколько переменных
Завести новый пайплайн
Создать для проекта раннеры
Настроить Appfile
1. Убеждаемся, что проект настроен корректно
Теперь проверим, что проект настроен корректно.
Настройка схем и таргетов
В проекте должны присутствовать кроме основного таргета еще таргет для тестирования с привязанной к нему схеме.
В данном гайде будет проект с 2 таргетами:
Как ранее было упомянуто, таргет для тестирования в нашем случае отвечает за unit тесты. Однако, если у Вас появляется желание или необходимость развернуть и UI тесты, дополнительно заводится таргет и схема под него.
Для работы CI/CD с нашей версией проекта необходимо создать как минимум первые 2 схемы:
1. Схема, с которой будет собираться проект
2. Схема, с которой будут проходить тесты
3. Схема, в которой ведется разработка
Таким образом, мы имеем 3 рабочих схемы, стоит убедиться, что для всех них стоит галочка на shared, в противном случае - схема видна будет только вам.
Интеграция с Firebase
Мы подразумеваем, что проект уже привязан к Firebase, поэтому смело пропускаем эту секцию, если все готово или Firebase использовать не планируется.
Шаги по настройке интеграции с Firebase.
Для начала переходим на страницу Firebase.
Если вы еще не создали проект Firebase, нажмите “Add project”.
После создания проекта, ассоциируем его с нашим iOS проектом. Для этого нажимаем “Add app” внутри проекта и выбираем iOS проект.
На данном этапе отобразится 5 шагов, самым главным для нас является “Apple bundle ID” - его берем из настроек проекта в Xcode.
Скачайте GoogleService-Info.plist и добавьте его в корень репозитория с iOS проектом. После завершения всех указанных шагов проект готов к работе.
Добавим возможность выкладывать сборки в Firebase.
Перейдите во вкладку “Release & Monitor” и выберете “App Distribution”. В открывшемся окне, нажмите “Get started”.
Настройка почти завершена.
Перейдите в таб “Testers & Groups” и добавьте группу для теста “Add group” (при желании можете добавить в нее себя).
На этом настройка тестовых групп завершена, скопируйте название группы - оно понадобится при настройке GitLab CI/CD
Теперь, перейдите в настройки проекта:
В табе “General” располагается информация о проекте и привязанных к нему приложениях. Нас интересует секция “Your apps” - в ней можно найти информацию о проекте и при необходимости ее скорректировать.
Отмечу, что Bundle ID менять для приложения крайне не рекомендую. При необходимости провести данную процедуру - стоит привязать новое приложение.
Скопируйте значение “App ID” - оно потребуется далее при настройке.
2. Заводим переменные для проекта
Для корректной работы CI/CD потребуется в рамках проекта на Gitlab завести 3 переменные.
"SETTINGS" → "CI/CD" → "Variables" → "Expand" → "Add variable"
Переменная |
Описание |
Пример |
PROJECT_NAME |
Имя проекта |
FastlaneProject |
PROJECT_REPO_URL |
Ссылка на репозиторий для использования git clone |
https://gitlab.com/XXXXXX/YYYYYY.git вместо XXXXXX и YYYYYY подставьте ваши значения |
XCWORKSPACE |
Eсли установлены поды, то необходимо предоставить имя .xcworkspace файла с расширением |
FastlaneProject.xcworkspace |
Как итог, должно получиться подобное представление:
Устанавливать 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”
Пока мы не можем найти нужные нам раннеры с тегами - создадим их, нажмем на “New project runner”. Необходимо указать теги для раннера.
В качестве названия раннеров можно, но не обязательно, использовать следующую нотацию для упрощения их идентификации: PROJECT_NAME/JOB_NAME/NUMBER.
После нажатия 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 в рамках пайплайна
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:
|
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"
Нажимаем "New Webhook" и просто копируем его URL в появившимся поле нового элемента:
В проекте [PROJECT repo] переходим в "Settings" -> "Integrations" -> "Discord Notifications". Заполняем следующие поля:
После этого, проект полностью подготовлен к запуску с использованием Gitlab CI/CD + Firebase + Fastlane + Jira и оповещением в Discord. Подводя итог, при условии правильной настройки CI/CD-сервера единственные шаги, необходимые для применения этого процесса CI/CD к новому проекту, следующие:
Создайте пустой частный репозиторий для его сертификатов.
Добавьте в свой проект
.gitlab-ci.yml
с ссылкой на.gitlab-ci-template.yml
.Добавьте переменные в проект GitLab.
Заполните переменные в .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)
hw_store
15.06.2024 10:47+1C ума сойти, кажется, впервые за последнюю пару-тройку лет лет я вижу запятую между частями сложносочинённого предложения, которым открывается статья на хабре! Супер.
petro_64
15.06.2024 10:47А что делать, если есть несколько проектов, и у них конфликтующие зависимости? Ведь их нельзя тогда пускать на один и тот же физический раннер. Есть ли у Apple технология управления окружениями, типа docker? Или предполагается что тогда надо покупать ещё один мак :-)
OoopsItsME Автор
15.06.2024 10:47+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 и прочее - тут это не так работает, к сожалению.
house2008
15.06.2024 10:47Попробуйте как-то через bash переменные для проблемного проекта выставить чтобы pip обновлялся и смотрелся не в системный, а для этого проекта отдельный путь или что-то подобное. Например, у нас постоянно были глюки с руби, что подхватывалась не та версия, в итоге мы просто на старте джобы зафорсили нужную версию выставив ее вначале поиска для bash
before_script: - export PATH="/opt/homebrew/opt/ruby@3.1/bin:$PATH"
petro_64
15.06.2024 10:47Да, спасибо, мы примерно так и поправили. Но вопрос у меня риторический, я написал об этом в последнем абзаце: хотелось бы предотвратить подобные вещи, а не с горящей пятой точкой чинить в пятницу вечером, когда у кого-то из команд в Pull-request, который открыли вдруг оказалось что-то типа в CI:
export TPM_DIR=".cache" ... rm -rf $HOME/$TMP_DIR
, который потом ещё пару раз перезапустили потому что «пайплайн почему-то зафейлился» или PR обновили пару раз (нашли опечатки в комментариях), который в итоге ещё на нескольких машинах побывал из-за этого.
Причем важнее даже изолировать команды/проекты друг от друга, одно дело когда твоя сборка поломалась из-за свежих изменений, а совсем другое - когда есть вероятность что она вдруг не работает из-за какого-то рандомного чела из другого здания.
Пока что, видимо - предполагается что надо покупать свои маки на команду или проект, что сложно назвать нормальным решением. Меня не покидает ощущение, что ну не может быть всё так плохо у Apple с автоматизацией - должно быть какое-то нормальное корпоративное/промышленное/фирменное решение.
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) так как другой возможности нет.
house2008
Мы недавно перешли на
concurrent = 2
так как много ресурсов простаивает без дела когда только одна джоба разрешена.
OoopsItsME Автор
Согласен, если ресурсы системы позволяют и это не приводит к замедлению общего выполнения, то можно спокойно увеличить на тех решениях, где собирается один проект.
Если же проектов несколько, то есть вероятность, что джобы будут друг другу мешать.
Например, если secure files одного проекта будут перезаписаны другим. Справиться с этим не трудно, но все-таки стоит учитывать.
house2008
Параллельные джобы запускаются в разных папках на CI, например project1/0, project1/1 если запущенны 2 параллельные джобы одного проекта.