Fastlane для Android разработчиков
Рост команды требует большего вовлечения в процессы и договорённости, которые, в свою очередь, требуют автоматизации и инспекции. Можно взять bash-скрипты и закрыть ими эту потребность, но насколько это будет удобно? Тут нужен инструмент, который упростит разработку и будет поддерживать команду в будущем. Сегодня расскажу про один из таких инструментов — Fastlane — и его возможности.
Статья будет полезна для ознакомления с Fastlane, тем, кто ищет решения для разработки автоматизации или рассматривает альтернативные решения по автоматизации сборок и процессов внутри компании. Для наглядности все примеры запускаются локально, это же решение можно перенести на CI/CD (Gitlab, Jenkins, Github Actions и тп).
Не хочу читать, дай мне код: https://github.com/maluginp/fastlanetestproject
Подготовка проекта под Fastlane
Всегда думал, что Fastlane больше подходит для iOS-разработчиков. Ох, как же сильно я заблуждался. На самом деле это огромный набор готовых инструментов для автоматизации всевозможных «хотелок»: прогон unit-тестов, линтеров, публикаций и т.п. У Fastlane большое комьюнити, которое вносит перманентный вклад в развитие продукта. Советую посмотреть список доступных методов (action-ов) под разные платформы тут. Если объединиться с iOS командой, то можно еще и код между собой шарить.
Теперь погнали настраивать среду для Fastlane. Для удобства подготовил тестовый проект под статью.
К счастью, по настройке Fastlane комьюнити подготовило полноценную инструкцию.
Fastlane настроен и добавлен в проект. После инициализации Fastlane сгенерировал команды (lane-ы) под Android:
default_platform(:android)
platform :android do
desc "Runs all the tests"
lane :test do
gradle(task: "test")
end
desc "Submit a new Beta Build to Crashlytics Beta"
lane :beta do
gradle(task: "clean assembleRelease")
crashlytics
# sh "your_script.sh"
# You can also use other beta testing services here
end
desc "Deploy a new version to the Google Play"
lane :deploy do
gradle(task: "clean assembleRelease")
upload_to_play_store
end
end
Изначально получаем три простые команды, которые закрывают базовые потребности для автоматизации публикации Android приложения: прогон юнит-тестов, отрпавка беты в Crashlytics и Play store.
Как организовать код
Поделюсь опытом организации кода, который мы выработали внутри команды. Все части мы делим на модули для переиспользования кода, не превращая Fastfile в свалку. Мне так удобно, поэтому все последующие примеры выполнены в том же стиле.
Не являюсь спецом в Ruby, поэтому пишу, как умею))
Каждый модуль — это одна фитча или несколько микро-фитч (если можно логически объединить), например, линтеры:
require 'fastlane'
# Linter methods
module Linter
def self.detekt(throw_if_fails: true)
# ./gradlew detekt
end
def self.lint(throw_if_fails: true)
# ./gradlew lint
end
end
Каждый модуль можно переиспользовать в любых частях кода.
Кейсы
Прогон линтеров, чтобы не упало качество кода
Для этого нам нужно:
Для оптимизации предлагаю прогонять detekt и lint независимо, в конце проверить результат и если есть ошибки, то возвращаем ошибку lane
.
В модуле для линтеров учитываем, что запуск gradle-а может завершиться ошибкой, и даем управлять вызывающим методом:
require 'fastlane'
# Linter methods
module Linter
def self.detekt(throw_if_fails: true)
Fastlane::Actions::GradleAction.run(
task: 'detekt',
project_dir: '../',
# Без print_* аргументов, результат
# не будет выводится в консоли
print_command: true,
print_command_output: true
)
true
# При ошибке выполнение команды Fastlane кидает
# исключение, которое можно отловить
rescue FastlaneCore::Interface::FastlaneShellError
# Кидает исключение на верх (вызывающим объектов)
# если не собираемся обрабаывать по своему
raise if throw_if_fails
false
end
def self.lint(throw_if_fails: true)
Fastlane::Actions::GradleAction.run(
task: 'lint',
project_dir: '../',
print_command: true,
print_command_output: true
)
true
rescue FastlaneCore::Interface::FastlaneShellError
raise if throw_if_fails
false
end
end
Добавим новый lane
в Fastfile для вызова команды:
desc 'Check linter rules'
lane :lint do
lint_res = Linter.lint(throw_if_fails: false)
# Пишем в логах ошибку
Fastlane::UI.error('Lint failed') unless lint_res
detekt_res = Linter.detekt(throw_if_fails: false)
# Пишем в логах ошибку
Fastlane::UI.error('Detekt failed') unless detekt_res
unless detekt_res && lint_res
Fastlane::UI.user_error!("Lint failed. Result detekt = #{detekt_res}, lint = #{lint_res}")
end
end
Для запуска в терминале набираем fastlane lint
, в тестовом проекте получаю, что lane закончен неуспешно и логи:
Lint failed. Result detekt = false, lint = true
Все работает, перейдем к следующему кейсу.
Safe-merge, прогон unit-тестов
Бывает такое, что в текущей ветке все тесты и линтеры проходят, но при мерже в главную ветку линтер или тесты ломаются (для простоты считаем, что все merge request-ы идут в главную ветку). Можно просить разработчиков подливать себе свежую главную ветку, но мы пойдем по пути автоматизации. Для этого реализуем safe merge (в ветку без merge-коммита подливаем последние коммиты из главной ветки), прогоняем тесты и линтеры.
Напишем модуль для safe-merge:
require 'fastlane'
# Safe merge for actual branch
module SafeMerge
def self.main_branch?
# Название главной ветки будет брать из env-параметров
ENV['MAIN_BRANCH'] == fetch_local_branch
end
def self.merge_main_no_commit
main_branch = ENV['MAIN_BRANCH']
local_git_branch = fetch_local_branch
command = [
'git',
'merge',
main_branch,
'--no-commit',
'--no-ff'
]
Fastlane::Actions.sh(command.join(' '))
# пишем в логах успешность мержа
Fastlane::UI.success("Successfully merged #{main_branch} (main branch) to #{local_git_branch}")
end
def self.fetch_local_branch
local_git_branch = Fastlane::Actions.git_branch_name_using_HEAD
local_git_branch = Fastlane::Actions.git_branch unless local_git_branch && local_git_branch != 'HEAD'
local_git_branch
end
end
и модуль для unit-тестов по аналогии с detekt и lint:
require 'fastlane'
# Unit test methods
module UnitTests
def self.run(throw_if_fails: true)
Fastlane::Actions::GradleAction.run(
task: 'test',
project_dir: '../',
print_command: true,
print_command_output: true
)
true
rescue FastlaneCore::Interface::FastlaneShellError
raise if throw_if_fails
false
end
end
Осталось добавить lane
для unit-тестов
desc 'Run unit tests'
lane :unitTest do
# Выводим отладочное сообщение, что не запускаем safe-merge
# так как текущая ветка и есть главная
UI.message('Skip safe merge, the branch is main') if SafeMerge.main_branch?
SafeMerge.merge_main_no_commit unless SafeMerge.main_branch?
UnitTests.run
end
Для запуска в терминале набираем fastlane unitTest
, если unit-тесты проходят успешно, то lane
завершится успехом.
Пока все просто, перейдем к более сложным кейсам.
Сборка релизных версий, отправка нотификаций через Slack
Чтобы QA не собирать сборку вручную (уверен, что они умеют это делать) и не отвлекать разработчиков, запросили автоматизацию сборки и отправки в Slack.
Новый модуль для релизных сборок для QA:
require 'fastlane'
require_relative 'building_slack'
# QA build methods
module QABuild
def self.run(debug: false)
# Это простой класс хелпер для отправки Slack-сообщений
thread = BuildingSlackThread.start(
# Параметры будем брать из env-параметров
ENV['SLACK_API_TOKEN'],
ENV['SLACK_QA_CHANNEL_BUILDS'],
'Building QA build...',
debug
)
begin
# Собираем релизный APK-файл
Fastlane::Actions::GradleAction.run(
task: 'assemble',
flavor: '',
build_type: 'Release',
project_dir: "../",
print_command: true,
print_command_output: true
)
# Достаем путь к собранному APK
completed_apk_path = Fastlane::Actions.lane_context[
Fastlane::Actions::SharedValues::GRADLE_APK_OUTPUT_PATH
]
# Прикрепляем APK-файл к Slack-треду
thread.attach_file('APK file', completed_apk_path, 'app.apk')
# Собираем релизный AAB-файл
Fastlane::Actions::GradleAction.run(
task: 'bundle',
flavor: '',
build_type: 'Release',
project_dir: "../",
print_command: true,
print_command_output: true
)
# Достаем путь к собранному AAB
completed_aab_path = Fastlane::Actions.lane_context[
Fastlane::Actions::SharedValues::GRADLE_AAB_OUTPUT_PATH
]
# Прикрепляем AAB-файл к Slack-треду
thread.attach_file('Bundle file', completed_aab_path, 'app.aab')
# Обновляем главный тред - сборка выполнена успешно,
# тут можно накидать смайлов
thread.success('Building QA build succeed')
rescue
# Обновляем главный тред - сборка провалилась,
# тут можно накидать смайлов и прикрепить детали
thread.failure('Building QA build failed')
raise # кидает исключение на верх к вызывающему методу
end
end
end
В итоге весь процесс нагляднее, так как видим, что процесс сборки идет, и по сути нам уже не нужно открывать CI и смотреть, в каком статусе сейчас находится выполнение.
Процесс сборки отображается в Slack
Напишем lane для запуска команды для сборки версий для QA:
desc 'Submit release builds to QA via Slack'
lane :qa do
QABuild.run(debug: true)
end
Для запуска в терминале набираем fastlane qa
Публикация приложений в Play Store с раскаткой 10%
Модуль очень похож на модуль по сборке релизных версий для QA, основное отличие в вызове action-а для публикации в Play Store — Fastlane::Actions::UploadToPlayStoreAction
require 'fastlane'
module Deploy
def self.run(debug: false)
thread = BuildingSlackThread.start(
ENV['SLACK_API_TOKEN'],
ENV['SLACK_RELEASE_CHANNEL_BUILDS'],
'Releasing build...',
debug
)
begin
Fastlane::Actions::GradleAction.run(
task: 'assemble',
flavor: '',
build_type: 'Release',
project_dir: "../",
print_command: true,
print_command_output: true
)
completed_apk_path = Fastlane::Actions.lane_context[
Fastlane::Actions::SharedValues::GRADLE_APK_OUTPUT_PATH
]
thread.attach_file('APK file', completed_apk_path, 'app.apk')
Fastlane::Actions::GradleAction.run(
task: 'bundle',
flavor: '',
build_type: 'Release',
project_dir: "../",
print_command: true,
print_command_output: true
)
completed_aab_path = Fastlane::Actions.lane_context[
Fastlane::Actions::SharedValues::GRADLE_AAB_OUTPUT_PATH
]
thread.attach_file('Bundle file', completed_aab_path, 'app.aab')
unless debug
Fastlane::Actions::UploadToPlayStoreAction.run(
aab: completed_aab_path,
track: 'production',
rollout: 0.1 # 10%
)
end
thread.success('Releasing is completed and rollout on 10%')
rescue
thread.failure('Releasing is failed')
raise # re-raise
end
end
end
Напишем lane для деплоя
desc 'Deploy a new version to the Google Play'
lane :deploy do
Deploy.run(debug: true)
end
Для запуска в терминале набираем fastlane deploy
Отправка процесса сборки через Slack
Напишем небольшой класс для работы с тредом в Slack, который позволит обновлять сообщение треда и прикреплять к нему файлы. При необходимости можно расширить функционал.
Не обязательно использовать Slack, можно выбрать любой другой мессенджер и доработать класс-хелпер.
Для упрощения работы с Slack API я подключил плагин к Fastlane: fastlane-plugin-slack_bot
Для подключения плагина нужно добавить следующие строчки в Gemfile (находится в корне проекта)
plugins_path = File.join(File.dirname(__FILE__), 'fastlane', 'Pluginfile')
eval_gemfile(plugins_path) if File.exist?(plugins_path)
В папке fastlane создать новый файл Pluginfile с содержимым
gem 'fastlane-plugin-slack_bot'
У вас должна получиться следующая структура:
{root}
|-- Gemfile
|-- fastlane
|-- Pluginfile
Сам код класса выглядит следующим образом
BuildingSlackThread.rb
require 'fastlane'
# Manage slack thread for building
class BuildingSlackThread
# приватные свойства
attr_accessor :api_token, :thread_ts, :thread_channel, :debug
def self.start(api_token, channel, message, debug = false)
Fastlane::UI.message("Sending start building message to Slack channel #{channel}")
# При дебаге не шлем сообщения, но логика проходит
if debug
return BuildingSlackThread.new(api_token, channel, message, debug: debug)
end
# Создаем новый тред в канале с которым и будем взаимодействовать
thread_result = Fastlane::Actions::PostToSlackAction.run(
api_token: @api_token,
message: message,
success: true,
channel: @channel,
payload: [],
attachment_properties: {},
default_payloads: %i[lane git_branch git_author last_git_commit],
)
thread_ts = thread_result[:json]["ts"]
thread_channel = thread_result[:json]["channel"]
Fastlane::UI.error("Failed sending message to Slack to #{channel} channel") unless thread_ts && thread_channel
BuildingSlackThread(api_token, thread_ts, thread_channel, debug)
end
def initialize(api_token, thread_ts, thread_channel, debug: false)
@api_token = api_token
@thread_ts = thread_ts
@thread_channel = thread_channel
@debug = debug
end
def attach_file(message, file_path, file_name)
ext = file_name.split('.').last
Fastlane::UI.message("Attaching #{file_name} (ext = #{ext}) file is located in path #{file_path}")
return if @debug
Fastlane::Actions::FileUploadToSlackAction.run(
api_token: @api_token,
initial_comment: message,
file_path: file_path,
file_name: file_name,
file_type: ext,
channels: @thread_channel,
thread_ts: @thread_ts
)
end
def success(message)
Fastlane::UI.message("Sending success building message")
return if @debug
Fastlane::Actions::UpdateSlackMessageAction.run(
ts: @thread_ts,
api_token: @api_token,
message: message,
success: true,
channel: @thread_channel,
payload: [],
attachment_properties: {},
default_payloads: %i[lane git_branch git_author last_git_commit],
)
end
def failure(message)
Fastlane::UI.message("Sending failure building message")
return if @debug
Fastlane::Actions::UpdateSlackMessageAction.run(
ts: @thread_ts,
api_token: @api_token,
message: message,
success: false,
channel: @thread_channel,
payload: [],
attachment_properties: {},
default_payloads: %i[lane git_branch git_author last_git_commit],
)
end
end
Подытожим
В данной статье показал самый минимум, доступный для автоматизации. Возможности расширения функционала безграничны. Для удобства весь код собран и доступен в репе.
Ключевые заметки:
Fastlane оказался удобен для разработки инструментов автоматизации
Fastlane подходит для Android и iOS приложений (для iOS функционала из коробки гораздо больше)
Все описанные кейсы можно перенести в CI/CD
Понять основы Ruby можно за день, если есть бэкграунд в разработке
С моей точки зрения, на Ruby c Fastlane разрабатывать автоматизацию гораздо проще, чем на том же Python.