
О чём эта статья?
Всем привет, меня зовут Кирилл и я 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.
Еще увидимся!
