Приветствую! Меня зовут Алексей Денискин, я тимлид мобильной команды СберМаркета. В этой статье я на примере покажу, как организовать CI для мобильных приложений на Android и iOS. Я буду использовать GitLab CI, но описанный подход применим к большинству стандартных стеков.
Зачем нужен CI. Опыт СберМаркета
До интеграции CI тяжело было следить за здоровьем проекта и поддерживать ручное тестирование без валидации изменений. А для каждого коммита приходилось запускать 15+ команд для проверки и сборки. Если у вашего приложения стабильный цикл релиза, это очень неудобно.
После интеграции CI снизилось количество «ручного труда», повысились надёжность проекта и качество кода, а также уменьшился Time-to-Market.
Определяем цели CI
Для примера настройки CI мы взяли стандартные цели:
валидация изменений (Lint, Test),
сборка для тестового стенда,
релизные сборки.
Создаём окружение
Сразу описать задачи для пайплайнов не получится. Нужно убедиться, что у нас описаны переменные и воркфлоу.
Описание переменных. Внесём в окружение проекта необходимые переменные:
ANDROID_CI_IMAGE — docker-образ с предустановленным Android SDK,
MY_KEYCHAIN — сертификат для подписи приложения,
MY_TOKEN_1, MY_TOKEN_2 — любые другие необходимые токены. Например, для доступа к Firebase.
Описание воркфлоу. Нужно определить типы пайплайнов. Их 3 по нашим целям:
release,
staging,
merge_request.
Напишем условия запуска для каждого из типов пайплайнов.
stages:
- test # Этап проверки кода
- build # Этап сборки кода
- deploy # Этап деплоя сборки
workflow:
rules:
- if: $CI_COMMIT_TAG # Если был git tag
variables:
PIPELINE_TYPE: "release"
- if: $CI_COMMIT_BRANCH == "master" # Если ветка -- master
variables:
PIPELINE_TYPE: "staging"
- if: $CI_PIPELINE_SOURCE == "merge_request_event" # Если это Merge Request
variables:
PIPELINE_TYPE: "merge_request"
- when: never
Теперь можно переходить к описанию задач.
Описываем ключевые шаги пайплайна
Для примера разберемся, как описать три ключевые шага пайплайна:
линтеры и тесты,
сборка,
деплой.
Обратите внимание, что для валидации изменений последние два этапа не запускаются.
Линтеры и тесты
Для Android запускаем JUnit.
junit:
image:
name: $ANDROID_CI_IMAGE # Используем контейнер с Android SDK
stage: test # Этап проверки кода
before_script:
- cd ./android # Для React Native проекта
script:
- ./gradlew testDebugUnitTest # Запускаем JUnit
rules:
- if: $PIPELINE_TYPE # Для любого типа пайплайнов
changes:
- "**/*.{kt,java}" # Если были изменения в .kt или в .java
when: always
- when: never
Также запускаем Ktlint.
ktlint:
image:
name: $ANDROID_CI_IMAGE # Используем контейнер с Android SDK
stage: test # Этап проверки кода
before_script:
- cd ./android # Для React Native проекта
script:
- ktlint --verbose --color "android/**/*.kt" # Запускаем KtLint
rules:
- if: $PIPELINE_TYPE # Для любого типа пайплайнов
changes:
- "**/*.{kt,java}" # Если были изменения в .kt или в .java
when: always
- when: never
Для iOS запускаем xcodebuild test
.
xcode-test:
stage: test # Этап проверки кода
before_script:
- cd ./ios # Для React Native проекта
script:
# Запускаем тесты XCode
- xcodebuild \
-project demoMobileCI.xcodeproj \
-scheme demoMobileCI \
-destination 'platform=iOS Simulator,name=iPhone 8,OS=15.2'\
test
tags:
- osx-runner # Запускаем на машине с MacOS и XCode
rules:
- if: $PIPELINE_TYPE # Для любого типа пайплайнов
changes:
- "**/*.{swift}" # Если были изменения в .swift
when: always
- when: never
Если используете React Native, стоит также добавить джобы на JavaScript- и TypeScript-тесты.
Пример скриптов в package.json:
{
"lint": "eslint . --ext .js,.jsx,.ts,.tsx",
"tsc": "tsc --project tsconfig.json --noEmit",
"test": "jest --silent",
}
# Для React Native проекта #
eslint:
image:
name: $NODE_CI_IMAGE # Используем контейнер с Node.JS
stage: test # Этап проверки кода
before_script:
- yarn install # Устанавливаем node_modules
script:
- yarn lint # Запускаем Eslint
rules:
- if: $PIPELINE_TYPE # Для любого типа пайплайнов
changes:
- "**/*.{js,jsx,ts,tsx}" # Если были изменения в JS или TS
when: always
- when: never
jest:
image:
name: $NODE_CI_IMAGE # Используем контейнер с Node.JS
stage: test # Этап проверки кода
before_script:
- yarn install # Устанавливаем node_modules
script:
- yarn test # Запускаем NPM тесты
rules:
- if: $PIPELINE_TYPE # Для любого типа пайплайнов
changes:
- "**/*.{js,jsx,ts,tsx}" # Если были изменения в JS или TS
when: always
- when: never
typescript:
image:
name: $NODE_CI_IMAGE # Используем контейнер с Node.JS
stage: test # Этап проверки кода
before_script:
- yarn install # Устанавливаем node_modules
script:
- yarn tsc # Запускаем typescript compiler
rules:
- if: $PIPELINE_TYPE # Для любого типа пайплайнов
changes:
- "**/*.{js,jsx,ts,tsx}" # Если были изменения в JS или TS
when: always
- when: never
Сборка
Если сборка запускается для master-ветки или релиза, приложение нужно подписать сертификатом.
Для Android запускаем ./gradlew assembleRelease
.
Важно: нужен docker-образ, в котором установлен Android SDK.
gradle-build:
image:
name: $ANDROID_CI_IMAGE # Используем контейнер с Android SDK
stage: build # Этап сборки кода
needs: ["junit", "ktlint"]
before_script:
- cd ./android # Для React Native проекта
script:
- ../gradlew assembleRelease # Запускаем сборку
- cp android/app/build/outputs/bundle/release/app-release.aab .
artifacts:
expire_in: 1 months
paths:
- app-release.aab # Путь к AAB / APK
rules:
- if: $PIPELINE_TYPE != "merge_request" # Запускаем только для master-ветки и релизов
when: always
- when: never
Для iOS запускаем xcodebuild build
.
Важно: требуется, чтобы gitlab-runner был запущен на MacOS.
xcode-build:
stage: build # Этап сборки кода
needs: ["xcode-test"]
before_script:
- cd ./ios # Для React Native проекта
script:
# Запускаем сборку XCode
- xcodebuild \
-project demoMobileCI.xcodeproj \
-scheme demoMobileCI \
-destination 'platform=iOS Simulator,name=iPhone 8,OS=15.2' \
build
- cp ios/builds/results/demoMobileCI.ipa .
artifacts:
expire_in: 1 months
paths:
- demoMobileCI.ipa # Путь к IPA
tags:
- osx-runner # Запускаем на машине с MacOS и XCode
rules:
- if: $PIPELINE_TYPE != "merge_request" # Запускаем только для master-ветки и релизов
when: always
- when: never
Деплой
В нашем примере деплой будет происходить в Firebase. Для отгрузки в AppStore и GooglePlay можно также использовать Fastlane.
Для Android в needs указываем gradle-build
.
deploy-android:
image:
name: $FIREBASE_IMAGE # Контейнер с установленным <https://github.com/firebase/firebase-tools>
stage: deploy # Этап деплоя сборки
needs: ["gradle-build"]
script:
- firebase appdistribution:distribute app-release.aab --app $MY_APP_ID --groups "QAMobile" --token "$MY_TOKEN_1"
rules:
- if: $PIPELINE_TYPE == "release" # Запускаем только для релизов
when: always
- when: never
Для iOS в needs указываем xcode-build
.
deploy-ios:
image:
name: $FIREBASE_IMAGE # Контейнер с установленным <https://github.com/firebase/firebase-tools>
stage: deploy # Этап деплоя сборки
needs: ["xcode-build"]
script:
- firebase appdistribution:distribute demoMobileCI.ipa --app $MY_APP_ID --groups "QAMobile" --token "$MY_TOKEN_1"
rules:
- if: $PIPELINE_TYPE == "release" # Запускаем только для релизов
when: always
- when: never
В итоге
Что получилось. Мы создали пайплайн с необходимой структурой, который позволяет валидировать изменения и собирать приложение для тестов и для релиза.
Что можно улучшить. Это простой вариант реализации CI, который далёк от идеала. Вот что можно добавить, чтобы улучшить CI:
кэширование,
GitLab Releases, Badges,
инкапсуляция CI в отдельном репозитории,
автоматическое ветвление,
менеджмент Merge Requests при помощи CI, Codeowners,
автотестирование,
автоматизация и интеграция с Jira, Slack, Confluence.
Кстати, прямо сейчас ищу в свою команду разработчика react native. Пишите :)