О чём эта статья?

Всем привет, меня зовут Кирилл и я 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 состоит из:

  1. Настраиваем CI/CD Android-проекта, часть 1. Начало.

  2. Настраиваем CI/CD Android-проекта, часть 2. Запуск Android-тестов.

  3. Вы находитесь здесь.

Настройка 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 настраиваем права:

  1. Для раздела App access - либо Admin, либо View app information and download bulk reports;

  2. Раздел Releases - ставим Release to production, exclude devices, and use Play App Signing;

  3. Остальное на ваше усмотрение.

Ну и в конце нажимаем Invite User.

С подключением Google Services закончили, переходим к настройке плагина.

Настройка Gradle Play Publisher

Публикация будет состоять из 2 этапов:

  1. Сборка и публикация приложения в Play Market, а так же отправка сообщения в Telegram-группу;

  2. Создание Gitlab Tag с описанием всех изменений.

В начале разберем работу с Gradle Play Publisher - это gradle-плагин, который позволяет автоматизировать публикацию нашего Android-приложения. Про подключение я рассказывать не буду, т.к. информации достаточно в самой документации, а вот настройку мы сделаем. Перед этим хочу напомнить, что в рамках данной статьи мы будем публиковать сборку исключительно в Play Market. Если у вас есть потребность работы с RuStore, то можно использовать готовый плагин для этого. Аналогичный плагин есть и для Huawei AppGallery.

Вернемся к Gradle Play Publisher, плагин требует создать несколько файлов для работы с описанием и названием сборки, детали будут отличаться в зависимости от ваших потребностей, подробнее можно прочитать тут.

Положим, что мы сразу хотим загружать наши изменения в production, для этого создадим следующие файлы:

  1. Для названия сборки (только для внутреннего пользования) - app/src/main/play/release-names/production.txt;

  2. Для описания изменений - 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 варианта, как использовать этот ключ:

  1. Сохранить в переменных окружения под названием ANDROID_PUBLISHER_CREDENTIALS, чтобы GPP по умолчанию брал его из переменной. Конечно же, мы поступим таким образом!

  2. Сохранить в локальном файле и затем указать путь к файлу.

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. Рекомендуется принимать название версии как название тэга, например, v.3.2.12

GIT_TAG_MESSAGE

Расширенное описание изменений, которые входят в сборку.

CI_BOT_TOKEN

Токен доступа бота, который будет работать с Gitlab tags.

CI_PROJECT_URL

URL проекта в формате gitlab.ru/{Путь к проекту}.git

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-систем, в которых мы уже можем вызывать нужные нам скрипты для выполнения наших задач.

Из основного, что он умеет, можно выделить следующее:

  1. Автоматизация сборки IOS и Android-приложений;

  2. Автоматизация тестирования;

  3. Публикация приложений в Play Store и App Store;

  4. Работа со скриншотами;

  5. Распространение тестовых сборок;

  6. Интеграция с CI/CD-системами.

Подробную настройку Fastlane описывать не буду, т.к. это описано в официальной документации. Выделю лишь несколько ключевых моментов:

  1. Fastlane для работы использует Ruby, который у вас скорее всего не установлен. Поэтому первым делом необходимо его установить;

  2. Для корректной работы с зависимостями рекомендуется использовать Bundler, значит его тоже необходимо установить;

  3. После чего устанавливаем Fastlane и инициализируем его. В результате у нас в проекте сгенерируются все необходимые файлы и настройки в них.

В итоге у нас получится следующая структура:

{root}
|-- Gemfile
|-- Gemfile.lock
|-- fastlane
  |-- Appfile
  |-- Fastfile

Где:

  1. Appfile - определяет общую конфигурацию для всего приложения;

  2. Fastfile в котором мы определяем наши скрипты;

  3. 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 проекта в формате gitlab.ru/{Путь к проекту}.git

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.

Еще увидимся!

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