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 lintin 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.

The build process is visible in Slack

The build process is visible in Slack

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.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *