О чём эта серия статей?

Всем привет, меня зовут Кирилл и я Android-разработчик в Scanny. Сразу оговорюсь, касаемо CI/CD, о котором я буду говорить - он достаточно простой и не претендует на идеальность и т.д. Если у вас есть предложения, как можно улучшить - буду рад видеть в комментариях.

Если говорить о мотивации написания данной серии статей, да, вы можете сказать, что уже не мало статей написано на Habr, да и на других источниках тоже. И будете абсолютно правы. Но на практике я столкнулся с 2 проблемами: во-первых, информации часто не хватало, чтобы сделать всё так, как нужно именно мне. А во-вторых, даже если ответы находились, они были раскиданы по разным источникам.

Поэтому в первую очередь, мне захотелось систематизировать знания для себя. Но верю, что чем больше будет понятных и доступных материалов, тем проще станет другим разобраться в теме.

Если коротко - моя главная мотивация написания "еще одной статьи про CI/CD на Android" - помочь себе и другим, закрыть свои потребности при работе с CI/CD. К тому же, на мой взгляд, каждая статья на эту тему получается разной - ведь задачи у всех разные.

Данная серия будет состоять из 3 статей:

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

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

  3. Настраиваем CI/CD Android-проекта, часть 3. Автоматизация публикации версий в Play Market.

Изначально была идея уместить всё в одной статье, но после пришёл к мнению, что вместе с объяснением, как подключать различные сервисы, статья раздуется до огромных размеров. Поэтому пусть это будут несколько небольших статей с объяснением как подключить и наладить CI/CD.

Теперь к сути

Официальное определение CI/CD звучит следующим образом: ... означает непрерывную интеграцию и непрерывную доставку/развертывание, которые нацелены на оптимизацию и ускорение жизненного цикла разработки ПО. С моей стороны, я это вижу как набор каких-либо практик, которые упрощают разработку и доставку продукта, а также хорошо экономят время, что не сильно то отличается от официальной трактовки.

Кому подойдет?

Если вы работаете над краткосрочным проектом, где после завершения работы вы к нему больше не вернетесь, то CI/CD, скорее, отнимет у вас время. В то же время, если вы работаете над долгоиграющим проектом, то CI/CD даст ускорение в тестировании и деплое вашего продукта.

Что будем делать?

Делать будем простой CI/CD на Gitlab, который будет покрывать базовые потребности. Ниже изображено схематичное представление нашего pipeline.

Теперь немного подробнее про каждый шаг:

1. Запуск статического анализатора кода

Запускать будем базовый, что называется "из под коробки", Android Lint. На самом деле существует не мало статических анализаторов кода, например, detekt, ktlint, SonarQube и многие другие. В pipeline статический анализатор кода будет запускаться при любом merge request'е. Отчёт будем сохранять в самых обычных артефактах, которые потом можно просмотреть на Gitlab.

2. Запуск тестов

Представим, что на вашем Android-проекте пишутся как Unit-тесты, так и Android-тесты. Unit-тесты проверяют какие-либо методы бизнес-слоя, без Android-составляющей.

Например, в нашей компании для написания Unit-тестов, в качестве основного набора мы используем AssertK, Mockito Kotlin и Kotlin coroutines. Android-тесты уже могут быть как UI, где мы используем Kaspresso, так и инструментальные тесты. Для этого можно использовать Robolectric или Kaspresso, который уже упоминался. Мы для этих целей используем Kaspresso, т.к. на мой взгляд, он очень удобен не только для UI, но и для инструментальных тестов.

Только вот встает вопрос: где и как запускать Android-тесты? Уже есть готовые инструменты, которые позволяют запускать тесты как на эмуляторах, так и на реальных устройствах. Я бы выделил 2 решения: Marathon Labs и Firebase Test Lab. Оба варианта предоставляют готовую инфраструктуру для запуска тестов, но если вам нужно более гибкое решение, то Marathon Labs никак не ограничивает вас, где и как запускать ваши тесты. В свою очередь, Firebase Test Lab не дает такой гибкости, но предоставляет возможность запустить тесты на реальных устройствах. Во второй части я расскажу, как подключить и запустить Android-тесты с помощью каждого из этих решений.

