Всем привет! Меня зовут Ярослав Фоменко, я iOS-разработчик в компании Даблтап. Мы с моим коллегой по отделу с конца мая работаем над внедрением, улучшением и масштабированием CI/CD на наших проектах. В этой статье мы хотим поделиться гайдом по подготовке проекта в Xcode и настройке раннеров, скриптов и конфигов, а также расскажем, как нам помогает CI/CD.

О том, как и почему мы пришли к решению использовать Mac mini для CI/CD, можно почитать здесь.

Как нам помогает CI/CD

После развертывания автоматизации на первом проекте задачки стали доходить до тестирования быстрее. 

Теперь у нас 1 задача = 1 сборка. Мы решили не мержить задачи в dev, пока тестирование не пропустит задачку дальше. Это позволяет более гибко действовать, если пора релизиться, а не все задачки протестированы.

Это экономит время на рутине:

  1. Не нужен разработчик, ответственный за деплой сборок на тестирование.

  2. В сборки добавляется название.

  3. На доске добавляется информация о версии сборки и меняется статус.

Вероятность занести в dev невалидный код стремится к нулю.

Внедрение CI/CD на проект

Используемые технологии

  • CI/CD работает на Mac mini (2018) с 3,2 GHz 6-ядерный процессор Intel Core i7, 16 ГБ 2667 MHz DDR4, macOS Monterey 12.4

  • Gitlab Runner

  • Fastlane 2.208.0

  • Xcode 13.4 и Xcode Command Line Tools

  • rbenv и ruby 2.6.8. Рекомендуем использовать именно этот менеджер зависимостей, а не rvm, т.к. с ним задачи начинают неожиданно падать.

  • Python 3.10

  • Youtrack API

  • Discord

Подготовка проекта

Большинство наших проектов имеют зависимости через Cocoapods и используют Rx и Firebase. 

Все сборки, которые собираются автоматически, мы заливаем в корпоративный аккаунт App Store Connect для внутреннего тестирования. Поэтому для начала создаем конфигурацию CI/CD, прописываем bundleID, ставим нужный аккаунт и проверяем, что все capabilities работают, а схемы имеют галочку Shared.

Если используется Firebase, то создаем в нем объект приложения с нужным ID и генерируем ключи в Connect для пушей (и прочие ключи, нужные вашему бэкенду), которые следует закинуть в Firebase. И не забываем скачать с Firebase Google plist и добавить в проект, а также изменить свой скрипт, который выбирает нужный файл при компиляции приложения.

Настройка раннеров

Как установить Gitlab runner, можно посмотреть здесь

Для дальнейших действий понадобится доступ к репозиторию не ниже уровня Maintainer. 

Во время регистрации необходимо будет ввести:

  1. URL;

  2. токен;

  3. название раннера;

  4. теги раннера;

  5. executor.

После установки зарегистрируем раннеры в терминале с помощью команды gitlab runner register, используя token, который можно найти в репозитории: Setting-CI/CD-Runners. Также в этой вкладке необходимо отключить Shared Runners для того, чтобы ненужные раннеры не брали наши job.

Нам нужно несколько общих раннеров, которые мы сможем использовать на нескольких проектах, и один специфичный для deploy с лимитом один. 

NOTE: Во время регистрации раннера важно не запустить команду под sudo, т.к. в дальнейшем это приведет к некорректной работе раннера. 

Для названий специфичных раннеров предлагаем использовать следующую схему: ProjectName/jobName/number. Для общих: jobName/number 

Для тегов: job:jobName (например, job:build). Указываемые теги в дальнейшем будут использоваться в yml файле для того, чтобы отдавать раннеру работу только при совпадении тегов (если на раннере включена проверка тегов). 

В качестве executor выбираем shell, т.к. мы выполняем действия напрямую на macOS. Теперь наш раннер зарегистрирован и отображается в Specific runners. Нам нужно сделать их общими, нажав на карандашик и изменив статус блокировки под проект.

Если мы так и оставим наши раннеры, то при попытке запустить несколько задач одновременно они будут перезаписывать друг друга и падать. Для этого в ~/.gitlab-runner/config.toml допишем в самое начало строчку concurrent = 4 (или любое другое число)

