GitHub Actions — инструмент для автоматизации рутинных действий с репозиторием и подспорье при создании CI/CD для вашего проекта.
Пользователи GitHub’а ежемесячно получают по 2000 минут, чтобы выполнять GitHub Actions на инфраструктуре сервиса. Применим это бесплатное время с пользой.
Как разработчик Flutter-приложений, даю инструкцию: как c помощью GitHub Actions на каждый pull request запускать тесты и анализатор кода, билдить артефакт и деплоить его для тестирования в Firebase.
Человек очень быстро привыкает к хорошему. Причём привыкает настолько, что уже даже не задумывается о том, что так было не всегда. Как гласит старый анекдот про мужика и козу, осознание всех прелестей бытия происходит именно в тот момент, когда ты этих самых прелестей лишаешься.
Если в вашем рабочем проекте всё хорошо с CI/CD — вы счастливый человек.
Может быть, вы работаете в стартапе и тщательно настраивали все пайплайны и хуки своими руками.
Может быть и такое, что о вашем благополучии заботится целая DevOps-команда: радует вас каждый месяц новыми интеграциями, тающим на глазах билд-таймом и продвинутыми техниками деплоя сборок во все мыслимые и немыслимые места.
Не важно. Главное, вы всегда уверены, что ваши сборки жизнеспособны, а сами вы при этом избавлены от большого количества очень скучных рутинных задач, мысли о которых всегда повергают разработчиков в тоску и уныние. Кстати, буду рад, если вы напишите в комментариях, когда в последний раз вы вручную меняли статус у задачи в Jira?
Куда выходят из зоны комфорта, а самое главное, зачем? Масса причин. Знакомый попросил помочь написать небольшое приложение для собственного бара, вы наконец-то нашли время для реализации пет-проекта своей мечты или решили зарелизить библиотеку, которая случайно родилась в рамках работы над проектом. Да наконец, просто вы с коллегой решили написать к воркшопу небольшой сэмпл проект.
Готов поспорить, что в любом из сценариев ваше воодушевление от новых интересных задач быстро столкнётся с суровой реальностью разработки ПО в «безвоздушной среде» (да, в какой-то момент умный сборщик будет нужен вам как воздух).
Что вы там обычно ещё себе в такие моменты говорите? «Я не разбираюсь в этом! Я просто пишу фронтенд/мобилку и ничего не знаю про эти ваши Дженкинсы!» А что, если я скажу, что вам ничего такого и не нужно знать?
Да-да, вам достаточно уметь собрать ваш проект, пользуясь консольными командами, — и всё. Вы можете значительно упростить свою жизнь, даже если речь идёт о персональном маленьком проекте, а не гигантском многомодульном монстре, который уже с трудом переваривает IDE.
Github Actions настолько прост, что даже ваша бабушка настроила бы его без особого труда.
Если всё так просто, зачем тратить время на прочтение сего опуса? Отвечу маркированным списком:
Github Actions — это сервис, который позволяет вам автоматизировать рабочий процесс в репозитории. Всё, что вы делаете вручную с вашим проектом, — кроме непосредственно написания кода — вы можете делегировать Github Actions. Если вы предпочитаете сразу знакомиться с первоисточниками, пройдите в официальную документацию.
Часто мы даже не знаем, что нам вообще нужно автоматизировать. У команды нет времени разбираться в сложном API сервиса, а затем писать и отлаживать решение с нуля. Эту проблему решает Marketplace: там опубликовано почти 5 тысяч готовых Actions, решающих массу типичных задач (например, отправки уведомлений о событиях в Telegram, анализа исходников проекта на наличие техдолга, установки меток на PR в зависимости от изменённых в нём файлов). Плохая новость: многие из них условно бесплатные — с довольно строгими лимитами на использование.
Всё в Github Actions крутится вокруг workflows. Каждый workflow отвечает на два вопроса: что сделать и когда сделать.
Что сделать. Тут вариантов бесчисленное множество: вы можете собирать, тестировать и деплоить ваши билды, используя готовые или созданные своими руками сценарии. Подробнее про конфигурацию workflow
Когда сделать. Можно триггерить workflows по событиям, происходящим в репозитории. Создание пул-реквеста, пуш тэга на коммит или даже появление новой звёздочки у вашего проекта. Полный список хуков
Если workflow должен выполняться не по событию, а в определённое время или с определенной периодичностью, в вашем распоряжении синтаксис POSIX cron. Подробнее про регулярные события
В репозитории может соседствовать сколько угодно разных workflows одновременно. Каждый workflow описывается в отдельном YAML-файле, каждый из которых должен храниться в директории .github/workflows в корне вашего репозитория. Подробнее про синтаксис workflows
Github Actions предлагает два варианта исполнения ваших workflows:
В моей статье я остановлюсь на первом варианте. Мы ведь идём по простейшему пути из возможных, верно?
Перед тем, как мы приступим к конфигурированию workflow, надо договориться о двух вещах.
Первое: основной ролью workflow будет усложнение поломки кодовой базы. В основную ветку не должен попасть код, который не собирается, содержит потенциальные проблемы или ломает тесты.
Второе: в моей конфигурации могут встречаться некоторые тонкости, которые не будут актуальны для вашего проекта. Я буду стараться их пояснять. Однако если вы используете данную статью как руководство — заимствуйте вдумчиво.
Наконец, давайте решим, что вообще должен делать наш workflow. Нужен план, который поможет нам двигаться в нужном направлении.
Приведённый план может использоваться в качестве чек-листа при настройке вашего собственного workflow. Нам предстоит:
Теперь наша работа приобрела осязаемые очертания. Перейдём к реализации.
Очевидно, что назвать наш workflow нужно так, чтобы наименование отражало его суть как можно точнее. Наименование (docs) ― первое, что мы увидим в Actions-консоли при выполнении workflow. Почему я назвал мой workflow именно так, вы узнаете через мгновение.
Блок «on» (docs) позволяет указать одно или несколько событий, при регистрации которых мы хотим запускать наш workflow. Более того, некоторые из событий можно тонко настроить.
Какое событие выбрать? Чтобы точно не пропустить поломку, можно указать хоть все существующие события. Тогда сборка будет идти практически непрерывно, но хотим ли мы этого? Нет, так как в этом случае лимит по нашему бесплатному тарифному плану закончится фантастически быстро. Будем искать оптимальное решение.
Предположим, что в нашем проекте соблюдаются договоренности, по которым в основную ветку проекта код не может быть запушен напрямую — только через создание пул-реквеста. Логично, если наш workflow будет реагировать на создание пул-реквеста и собирать проект из изменённой кодовой базы:
$ Этого достаточно для работы, но решение пока не совсем оптимально. Сборка будет триггериться на каждый созданный пул-реквест. Это избыточно, так как нас интересуют только пул-реквесты, направленные в основную ветку проекта. Синтаксис Github Actions позволяет указать наименования (или маски) веток, которые нас интересуют.
$ И снова ищем пути оптимизации процесса. Есть файлы, которые даже в теории не могут повредить вашему проекту: проектная документация, Swagger, общие настройки код-стайла и IDE. Благо, мы располагаем возможностью заигнорить такие файлы по маске пути. В итог блок “on” будет выглядеть следующим образом:
Наконец мы готовы к тому, чтобы сконфигурировать job (docs). Самое время для того чтобы пояснить, какую роль job играет в workflow.
Каждый workflow должен включать в себя хотя бы один job. Именно job содержит пошаговое описание действий (steps), которые мы производим с нашим проектом. Количество job’ов в одном workflow не лимитировано, как и количеством step’ов в одном job’е. По умолчанию все job’ы исполняются параллельно, если не указана зависимость одного job’а от результатов другого. В нашем проекте будет единственный job, который и будет отвечать за сборку проекта.
Каждый раз workflow запускается на чистом инстансе виртуальной машины. Единственное, что мы можем выбрать, ― операционную систему, которая будет на этой машине стоять. Что выбрать?
Велик соблазн выбрать macOS, ведь мы же планируем собирать Flutter приложение под целевые платформы: Android и iOS. Плохие новости. Одна минута использования инстанса с macOS тарифицируется как десять (10!!!) минут использования инстанса с Ubuntu. В инстансе с Windows в нашем случае вообще нет никакого смысла, так как iOS-сборку там собрать всё равно не получится, а его время использования вдвое дороже инстанса с Ubuntu. Подробнее про биллинг
$ Как же сделать так, чтобы наши 2000 бесплатных минут не превратились в 200? Хорошего решения не существует. Я решил отказаться от сборки билда на iOS при создании пул-реквеста. Это потенциально ударит по стабильности iOS-сборки. Есть и компромиссный вариант ― собирать iOS-билд на macOS только при изменении pubspec.yaml или любого файла из директории /ios, в остальных случаях собирать только Android-билд на инстансе с Ubuntu. Сделать это можно по аналогии с тем, как мы настраивали игнор-файлы для блока «on».
Вы можете посмотреть технические характеристики, а также список установленного «из коробки» софта. Flutter и Java, к сожалению, в этот список не входят. Их придётся устанавливать вручную при каждом исполнении workflow.
Не спешите расстраиваться. На помощь нам придут готовые action’ы, которые мы можем использовать в step’ах нашего job’а. Мы будем использовать два:
У нас есть чистый инстанс машины, арендованной у Github на несколько минут. В предыдущем шаге мы установили на него весь необходимый софт. Теперь нам следует склонировать репозиторий с исходным кодом нашего проекта. Для этого воспользуемся готовым инструментом:
До этого момента мы не реализовывали step’ы своими руками, а пользовались лишь тем, что нам предлагаю готовые action’ы. Теперь мы переходим к реализации активной фазы сборки нашего проекта, поэтому пришло время написать реализацию step’а самостоятельно.
Перед сборкой нам нужно загрузить все пакеты, которые указаны в блоке dependencies нашего pubspec.yaml файла, а также все их транзитивные зависимости. Для этого Flutter SDK из коробки предлагает простую команду
В случае, если ваш проект имеет сложную структуру и содержит ряд dart-пакетов, подключаемых локально, вы столкнётесь с проблемой. Без явного вызова
Так как реализацией step’а может быть любая терминальная команда, ничто не мешает нам исполнить наш shell-скрипт.
Flutter предлагает нам пользоваться встроенной командой
Мы могли бы воспользоваться коробочной возможностью без доработок, но, увы, стандартное поведение команды
Проблемы, найденные анализатором, классифицируются по трём уровням критичности: info, warning, error. В этом issue описано, что даже в том случае, если в процессе анализа обнаружены только проблемы класса info (а на их исправление не всегда стоит тратить время здесь и сейчас), команда возвращает код «1», в результате чего ваша сборка упадёт.
В качестве временного решения предлагаю использовать следующий скрипт. Отныне сборка будет падать только при наличии проблем уровня error:
Исполняем shell-скрипт в следующем step’е нашего workflow:
Если у вас в проекте есть тесты — вы на правильном пути! Чтобы тесты работали, мало их написать — их нужно регулярно запускать, чтобы вовремя исправлять огрехи реализации или актуализировать при необходимости. Поэтому следующим step’ом мы реализуем
Будьте внимательны: пустые тестовые классы, не содержащие ни одного реализованного теста, будут приводить к падению всего workflow. Выход один: не объявлять тестовые классы до тех пор, пока не будете готовы реализовать в рамках него хотя бы один тест.
Все подготовительные работы позади. Мы убедились, что код, скорее всего, не содержит очевидных проблем. Теперь мы переходим к самому ключевому этапу — производству артефакта. Иными словами, мы будем собирать APK.
Сама по себе сборка реализуется крайне просто. В нашем распоряжении терминальная команда flutter build, которая крайне глубоко конфигурируется и позволяет собрать артефакт для конкретного флейвора, main-файла, ABI. Мы не рассматриваем эти нюансы в рамках статьи, поэтому используйте дополнительные флаги команды при необходимости.
Наша цель — получить сборку, подписанную релизным ключом. И на этом этапе нам придётся решить проблему секьюрности, ведь нам нужно где-то хранить релизный keystore, а также все его алиасы и пароли.
Github позволяет безопасно хранить строковые значения в специальном хранилище Secrets. Имеющиеся здесь данные хранятся в рамках соответствующего репозитория и могут быть прочитаны программно из любого step’а вашего workflow. При этом значения невозможно увидеть через web-интерфейс Github. Позволяется только удаление или перезапись.
Это выглядит подходящим решением для алиасов и паролей, особенно если вы сами себе служба безопасности, но что делать с самим *.jks-файлом? Пушить его в репозиторий не выглядит удачной идеей, даже если ваш репозиторий приватный. К сожалению, никакого безопасного способа хранения файлов Github не предоставляет, поэтому придётся изворачиваться.
Неплохо было бы представить наш keystore-файл в виде строки. И это реально — стоит лишь закодировать его в base64. Для этого нужно открыть терминал в директории, содержащей наш *.jks-файл и выполнить следующую команду. Рядом будет создан текстовый файл из которого можно скопировать base64-представление нашего keystore, чтобы затем… сохранить его в Github Secrets.
Теперь, когда все необходимые составляющие успешного подписания сборки в наличии, мы приступим к конфигурации step’а. В блоке env мы объявляем все переменные окружения для этого конкретного step’а. Значения этих переменных мы возьмём из Secrets.
В нашем Android-хосте нам предстоит описать конфигурацию сборки таким образом, чтобы мы обрели возможность подписывать *.apk-файл и на CI, не потеряв возможность собирать подписанную сборку локально. За этот момент ответственен файл keystoreConfig.gradle.
В случае если keystore_release.properties файл найден, известно, что сборка происходит локально, а значит можно проинициализировать все свойства keystoreConfig, просто прочитав их из файла. В противном случае сборка происходит на CI, а это значит, что единственный источник сенситив-данных — Github Secrets.
А так выглядит файл keystore_release.properties:
Последний шаг в build.gradle файла нашего Android-хоста применяем keystoreConfig-файл к нашей конфигурации подписания релизной сборки:
Подписанная сборка уже у нас в руках! Но как распространить её на своих коллег для тестирования?
Github Actions позволяет настроить выгрузку артефактов практически в любой известный инструмент для дистрибьюции сборок, но мы рассмотрим лишь два варианта:
Github Storage
Github Storage легко интегрируется через официальный action. Вам нужно лишь указать наименование сборки таким, каким его увидят ваши коллеги в web-интерфейсе, а также путь к собранному *.apk-файлу на CI.
Основная проблема заключается в ограниченном объёме хранилища. На бесплатном тарифном плане вам предоставляется всего лишь 500 Мб. Самое странное, что я не нашёл никакой возможности вручную очистить всё хранилище сразу через web-интерфейс, поэтому вышел из положения через… написание отдельного workflow, ответственного только за очищение Storage от замшелых артефактов.
Workflow запускается ежедневно в час ночи и удаляет все артефакты старше одной недели:
Firebase App Distribution
Что касается Firebase App Distribution, то для интеграции с ним я использовал готовый action wzieba/Firebase-Distribution-Github-Action.
Для корректной работы action’а ему необходимо передать параметры:
Наш простейший workflow готов! Всё, что нам осталось сделать, — запустить инициирующее событие и наблюдать за ходом исполнения workflow.
Теперь вы можете пользоваться всеми преимуществами простого CI/CD механизма на вашем Flutter-проекте, независимо от его масштабов, интенсивности разработки, а также вашего кошелька.
Напоследок вот вам несколько советов и наблюдений, к которым я пришёл в процессе работы над этим workflow:
Впереди ещё масса потенциальных улучшений и доработок. Пишите в комментарии свои идеи для улучшения workflow. Чего в нём не хватает лично вам? Возможно, реализация самых интересных идей читателей ляжет в основу следующей статьи по теме.
Все скрипты и workflow можно найти в репозитории с тестовым приложением.
Спасибо за внимание.
P.S. Наша команда Surf выпускает много полезных библиотек для Flutter. Выкладываем их в репозитории SurfGear.
Пользователи GitHub’а ежемесячно получают по 2000 минут, чтобы выполнять GitHub Actions на инфраструктуре сервиса. Применим это бесплатное время с пользой.
Как разработчик Flutter-приложений, даю инструкцию: как c помощью GitHub Actions на каждый pull request запускать тесты и анализатор кода, билдить артефакт и деплоить его для тестирования в Firebase.
Человек очень быстро привыкает к хорошему. Причём привыкает настолько, что уже даже не задумывается о том, что так было не всегда. Как гласит старый анекдот про мужика и козу, осознание всех прелестей бытия происходит именно в тот момент, когда ты этих самых прелестей лишаешься.
Если в вашем рабочем проекте всё хорошо с CI/CD — вы счастливый человек.
Может быть, вы работаете в стартапе и тщательно настраивали все пайплайны и хуки своими руками.
Может быть и такое, что о вашем благополучии заботится целая DevOps-команда: радует вас каждый месяц новыми интеграциями, тающим на глазах билд-таймом и продвинутыми техниками деплоя сборок во все мыслимые и немыслимые места.
Не важно. Главное, вы всегда уверены, что ваши сборки жизнеспособны, а сами вы при этом избавлены от большого количества очень скучных рутинных задач, мысли о которых всегда повергают разработчиков в тоску и уныние. Кстати, буду рад, если вы напишите в комментариях, когда в последний раз вы вручную меняли статус у задачи в Jira?
Выход из зоны комфорта
Куда выходят из зоны комфорта, а самое главное, зачем? Масса причин. Знакомый попросил помочь написать небольшое приложение для собственного бара, вы наконец-то нашли время для реализации пет-проекта своей мечты или решили зарелизить библиотеку, которая случайно родилась в рамках работы над проектом. Да наконец, просто вы с коллегой решили написать к воркшопу небольшой сэмпл проект.
Готов поспорить, что в любом из сценариев ваше воодушевление от новых интересных задач быстро столкнётся с суровой реальностью разработки ПО в «безвоздушной среде» (да, в какой-то момент умный сборщик будет нужен вам как воздух).
«CI/CD это сложно...»
Что вы там обычно ещё себе в такие моменты говорите? «Я не разбираюсь в этом! Я просто пишу фронтенд/мобилку и ничего не знаю про эти ваши Дженкинсы!» А что, если я скажу, что вам ничего такого и не нужно знать?
Да-да, вам достаточно уметь собрать ваш проект, пользуясь консольными командами, — и всё. Вы можете значительно упростить свою жизнь, даже если речь идёт о персональном маленьком проекте, а не гигантском многомодульном монстре, который уже с трудом переваривает IDE.
Github Actions настолько прост, что даже ваша бабушка настроила бы его без особого труда.
Тогда о чём пост?
Если всё так просто, зачем тратить время на прочтение сего опуса? Отвечу маркированным списком:
- Flutter. Мы настроим не абстрактный CI в вакууме. Мы сделаем такой пайплайн, с которым ваше Flutter-приложение будет чувствовать себя хорошо. Поэтому затронем некоторые специфичные моменты, касающиеся конкретного фреймворка
- Деньги. Github Actions — очень доступный сервис. Вы получаете бесплатный безлимит для публичных репозиториев и 2 000 бесплатных минут в месяц для приватных (что немало). И всё-таки мы сделаем упор на то, чтобы реализовать нужные сценарии максимально эффективно при минимальных затратах.
- Деплой. Flutter при компиляции чудесным образом превращается в Android и iOS сборки, которые нужно как-то подписывать и деплоить. Это совсем не простой этап, который, тем не менее, мы тоже реализуем в рамках этого поста.
В cтатье мы сфокусируемся на оптимальной конфигурации CI/CD с учётом ограничений, которые существуют у приватного репозитория на бесплатном тарифе. Некоторые решения обусловлены именно соображениями экономии. Они будут помечены иконкой доллара ($).
Github Actions, очень приятно!
Github Actions — это сервис, который позволяет вам автоматизировать рабочий процесс в репозитории. Всё, что вы делаете вручную с вашим проектом, — кроме непосредственно написания кода — вы можете делегировать Github Actions. Если вы предпочитаете сразу знакомиться с первоисточниками, пройдите в официальную документацию.
Часто мы даже не знаем, что нам вообще нужно автоматизировать. У команды нет времени разбираться в сложном API сервиса, а затем писать и отлаживать решение с нуля. Эту проблему решает Marketplace: там опубликовано почти 5 тысяч готовых Actions, решающих массу типичных задач (например, отправки уведомлений о событиях в Telegram, анализа исходников проекта на наличие техдолга, установки меток на PR в зависимости от изменённых в нём файлов). Плохая новость: многие из них условно бесплатные — с довольно строгими лимитами на использование.
Рабочий процесс
Всё в Github Actions крутится вокруг workflows. Каждый workflow отвечает на два вопроса: что сделать и когда сделать.
Что сделать. Тут вариантов бесчисленное множество: вы можете собирать, тестировать и деплоить ваши билды, используя готовые или созданные своими руками сценарии. Подробнее про конфигурацию workflow
Когда сделать. Можно триггерить workflows по событиям, происходящим в репозитории. Создание пул-реквеста, пуш тэга на коммит или даже появление новой звёздочки у вашего проекта. Полный список хуков
Если workflow должен выполняться не по событию, а в определённое время или с определенной периодичностью, в вашем распоряжении синтаксис POSIX cron. Подробнее про регулярные события
В репозитории может соседствовать сколько угодно разных workflows одновременно. Каждый workflow описывается в отдельном YAML-файле, каждый из которых должен храниться в директории .github/workflows в корне вашего репозитория. Подробнее про синтаксис workflows
Среда исполнения
Github Actions предлагает два варианта исполнения ваших workflows:
- Github-hosted runners — виртуальные машины, которые сервис любезно предоставит вам на время. Доступны машины на Windows, Linux или macOS. Машинки послабее, чем у Codemagic, но всё же довольно бодрые (подробнее про характеристики можно узнать тут). Они полностью сконфигурированы и готовы к работе, предоставляют чистый инстанс с некоторым количеством установленного софта для исполнения каждого джоба, но пользуетесь вы ими на условиях вашего тарифного плана;
- Self-hosted runners — ваши собственные машины, которые вы конфигурируете каким угодно образом и подключаете в контур. Пользоваться ими Github позволяет бесплатно, но само железо нужно самостоятельно купить и обслуживать.
В моей статье я остановлюсь на первом варианте. Мы ведь идём по простейшему пути из возможных, верно?
Настраиваем базовый workflow для Flutter
Перед тем, как мы приступим к конфигурированию workflow, надо договориться о двух вещах.
Первое: основной ролью workflow будет усложнение поломки кодовой базы. В основную ветку не должен попасть код, который не собирается, содержит потенциальные проблемы или ломает тесты.
Второе: в моей конфигурации могут встречаться некоторые тонкости, которые не будут актуальны для вашего проекта. Я буду стараться их пояснять. Однако если вы используете данную статью как руководство — заимствуйте вдумчиво.
Наконец, давайте решим, что вообще должен делать наш workflow. Нужен план, который поможет нам двигаться в нужном направлении.
Шаг за шагом к готовой сборке
Приведённый план может использоваться в качестве чек-листа при настройке вашего собственного workflow. Нам предстоит:
- дать workflow осмысленное наименование;
- указать, по какому событию наш workflow запустится;
- решить, на машине с какой конфигурацией он запустится;
- определиться с шагами, из которых будет состоять наш workflow:
- чекаут проекта,
- установка Java;
- установка Flutter (как вы помните, каждый раз у нас в распоряжении чистый инстанс),
- скачивание пакетов проекта,
- запуск статического анализатора,
- запуск тестов,
- собственно сама сборка билда,
- деплой билда в какое-то место, откуда его смогут получить тестировщики.
Теперь наша работа приобрела осязаемые очертания. Перейдём к реализации.
Как будет выглядеть наш workflow в самом конце
Если вам нужно просто взять сэмпл для вдохновения — он ваш. Если хотите узнать, почему он сделан именно так, читайте дальше.
name: Flutter PR
on:
pull_request:
branches:
- "dev/sprint-**"
paths-ignore:
- "docs/**"
- "openapi/**"
- ".vscode/**"
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
- uses: actions/setup-java@v1
with:
java-version: "12.x"
- uses: subosito/flutter-action@v1
with:
channel: "stable"
- run: sh ./scripts/flutter_pub_get.sh
- run: sh ./scripts/flutter_analyze.sh
- run: flutter test
- run: flutter build apk --release
- uses: actions/upload-artifact@v1
with:
name: APK for QA
path: build/app/outputs/apk/dev/debug/apk_name.apk
- name: Upload artifact to Firebase App Distribution
uses: wzieba/Firebase-Distribution-Github-Action@v1
with:
appId: ${{ secrets.FIREBASE_ANDROID_PROD_APP_ID }}
token: ${{ secrets.FIREBASE_TOKEN }}
groups: testers
file: build/app/outputs/apk/dev/debug/apk_name.apk
debug: true
Наименование
Очевидно, что назвать наш workflow нужно так, чтобы наименование отражало его суть как можно точнее. Наименование (docs) ― первое, что мы увидим в Actions-консоли при выполнении workflow. Почему я назвал мой workflow именно так, вы узнаете через мгновение.
name: Flutter PR
Инициирующее событие
Блок «on» (docs) позволяет указать одно или несколько событий, при регистрации которых мы хотим запускать наш workflow. Более того, некоторые из событий можно тонко настроить.
Какое событие выбрать? Чтобы точно не пропустить поломку, можно указать хоть все существующие события. Тогда сборка будет идти практически непрерывно, но хотим ли мы этого? Нет, так как в этом случае лимит по нашему бесплатному тарифному плану закончится фантастически быстро. Будем искать оптимальное решение.
Предположим, что в нашем проекте соблюдаются договоренности, по которым в основную ветку проекта код не может быть запушен напрямую — только через создание пул-реквеста. Логично, если наш workflow будет реагировать на создание пул-реквеста и собирать проект из изменённой кодовой базы:
on: pull_request
$ Этого достаточно для работы, но решение пока не совсем оптимально. Сборка будет триггериться на каждый созданный пул-реквест. Это избыточно, так как нас интересуют только пул-реквесты, направленные в основную ветку проекта. Синтаксис Github Actions позволяет указать наименования (или маски) веток, которые нас интересуют.
on:
pull_request:
branches:
- "dev/sprint-**"
$ И снова ищем пути оптимизации процесса. Есть файлы, которые даже в теории не могут повредить вашему проекту: проектная документация, Swagger, общие настройки код-стайла и IDE. Благо, мы располагаем возможностью заигнорить такие файлы по маске пути. В итог блок “on” будет выглядеть следующим образом:
on:
pull_request:
branches:
- "dev/sprint-**"
paths-ignore:
- "docs/**"
- "drz-swagger/**"
- ".vscode/**"
Важно: делайте пул-реквест только в том случае, если готовы слить его. Каждый следующий пуш в уже созданный пул-реквест приведёт к перезапуску workflow.
Конфигурация джоба
Наконец мы готовы к тому, чтобы сконфигурировать job (docs). Самое время для того чтобы пояснить, какую роль job играет в workflow.
Каждый workflow должен включать в себя хотя бы один job. Именно job содержит пошаговое описание действий (steps), которые мы производим с нашим проектом. Количество job’ов в одном workflow не лимитировано, как и количеством step’ов в одном job’е. По умолчанию все job’ы исполняются параллельно, если не указана зависимость одного job’а от результатов другого. В нашем проекте будет единственный job, который и будет отвечать за сборку проекта.
Настройка окружения
Каждый раз workflow запускается на чистом инстансе виртуальной машины. Единственное, что мы можем выбрать, ― операционную систему, которая будет на этой машине стоять. Что выбрать?
Велик соблазн выбрать macOS, ведь мы же планируем собирать Flutter приложение под целевые платформы: Android и iOS. Плохие новости. Одна минута использования инстанса с macOS тарифицируется как десять (10!!!) минут использования инстанса с Ubuntu. В инстансе с Windows в нашем случае вообще нет никакого смысла, так как iOS-сборку там собрать всё равно не получится, а его время использования вдвое дороже инстанса с Ubuntu. Подробнее про биллинг
$ Как же сделать так, чтобы наши 2000 бесплатных минут не превратились в 200? Хорошего решения не существует. Я решил отказаться от сборки билда на iOS при создании пул-реквеста. Это потенциально ударит по стабильности iOS-сборки. Есть и компромиссный вариант ― собирать iOS-билд на macOS только при изменении pubspec.yaml или любого файла из директории /ios, в остальных случаях собирать только Android-билд на инстансе с Ubuntu. Сделать это можно по аналогии с тем, как мы настраивали игнор-файлы для блока «on».
jobs:
build:
runs-on: ubuntu-latest
Вы можете посмотреть технические характеристики, а также список установленного «из коробки» софта. Flutter и Java, к сожалению, в этот список не входят. Их придётся устанавливать вручную при каждом исполнении workflow.
Не спешите расстраиваться. На помощь нам придут готовые action’ы, которые мы можем использовать в step’ах нашего job’а. Мы будем использовать два:
- actions/setup-java — официальный action для настройки Java-окружения;
- subosito/flutter-action — неофициальный action для скачивания и установки Flutter SDK. Он неплохо себя зарекомендовал: позволяет делать всё, что вам может потребоваться — например, указывать нужный channel фреймворка или переключаться на конкретную версию SDK.
steps:
- uses: actions/setup-java@v1
with:
java-version: "12.x"
- uses: subosito/flutter-action@v1
with:
channel: "stable"
Клонирование репозитория
У нас есть чистый инстанс машины, арендованной у Github на несколько минут. В предыдущем шаге мы установили на него весь необходимый софт. Теперь нам следует склонировать репозиторий с исходным кодом нашего проекта. Для этого воспользуемся готовым инструментом:
- actions/checkout — официальный action для клонирования репозитория с массой настроек, которые в большинстве случаев нам не понадобятся. Так как workflow запускается непосредственно в репозитории, который мы клонируем, нам не нужно указывать его явно.
- uses: actions/checkout@v1
Подгрузка зависимостей
До этого момента мы не реализовывали step’ы своими руками, а пользовались лишь тем, что нам предлагаю готовые action’ы. Теперь мы переходим к реализации активной фазы сборки нашего проекта, поэтому пришло время написать реализацию step’а самостоятельно.
Перед сборкой нам нужно загрузить все пакеты, которые указаны в блоке dependencies нашего pubspec.yaml файла, а также все их транзитивные зависимости. Для этого Flutter SDK из коробки предлагает простую команду
flutter pub get
. Реализация step’а может заключаться в вызове одной терминальной команды. В этом случае следующий step будет вызван только по завершению этой команды.- run: flutter pub get
В случае, если ваш проект имеет сложную структуру и содержит ряд dart-пакетов, подключаемых локально, вы столкнётесь с проблемой. Без явного вызова
flutter pub get
для каждого из этих пакетов собрать проект невозможно. В моём проекте такие пакеты собраны в папке /core, расположенной в корневой директории. Ниже — скрипт, решающий эту проблему. Он описан в файле flutter_pub_get.sh в папке /scripts в той же самой корневой директории.flutter pub get
cd core
for dir in */ ; do
echo ${dir}
cd ${dir}
pwd
flutter pub get
cd ..
pwd
if [ "$#" -gt 0 ]; then shift; fi
# shift
done
Так как реализацией step’а может быть любая терминальная команда, ничто не мешает нам исполнить наш shell-скрипт.
- run: sh ./scripts/flutter_pub_get.sh
Статический анализ кода
Flutter предлагает нам пользоваться встроенной командой
flutter analyze
для запуска статического анализатора. Это поможет выявить потенциальные проблемы с нашей кодовой базой на раннем этапе: до того как в проде «выстрелит» баг, либо наш код и вовсе превратится в нечитаемое и неподдерживаемое месиво.Мы могли бы воспользоваться коробочной возможностью без доработок, но, увы, стандартное поведение команды
flutter analyze
имеет изъян, который рушит наш workflow в неподходящий момент. Проблемы, найденные анализатором, классифицируются по трём уровням критичности: info, warning, error. В этом issue описано, что даже в том случае, если в процессе анализа обнаружены только проблемы класса info (а на их исправление не всегда стоит тратить время здесь и сейчас), команда возвращает код «1», в результате чего ваша сборка упадёт.
В качестве временного решения предлагаю использовать следующий скрипт. Отныне сборка будет падать только при наличии проблем уровня error:
OUTPUT="$(flutter analyze)"
echo "$OUTPUT"
echo
if grep -q "error •" echo "$OUTPUT"; then
echo "flutter analyze found errors"
exit 1
else
echo "flutter analyze didn't find any errors"
exit 0
fi
Исполняем shell-скрипт в следующем step’е нашего workflow:
- run: sh ./scripts/flutter_analyze.sh
Запуск тестов
Если у вас в проекте есть тесты — вы на правильном пути! Чтобы тесты работали, мало их написать — их нужно регулярно запускать, чтобы вовремя исправлять огрехи реализации или актуализировать при необходимости. Поэтому следующим step’ом мы реализуем
- run: flutter test
Будьте внимательны: пустые тестовые классы, не содержащие ни одного реализованного теста, будут приводить к падению всего workflow. Выход один: не объявлять тестовые классы до тех пор, пока не будете готовы реализовать в рамках него хотя бы один тест.
Сборка и подписание
Все подготовительные работы позади. Мы убедились, что код, скорее всего, не содержит очевидных проблем. Теперь мы переходим к самому ключевому этапу — производству артефакта. Иными словами, мы будем собирать APK.
Сама по себе сборка реализуется крайне просто. В нашем распоряжении терминальная команда flutter build, которая крайне глубоко конфигурируется и позволяет собрать артефакт для конкретного флейвора, main-файла, ABI. Мы не рассматриваем эти нюансы в рамках статьи, поэтому используйте дополнительные флаги команды при необходимости.
- run: flutter build apk --release
Наша цель — получить сборку, подписанную релизным ключом. И на этом этапе нам придётся решить проблему секьюрности, ведь нам нужно где-то хранить релизный keystore, а также все его алиасы и пароли.
Github позволяет безопасно хранить строковые значения в специальном хранилище Secrets. Имеющиеся здесь данные хранятся в рамках соответствующего репозитория и могут быть прочитаны программно из любого step’а вашего workflow. При этом значения невозможно увидеть через web-интерфейс Github. Позволяется только удаление или перезапись.
Это выглядит подходящим решением для алиасов и паролей, особенно если вы сами себе служба безопасности, но что делать с самим *.jks-файлом? Пушить его в репозиторий не выглядит удачной идеей, даже если ваш репозиторий приватный. К сожалению, никакого безопасного способа хранения файлов Github не предоставляет, поэтому придётся изворачиваться.
Неплохо было бы представить наш keystore-файл в виде строки. И это реально — стоит лишь закодировать его в base64. Для этого нужно открыть терминал в директории, содержащей наш *.jks-файл и выполнить следующую команду. Рядом будет создан текстовый файл из которого можно скопировать base64-представление нашего keystore, чтобы затем… сохранить его в Github Secrets.
openssl base64 < key_store_filename.jks | tr -d '\n' | tee keystore.jks.base64.txt
Теперь, когда все необходимые составляющие успешного подписания сборки в наличии, мы приступим к конфигурации step’а. В блоке env мы объявляем все переменные окружения для этого конкретного step’а. Значения этих переменных мы возьмём из Secrets.
- run: flutter build apk --release
env:
STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
STORE_FILE: ${{ secrets.STORE_FILE }}
В нашем Android-хосте нам предстоит описать конфигурацию сборки таким образом, чтобы мы обрели возможность подписывать *.apk-файл и на CI, не потеряв возможность собирать подписанную сборку локально. За этот момент ответственен файл keystoreConfig.gradle.
В случае если keystore_release.properties файл найден, известно, что сборка происходит локально, а значит можно проинициализировать все свойства keystoreConfig, просто прочитав их из файла. В противном случае сборка происходит на CI, а это значит, что единственный источник сенситив-данных — Github Secrets.
ext {
def releaseKeystorePropsFile = rootProject.file("keystore/keystore_release.properties")
if (releaseKeystorePropsFile.exists()) {
println "Start extract release keystore config from keystore_release.properties"
def keystoreProps = new Properties()
keystoreProps.load(new FileInputStream(releaseKeystorePropsFile))
keystoreConfig = [
storePassword: keystoreProps['storePassword'],
keyPassword : keystoreProps['keyPassword'],
keyAlias : keystoreProps['keyAlias'],
storeFile : keystoreProps['storeFile']
]
} else {
println "Start extract release keystore config from global vars"
keystoreConfig = [
storePassword: "$System.env.STORE_PASSWORD",
keyPassword : "$System.env.KEY_PASSWORD",
keyAlias : "$System.env.KEY_ALIAS",
storeFile : "$System.env.STORE_FILE"
]
}
println "Extracted keystore config: $keystoreConfig"
}
А так выглядит файл keystore_release.properties:
storePassword={тут_будет_ваш_пароль}
keyPassword={тут_будет_ваш_пароль}
keyAlias={тут_будет_ваш_алиас}
storeFile=../keystore/keystore.jks
Последний шаг в build.gradle файла нашего Android-хоста применяем keystoreConfig-файл к нашей конфигурации подписания релизной сборки:
android {
signingConfigs {
release {
apply from: '../keystore/keystoreConfig.gradle'
keyAlias keystoreConfig.keyAlias
keyPassword keystoreConfig.keyPassword
storeFile file(keystoreConfig.storeFile)
storePassword keystoreConfig.storePassword
}
}
}
Подписанная сборка уже у нас в руках! Но как распространить её на своих коллег для тестирования?
Выгрузка
Github Actions позволяет настроить выгрузку артефактов практически в любой известный инструмент для дистрибьюции сборок, но мы рассмотрим лишь два варианта:
- Github Storage — самый простой способ выгрузки сборок в собственное хранилище Github, который работает «из коробки», но имеет некоторые ограничения;
- Firebase App Distribution — сервис из экосистемы Firebase, пришедший на смену Beta by Crashlytics. Интеграция немного сложнее в настройке, но сам сервис при этом гораздо удобнее для использования.
Github Storage
Github Storage легко интегрируется через официальный action. Вам нужно лишь указать наименование сборки таким, каким его увидят ваши коллеги в web-интерфейсе, а также путь к собранному *.apk-файлу на CI.
- uses: actions/upload-artifact@v1
with:
name: APK for QA
path: build/app/outputs/apk/dev/debug/apk_name.apk
Основная проблема заключается в ограниченном объёме хранилища. На бесплатном тарифном плане вам предоставляется всего лишь 500 Мб. Самое странное, что я не нашёл никакой возможности вручную очистить всё хранилище сразу через web-интерфейс, поэтому вышел из положения через… написание отдельного workflow, ответственного только за очищение Storage от замшелых артефактов.
Workflow запускается ежедневно в час ночи и удаляет все артефакты старше одной недели:
name: Github Storage clear
on:
schedule:
- cron: '0 1 * * *'
jobs:
remove-old-artifacts:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Remove old artifacts
uses: c-hive/gha-remove-artifacts@v1
with:
age: '1 week'
Firebase App Distribution
Что касается Firebase App Distribution, то для интеграции с ним я использовал готовый action wzieba/Firebase-Distribution-Github-Action.
- name: Upload artifact to Firebase App Distribution
uses: wzieba/Firebase-Distribution-Github-Action@v1
with:
appId: ${{ secrets.FIREBASE_ANDROID_PROD_APP_ID }}
token: ${{ secrets.FIREBASE_TOKEN }}
groups: testers
file: build/app/outputs/apk/dev/debug/apk_name.apk
debug: true
Для корректной работы action’а ему необходимо передать параметры:
- appId — идентификатор приложения, который можно найти в настройках Firebase-проекта;
- token — токен для аутентификации в ваш FIrebase-проект, что необходимо для загрузки сборки в сервис. Получить токен можно только через Firebase CLI, о чём подробнее можно почитать в официальной документации;
- file — путь к собранному *.apk-файлу на CI;
- groups — параметр необязателен, однако позволяет указать вам алиас группы тестировщиков, на который загруженная сборка будет расшариваться автоматически.
Запуск и наблюдение
Наш простейший workflow готов! Всё, что нам осталось сделать, — запустить инициирующее событие и наблюдать за ходом исполнения workflow.
Советы и напутствия
Теперь вы можете пользоваться всеми преимуществами простого CI/CD механизма на вашем Flutter-проекте, независимо от его масштабов, интенсивности разработки, а также вашего кошелька.
Напоследок вот вам несколько советов и наблюдений, к которым я пришёл в процессе работы над этим workflow:
- Настраивайте workflow в публичном репозитории. Если вы настраиваете workflow для приватного репозитория, сконфигурируйте его сперва в публичном, а затем перенесите в приватный. Так вы сэкономите драгоценные бесплатные минуты билд-тайма вашего тарифного плана. Ведь проблемы, из-за которых вам придётся снова и снова перезапускать workflow при настройке, у вас обязательно будут.
- Реализуйте step’ы в отдельных shell-скриптах. Сложный workflow становится нагляднее. К тому же упрощается ручной запуск скриптов на машине для быстрой проверки. В тестовом примере я не всегда соблюдал эту рекомендацию для упрощения.
- Следите за Run Duration вашего workflow. Обычно время исполнения одного и того же workflow почти не меняется от запуска к запуску, если на то нет объективных причин. В деталях каждого исполненного workflow можно увидеть, сколько минут и секунд заняло исполнение каждого отдельного step’а. Самой времязатратной всегда будет сама сборка артефакта. Установка Flutter SDK тоже займёт не меньше минуты. Нормальное время сборки для маленького проекта — 5-6 минут.
Впереди ещё масса потенциальных улучшений и доработок. Пишите в комментарии свои идеи для улучшения workflow. Чего в нём не хватает лично вам? Возможно, реализация самых интересных идей читателей ляжет в основу следующей статьи по теме.
Все скрипты и workflow можно найти в репозитории с тестовым приложением.
Спасибо за внимание.
P.S. Наша команда Surf выпускает много полезных библиотек для Flutter. Выкладываем их в репозитории SurfGear.
Больше полезного про Flutter — в телеграм-канале Surf Flutter Team. Публикуем кейсы, лучшие практики, новости и вакансии Surf, а также проводим прямые эфиры. Присоединяйтесь!