В pipeline Unit-тесты будут запускаться при любом merge request'е. А вот Android-тесты будут запускаться только при merge request'ах в master.

3. Сборка различных вариантов приложения

После сборки проекта, мы будем отправлять сборку в группу Telegram с описанием, которое будет браться из последнего commit'а. В pipeline сборка будет запускаться при merge request'е в develop.

4. Выкладываем release-сборку в Play Market

Тут всё просто, представим, что в проекте у нас постепенно накапливаются исправления багов, новые фичи и т.д. Впоследствии изменения войдут в release-сборку и она улетит в Play Market на проверку. Для этого мы рассмотрим работу с Gradle Play Publisher и Fastlane для публикации нашего приложения.

В pipeline сборка для Play Market будет собираться при merge request'е в master.

5. Создание Git-Tag в Gitlab

Для отслеживания истории изменений/release'ов будем использовать Git-Tag, тут все по классике. В pipeline теги будем отправлять в Gitlab при merge request'ах в master.

Переходим к CI/CD

Подготовка

Есть много инструментов для работы с CI/CD, например GitHub Actions, Jenkins, GitLab, мы будем использовать последний. Для работы с ним создадим файл .gitlab-ci.yml в корне проекта, тут будет располагаться наш pipeline. Да, каждый этап можно вынести в отдельный файл и т.д., но начнем с простого и сделаем все в одном файле, более подробно про то, как выносить скрипты в отдельные файлы, можно почитать тут. Для создания файла тыкаем ПКМ по корню проекта -> New -> File -> Вводим название .gitlab-ci.yml.

В итоге получим результат ниже. Который будет запускать наш Gitlab Runner.

Далее переходим непосредственно к коду.

image: jangrewe/gitlab-ci-android:33  
  
# Задаем этапы  
stages:  
  - lint  
  - tests  
  - build_flavors  
  - release  
  - create_git_tag  
  
# Определяем общий before_script для всех jobs
before_script:  
  - chmod +x ./gradlew  

Для сборки приложения необходим Android SDK и прочее окружение. Чтобы упростить себе жизнь, можно использовать уже готовый Docker image jangrewe/gitlab-ci-android.

image: jangrewe/gitlab-ci-android:33  

В stages задаем основные этапы, которые должны выполняться последовательно.

stages:  
  - lint  
  - tests  
  - build_flavors  
  - release  
  - create_git_tag 

В before_script, строчка chmod +x ./gradlew делает файл gradlew исполняемым (дает ему права на выполнение). Подробнее про before_script читаем тут.

before_script:  
  - chmod +x ./gradlew  

Статический анализатор кода

runAndroidLint:  
  stage: lint  
  script:  
    - ./gradlew lint  
  artifacts:  
    paths:  
      - "**/lint-results.html"  
    expire_in: 10 days  
    when:  
      always  
  rules:  
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'

Первым запускается статический анализатор кода при любом merge request'е.

Тут мы даем название нашей Job'е.

runAndroidLint:  

В stage мы указываем, к какому этапу относится эта job'а. В нашем случае lint.

stage: lint

В блоке script непосредственно пишем сам скрипт того, что мы хотим выполнить. Здесь строчка ./gradlew lint запускает базовый Android Lint.

script:  
    - ./gradlew lint 

В блоке artifacts мы настраиваем сохранение результатов работы. В данном случае в paths указываем путь к файлам, которые мы хотим сохранить, в expire_in указываем сколько дней будет храниться и в when указываем при каких условиях сохраняем артефакты. Подробнее про artifacts можно прочитать здесь.

artifacts:  
    paths:  
      - "**/lint-results.html"  
    expire_in: 10 days  
    when:  
      always  

В блоке rules мы задаем условия, при которых наша job'а будет запускаться. В данном случае '$CI_PIPELINE_SOURCE == "merge_request_event"' указывает на запуск при любом merge request'е. Подробнее про rules можно прочитать тут.

