
О чём эта статья?
Всем привет, меня зовут Кирилл и я Android-разработчик в Scanny. В прошлых статьях мы описали то, как будет выглядеть наш CI/CD, научились запускать статический анализатор кода, выполнять Android (Marathon Labs и Firebase Test Lab) и Unit-тестирование, собирать различные Build Flavors и отправлять их в нашу Telegram-группу.
В этой статье мы настроим публикацию свежих версий в Play Market на примере Gradle Play Publisher и Fastlane, а так же создадим пометку в Gitlab Tag с описанием изменений, которые вошли в нашу сборку.
Так же мы улучшим наш CI/CD, собрав свой Docker-образ со всем необходимым окружением. Благодаря этому нам не придется каждый раз устанавливать все инструменты (Python, awscli и другие), что позволит ускорить наш pipeline.
Цикл статей про CI/CD для Android состоит из:
Настраиваем CI/CD Android-проекта, часть 2. Запуск Android-тестов.
Вы находитесь здесь.
Настройка Play Console
Начнем с настройки Play Console и Google Services, будем считать, что у вас уже есть аккаунт разработчика в Play Console. Так же у вас уже есть страница приложения в Play Store, заполнена информация о компании и приложение уже опубликовано в первый раз.
Перейдем к делу, нам необходимо связать Google Services с нашей Play Console, для этого перейдем в Service Accounts, в верхней части страницы нажимаем на кнопку Create service account
. В новом окне заполняем название, описание, устанавливаем права Basic - Viewer
. Аналогичные шаги мы уже делали в прошлой статье про CI/CD, когда связывали Google Services с Firebase, поэтому так подробно расписывать не буду. После того как Service Account создан, нам необходимо сгенерировать JSON-ключ и сохранить его после создания.

Дальше переходим в наш Play Console, выбираем вкладку Users and Permissions
и нажимаем на кнопку Invite new users
в выпадающем меню.

В поле Email address
копируем почту нашего Service Account. В Account permissions
настраиваем права:
Для раздела
App access
- либоAdmin
, либоView app information and download bulk reports
;Раздел
Releases
- ставимRelease to production, exclude devices, and use Play App Signing
;Остальное на ваше усмотрение.
Ну и в конце нажимаем Invite User
.

