This post is a continuation of the previous post. If you didn’t read the last post, please read that first. It explains pre-requisites to integrate CI/CD for Android projects using Fastlane.
Fastlane
Fastlane is an excellent open-source tool based on Ruby, using which you can automate your Android or iOS app build & deployment. You can build, run tests, code signing, take screenshots, generate build files, upload to the app store and Play Store, and do many other tasks. Fastlane projects are written in Ruby.
Here you’ll execute most of the tasks in Fastlane so that you can dock jobs anywhere and run those in any CI/CD platform.
Starting with Fastlane first…
because you can run Fastlane projects locally very efficiently. If you can do this part first, you can host this to any CI/CD platform. Or you can run locally to manage the app delivery easily. It makes the app building and releasing process very easy.
Now Let’s Start Integration
At first, open a terminal/shell window and change the working directory to the root of your project.
Initiate Fastlane
Create a file named Gemfile at the root of your project. Please put the following contents in the file by opening it with a text editor.
source "https://rubygems.org" gem "fastlane"
Now from the opened terminal/shell window, run bundle update command. It will create a .Gemfile and a .Gemfile.lock. You can ignore these two files from .gitignore.
Every time you’ve to run the bundle install command. It installs all dependencies to run the Ruby project.
Create Fastlane Project
Using a Fastlane command, you can create the Fastlane project. But you can make this manually. Just create a fastlane folder at the root of your project, and inside that folder, create a file named Fastfile.
You’ll need to use multiple Fastlane plugins. Those plugin names are defined in the plugin file. Create a file named Pluginfile in the fastlanefolder. Also, write reference of this file at the bottom of Gemfile as follows:
plugins_path = File.join(File.expand_path("..", __FILE__), 'fastlane', 'Pluginfile') eval_gemfile(plugins_path)
Secrets management
You’ll also need to keep some secrets somewhere. You can load those in key-value pairs to Fastlane project defining into a separate env file using Dotoenv system. Create a file named .env.secret in Fastlane folder. For now, you can keep your app signing keystore’s alias and password.
KEYSTORE_ALIAS=<alias here> KEYSTORE_PASSWORD=<password here>
Defining Steps in Fastfile
Now you need to define steps to complete the whole building to release the app process. Each of the steps here is called a lane. Steps can be:
- Load secret values from .env
- Define global variables (if any)
- Increase version code
- Run unit test
- Assemble and build a bundle file
- Distribute App to Firebase App Distribution
- Upload app to Play Store for review
- Push updated code
Load Secret
You’re using Dotenv system here to keep secrets in a key-value pair at a .env.secret file. Also, secrets should be loaded before all other steps. There’s a default block named before_all to do some things before all other things. You can load your secrets in the before_all block.
fastlane_require "dotenv" before_all do Dotenv.overload ".env.secret" end
Define Global Variables (if any)
Do you remember two JSON key files named firebase-app-distribution-key.json and playstore-app-distribution-key.json? These key files are needed to upload the bundle into Firebase and Play Console. Create a folder named certificates at the root of your project. Put these files there.
Also, put your Keystore .jks file there.
At this point, you’ll need to define these three files paths to 3 global variables so that you can access those files from several lanes. Also, you need to keep your .aab bundle file path to a global variable so that it can also be accessible from different lanes because you’ll need to access the same bundle file to upload at both Firebase and Play Store.
You can also define these paths in before_all block.
fastlane_require "dotenv" | |
PACKAGE_NAME = "<package name here>" | |
FIREBASE_APP_ID = "<firebase app id here>" | |
KEYSTORE_PATH = nil | |
FIREBASE_KEY_PATH = nil | |
PLAYSTORE_KEY_PATH = nil | |
BUNDLE_FILE_PATH = nil | |
UPDATED_VERSION_CODE = nil | |
before_all do | |
Dotenv.overload ".env.secret" | |
KEYSTORE_PATH = Dir.pwd + "/../certificates/<keystore_name>.jks" | |
FIREBASE_KEY_PATH = Dir.pwd + "/../certificates/firebase-app-distribution-key.json" | |
PLAYSTORE_KEY_PATH = Dir.pwd + "/../certificates/playstore-app-distribution-key.json" | |
BUNDLE_FILE_PATH = Dir.pwd + "/../app/outputs/bundle/release/app-release.aab" | |
end |
Increase Version Code
Increasing version code from Gradle is a kind of a nightmare to me. Most of the time, I forget to increase it. After uploading the app to Play Console, I increase the version code when it shows the error. Almost every time, this happens to me. That’s why I think it’s good to increase version code automatically.
Fastlane has a plugin increment_version_code for that. But you need to send the incremented version code as a parameter to that. For that, you also need to get the current version code. Now you can get the current version code in 2 ways; from the project and from the Play Console. I choose to get version code from the play console.
So to get version code from the Play Store, you’ve to use the google_play_track_version_codes action of Fastlane. Otherwise, if you want to get version code locally, you can use the android_get_version_code action.
Now create a lane whose responsibility is to get version code from the Play Store and increase the version code of the project.
desc "Responsible for fetching version code from play console and incrementing version code." | |
lane :increment_version_code_in_project_gradle do | |
version_code_from_play_store_strings = google_play_track_version_codes( | |
package_name: PACKAGE_NAME, # PACKAGE_NAME is a global variable defined earlier | |
track: "production", # this can be alpha, beta etc. | |
json_key: PLAYSTORE_KEY_PATH, | |
) | |
version_code_from_play_store = version_code_from_play_store_strings[0].to_i | |
UPDATED_VERSION_CODE = version_code_from_play_store + 1 | |
increment_version_code( | |
gradle_file_path: Dir.pwd + "/../app/build.gradle", | |
version_code: UPDATED_VERSION_CODE.to_i | |
) | |
end |
Job Offers
To use the incremented_version_code plugin, you’ve to add this plugin into Pluginfile. Add this plugin as follows:
gem 'fastlane-plugin-increment_version_code'
Run Unit Test
This can be done simply by Gradle command. Fastlane has action to run Gradle commands. Create a lane for running unit tests.
desc "Run unit tests." | |
lane :run_unit_tests do | |
gradle( | |
task: "test" | |
) | |
end |
In your local machine, you can face some permission issues. In that case run
chmod +x gradlew
command from the terminal to give execution permission to Gradle.
Assemble And Build a Bundle File
This can also be done by Gradle command. You’ve to pass Keystore alias and passwords, which you defined already in a global variable KEYSTORE_PATHand .env.secret file.
desc "Build the .aab file" | |
lane :build do | |
gradle( | |
task: "bundle", | |
build_type: "Release", | |
properties: { | |
"android.injected.signing.store.file" => KEYSTORE_PATH, | |
"android.injected.signing.store.password" => ENV['KEYSTORE_PASSWORD'], | |
"android.injected.signing.key.alias" => ENV['KEYSTORE_ALIAS'], | |
"android.injected.signing.key.password" => ENV['KEYSTORE_PASSWORD'] | |
} | |
) | |
end |
Distribute App to Firebase App Distribution
To distribute the app to Firebase App Distribution, Firebase provides a Fastlane plugin firebase_app_distribution for that. Add this plugin to Pluginfile.
gem 'fastlane-plugin-firebase_app_distribution'
And create a lane in Fastfile to upload the .aab file to Firebase App Distribution:
desc "Responsible for uploading .aab to Firebase app distribution." | |
lane :distribute_to_firebase do | |
firebase_app_distribution( | |
app: FIREBASE_APP_ID, | |
release_notes: "Uploaded from CI/CD", | |
android_artifact_type: "AAB", | |
android_artifact_path: BUNDLE_FILE_PATH, | |
service_credentials_file: FIREBASE_KEY_PATH, | |
groups: "tester-team" | |
) | |
end |
Upload App to Play Store for Reviewing
You can use the upload_to_play_store action of Fastlane to upload bundle files to the Play Store. Put this task in another lane.
desc "Responsible for uploading aab to playstore" | |
lane :distribute_playstore do | |
upload_to_play_store( | |
track: "production", | |
aab: BUNDLE_FILE_PATH, | |
json_key: PLAYSTORE_KEY_PATH, | |
package_name: PACKAGE_NAME | |
) | |
end |
Push Updated Code
After successfully executing all the tasks, you may want to do other tasks, like pushing updated code to remote or sending messages to slack and others. This can be done in the after_all block.
You shouldn’t push updated code unless it’s successfully uploaded to Play Store or Firebase. That’s why you should do this task in the after_all block. But you can do this task in a separate lane also.
desc "After successful execution of all task, this block is called" | |
after_all do | |
git_add(path: "*") | |
git_commit( | |
path: "*", | |
message: "#" + UPDATED_VERSION_CODE + " released" | |
) | |
push_to_git_remote( | |
local_branch: buildConfigs.key(options[:buildConfig]), | |
remote: "origin", | |
remote_branch: buildConfigs.key(options[:buildConfig]), | |
tags: true, | |
) | |
end |
Here you created multiple lanes in Fastfile. To execute a lane, just run the following command.
bundle exec fastlane <lane name>
As you’ve created multiple lanes, you’ve to run this command for each lane. It’s another hassle. You can call all these lanes from another lane to execute only the lane from the terminal, and it will do all tasks one after another.
desc "Responsible for testing, building and uploading bundle to firebase app distribution and playstore by calling other private lanes." | |
lane build_and_distribute: | |
increment_version_code_in_project_gradle() | |
run_unit_tests() | |
build() | |
distribute_to_firebase() | |
distribute_playstore() | |
end |
As you’re calling all other lanes from one lane, you shouldn’t keep other lanes public for execution. You can make other lanes private by replacing lane with private_lane.
After wrapping up altogether, you’ll get Fastfile file as follows:
fastlane_require "dotenv" | |
PACKAGE_NAME = "<package name here>" | |
FIREBASE_APP_ID = "<firebase app id here>" | |
KEYSTORE_PATH = nil | |
FIREBASE_KEY_PATH = nil | |
PLAYSTORE_KEY_PATH = nil | |
BUNDLE_FILE_PATH = nil | |
UPDATED_VERSION_CODE = nil | |
before_all do | |
Dotenv.overload ".env.secret" | |
KEYSTORE_PATH = Dir.pwd + "/../certificates/<keystore_name>.jks" | |
FIREBASE_KEY_PATH = Dir.pwd + "/../certificates/firebase-app-distribution-key.json" | |
PLAYSTORE_KEY_PATH = Dir.pwd + "/../certificates/playstore-app-distribution-key.json" | |
BUNDLE_FILE_PATH = Dir.pwd + "/../app/outputs/bundle/release/app-release.aab" | |
end | |
lane :build_and_distribute do | |
increment_version_code_in_project_gradle() | |
run_unit_tests() | |
build() | |
distribute_to_firebase() | |
distribute_playstore() | |
end | |
desc "Responsible for fetching version code from play console and incrementing version code." | |
private_lane :increment_version_code_in_project_gradle do | |
version_code_from_play_store_strings = google_play_track_version_codes( | |
package_name: PACKAGE_NAME, # PACKAGE_NAME is a global variable defined earlier | |
track: production, # this can be alpha, beta etc. | |
json_key: PLAYSTORE_KEY_PATH, | |
) | |
version_code_from_play_store = version_code_from_play_store_strings[0].to_i | |
incremented_version_code = version_code_from_play_store + 1 | |
increment_version_code( | |
gradle_file_path: Dir.pwd + "/../app/build.gradle", | |
version_code: incremented_version_code.to_i | |
) | |
end | |
desc "Run unit tests." | |
private_lane :run_unit_tests do | |
gradle( | |
task: "test" | |
) | |
end | |
desc "Build the .aab file" | |
private_lane :build do | |
gradle( | |
task: "bundle", | |
build_type: "Release", | |
properties: { | |
"android.injected.signing.store.file" => KEYSTORE_PATH, | |
"android.injected.signing.store.password" => ENV['KEYSTORE_PASSWORD'], | |
"android.injected.signing.key.alias" => ENV['KEYSTORE_ALIAS'], | |
"android.injected.signing.key.password" => ENV['KEYSTORE_PASSWORD'] | |
} | |
) | |
end | |
desc "Responsible for uploading .aab to Firebase app distribution." | |
private_lane :distribute_to_firebase do | |
firebase_app_distribution( | |
app: FIREBASE_APP_ID, | |
release_notes: "Uploaded from CI/CD", | |
android_artifact_type: "AAB", | |
android_artifact_path: BUNDLE_FILE_PATH, | |
service_credentials_file: FIREBASE_KEY_PATH, | |
groups: "tester-team" | |
) | |
end | |
desc "Responsible for uploading aab to playstore" | |
private_lane :distribute_playstore do | |
upload_to_play_store( | |
track: production, | |
aab: BUNDLE_FILE_PATH, | |
json_key: PLAYSTORE_KEY_PATH, | |
package_name: PACKAGE_NAME | |
) | |
end | |
desc "After successful execution of all task, this block is called" | |
after_all do | |
git_add(path: "*") | |
git_commit( | |
path: "*", | |
message: "#" + UPDATED_VERSION_CODE + " released" | |
) | |
push_to_git_remote( | |
local_branch: buildConfigs.key(options[:buildConfig]), | |
remote: "origin", | |
remote_branch: buildConfigs.key(options[:buildConfig]), | |
tags: true, | |
) | |
end |
And Pluginfile will look like this:
gem 'fastlane-plugin-firebase_app_distribution' gem 'fastlane-plugin-increment_version_code'
That’s it…
Now from the terminal, run
bundle exec fastlane build_and_distribute
After a successful run, you’ll find your app in Firebase App Distribution and Play Store.
This is how you can build and distribute your app with just one command locally. But now, you need to put this into Github so that after every feature push, your app will be distributed automatically. You don’t have to run this command also. This is the easy part. In the next post, I’ll show how to start your CI using Github actions.