rules:  
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'

Переменные окружения:

Название переменной

Описание

CI_PIPELINE_SOURCE

Хранит информацию о том, как был запущен pipeline. Тут описание возможных вариантов

Unit-tests

Переходим к тестам, пока расскажу только про Unit-тесты, к Android-тестам вернемся в следующей статье, где более подробно посмотрим как связать Marathon Labs и Firebase Test Lab с нашим CI/CD.

runUnitTests:  
  stage: tests  
  script:  
    - ./gradlew app:test  
    - ./gradlew core:some-module:impl:test  

	...

  artifacts:  
    paths:  
      - "**/build/reports/tests/testDebugUnitTest/classes"  
      - "**/build/reports/tests/testDebugUnitTest/packages"  
      - "**/build/reports/tests/testDebugUnitTest/index.html"  
    expire_in: 10 days  
    when:  
      always  
  rules:  
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'

В stage все аналогично объяснению выше, за тем исключением, что теперь это этап тестирования;

stage: tests

Для запуска тестов в определенных модулях пользуемся схемой ниже.

./gradlew {Модуль с полным указанием пакетов}:test

Как пример, для запуска тестов в главном app модуле мы пишем просто app:test, а для запуска тестов в других модулях уже указываем сам модуль с учетом пакетов. Как пример: core:some-module:impl:test.

script:  
    - ./gradlew app:test  
    - ./gradlew core:some-module:impl:test  

В artifacts все аналогично описанию выше.

artifacts:  
    paths:  
      - "**/build/reports/tests/testDebugUnitTest/classes"  
      - "**/build/reports/tests/testDebugUnitTest/packages"  
      - "**/build/reports/tests/testDebugUnitTest/index.html"  
    expire_in: 10 days  
    when:  
      always

Build flavors

Далее на очереди у нас сборка различных вариантов приложения и публикация сборки в Telegram. Доступ к APK будет осуществляться по ссылке.

assembleSomeBuildFlavor:  
  stage: build_flavors  
  script:  
    - ./gradlew assembleSomeBuildFlavorDebug  
   
    - apt update  
    - apt install python3-pip --yes  
  
    - pip3 install awscli --upgrade  
  
    - export VERSION_NAME="TEST VERSION"  
    - export MR_COMMIT_MESSAGE=${CI_COMMIT_MESSAGE}  
  
    - aws s3 cp app/build/outputs/apk/{Ваш flavor name}/debug/{Ваш APK name}.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/{Ваш flavor name}/  
    expire_in: 10 days  
  rules:  
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "develop"'

Тут несколько интереснее, т.к. сборка улетает в Telegram.

В скрипте мы собираем наш APK.

script:  
    - ./gradlew assembleSomeBuildFlavorDebug

Ниже в таблице указаны команды для сборки различных вариантов приложения, подробнее про это тут (Варианты сборок) и тут (Сборка с помощью command line).

Тип сборки

Как запустить сборку

APK

./gradlew assemble{Variant name/flavor}

Bundle

./gradlew bundle{Variant name/flavor}

В нашей Telegram-группе, доступ к APK будет осуществляться по ссылке на файл в облаке. Для этого мы будем использовать Amazon Web Services Command Line Interface или же AWS CLI, это инструмент, который позволяет управлять ресурсами Amazon Web Services из командной строки. И Yandex Object Storage для хранения файла APK в облаке. Более подробно про работу AWS CLI с Yandex Object Storage можно прочитать здесь.

Теперь давайте пробежимся по тому, что написано в нашем скрипте.

Сначала обновим списки пакетов, чтобы использовать актуальные версии.

- apt update  

Затем устанавливаем python и pip (система управления пакетами). Где, --yes указываем чтобы не подтверждать установку вручную.

- apt install python3-pip --yes  

Далее устанавливаем утилиту для командной строки (awscli) о которой говорилось выше, для того чтобы делать ссылку на скачивание нашего приложения.