А в каждый раннер напишем limit = 2 и разрешим кастомную директорию для билда.

Отличие concurrent от limit в том, что concurrent обозначает количество задач, которое может выполняться суммарно на всех раннерах, а limit ограничивает количество задач на конкретном раннере.

Настройка стадии CI

Для этой стадии нам нужен Xcode и xcpretty (gem install xcpretty) для логирования.

CI содержит 2 стадии: build, которая проверяет сборку, и test, которая прогоняет файл с тестами.

Также мы хотим, чтобы эти стадии выполнялись только тогда, когда мы открываем merge request. И если запускается несколько pipeline одновременно, то чтобы каждая  job выполнялась в своей папке.

Также у нас для зависимостей используется Cococapods.

Приведем скрипт, отвечающий нашим условиям:

workflow:
  rules: 
    - if: $CI_PIPELINE_SOURCE == 'merge_request_event'
      when: always
    - when: never

stages:
  - build
  - test

variables:
    LC_ALL: "en_US.UTF-8"
    LANG: "en_US.UTF-8"

.general: &general_config
  before_script:
    - pod install

build:
  stage: build
  <<: *general_config
  tags:
    - job:build
  script:
    - xcodebuild clean -workspace project-ios.xcworkspace -scheme "Scheme debug" | xcpretty
    - xcodebuild build -workspace project-ios.xcworkspace -scheme "Scheme debug" -destination 'platform=iOS Simulator,name=iPhone 13,OS=15.5' | xcpretty -s
    - './scripts/youTrack.py "$CI_MERGE_REQUEST_TITLE" "Review"'

test:
  stage: test
  <<: *general_config
  tags: 
    - job:test
  script: 
    - xcodebuild clean -workspace project-ios.xcworkspace -scheme " Scheme debug" | xcpretty
    - xcodebuild test -workspace project-ios.xcworkspace -scheme ProjectTests -destination 'platform=iOS Simulator,name=iPhone 13,OS=15.5' | xcpretty -s

LC_ALL и Lang нужны для того, чтобы не возникало конфликтов кодировок.

.general: &general_config является объектом, на который в дальнейшем указывается ссылка, и в то место подставляется написанный код. 

В xcodebuild clean, build и test мы просто подставляем название нужного workspace/проекта и девайса.

Как упоминалось ранее, от tags зависит то, возьмет ли раннер работу.

У себя в компании мы работаем в youTrack. Основными статусами для нас как для разработчиков являются статусы «Открыт»  → «В работе»  → «Ревью»  → «Можно тестировать». Поэтому после build передвинем карточку с названием, равным названию PR в ревью, с помощью './scripts/youTrack.py "

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

Настройка стадии CD

Для деплоя мы используем Fastlane.

Перед тем как писать какой-либо скрипт, установим Fastlane с помощью команды gem install fastlane. 

Перейдем в терминале в папку нашего проекта и проинициализируем Fastlane с помощью fastlane init. Данная команда запросит данные от аккаунта App Store Connect (необходим аккаунт, который может создавать приложения и загружать сборки). Также команда создаст все нужные файлы и объект приложения в Connect, если он еще не создан.

После создания появится папка Fastlane, в которой будет fastfile (файл с исполняемыми скриптами) и Appfile, который содержит информацию о bundleId, аккаунте и команде.

От Fastlane мы хотим, чтобы он создавал нужные сертификаты, архивировал наш проект и загружал .ipa файл в Connect. Также рекомендуем использовать для авторизации запросов App Store Connect API Key (нужны права владельца аккаунта). Но хранить его прямо в папке проекта не рекомендуем: для этого лучше воспользоваться переменными в Gitlab.

Также нам понадобится плагин versioning для получения номера версии из project (fastlane add_plugin versioning в папке проекта).

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

Рассмотрим скрипт: перед выполнением lane мы устанавливаем ключ, а в самой lane под названием upload ждем передаваемых переменных.

