Привет, Хабр! Меня зовут Паша Филимонов и я Android-разработчик в Учи.ру. Наша небольшая команда занимается разработкой мобильных приложений — «Учи.ру» и «Учи.ру для учителей». Мы сторонники автоматизации и считаем, что настройка CI/CD необходима для улучшения процессов разработки продукта. Каждый раз, приступая к новой сборке приложения, нам приходилось вручную прописывать ее код версии. Это довольно рутинная задача, которую мы решили автоматизировать. Рассказываю, как мы это сделали.
Как раньше строились процессы
Наши билды собираются с помощью сервисов Github Actions. У нас есть 3 основных типа сборки:
stage — для тестирования фичи;
release candidate — stage сборка — релиз кандидат для регрессионного тестирования;
release — готовая сборка для публикации в Play Store.
Когда мы делали новую версию приложений, то вручную прописывали новый воркфлоу в гитхабе, указывая необходимые параметры:
versionName — версия приложения, в которую пойдет новый функционал;
versionCode — код версии (инкремент последней сборки);
releaseNotes — описание того, что изменилось в этой сборке.
Код версии для каждой новой сборки уникален, его значение равно инкременту предыдущей сборки (исключением являются релизные сборки). В примере ниже показана трансформация versionCode, который указан в скобках:
После того как сборка была готова, в канал нашей команды в Slack приходило соответствующее уведомление с вышеуказанными параметрами и автором инициализации воркфлоу.
Здесь и возникает проблема: каждый раз, инициируя новую сборку, нам нужно знать versionCode предыдущей.
Самый просто способ его выяснить — открыть канал в Slack, посмотреть код версии последней сборки и в уме прибавить к нему единицу. Это задача постоянно отнимала время, поэтому мы решили автоматизировать процесс. К тому же мы не застрахованы от race condition — ситуации, когда несколько человек могут создать сборку с одинаковыми значениями кода версии.
Так выглядел наш набор параметров, необходимых для создания сборки: поля Version code и Version name являлись обязательными для заполнения.
К чему хотим прийти
Мы решили избавиться от ручного указания номера и кода версии при создании новой сборки.
Для этого нам было необходимо определить мастер-систему (хранилище), в которой будут храниться актуальные данные о последней сборке. Это позволило бы автоматически получать данные о сборке и вычислять номер новой.
При этом мы хотели оставить возможность указывать номер и код версии вручную, чтобы при проблемах с доступом в хранилище можно было самостоятельно указывать необходимые данные и не останавливать процесс разработки. А также в частном порядке создавать сборки с нужными версиями, не меняя ничего в хранилище.
Выбор хранилища
Один из вариантов решения — хранить эти данные в файле в текущем проекте. Но этот подход заведомо обречен, поскольку feature-ветки не будут знать про изменения версии в других ветках. А при объединении веток будут постоянные конфликты из-за изменений в одном файле с версией.
Поэтому в первую очередь было необходимо найти хранилище, которое:
бесплатное;
имеет возможность записывать/читать данные без ограничений (или с адекватными лимитами);
обеспечивает безопасное хранение данных;
позволяет просматривать и редактировать данные в реальном времени;
простое во внедрении.
Нам важно, чтобы внедряемая база данных требовала минимальных усилий для поддержания ее работоспособности.
Вот какие решения мы рассматривали.
Варианты хранилища:
Попросить DBA «поднять» отдельную базу данных для наших артефактов
Отличный вариант для тех, у кого есть такая возможность.
Плюсы:
артефакты будут храниться в рамках вашей компании;
неограниченный круг возможностей в проектировании.
Минусы:
зависимость от других отделов/людей, у которых не всегда будет время на наши нужды.
2. Использовать отдельный репозиторий
Плюсы:
прост в использовании.
Минусы:
неудобство ручного редактирования данных. Для изменения данных вручную, придется коммитить и пушить в этот репозиторий, что также может привести к конфликтам.
Использовать Github gists
Плюсы:
прост в использовании.
Минусы:
Github gists могут быть привязаны только к пользователю. Чтобы не привязывать их к определенному члену команды, потребуется создать дополнительный аккаунт с доступом к репозиторию.
Внедрить стороннюю онлайн базу данных
Плюсы:
независимое хранилище.
Минусы:
дополнительная точка отказа;
большинство сервисов предлагают свои услуги, которые требуют инвестиций.
Использовать Firebase
Его можно отнести к предыдущему варианту, но я выделю его отдельно, потому как в нашем проекте мы активно используем сервисы от Firebase, и он не станет для нас новой точкой отказа.
Плюсы:
несколько способов доступа к данным (с помощью cUrl и пользовательский интерфейс);
бесплатный пакет;
прост в подключении и использовании.
Минусы:
лимиты в бесплатной версии, но для нашей задачи их хватит с головой (на момент публикации статьи: 1 гб. на хранение данных, 10 гб. в месяц на загрузку данных).
На чем мы остановились
Так как мы активно используем сервисы Firebase, мы решили, что интегрировать их базы данных будет рационально: нам не придется подключать к процессу сторонний сервис.
Firebase предоставляет два типа баз данных: Realtime Database и Cloud Firestore. Различия между ними можете прочитать здесь и здесь.
На текущий момент наши потребности просты: записывать и читать данные строго для последней сборки. Поэтому мы остановились на самом подходящем варианте — Realtime Database. Бесплатный план имеет лимиты, но в нашем случае нам этого хватает. Документация по Realtime Database и Cloud Firestore.
Пример, как можно обновить данные в Realtime Database с помощью cUrl:
curl --show-error --fail --request PATCH --url
"<https://example.firebaseio.com/build_info/stage.json>"
--data-raw "{\\"version_code\\":12}"
Как внедряли автоматизацию
Основной флоу на CI/CD у нас уже был настроен. Нам оставалось добавить получение, инкрементацию и сохранение сборки в Firebase.
Мы создаем повторно используемый воркфлоу, чтобы переиспользовать его логику в двух приложениях — «Учи.ру» и «Учи.ру для учителя». Затем объявляем выходные параметры VERSION_NAME и VERSION_CODE, связываем их с выходными параметрами задачи InitAppVersion. На вход передаем Uri базы данных, к которой будем обращаться за данными.
name: Initialize app version
on:
workflow_call:
secrets:
databaseUri:
required: true
outputs:
VERSION_NAME:
value: ${{ jobs.InitAppVersion.outputs.versionName }}
VERSION_CODE:
value: ${{ jobs.InitAppVersion.outputs.versionCode }}
Объявляем задачу, которая будет выполнять инициализацию значений текущей версии, а также — выходные параметры этой задачи:
jobs:
InitAppVersion:
name: Init app version
runs-on: ubuntu-latest
outputs:
versionName: ${{ steps.finish.outputs.versionName }}
versionCode: ${{ steps.finish.outputs.versionCode }}
Далее разбиваем задачу на шаги:
Получаем из базы последние значения versionName и versionCode и объявляем их как выходные параметры этого шага. Он выполняется, если нет введенных вручную номера или кода версии. Все запросы к хранилищу мы выполняем с помощью cURL запросов.
- name: Pull build info from realtime database
id: pull
if: ${{ github.event.inputs.versionName == '' ||
github.event.inputs.versionCode == '' }}
run: |
curl --show-error --fail --request GET --url "${{
secrets.databaseUri }}" > response.json
echo '::set-output name=lastVersionName::'$(jq -r
'.version_name' response.json)
echo '::set-output name=lastVersionCode::'$(jq -r
'.version_code' response.json)
Записываем данные в переменные окружения. Введенные вручную значения мы оставляем приоритетными, чтобы у нас всегда была возможность задать руками версию приложения.
# Устанавливаем значение номера версии
- name: Initial version name
run: echo "newVersionName=${{
github.event.inputs.versionName ||
steps.pull.outputs.lastVersionName }}" >> $GITHUB_ENV
# Устанавливаем значение номера билда
- name: Initial version code
env:
versionCode: $((${{
steps.pull.outputs.lastVersionCode }}+1))
run: echo "newVersionCode=${{
github.event.inputs.versionCode || env.versionCode }}" >>
$GITHUB_ENV
Сохраняем новое значение кода версии в БД только в том случае, если код версии не был введен вручную. Мы не сохраняем номер версии (versionName) в БД из рабочего процесса (подробнее ниже).
- name: Push version code to realtime database
if: ${{ github.event.inputs.versionCode == '' }}
run: curl --show-error --fail --request PATCH --url "${{
secrets.databaseUri }}" --data-raw "{\\"version_code\\":${{
env.newVersionCode }}}"
Инициализируем выходные параметры задачи.
- name: Setup outputs
id: finish
run: |
echo "::set-output name=versionName::${{
env.newVersionName }}"
echo "::set-output name=versionCode::${{
env.newVersionCode }}"
Так выглядит наш набор параметров, необходимых для создания сборки, после изменений:
Теперь поля Version code и Version name стали необязательными: заполнять их нет необходимости. Данные автоматически подтягиваются из БД, если не введены вручную.
Однако все еще осталась нерешенной проблема с сохранением номера версии: при работе над несколькими релизами одновременно придется постоянно указывать номер вручную, поскольку в БД хранится только одно его значение.
Что насчет релизных сборок
Релизные сборки также подверглись небольшим изменениям. Теперь перед тем как запустить релизную сборку, мы создаем Release Candidate(RC). После этого — тег для коммита, для которого запускается сборка вида: v1.1(256). Ее название не имеет значения, главное — она должна содержать номер (versionName) и код (versionCode) версии.
При создании релизной сборки из того же коммита, что и RC, мы можем получить тег. Затем вытащить из него нужную информацию по номеру и коду версии и присвоить их релизной сборке. Важно лишь указать то, как мы получаем эти данные из тега:
Например, для версии v1.1(256):
steps:
- uses: actions/checkout@v2
with:
fetch-depth: 0
# Получаем тэг, если хоть одно поле пустое
- name: Fetch tag
id: tag
if: ${{ github.event.inputs.versionName == '' ||
github.event.inputs.versionCode == '' }}
run: echo "::set-output name=tagName::$(git describe
-- abbrev=0 --tags)"
# Устанавливаем значение номера версии
- name: Initial version name
run: |
if ${{ github.event.inputs.versionName != '' }}; then
echo "newVersionName=${{
github.event.inputs.versionName }}" >> $GITHUB_ENV
else
echo "newVersionName=$(echo "${{
steps.tag.outputs.tagName }}" | sed 's/.*version//; s/(.*//')"
>> $GITHUB_ENV # получаем значение между буквой v и началом
открытия скобок: "1.1"
fi
# Устанавливаем значение номера билда
- name: Initial version code
run: |
if ${{ github.event.inputs.versionCode != '' }}; then
echo "newVersionCode=${{ github.event.inputs.versionCode }}" >> $GITHUB_ENV
else
echo "newVersionCode=$(echo "${{ steps.tag.outputs.tagName }}" | sed 's/.*(//; s/).*//')" >> $GITHUB_ENV # получаем значение в скобках: "256"
fi
Таким образом, нам удалось решить проблему ручной инкрементации сборки, избавиться от проблем при асинхронных действиях разработчиков и делегировать рутинные операции машине. Для этого мы использовали инструменты автоматизации от Firebase. В статье мы поделились и альтернативными вариантами решения этой проблемы.
В будущем мы планируем решить проблему с сохранением номера версии, а также тщательно проработать идею формирования changelog’а из описания пул-реквестов или номера задач в Jira.
Полный код воркфлоу на Github.
Если ты разделяешь наш подход к аналитике, присоединяйся, чтобы вместе с нами развивать школьный EdTech.
sashaxorev
На прошлой работе для сборок под мобилки использовал fastlane. Он мог дёрнуть API из Google Play и получить последнюю версию билда для разных типов (alpha, beta, prod), что полностью решало вопрос с версионированием.
Такой вариант вариант не рассматривали? По-сути ведь Google Play всегда знает все версии сам и хранит их, он же единственный источник правды
pf1ll Автор
Да, это хороший вариант, но при таком подходе мы не исключаем race condition.
Между получением последней сборки и сохранением новой - проходит время (как минимум: билд + отправка через fastlane), и мы не застрахованы что во время этого этапа кто-нибудь из разработчиков сделает новую сборку, соответственно - данные для новой сборки будут неконсистентными
sashaxorev
Проблема race condition решается тем что ставится ограничение на CI выполнять только 1 экземпляр инстанса - тогда всегда будет собираться только одна версия в моменте.
Так же, если я правильно понял, в вашем подходе проблема race condition никак не решается (поправте если я ошибаюсь)
keekkenen
а классический вариант с артефактори не рассматривали ? разрабы могут делать снапшоты, CI - релизы, никто никому не мешает