- pip3 install awscli --upgrade  

После объявляем локальные переменные.

- export VERSION_NAME="TEST VERSION"   #Название сборки
- export MR_COMMIT_MESSAGE=${CI_COMMIT_MESSAGE}  #Описание сборки. В нашем случае текст берется как последний commit в ветке

Теперь загрузим наш собранный APK в s3-хранилище. Здесь мы указываем путь к файлу, который хотим загрузить, после указываем s3-bucket и путь по которому необходимо сохранить файл. Более подробно тут.

- aws s3 cp app/build/outputs/apk/{Ваш flavor name}/debug/{Ваш apk name}.apk s3://{Задайте название бакета в s3}/{Задайте название файла в бакете}.apk --endpoint https://storage.yandexcloud.net 

Далее делаем скрипт ниже исполняемым, об этом будет написано позже, просто скажу, что он нам необходим, чтобы отправить сообщение с помощью бота в группу Telegram.

- chmod a+x ./upload_telegram_link.sh  

Затем получаем Presigned Url, по которому можно будет получить доступ к приватному файлу в хранилище на протяжении некоторого времени. Так же дополнительно можно посмотреть тут и небольшой пример тут.

- aws s3 presign s3://{Задайте название бакета в s3}/{Задайте название файла в бакете}.apk --endpoint-url "https://storage.yandexcloud.net/" --expires-in 604800|source upload_telegram_link.sh  

Отдельно хочу отметить, что в команде --expires-in 604800 мы указываем время жизни ссылки в секундах, что равняется 7 дням. А через символ | (pipe) мы передаем результат предыдущей команды (в данном случае - сгенерированную ссылку) в следующую команду, где source upload_telegram_link.sh запускает наш скрипт (О нем чуть ниже будет написано).

--expires-in 604800|source upload_telegram_link.sh  

В артефактах GitLab сохраняем наш APK.

artifacts:  
    paths:  
      - app/build/outputs/apk/{Ваш flavor name}/  
    expire_in: 10 days

Выполняем наш скрипт при MR в develop ветку.

rules:  
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "develop"'

Переменные окружения, подробнее про них тут:

Название переменной

Описание

VERSION_NAME

Локальная переменная, а именно название сборки

MR_COMMIT_MESSAGE

Локальная переменная, сообщения изменений в сборке, берется из CI_COMMIT_MESSAGE

CI_COMMIT_MESSAGE

Текст последнего коммита

CI_MERGE_REQUEST_TARGET_BRANCH_NAME

Содержит название ветки, в которую мы хотим влить изменения

Про то, как создавать бота я писать не буду, думаю информации полно, а вот к разбору кода перейдем. Нам необходим скрипт для отправки сообщения в Telegram. Для этого тыкаем ПКМ по корню проекта -> New - > File -> Задаем название файлу, в нашем случае upload_telegram_link.sh, после чего пишем следующий код:

#!/bin/bash  
encodeurl() {  
  python3 -c "import urllib.parse; import sys; url = sys.stdin.read(); print(urllib.parse.quote(url))"  
}  
TIME="10"  
TG_API_URL="https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage"  
  
read S3URL  
ENCODED_S3URL=$(echo ${S3URL} | encodeurl)  
curl -s --max-time ${TIME} -d "chat_id=${TELEGRAM_CHAT_ID}&disable_web_page_preview=1&text=${VERSION_NAME} %0A${MR_COMMIT_MESSAGE} %0A${ENCODED_S3URL}" ${TG_API_URL} > /dev/null

Указываем, что данный файл должен выполняться с помощью интерпретатора Bash.

#!/bin/bash

Создаем функцию encodeurl, которая будет кодировать специальные символы в URL формат. Это делается для того, чтобы наши ссылки на скачивание из s3 не сломались.

encodeurl() {  
  python3 -c "import urllib.parse; import sys; url = sys.stdin.read(); print(urllib.parse.quote(url))"  
}

Устанавливаем время ожидания (timeout) для curl в 10 секунд.