Получаем версию из проекта, проверяем последний билд из коннекта, инкрементируем и устанавливаем это значение в проект в нужную конфигурацию. 

Подписываем и генерируем нужные сертификаты с помощью cert и sigh. Архивируем проект в эту же папку, чтобы не засорять Mac сборками. Ставим skip_package_dependencies_resolution, если не используется SPM, а затем выгружаем с заданными данными билд, пропуская ожидание обработки сборки, чтобы сэкономить время на этой стадии. 

sh("../scripts/csvFileExecutor.py '#{options[:task_name]};#{version}-#{build}' append") используется для того, чтобы сохранить данные о сборке и задаче в файл на отдельном репозитории, чтобы в дальнейшем занести эту информацию в сборку. task_name — название переменной, которая должна быть передана в скрипт.

lane :distribute do |options|
 	var = sh("../scripts/csvFileExecutor.py '#{options[:task_name]}' read")
	splitted = var.split("\n").last()
	version = splitted.split("-").first()
	build = splitted.split("-").last()
	pilot(
		app_platform: "ios",
		distribute_only: true,
		app_version: version,
		build_number: build,
		localized_build_info: {
			"default": {whats_new: options[:task_name]},
			"ru": {whats_new: options[:task_name]},
			"en-GB": {whats_new: options[:task_name]},
			"en-US": {whats_new: options[:task_name]}
		})
	sh("../scripts/csvFileExecutor.py '#{options[:task_name]}' remove")
	sh("../scripts/youTrack.py '#{options[:task_name]}' 'Можно тестировать' '#{version}(#{build})'")
  end

Также наш файл содержит lane: distribute, которая как раз таки и будет ждать завершения обработки билда. Для этого с помощью скрипта мы читаем нужные нам данные из файла, а затем в pilot указываем distribute_only: true для того, чтобы ничего не выгружать, а сделать действие по распространению сборки. В localized_build_info мы передаем одно и то же описание, чтобы все корректно отображалась на iPhone с разными языками. Затем скрипты удалят информацию о задаче из файла и переведут карточку в «Можно тестировать».

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

Стадию deploy мы запускаем вручную, когда задача прошла ревью. Стадия distribute выполняется только в том случае, если предыдущая стадия завершилась успешно.

Теперь допишем в наш yml файл стадии deploy и distribute

stages:
  - build
  - test
  - deploy
  - distribute

…

testflight_build:
  stage: deploy
  <<: *general_config
  tags:
    - job:deploy
  script:
    - fastlane upload task_name:"" class="formula inline">CI_MERGE_REQUEST_TITLE"
  rules:
    - if: 
      when: manual

distribute:
  stage: distribute
  tags:
    - job:distribute
  script:
    - fastlane distribute task_name:"" class="formula inline">CI_MERGE_REQUEST_TITLE"
  needs: ["testflight_build"]
  when: on_success

task_name:"$CI_MERGE_REQUEST_TITLE" как раз и является той переменной, что ждет наш скрипт в массиве options.

Теперь осталось только лишь запушить наши изменения на Gitlab ????

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

Рабочее общение у нас происходит в Discord, поэтому вишенкой на торте является очень простая в плане настройки интеграция с Discord. Для этого необходимо на нужном сервере зайти в Настройки — Интеграции — Вебхуки — Новый вебхук. Копируем его URL и идем в репозиторий. Settings — Integrations — Discord Notification. Ставим нужные галочки и скопированный URL. Тестируем, сохраняем и начинаем ждать оповещения).

Вывод  

После выполнения действий из гайда должны получиться:

  1. три общих раннера и один специфический;

  2. YML файл с четырьмя стадиями: build, test, deploy и distribute;

  3. fastlane файл, выполняющий deploy и distribute в Testflight;

  4. интеграция с Discord.

А также вы получите кучу сэкономленного времени на деплое) 

Если у вас есть опыт, которым хотите поделиться, или вопросы, то ждем вас в комментариях.

Если вам показалось мало данной статьи и хочется узнать о том, какие еще есть варианты настройки, то приглашаем почитать статью о сравнении подходов по настройке CI/CD.

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