С подключением Google Services закончили, переходим к настройке плагина.
Настройка Gradle Play Publisher
Публикация будет состоять из 2 этапов:
Сборка и публикация приложения в Play Market, а так же отправка сообщения в Telegram-группу;
Создание Gitlab Tag с описанием всех изменений.
В начале разберем работу с Gradle Play Publisher - это gradle-плагин, который позволяет автоматизировать публикацию нашего Android-приложения. Про подключение я рассказывать не буду, т.к. информации достаточно в самой документации, а вот настройку мы сделаем. Перед этим хочу напомнить, что в рамках данной статьи мы будем публиковать сборку исключительно в Play Market. Если у вас есть потребность работы с RuStore
, то можно использовать готовый плагин для этого. Аналогичный плагин есть и для Huawei AppGallery.
Вернемся к Gradle Play Publisher, плагин требует создать несколько файлов для работы с описанием и названием сборки, детали будут отличаться в зависимости от ваших потребностей, подробнее можно прочитать тут.
Положим, что мы сразу хотим загружать наши изменения в production
, для этого создадим следующие файлы:
Для названия сборки (только для внутреннего пользования) -
app/src/main/play/release-names/production.txt
;Для описания изменений -
app/src/main/play/release-notes/ru-RU/production.txt
.
Визуальная структура файлов изображена ниже.
{root}
|-- app
|-- src
|-- main
|-- play
|-- release-names
|-- production.txt
|-- release-notes
|-- ru-RU
|-- production.txt
При каждом релизе лезть в Gradle
, чтобы менять название сборки и версию может быть утомительным занятием. Поэтому можно упростить жизнь и определять versionName
динамически. Для этого, мы сделаем функцию getVersionName()
, которая будет возвращать название версии для Play Console. А вот versionCode
можем установить в единицу (или любое другое), т.к. актуальную версию будет проставлять наш плагин.
Переходим к настройке плагина, для этого открываем app/build.gradle
.
internal fun getVersionName(): String {
val file = file("src/main/play/release-names/production.txt")
val versionName = file.readText()
return versionName.ifEmpty { "Test Version" }
}
play {
/** Отключаем GPP по умолчанию для всех сборок, мы ведь не хотим каждый build отправлять в Play Market? **/
enabled.set(false)
/** Доступные варианты: internal, alpha, beta, production.
Однако, мы же хотим сразу в production! **/
track.set("production")
/** Указываем процент пользователей, которые получат обновление.
Где, 0.6 = 60%. Только для IN_PROGRESS и HALTED. **/
userFraction.set(0.6)
/** По умолчанию GPP отправляет APK, но нам же нужен Bundle?
Где true - по умолчанию собираем bundle, false - APK. **/
defaultToAppBundles.set(true)
/** Тут мы указываем, что делать в случае, если сборка с таким versionCode уже существует.
AUTO_OFFSET позволяет увеличивать текущий versionCode в Play Console на единицу. **/
resolutionStrategy.set(ResolutionStrategy.AUTO_OFFSET)
/** Здесь: COMPLETED - полная раскатка на всех пользователей;
DRAFT - черновик, сборка еще не готова к публикации;
HALTED - публикация остановлена;
IN_PROGRESS - релиз проходит поэтапную публикацию, например, 60% **/
releaseStatus.set(ReleaseStatus.IN_PROGRESS)
}
android {
...
playConfigs {
register("productionRelease") {
/** Включаем GPP только для определенного build flavor.
В нашем случае пусть это будет production release. **/
enabled.set(true)
}
}
defaultConfig {
...
versionCode = 1
versionName = getVersionName()
...
}
}
Подробнее про Release Statuses можно прочитать здесь. Ранее мы связывали Google Services с Play Console, в результате чего у нас сохранился JSON-ключ, он нам понадобится для того, чтобы GPP смог связаться с нашей Play Console.
У нас есть 2 варианта, как использовать этот ключ:
Сохранить в переменных окружения под названием
ANDROID_PUBLISHER_CREDENTIALS
, чтобы GPP по умолчанию брал его из переменной. Конечно же, мы поступим таким образом!Сохранить в локальном файле и затем указать путь к файлу.
play {
serviceAccountCredentials.set(file("your-key.json"))
}
На этом настройка плагина закончена и мы можем переходить к CI/CD pipeline'у.
Настройка CI/CD
Перед нами стоит 2 задачи, это - выложить приложение в Play Market и создать пометку об изменениях, которые войдут в этот релиз, для этого мы будем пользоваться Gitlab Tag'ами. Дополнительно можно немного усложнить задачу, предположим, мы хотим еще и информировать о новом релизе в нашей Telegram-группе. В первой части мы уже разбирали скрипт, который поможет нам отправлять сообщения в Telegram-группу, поэтому здесь мы просто воспользуемся этим решением.
Перед тем как переходить дальше, добавим в stages
2 новых: deploy_release
и create_git_tag
.
stages:
- lint
- tests
- build_flavors
- deploy_release
- create_git_tag
Публикация сборки с помощью GPP
Дополнительно напоминаю, что ранее мы создали 2 файла: app/src/main/play/release-names/production.txt
для названия нашей сборки и app/src/main/play/release-notes/ru-RU/production.txt
для описания изменений, которые войдут в эту сборку. Перед публикацией мы описываем в них все изменения, которые затем будут отображаться в Play Store.
Ниже представлена Job'а, которая собирает и отправляет сборку в Play Market, а так же отправляет сообщение о новом релизе в Telegram-группу.
deployRelease:
stage: deploy_release
before_script:
- apt update
- apt install python3-pip --yes
- pip3 install awscli --upgrade
script:
- ./gradlew publishProductionReleaseBundle
- ./gradlew assembleProductionRelease
- export VERSION_NAME="#PRODUCTION_RELEASE $(cat app/src/main/play/release-names/production.txt)\n"
- export CHANGELOG="$(cat app/src/main/play/release-notes/ru-RU/production.txt)\n"
- aws s3 cp app/build/outputs/apk/production/release/app-production-release.apk s3://{Задайте название бакета в s3}/{Задайте название файла в бакете}.apk --endpoint https://storage.yandexcloud.net
- chmod a+x ./upload_telegram_link.sh
- aws s3 presign s3://{Задайте название бакета в s3}/{Задайте название файла в бакете}.apk --endpoint-url "https://storage.yandexcloud.net/" --expires-in 604800|source upload_telegram_link.sh
artifacts:
paths:
- app/build/outputs/apk/
expire_in: 10 days
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "master"'
Здесь в before_script
мы устанавливаем Python3
и pip
для работы с awscli. Чтобы работать с ним нам нужны 2 переменные окружения AWS_ACCESS_KEY_ID
(ID статического ключа) и AWS_SECRET_ACCESS_KEY
(Содержимое ключа). Более подробно про то, как их получить, можно прочитать здесь.
before_script:
- apt update
- apt install python3-pip --yes
- pip3 install awscli --upgrade
Дальше специальной командой мы собираем и отправляем на проверку нашу сборку в Play Console. В данном случае мы собираем и отправляем Bundle. Если же мы по каким-то причинам хотим публиковать APK, то пользуемся командой publishProductionReleaseApk
. Общая схема выглядит следующим образом: publish{Build flavor}{Bundle/Apk}
.
./gradlew publishProductionReleaseBundle
После успешной загрузки сборки в Play Console мы хотим получить новое сообщение в Telegram-группе о новой сборке, для этого мы отдельно собираем APK.
./gradlew assembleProductionRelease
Как я упоминал ранее, в данных файлах мы описываем название сборки и изменения, которые в нее входят. Эту же информацию мы можем указать в сообщении о новом релизе.
export VERSION_NAME="#PRODUCTION_RELEASE $(cat app/src/main/play/release-names/production.txt)\n"
export CHANGELOG="$(cat app/src/main/play/release-notes/ru-RU/production.txt)\n"
В первой части мы уже разбирали, что здесь происходит, но давайте пройдем еще раз. Данной командой мы отправляем APK в наше s3-хранилище (Yandex Object Storage), для этого указываем путь к файлу, после задаем s3-bucket и путь по которому необходимо сохранить файл. За подробностями сюда.
aws s3 cp app/build/outputs/apk/production/release/app-production-release.apk s3://{Задайте название бакета в s3}/{Задайте название файла в бакете}.apk --endpoint https://storage.yandexcloud.net
Теперь делаем upload_telegram_
link.sh
скрипт исполняемым. Он же у нас и отвечает за отправку сообщения в Telegram-группу. Как устроен этот скрипт описано в первой части.
chmod a+x ./upload_telegram_link.sh
Далее получаем Pre-signed Url, по которому мы будем скачивать наш APK. Подробнее можно посмотреть тут. В --expires-in 604800
указываем время жизни ссылки в секундах (7 дней). Через pipe |
передаем ссылку в source upload_telegram_
link.sh
скрипт.
aws s3 presign s3://{Задайте название бакета в s3}/{Задайте название файла в бакете}.apk --endpoint-url "https://storage.yandexcloud.net/" --expires-in 604800|source upload_telegram_link.sh
В артефактах так же сохраняем наши сборки в Gitlab-артефактах.
artifacts:
paths:
- app/build/outputs/apk/
expire_in: 10 days
А в rules
устанавливаем правила, по которым будет запускаться наша Job'а. В данном случае, Job'а запускается при merge request'е в master. Вы можете более гибко настроить эти правила исходя из ваших задач, прочитать подробнее можно здесь.
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "master"'
Переменные окружения:
Название переменной |
Описание |
---|---|
AWS_ACCESS_KEY_ID |
ID статического ключа, необходим для доступа в наше s3-хранилище. |
AWS_SECRET_ACCESS_KEY |
Содержимое статического ключа доступа. |
CHANGELOG |
Описание изменений в release-сборке. Название переменной можно изменить в нашем upload_telegram_link.sh скрипте. |
VERSION_NAME |
Название нашей сборки. Требуется в upload_telegram_link.sh скрипте. |
ANDROID_PUBLISHER_CREDENTIALS |
JSON-ключ для работы с Play Console. Данную переменную использует GPP. |
Сохранение информации о релизе в Gitlab Tag
Добавить описание можно вручную на Gitlab > Code > Tags
, но так не интересно. Поэтому давайте автоматизируем с помощью CI/CD. Описание изменений я бы предложил разделить отдельно для Play Market и отдельно для внутреннего пользования. Для этого создадим в корне проекта файл changelog.txt
, либо в любом другом удобном для нас месте. Сюда мы будем вносить более подробное описание изменений, которые будут входить в сборку.
createGitTag:
stage: create_git_tag
script:
- export GIT_TAG=$(cat app/src/main/play/release-names/production.txt)
- export GIT_TAG_MESSAGE=$(cat changelog.txt)
- git remote set-url origin https://oauth2:$CI_BOT_TOKEN@$CI_PROJECT_URL
- git config --global user.email $CI_BOT_EMAIL
- git config --global user.name $CI_BOT_USERNAME
- git tag -a -f $GIT_TAG -m $CI_BOT_USERNAME
- git push -f origin $GIT_TAG
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "master"'
Определим 2 локальные переменные: GIT_TAG
для названия тэга и GIT_TAG_MESSAGE
для описания. Название для GIT_TAG
можно взять из названия нашей сборки в Play Market. А в качестве описания будем брать информацию из нашего changelog.txt
, который мы создали ранее.
export GIT_TAG=$(cat app/src/main/play/release-names/production.txt)
export GIT_TAG_MESSAGE=$(cat changelog.txt)
Дальше мы устанавливаем URL, который будет использоваться для авторизации и доступа к нашему проекту в Gitlab. В переменной CI_BOT_TOKEN
хранится токен доступа нашего бота (аккаунта, от имени которого мы будем авторизовываться и работать с тэгами), а в переменной CI_PROJECT_URL
хранится ссылка на проект в формате gitlab.ru/{Путь к проекту}.git
.
Итоговый URL будет следующего вида: https://oauth2:{Токен
доступа}@gitlab.ru
/{Путь к проекту}.git
Более подробно, как их получить расскажу позже.
git remote set-url origin https://oauth2:$CI_BOT_TOKEN@$CI_PROJECT_URL
Теперь устанавливаем почту и имя бота для корректной записи в историю тэгов. Данные переменные определены в переменных окружения.
git config --global user.email $CI_BOT_EMAIL
git config --global user.name $CI_BOT_USERNAME
И, наконец, создаем новый аннотированный тэг с названием и сообщением. Обратите внимание на параметр -f
, он позволяет перезаписывать уже существующий tag.
git tag -a -f $GIT_TAG -m $GIT_TAG_MESSAGE
Ну вот и все, осталось сделать push
и отправить изменения на удаленный репозиторий.
git push -f origin $GIT_TAG
Выполняем данный скрипт при merge request в master.
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "master"'
Переменные окружения:
Название переменной |
Описание |
---|---|
GIT_TAG |
Название нашего тэга. В данном случае мы приняли его как название нашей сборки в Play Market. Рекомендуется принимать название версии как название тэга, например, |
GIT_TAG_MESSAGE |
Расширенное описание изменений, которые входят в сборку. |
CI_BOT_TOKEN |
Токен доступа бота, который будет работать с Gitlab tags. |
CI_PROJECT_URL |
URL проекта в формате |
CI_BOT_EMAIL |
Email бота от имени которого будет создаваться запись. |
CI_BOT_USERNAME |
Username бота от имени которого будет создаваться запись. |
Чтобы работать с Gitlab tags нам нужен аккаунт, у которого есть доступ к нашему Gitlab-проекту. Можно использовать свой аккаунт, либо же создать специальный для этого - как вам больше нравится.
Чтобы получить CI_BOT_TOKEN
токен, заходим в профиль аккаунта нажав на Edit profile
.