TIME="10"

Формируем URL, для отправки сообщения с помощью бота через API Telegram. Подробнее про команду sendMessage.

TG_API_URL="https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage"

Cчитываем ссылку на скачивание из s3 (она передается нам из предыдущей команды через pipe).

read S3URL  

Далее используем функцию encodeurl для кодирования ссылки, которая будет использоваться в Telegram.

ENCODED_S3URL=$(echo ${S3URL} | encodeurl)

И наконец отправляем наше сообщение в группу.

curl -s --max-time ${TIME} -d "chat_id=${TELEGRAM_CHAT_ID}&disable_web_page_preview=1&text=${VERSION_NAME} %0A${MERGE_REQUEST_COMMIT_MESSAGE} %0A${ENCODED_S3URL}" ${TG_API_URL} > /dev/null

Где указываем chat id, куда отправлять сообщение.

chat_id=${TELEGRAM_CHAT_ID}

Отключаем превью веб-страниц для ссылок.

disable_web_page_preview=1

Переменные окружения:

Название переменной

Описание

TELEGRAM_BOT_TOKEN

Токен Telegram бота

TELEGRAM_CHAT_ID

Id Telegram чата, в который будет отправляться сообщение

S3URL

URL для скачивания нашей APK, который выдает s3

ENCODED_S3URL

Локальная переменная, перекодированный URL для скачивания APK

TIME

Время максимального ожидания к подключению

Создаем собственные переменные окружения в Gitlab

GitLab предоставляет удобный и гибкий механизм определения собственных переменных окружения. Для того, чтобы добавить свои переменные окружения через UI, необходимо зайти в ваш проект на Gitlab'е, далее переходим по следующим вкладкам: Settings -> CI/CD -> Variables.

Теперь мы можем добавлять наши переменные. Для этого нажимаем на Add variable. И видим окно настроек новой переменной.

Тут нас интересуют ключ (key) - уникальное название наших переменных, значение самой переменной (value), по желанию описание (description). Видимость (visibility) лучше устанавливать как Masked (скрыто в логах CI/CD) или Masked and hidden (скрыто в логах CI/CD и в Gitlab UI).

ВАЖНО
Чувствительные секреты (токены, ключи и т.д.) лучше хранить за пределами Gitlab variables. Как пример, можно использовать Yandex Lockbox, Google Cloud Secret Manager или HashiCorp Vault, но об этом в другой статье.

На этом все, в итоге мы получаем такую красоту:

Полезные ссылки

Хочу сказать, что список далеко не исчерпывающий:

  1. Информация о Gitlab jobs

  2. Информация о Gitlab pipelines

  3. Информация о Gitlab variables

  4. Примеры CI/CD от GitLab

Заключение

Надеюсь кому-нибудь эта статья будет полезна. Если вам есть что добавить или исправить, милости прошу в комментарии! С удовольствием буду читать.

В следующей части мы рассмотрим 2 сервиса для Android-тестирования: Marathon Labs и Firebase Test Lab.

Про загрузку release-сборок в Play Market я расскажу в 3 части, где подключим необходимые сервисы и напишем pipeline. Создание тэгов в Gitlab будет в этой же статье.

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

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


  1. olku
    14.06.2025 16:10

    APK ещё можно собирать и в GitLab Release публиковать по тегу, поставить артефакту 5 лет жизни. Не подскажете самый простой способ матрицу тестов сделать в GitLab на разных Android SDK?


    1. kirlozavr Автор
      14.06.2025 16:10

      Если правильно понял вопрос, то в целом можно задать необходимые версии ОС при конфигурации тестов (Marathon/Firebase).

      Например для Firebase:
      --device model=Nexus6,version=21 \
      --device model=Nexus7,version=19

      Для Marathon:
      --os-version 10


      1. kirlozavr Автор
        14.06.2025 16:10

        Извиняюсь, что не предоставил ссылки на документацию, поэтому на всякий случаю оставлю их здесь. Документация для Firebase и для Marathon.