Fastlane for Android developers
Team growth requires greater involvement in processes and agreements, which in turn require automation and inspection. You can take bash scripts and fill this need with them, but how convenient will it be? What we need here is a tool that will simplify development and support the team in the future. Today I’ll tell you about one of these tools – Fastlane – and its capabilities.
The article will be useful for getting acquainted with Fastlane, for those who are looking for solutions for automation development or are considering alternative solutions for automating assemblies and processes within the company. For clarity, all examples are run locally; the same solution can be transferred to CI/CD (Gitlab, Jenkins, Github Actions, etc.).
I don't want to read it, give me the code: https://github.com/maluginp/fastlanetestproject
Preparing a project for Fastlane
I always thought that Fastlane was more suitable for iOS developers. Oh, how wrong I was. In fact, this is a huge set of ready-made tools for automating all sorts of “wants”: running unit tests, linters, publications, etc. Fastlane has a large community that constantly contributes to the development of the product. I advise you to look at the list of available methods (actions) for different platforms here. If you team up with an iOS team, you can also share code with each other.
Now let's set up the environment for Fastlane. For convenience, I prepared a test project for article.
Fortunately, the community has prepared full instructions.
Fastlane is configured and added to the project. After initialization, Fastlane generated commands (lanes) for 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
Initially, we get three simple commands that cover the basic needs for automating the publication of an Android application: running unit tests, submitting a beta to Crashlytics and the Play store.
How to organize your code
I will share my experience in organizing the code that we developed within the team. We divide all parts into modules for code reuse without turning Fastfile into a dump. It’s convenient for me, so all subsequent examples are made in the same style.
I am not an expert in Ruby, so I write as best I can))
Each module is one feature or several micro-features (if they can be logically combined), for example, linters:
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
Each module can be reused in any part of the code.
Cases
Running linters to prevent code quality from deteriorating
For this we need:
For optimization, I suggest running detekt and lint independently, checking the result at the end and if there are errors, then returning an error lane
.
In the module for linters, we take into account that launching gradle may fail with an error, and let us control the calling method:
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
Let's add a new one lane
in Fastfile to call the command:
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
To start in the terminal we type fastlane lint
in the test project I get that lane completed unsuccessfully and the logs:
Lint failed. Result detekt = false, lint = true
Everything works, let's move on to the next case.
Safe-merge, running unit tests
It happens that in the current branch all tests and linters pass, but when merged into the main branch, the linter or tests break (for simplicity, we assume that all merge requests go to the main branch). You can ask developers to give themselves a fresh master branch, but we will take the automation path. To do this, we implement a safe merge (we add the latest commits from the main branch to a branch without a merge commit), run tests and linters.
Let's write a module for 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
and a module for unit tests similar to detekt and 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
All that remains is to add lane
for unit tests
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
To start in the terminal we type fastlane unitTest
if the unit tests pass successfully, then lane
will end in success.
So far everything is simple, let's move on to more complex cases.
Build release versions, send notifications via Slack
In order for QA not to assemble the assembly manually (I’m sure they know how to do this) and not to distract the developers, they requested automation of the assembly and sending to Slack.
New module for release builds for 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
As a result, the whole process is more clear, since we see that the build process is underway, and in fact we no longer need to open CI and see what the current status of the execution is.
Let's write lane to run the command to build versions for QA:
desc 'Submit release builds to QA via Slack'
lane :qa do
QABuild.run(debug: true)
end
To start in the terminal we type fastlane qa
Publishing applications in the Play Store with a 10% discount
The module is very similar to the module for assembling release versions for QA, the main difference is in calling the action for publishing in the 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
Let's write lane for deployment
desc 'Deploy a new version to the Google Play'
lane :deploy do
Deploy.run(debug: true)
end
To start in the terminal we type fastlane deploy
Sharing the build process via Slack
Let's write a small class for working with a thread in Slack, which will allow you to update the thread message and attach files to it. If necessary, you can expand the functionality.
It is not necessary to use Slack; you can choose any other messenger and modify the helper class.
To simplify working with the Slack API, I connected the plugin to Fastlane: fastlane-plugin-slack_bot
To connect the plugin, you need to add the following lines to the Gemfile (located in the project root)
plugins_path = File.join(File.dirname(__FILE__), 'fastlane', 'Pluginfile')
eval_gemfile(plugins_path) if File.exist?(plugins_path)
In the fastlane folder, create a new Pluginfile with the contents
gem 'fastlane-plugin-slack_bot'
You should end up with the following structure:
{root}
|-- Gemfile
|-- fastlane
|-- Pluginfile
The class code itself looks like this:
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
Let's summarize
This article showed the bare minimum available for automation. The possibilities for expanding functionality are endless. For convenience, all code is collected and available in turnips.
Key Notes:
Fastlane turned out to be convenient for developing automation tools
Fastlane is suitable for Android and iOS applications (for iOS there is much more functionality out of the box)
All described cases can be transferred to CI/CD
You can understand the basics of Ruby in a day if you have a background in development
From my point of view, it is much easier to develop automation in Ruby with Fastlane than in Python.