Дальше переходим Access tokens
и нажимаем на Add new token
.

В форме заполняем название токена, выбираем период действия и даем разрешения: api
, read_user
, read_repository
. После чего копируем наш токен и добавляем его в переменные окружения.

С CI_BOT_USERNAME
и CI_BOT_EMAIL
я думаю понятно, что это username аккаунта и почта соответственно.
На этом все. Чтобы, увидеть наш тэг после того, как CI/CD отработал, переходим в Code > Tags
и видим нашу красоту. Ниже привел пример, как он может выглядеть. Так же здесь мы можем создать наш release на основе tag'а.

Дальше мы рассмотрим, как получить аналогичный результат, но уже с другим инструментом.
Fastlane
Fastlane является инструментом, который позволяет автоматизировать рутинные задачи в разработке и развертывании мобильных приложений. Он позволяет описывать скрипты, которые будут решать конкретные задачи, однако он не заменяет полноценную CI/CD-систему. Fastlane интегрирован со множеством CI/CD-систем, в которых мы уже можем вызывать нужные нам скрипты для выполнения наших задач.
Из основного, что он умеет, можно выделить следующее:
Автоматизация сборки IOS и Android-приложений;
Автоматизация тестирования;
Публикация приложений в Play Store и App Store;
Работа со скриншотами;
Распространение тестовых сборок;
Интеграция с CI/CD-системами.
Подробную настройку Fastlane описывать не буду, т.к. это описано в официальной документации. Выделю лишь несколько ключевых моментов:
Fastlane для работы использует Ruby, который у вас скорее всего не установлен. Поэтому первым делом необходимо его установить;
Для корректной работы с зависимостями рекомендуется использовать Bundler, значит его тоже необходимо установить;
После чего устанавливаем Fastlane и инициализируем его. В результате у нас в проекте сгенерируются все необходимые файлы и настройки в них.
В итоге у нас получится следующая структура:
{root}
|-- Gemfile
|-- Gemfile.lock
|-- fastlane
|-- Appfile
|-- Fastfile
Где:
Appfile
- определяет общую конфигурацию для всего приложения;Fastfile
в котором мы определяем наши скрипты;Gemfile
иGemfile.lock
для определения зависимостей.
В Appfile
нас интересует только пакет нашего приложения, поэтому укажем его.
package_name("com.example.package")
К Fastfile
придем немного позже. Мы можем написать все наши скрипты прямо в нем, но тогда он может раздуться. Поэтому давайте вынесем их в отдельные Fast-файлы.
Fastlane. Публикация приложения
Начнем с публикации приложения в Play Store, ранее мы уже произвели все подготовительные этапы и теперь нам остается только определить наш скрипт используя Fastlane. Для этого создадим файл DeployRelease
по следующему пути: fastlane/lanes/DeployRelease
. Название файла и его расположение можно задавать произвольное, как вам удобно.
Итогом имеем следующую структуру:
{root}
|-- Gemfile
|-- Gemfile.lock
|-- fastlane
|-- Appfile
|-- Fastfile
|-- lanes
|-- DeployRelease
Ниже представлено содержание нашего DeployRelease
файла:
platform :android do
lane :incrementVersionCode do
previous_version_code = google_play_track_version_codes(
package_name: "com.example.package",
track: "production", # Стоит по умолчанию
json_key_data: ENV["PLAY_CONSOLE_CREDENTIALS"]
)[0]
new_version_code = previous_version_code + 1
new_version_code
end
lane :buildProductionReleaseBundle do
gradle(
task: "bundle",
flavor: "Production",
build_type: "Release",
properties: {
"android.injected.version.code" => incrementVersionCode
}
)
end
lane :buildProductionReleaseApk do
gradle(
task: "assemble",
flavor: "Production",
build_type: "Release",
properties: {
"android.injected.version.code" => incrementVersionCode
}
)
end
lane :deployProductionRelease do
buildProductionReleaseBundle
supply(
package_name: "com.example.package",
track: "production",
aab: lane_context[SharedValues::GRADLE_AAB_OUTPUT_PATH],
json_key_data: ENV["PLAY_CONSOLE_CREDENTIALS"],
release_status: "inProgress",
rollout: "0.6",
skip_upload_metadata: true,
skip_upload_images: true,
skip_upload_screenshots: true
)
end
end
В отрывке кода ниже, мы указываем платформу, для которой будем выполнять наши скрипты.
platform :{android/ios} do
...
end
Сами скрипты обозначаются как lane
, их мы можем вызывать из других lanes
или терминала. Здесь мы указываем нашу логику, которая должна быть выполнена, можем передавать в него аргументы, а так же возвращать результат.
lane :{Название функции} do
...
end
При использовании Gradle Play Publisher у нас была очень удобная опция: мы могли указать, как стоит разрешать конфликты с versionCode
, в нашем случае мы приняли инкремент на 1 от versionCode
в Play Store. В Fastlane такой опции нет, поэтому нам самим надо написать скрипт incrementVersionCode
для увеличения версии на +1 от текущей в Play Console.
Перед тем как продолжить, введу 2 термина, которые мы будем использовать в дальнейшем: action
- это скрипты, которые идут из под коробки в Fastlane, а plugin
- это скрипты, которые добавляются из вне, так же вы сами можете написать свои плагины и работать с ними. Подробнее про actions читаем тут, а про работу с plugins здесь.
Давайте разбираться, google_play_track_version_codes action возвращает список версий, которые есть у нас в Play Console, из которого мы берем первую (последнюю добавленную). По аргументам тут все довольно просто: package_name
название пакета нашего приложения, track
- internal, alpha, beta, production (по умолчанию берется production, но мы его на всякий случай все равно указываем), json_key_data
- JSON-ключ, который мы ранее получили. Ну и дальше мы увеличиваем значение на +1 и возвращаем результат.
lane :incrementVersionCode do
previous_version_code = google_play_track_version_codes(
package_name: "com.example.package",
track: "production", # Стоит по умолчанию
json_key_data: ENV["PLAY_CONSOLE_CREDENTIALS"]
)[0]
new_version_code = previous_version_code + 1
new_version_code
end
Дальше мы определим buildProductionReleaseBundle
и buildProductionReleaseApk
для сборки разных build flavors нашего приложения, с этим нам поможет gradle action. В properties
мы устанавливаем новое значение versionCode
. После сборки приложения, в lane_context
мы получаем путь к сборке, которым позже сможем воспользоваться. Более подробно можно прочитать в документации по gradle action и документации по lanes.
lane :buildProductionReleaseBundle do
gradle(
task: "bundle",
flavor: "Production",
build_type: "Release",
properties: {
"android.injected.version.code" => incrementVersionCode
}
)
end
lane :buildProductionReleaseApk do
gradle(
task: "assemble",
flavor: "Production",
build_type: "Release",
properties: {
"android.injected.version.code" => incrementVersionCode
}
)
end
И, наконец, пришло время для загрузки сборки в Play Console, для этого мы соберем bundle используя наш buildProductionReleaseBundle
скрипт. Для работы с Play Console воспользуемся supply action, он позволяет гибко работать с консолью разработчика.
lane :deployProductionRelease do
buildProductionReleaseBundle
supply(
package_name: "com.example.package",
track: "production",
aab: lane_context[SharedValues::GRADLE_AAB_OUTPUT_PATH],
json_key_data: ENV["PLAY_CONSOLE_CREDENTIALS"],
release_status: "inProgress",
rollout: "0.6",
skip_upload_metadata: true,
skip_upload_images: true,
skip_upload_screenshots: true
)
end
Отдельно хочется поговорить про название сборки в Play Console и описание изменений. По умолчанию название сборки будет браться из вашего versionName
в build.gradle
. Можно создать version_name.txt
файл в корне проекта, откуда будет браться название версии, а дальше уже использовать его в нашем build.gradle
. А для описания изменений, уже надо будет создать новый файл в fastlane/metadata/android/ru-RU/changelogs/{versionCode вашей будущей версии}.txt
и в него добавить описание всех изменений в новой сборке. Пример структуры файлов изображен ниже.
{root}
|-- version_name.txt
|-- fastlane
|-- Appfile
|-- Fastfile
|-- metadata
|-- android
|-- ru-RU
|-- changelogs
|-- 1.txt
|-- 2.txt
|-- {versionCode вашей будущей версии}.txt
Переменные окружения:
Название переменной |
Описание |
---|---|
PLAY_CONSOLE_CREDENTIALS |
JSON-ключ для работы с Play Console. |
Fastlane. Загрузка сборки в s3
С публикацией приложения разобрались, теперь вспомним, что еще мы хотим отправлять сообщение о новой версии в нашу Telegram-группу. Поэтому займемся этим вопросом. Начнем с Aws, для этого создадим файл fastlane/lanes/Aws
.
{root}
|-- Gemfile
|-- Gemfile.lock
|-- fastlane
|-- Appfile
|-- Fastfile
|-- lanes
|-- DeployRelease
|-- Aws
После добавим наш uploadApkToS3
скрипт в него.
lane :uploadApkToS3 do |options|
apk_path = options[:apk_path] || lane_context[SharedValues::GRADLE_APK_OUTPUT_PATH]
if apk_path.nil?
UI.user_error!("The 'apk_path' parameter must not be null")
end
s3_path = "s3://{Задайте название бакета в s3}/{Задайте название файла в бакете}.apk"
endpoint = "https://storage.yandexcloud.net"
sh("aws", "s3", "cp", apk_path, s3_path, "--endpoint-url", endpoint)
presigned_url = sh("aws", "s3", "presign", s3_path, "--endpoint-url", endpoint, "--expires-in", "604800", log: false).strip
Actions.lane_context["APK_DOWNLOAD_URL"] = presigned_url
presigned_url
end
Здесь наш lane
принимает уже параметры на входе, в данном случае это путь к APK-файлу. Конструкция ||
позволяет устанавливать значения по умолчанию. Подробней узнать про GRADLE_APK_OUTPUT_PATH и другие доступные значения можно здесь.
lane :uploadApkToS3 do |options|
apk_path = options[:apk_path] || lane_context[SharedValues::GRADLE_APK_OUTPUT_PATH]
На всякий случай проверяем переменную apk_path
на null и в случае необходимости завершаем работу с ошибкой. Подробнее об этом написано тут.
if apk_path.nil?
UI.user_error!("The 'apk_path' parameter must not be null")
end
Дальше сохраняем в переменную s3_path
путь, куда сохранить наш файл в s3 и endpoint
с которым мы будем работать.
s3_path = "s3://{Задайте название бакета в s3}/{Задайте название файла в бакете}.apk"
endpoint = "https://storage.yandexcloud.net"
В конце добавляем скрипт, который мы использовали ранее. Здесь мы используем sh
action для работы с Shell командами.
Для работы с s3 есть соответствующий plugin, которым мы не пользуемся, но почему? Дело в том, что из под коробки данный plugin не работает с Yandex Object Storage и с pre-signed urls. По крайней мере я не нашел информации об этом, однако если вы уже сталкивались с этой проблемой, то буду рад увидеть ваш ответ в комментариях. Возвращаясь к проблеме, для ее решения мы можем воспользоваться sh
action и просто работать с Yandex Object Storage из командной строки.
sh("aws", "s3", "cp", apk_path, s3_path, "--endpoint-url", endpoint)
presigned_url = sh("aws", "s3", "presign", s3_path, "--endpoint-url", endpoint, "--expires-in", "604800", log: false).strip
Так же прошу заметить, что при работе с sh
action мы передаем команды в виде списка аргументов. Это общая рекомендация при работе с system
и sh
actions, которая продиктована необходимостью экранирования аргументов в Shell. Однако мы все еще можем передать нашу shell-команду в виде строки.
sh("aws s3 cp #{apk_path.shellescape} #{s3_path.shellescape} --endpoint-url #{endpoint.shellescape}")
После, можно записать наш presigned_url
в lane_context
для использования в других функциях, это скорее опциональный вариант, я лишь показал как можно.
Actions.lane_context["APK_DOWNLOAD_URL"] = presigned_url
Fastlane. Работа с Telegram
Мы сохранили наш APK в s3, теперь можно переходить к работе с Telegram, чтобы отправлять сообщения о новой версии в нашу группу. Упростим себе жизнь и воспользуемся готовым Telegram plugin.
Для этого в терминале выполним следующую команду:
fastlane add_plugin telegram
После чего у вас появится новый файл Pluginfile
, а так же обновятся Gemfile
и Gemfile.lock
. Не забудьте сохранить все в вашей системе контроля версий.
{root}
|-- Gemfile
|-- Gemfile.lock
|-- fastlane
|-- Appfile
|-- Fastfile
|-- Pluginfile
|-- lanes
|-- DeployRelease
|-- Aws
Убедитесь, что в Gemfile
появились следующие строки.
plugins_path = File.join(File.dirname(__FILE__), "fastlane", "Pluginfile")
eval_gemfile(plugins_path) if File.exist?(plugins_path)
Теперь создадим новый файл fastlane/lanes/Telegram
, куда запишем наш sendMessageToTelegram
скрипт. Здесь нам уже все знакомо, мы передаем аргументы в функцию, проверяем на null. А вот с переменной formatted_message
уже интереснее, здесь мы пользуемся Ruby Heredoc для формирования многострочного сообщения. После чего мы уже отправляем сообщение в нашу группу используя telegram
плагин.
lane :sendMessageToTelegram do |options|
title = options[:title]
changelog = options[:changelog]
download_url = options[:download_url]
if title.nil?
UI.user_error!("The 'title' parameter must not be null")
end
if changelog.nil?
UI.user_error!("The 'changelog' parameter must not be null")
end
if download_url.nil?
UI.user_error!("The 'download_url' parameter must not be null")
end
formatted_message = <<~MSG
#{title}
#{changelog}
#{download_url}
MSG
telegram(
token: ENV["TELEGRAM_BOT_TOKEN"],
chat_id: ENV["TELEGRAM_CHAT_ID"],
text: formatted_message
)
end
Переменные окружения:
Название переменной |
Описание |
---|---|
TELEGRAM_BOT_TOKEN |
Токен Telegram-бота |
TELEGRAM_CHAT_ID |
Id Telegram чата, в который будет отправляться сообщение |
Fastlane. Работа с Git
Release-сборка загружена в Play Console на проверку, в Telegram появилось наше сообщение, теперь остается создать пометку об изменениях в Gitlab tags. Для этого создадим новый файл fastlane/lanes/Git
, где запишем наш createGitTag
скрипт. Тут я уже думаю для вас ничего нового нет, все настройки аналогичны предыдущим нашим скриптам.
lane :createGitTag do |options|
tag_name = options[:tag_name]
message = options[:message]
if tag_name.nil?
UI.user_error!("The 'tag_name' parameter must not be null")
end
if message.nil?
UI.user_error!("The 'message' parameter must not be null")
end
bot_token = ENV["CI_BOT_TOKEN"]
project_url = ENV["CI_PROJECT_URL"]
remote_url = "https://oauth2:#{bot_token}@#{project_url}"
sh("git", "remote", "set-url", "origin", remote_url)
sh("git", "config", "--global", "user.email", ENV["CI_BOT_EMAIL"])
sh("git", "config", "--global", "user.name", ENV["CI_BOT_USERNAME"])
sh("git", "tag", "-a", "-f", tag_name, "-m", message)
sh("git", "push", "-f", "origin", tag_name)
end
Переменные окружения:
Название переменной |
Описание |
---|---|
CI_BOT_TOKEN |
Токен доступа для бота, который будет работать с Gitlab tags. |
CI_PROJECT_URL |
URL проекта в формате |
CI_BOT_EMAIL |
Email бота от имени которого будет создаваться запись. |
CI_BOT_USERNAME |
Username бота от имени которого будет создаваться запись. |
Fastlane. Настраиваем CI/CD
Мы написали скрипты для создания release-сборок, а так же для работы с Telegram, теперь осталось соединить все вместе. Вернемся к нашему Fastfile
, в нем мы подключим наши Fast-файлы со скриптами, а так же напишем один небольшой скрипт, в котором мы будем собирать APK и затем отправлять его в нашу группу.
Для начала необходимо импортировать все наши скрипты в главный Fastfile
.
import("./lanes/DeployRelease")
import("./lanes/Git")
import("./lanes/Aws")
import("./lanes/Telegram")
Теперь определим новый notifyReleaseToTelegram
скрипт, в котором мы организуем сборку APK, а после отправим сообщение о нашем релизе. Здесь мы воспользуемся определенными ранее скриптами.
Сначала соберем APK используя buildProductionReleaseApk
скрипт, затем загрузим его в s3 и получим ссылку на скачивание с помощью uploadApkToS3
. В конце, создадим сообщение и отправим его в нашу Telegram-группу благодаря sendMessageToTelegram
скрипту.
platform :android do
lane :notifyReleaseToTelegram do |options|
version = options[:version]
changelog = options[:changelog]
if version.nil?
UI.user_error!("The 'version' parameter must not be null")
end
if changelog.nil?
UI.user_error!("The 'changelog' parameter must not be null")
end
buildProductionReleaseApk
download_url = uploadApkToS3
sendMessageToTelegram(
title: "#PRODUCTION_RELEASE #{version}",
changelog: changelog,
download_url: download_url
)
end
end
Ну вот и все, теперь осталось настроить окружение в нашей Job'е и запустить в ней Fastlane-скрипты. Полный Job-скрипт изображен ниже.
deployReleaseUsingFastlane:
stage: deploy_release
variables:
LC_ALL: "en_US.UTF-8"
LANG: "en_US.UTF-8"
script:
- curl -sSL https://get.rvm.io | bash -s stable
- source /usr/local/rvm/scripts/rvm
- rvm install 3.2.2
- bundle install
- bundle exec fastlane install_plugins
- export VERSION_NAME="#PRODUCTION_RELEASE $(cat version_name.txt)"
- export CHANGELOG="$(cat changelog.txt)"
- bundle exec fastlane deployRelease
- bundle exec fastlane notifyReleaseToTelegram version:${VERSION_NAME} changelog:${CHANGELOG}
- bundle exec fastlane createGitTag tag_name:${VERSION_NAME} message:${CHANGELOG}
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "master"'
Fastlane требует следующие переменные окружения для корректной работы. В частности, если в shell
профайле locale
не установлен UTF-8
, то сборка и загрузка вашего приложения будет работать некорректно. Подробнее можно прочитать здесь.
variables:
LC_ALL: "en_US.UTF-8"
LANG: "en_US.UTF-8"
Дальше мы скачиваем и устанавливаем RVM (Ruby Version Manager), чтобы потом установить нужную версию Ruby.
curl -sSL https://get.rvm.io | bash -s stable
Загружаем RVM в наш PATH
, чтобы потом работать с ним из командной строки. (В Linux, здесь система ищет исполняемые файлы, когда мы работаем с ними из командной строки).
source /usr/local/rvm/scripts/rvm
Устанавливаем Ruby нужной нам версии, но почему же 3.2.2? Вы можете спокойно указать наиболее подходящую вам версию. Cкажу лишь, что лучше устанавливать Ruby версии 3+, т.к. в них из под коробки уже установлен bundler
для работы с зависимостями и его не придется устанавливать отдельно. Но на совсем новых версиях Fastlane может работать нестабильно.
rvm install 3.2.2
Теперь загрузим все зависимости, которые определены в наших Gemfile
и Gemfile.lock
с помощью bundler
.
bundle install
Когда все зависимости загружены, можно заняться установкой плагинов, которые определены в нашем Pluginfile
.
bundle exec fastlane install_plugins
Ранее мы создавали version_name.txt
файл для названия версии и changelog.txt
для Gitlab tags. Мы можем брать значения из них для описания изменений.
export VERSION_NAME="#PRODUCTION_RELEASE $(cat version_name.txt)"
export CHANGELOG="$(cat changelog.txt)"
В конце запускаем наши Fastlane-скрипты, здесь мы загружаем сборку в Play Console, после чего уведомляем о новом релизе в Telegram-группе и последним шагом создаем пометку с изменениями.
bundle exec fastlane deployRelease
bundle exec fastlane notifyReleaseToTelegram version:${VERSION_NAME} changelog:${CHANGELOG}
bundle exec fastlane createGitTag tag_name:${VERSION_NAME} message:${CHANGELOG}
С Fastlane закончили, и, мне бы хотелось сказать, что Fastlane - это не только про публикацию приложения в Store'ах. С помощью данного инструмента можно настроить запуск тестов, раскатку тестовых сборок и т.д., т.е. это довольно гибкий инструмент для настройки нашего CI/CD.
Создаем свой Docker-образ
До этого момента мы пользовались docker-образом jangrewe/gitlab-ci-android:33
, в котором идет только Android SDK и Java 11. Почти в каждой Job'е при запуске мы заново загружали новое окружение для работы нашего pipeline, например python, awscli, ruby и т.д. Это достаточно неэффективный путь, т.к. окружение из Job'ы к Job'е может дублироваться, дополнительно к этому, время выполнения нашего CI/CD pipeline может быть увеличено за счет времени, которое тратится на установку нужного инструмента.
Чтобы решить проблему с окружением, его можно заранее настроить, для этого мы создадим свой Docker-образ. С помощью него мы и настроим наше окружение, чтобы потом просто переиспользовать его в нашем CI/CD.
В корне проекта создаем Dockerfile
, в котором и будем записывать наши настройки, желательно все это делать в отдельном репозитории. Ниже полное содержимое нашего Dockerfile
.
FROM jangrewe/gitlab-ci-android:33
ENV LC_ALL="en_US.UTF-8" \
LANG="en_US.UTF-8" \
MARATHON_VERSION="1.0.46"
RUN apt-get update && \
apt-get install -y --no-install-recommends \
openjdk-17-jdk \
curl \
gnupg2 \
python3-pip \
tar \
bash && \
gpg2 --keyserver hkp://keyserver.ubuntu.com --recv-keys \
409B6B1796C275462A1703113804BB82D39DC0E3 \
7D2BAF1CF37B13E2069D6956105BD0E739499BDB && \
rm -rf /var/lib/apt/lists/*
RUN curl -sSL https://get.rvm.io | bash -s stable && \
/bin/bash -lc "rvm requirements" && \
/bin/bash -lc "rvm install 3.2.2"
ENV PATH="/usr/local/rvm/gems/ruby-3.2.2/bin:/usr/local/rvm/rubies/ruby-3.2.2/bin:/usr/local/rvm/bin:$PATH"
RUN pip3 install awscli==1.36.0
RUN curl https://dl.google.com/dl/cloudsdk/channels/rapid/google-cloud-sdk.tar.gz --output /tmp/google-cloud-sdk.tar.gz && \
mkdir -p /google && \
tar zxf /tmp/google-cloud-sdk.tar.gz --directory /google && \
/google/google-cloud-sdk/install.sh --quiet
ENV PATH="/google/google-cloud-sdk/bin:$PATH"
RUN curl -L https://github.com/MarathonLabs/marathon-cloud-cli/releases/download/${MARATHON_VERSION}/marathon-cloud-v${MARATHON_VERSION}-{Архив, который нам нужен} -o /tmp/marathon-cloud && \
mkdir -p /marathon && \
tar -xzf /tmp/marathon-cloud --directory /marathon && \
mv /marathon/marathon-cloud-v${MARATHON_VERSION}-{Архив, который нам нужен}/marathon-cloud /usr/local/bin/ && \
chmod +x /usr/local/bin/marathon-cloud
WORKDIR /app
CMD ["bash"]
Описание нашего Docker-образа начинается с инструкции FROM
, которая задает базовый образ, поверх которого мы будем строить свой собственный.
FROM jangrewe/gitlab-ci-android:33
Устанавливаем переменные окружения.
ENV LC_ALL="en_US.UTF-8" \
LANG="en_US.UTF-8" \
MARATHON_VERSION="1.0.46"
Дальше устанавливаем зависимости через apt
(Advanced Packaging Tool).
Где:
openjdk-17-jdk
может понадобиться нам, если мы используем 17 версию Java, либо любую другую, которая используется у вас на проекте;curl
понадобится для скачивания и установкиMarathon
илиGoogle Cloud SDK
;gnupg2
необходим для скачивания и установкиRVM
;python3-pip
для установкиawscli
и работы сgoogle cloud
;tar
,bash
понадобятся для распаковки архивов и выполнения скриптов.
Следующим шагом устанавливаем GPG-ключи
для верификации RVM
, поскольку они требуются при установке RVM
. И после всего, чистим кэш apt
с помощью rm -rf /var/lib/apt/lists/*
, чтобы уменьшить размер образа.
RUN apt-get update && \
apt-get install -y --no-install-recommends \
openjdk-17-jdk \
curl \
gnupg2 \
python3-pip \
tar \
bash && \
gpg2 --keyserver hkp://keyserver.ubuntu.com --recv-keys \
409B6B1796C275462A1703113804BB82D39DC0E3 \
7D2BAF1CF37B13E2069D6956105BD0E739499BDB && \
rm -rf /var/lib/apt/lists/*
Теперь скачиваем и устанавливаем RVM
и Ruby
. После установки Ruby
, добавляем его в PATH
, о том, что это такое я уже писал выше.
RUN curl -sSL https://get.rvm.io | bash -s stable && \
/bin/bash -lc "rvm requirements" && \
/bin/bash -lc "rvm install 3.2.2"
ENV PATH="/usr/local/rvm/gems/ruby-3.2.2/bin:/usr/local/rvm/rubies/ruby-3.2.2/bin:/usr/local/rvm/bin:$PATH"
Ставим awscli
.
RUN pip3 install awscli==1.36.0
Дальше ставим Google Cloud SDK
для работы с Firebase Test Lab
, чтобы запускать наши Android-тесты с его помощью.
RUN curl https://dl.google.com/dl/cloudsdk/channels/rapid/google-cloud-sdk.tar.gz --output /tmp/google-cloud-sdk.tar.gz && \
mkdir -p /google && \
tar zxf /tmp/google-cloud-sdk.tar.gz --directory /google && \
/google/google-cloud-sdk/install.sh --quiet
ENV PATH="/google/google-cloud-sdk/bin:$PATH"
Для запуска Android-тестов на Marathon
, нам понадобится установить Marathon Cloud CLI
.
RUN curl -L https://github.com/MarathonLabs/marathon-cloud-cli/releases/download/${MARATHON_VERSION}/marathon-cloud-v${MARATHON_VERSION}-{Архив, который нам нужен} -o /tmp/marathon-cloud && \
mkdir -p /marathon && \
tar -xzf /tmp/marathon-cloud --directory /marathon && \
mv /marathon/marathon-cloud-v${MARATHON_VERSION}-{Архив, который нам нужен}/marathon-cloud /usr/local/bin/ && \
chmod +x /usr/local/bin/marathon-cloud
Предпоследним шагом задаем рабочую директорию, где будут выполняться все остальные наши команды в CI/CD.
WORKDIR /app
И последним шагом с помощью CMD
указываем команду, которую необходимо выполнить, когда контейнер запущен.
CMD ["bash"]
Теперь осталось собрать наш образ и загрузить на Docker Hub. Для начала зарегистрируемся на Docker.com, после регистрации скачаем Docker Desktop на наш ПК. Далее, для удобства можно установить Docker Plugin для IntelliJ IDEA и уже работать через него.
Для сборки нашего образа, нажимаем на Build Image for...
и ждем, когда образ будет собран.

Как только образ собран, в среде разработки переходим в Services > {Выбираем наш образ} > Dashboard
, нажимаем на Tags Add...
и указываем название нашего репозитория и версию образа.

Теперь осталось отправить наш образ в Docker Hub, для этого переходим в Docker Desktop, который мы установили ранее. Переходим во вкладку Images
, в списке находим наш образ и нажимаем Push to Docker Hub
. После этого, наш образ можно будет увидеть в личном кабинете Docker Hub, а значит и использовать его в нашем CI/CD.

После того, как наш Docker-образ будет готов, мы сможем облегчить CI/CD убрав команды, отвечающие за установку и настройку окружения. Для примера, ниже я представил упомянутый ранее скрипт публикации нашего Android-приложения с использованием Fastlane, предварительно убрав ненужные команды.
deployReleaseUsingFastlane:
stage: deploy_release
image: {Указываем наш Docker-образ для данной задачи}
script:
- bundle install
- bundle exec fastlane install_plugins
- export VERSION_NAME="#PRODUCTION_RELEASE $(cat version_name.txt)"
- export CHANGELOG="$(cat changelog.txt)"
- bundle exec fastlane deployRelease
- bundle exec fastlane notifyReleaseToTelegram version:${VERSION_NAME} changelog:${CHANGELOG}
- bundle exec fastlane createGitTag tag_name:${VERSION_NAME} message:${CHANGELOG}
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "master"'
ВАЖНО
Приведенный выше пример Docker-образа является избыточным. В нем я привел инициализацию всего окружения, которое мы использовали на протяжении всех 3-х статей про CI/CD. В реальности не стоит делать ваш Docker-образ избыточным, добавляя туда все зависимости, т.к. в этом случае вы вряд-ли ускорите ваш pipeline ввиду тяжелого образа.Лучшим вариантом будет создание нескольких Docker-образов под определенные задачи, например, для Android-тестов на Marathon сделать свой образ, куда войдет Marathon Cloud CLI и Android SDK, а для Firebase Test Lab сделать свой образ. Тем самым вы облегчите размер ваших Docker-образов.
Более подробно про оптимизацию вашего Gitlab CI/CD, можно прочитать в этой замечательной статье.
Заключение
Вот мы и подошли к концу нашей серии статей про Gitlab CI/CD для Android-проекта. Мы построили свой собственный CI/CD, который покрывает базовые потребности по сборке, публикации, тестированию нашего приложения. Рассмотрели разные инструменты для запуска Android-тестов, а так же разобрали разные варианты публикации нашего приложения в Play Store. И немного коснулись создания собственных Docker-образов для улучшения нашего CI/CD.
Темы, которые мы затрагивали, были достаточно обширными, но я постарался более подробно, пусть и по верхам, описать работу с каждым инструментом. Надеюсь, что кому-нибудь данная серия статей поможет при настройке собственного CI/CD.
Еще увидимся!