
Приветствую! Меня зовут Алексей Денискин, я тимлид мобильной команды СберМаркета. В этой статье я на примере покажу, как организовать 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. Пишите :)
