Continuous integration for a Bazel Android project
Developer Advocate
Bazel (pronounced like the tasty herb: “bay-zell”) is an universal build tool developed by Google. Some notable companies like Twitter and projects like the Android Open Source project have migrated to Bazel. In this tutorial, you will learn how to build a Bazel Android project and set it up for continuous integration with CircleCI. We will wrap up by automatically running tests and producing a binary APK file.
In addition to the written guide there is a working sample project. The sample project is also available to view on CircleCI.
About the sample project
The sample project for this tutorial is a minimal Android app written in Kotlin with a Bazel build configuration. The project app has build targets for both app binary - //app/src:app
, as well as unit tests with Robolectric - //app/src:unit_tests
.
Prerequisites
To complete this tutorial, you should have some experience with modern Android development, Kotlin, Gradle, and Git. You do not need any experience with Bazel.
Setting up a project for Bazel
To get started, you will need to go to GitHub, clone the sample project, and review the setup.
The sample project is a standard, minimal Android Gradle application that uses Kotlin. It has a project file, and an app
module with its own build.gradle
.
Inside the app
directory there is a common file hierarcy, with main
, test
, and androidTest
subdirectories for various build types.
Note: I had issues installing with Homebrew on Mac OS Catalina, so your mileage may vary.
Using workspace, builds, and rules
You need two files to get started with Bazel - WORKSPACE
and BUILD
.
The WORKSPACE
file describes just that - your workspace.
WORKSPACE
should be in the top level directory from where all other resources are referenced. It is the equivalent of the top level build.gradle
where you specify where to find the repositories for dependencies.
The first line you see in the WORKSPACE
file is:
load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive")
The load
method gives you access to other scripts in that location; in our case http_archive
. Use this method to fetch other remote resources from the web, such as library releases from GitHub. Another nice feature of Bazel is the sha256
argument for verifying the integrity of downloaded files.
http_archive(
name = "rules_android",
urls = ["https://github.com/bazelbuild/rules_android/archive/v0.1.1.zip"],
sha256 = "cd06d15dd8bb59926e4d65f9003bfc20f9da4b2519985c27e190cddc8b7a7806",
strip_prefix = "rules_android-0.1.1",
)
Of course, because Skylark is valid Python, variables and constants work as you might expect.
RULES_JVM_EXTERNAL_TAG = "2.2"
RULES_JVM_EXTERNAL_SHA = "f1203ce04e232ab6fdd81897cf0ff76f2c04c0741424d192f28e65ae752ce2d6"
http_archive(
name = "rules_jvm_external",
strip_prefix = "rules_jvm_external-%s" % RULES_JVM_EXTERNAL_TAG,
sha256 = RULES_JVM_EXTERNAL_SHA,
url = "https://github.com/bazelbuild/rules_jvm_external/archive/%s.zip" % RULES_JVM_EXTERNAL_TAG,
)
load("@rules_jvm_external//:defs.bzl", "maven_install")
You have seen how to load new scripts using http_archive
, and then use them to load a new script called maven_install
.
Fetching dependencies
In Android and JVM projects, you usually fetch dependencies from a Maven repository. The two most common repositories for open source dependencies are either Maven Central or JCenter. For Android-specific dependencies there is also Google’s own Maven repository.
Bazel takes a familiar approach with the maven_install
method:
maven_install(
artifacts = [
"androidx.core:core-ktx:1.2.0",
"androidx.appcompat:appcompat:1.1.0",
"androidx.fragment:fragment:1.0.0",
"androidx.core:core:1.0.1",
"androidx.lifecycle:lifecycle-runtime:2.0.0",
"androidx.lifecycle:lifecycle-viewmodel:2.0.0",
"androidx.lifecycle:lifecycle-common:2.0.0",
"androidx.drawerlayout:drawerlayout:1.0.0",
"androidx.constraintlayout:constraintlayout:1.1.3",
"com.google.android.material:material:1.0.0",
"org.jetbrains.kotlinx:kotlinx-coroutines-core:1.4.1",
"org.jetbrains.kotlinx:kotlinx-coroutines-android:1.4.1",
"junit:junit:4.+",
],
repositories = [
"https://maven.google.com",
"https://jcenter.bintray.com",
],
fetch_sources = True,
)
The artifacts
argument contains all the dependencies and their versions, and repositories
specifies where they come from.
The dependencies are downloaded for the whole workspace, and not included in the app yet. You will find them included a bit later in this tutorial.
Incorporating Kotlin
When reading through the Bazel documentation you will learn that Kotlin is not officially supported by the tool. Fortunately, there is an official community rule for Kotlin that brings the compilation features to it.
Currently, Kotlin 1.4 is not supported fully by the Bazel plugin. You can pull in either the 1.3.0 stable or the 1.4.0 release candidates.
rules_kotlin_version = "legacy-1.4.0-rc4"
rules_kotlin_sha = "9cc0e4031bcb7e8508fd9569a81e7042bbf380604a0157f796d06d511cff2769"
http_archive(
name = "io_bazel_rules_kotlin",
urls = ["https://github.com/bazelbuild/rules_kotlin/releases/download/%s/rules_kotlin_release.tgz" % rules_kotlin_version],
sha256 = rules_kotlin_sha,
)
load("@io_bazel_rules_kotlin//kotlin:kotlin.bzl", "kotlin_repositories", "kt_register_toolchains")
kotlin_version = "1.4.20"
kotlin_release_sha = "11db93a4d6789e3406c7f60b9f267eba26d6483dcd771eff9f85bb7e9837011f"
rules_kotlin_compiler_release = {
"urls": [
"https://github.com/JetBrains/kotlin/releases/download/v{v}/kotlin-compiler-{v}.zip".format(v = kotlin_version),
],
"sha256": kotlin_release_sha,
}
kotlin_repositories(compiler_release = rules_kotlin_compiler_release)
kt_register_toolchains()
You can use the bundled version of the Kotlin compiler that comes with the Kotlin Bazel rule. If you prefer to use something else, you can pull in any other compiler version from the Kotlin releases page on GitHub.
Now that we have completed your Kotlin Android app’s Bazel WORKSPACE
, we can focus on the individual package and its BUILD
file.
What is a Bazel package?
Bazel apps are called targets, and they are located inside Bazel packages. A Bazel package is any directory that has a BUILD
file and its subdirectories. That is, unless a subdirectory contains its own BUILD
file. In that case that particular subdirectory becomes its own package.
Packages in Bazel are addressed from within the workspace with a double slash //
and their directory structure. Our application has a single package: //app/src
. That is where the BUILD
file is located.
Using targets in Bazel applications
The BUILD
file contains the load
method calls we covered earlier, as well as kt_android_binary
, android_test
, and kt_android_library
calls. These elements are targets in the Bazel application. Targets can be anything that takes input, and produces an output of the build. In our case that can be source code, or another target. Each Bazel application can contain multiple targets.
For this tutorial, we will use the test
and android_binary
targets. The Android Binary outputs your .apk file, and test
does the test. You can find documentation for both in the Bazel docs. For each Android target, you must include the Android Manifest file.
android_binary(
name = "my_bazel_app",
manifest = MANIFEST,
custom_package = PACKAGE,
manifest_values = {
"minSdkVersion": "21",
"versionCode" : "2",
"versionName" : "0.2",
"targetSdkVersion": "29",
},
deps = [
":bazel_res",
":bazel_kt",
artifact("androidx.appcompat:appcompat"),
],
)
Building the project using Bazel commands
To build, use bazel build [target]
. The [target]
is the fully qualified Bazel target in your workspace. For the example app in this tutorial, the target is: //app/src:app
, so the command would be bazel build //app/src:app
.
The first build may take some time, but Bazel will cache most dependencies and interim artifacts, so future builds will be faster.
Installing the sample application
All final build artifacts are stored in bazel-bin/app/src/main/app.apk
. To install the app, Bazel has a convenient mobile-install
command:
bazel mobile-install //app/src:app
This command calls adb install
with arguments relevant to your connected device.
Setting up a Bazel project with CircleCI
CircleCI has a number of Android Docker images that ship with everything you need to build Android applications. That is, almost everything. Bazel is not installed by default so that will be our first step.
The circleci/android
Docker images are based on Debian Linux, so you can use the Ubuntu installation instructions from the Bazel documentation. There are two steps:
- Install the Bazel apt repositories
- install Bazel itself with
apt install
A single setup-bazel
CircleCI command will do the work.
commands:
setup-bazel:
description: |
Setup the Bazel build system used for building Android projects
steps:
- run:
name: Add Bazel Apt repository
command: |
sudo apt install curl gnupg
curl -fsSL https://bazel.build/bazel-release.pub.gpg | gpg --dearmor > bazel.gpg
sudo mv bazel.gpg /etc/apt/trusted.gpg.d/
echo "deb [arch=amd64] https://storage.googleapis.com/bazel-apt stable jdk1.8" | sudo tee /etc/apt/sources.list.d/bazel.list
- run:
name: Install Bazel from Apt
command: sudo apt update && sudo apt install bazel
This snippet is mostly copy-pasteable and reusable. You might just want to pin a specific version of Bazel for even more deterministic builds. I show you why in the next steps, and in the final sample project.
Testing and building Bazel targets
To test and build Bazel targets, you need bazel test
and bazel build
commands respectively, passing the qualified package and name for the correct target. In the case of our example these are //app/src:unit_tests
for the tests, and //app/src:app
for the application binary.
In the example we have them built right after the setup-bazel
step.
jobs:
build:
docker:
- image: circleci/android:api-29
steps:
- checkout
- android/accept-licenses
- setup-bazel
- run:
name: Run tests
command: bazel test //app/src:unit_tests # Depending on your Bazel package and target
- run:
name: Run build
command: bazel build //app/src:app # Depending on your Bazel package and target
Storing test and build artifacts
Bazel for Android stores all test output in bazel-testlogs
and all binary output in bazel-bin
directory in the project.
The outputs will take the same package structure as Bazel targets - src/app
in our case. CircleCI stores every useful piece of output when you add these stanzas:
- store_test_results:
path: ~/project/bazel-testlogs/app/src/unit_tests
- store_artifacts:
path: ~/project/bazel-testlogs/app/src/unit_tests
- store_artifacts:
path: ~/project/bazel-bin/app/src/app.apk
- store_artifacts:
path: ~/project/bazel-bin/app/src/app_unsigned.apk
We are also storing the app_unsigned.apk
because you will need to sign it yourself, if you want to produce a release build to distribute it. You can read more about signing manually on the Android developers portal.
Installing and using a specific Bazel version for more deterministic builds
When installing Bazel using apt install bazel
you are installing the latest stable version. Always using the latest and greatest may be fine on a local machine, but in a CI/CD context you likely want more determinism in your builds.
By modifying your apt install bazel
line to use a specific version you ensure using the latest version consistently: apt install bazel-3.7.2
. You will need to make sure to use that specific version in all subsequent calls. For example, bazel-3.7.2 build ...
.
One way to use a specific version of Bazel is by using CircleCI reusable parameters in your config.yml
. The sample project uses parameters inside the build
job:
jobs:
build:
parameters:
bazel-version:
description: "Pinned Bazel version Replace with your one"
default: "bazel-3.7.2"
type: string
...
steps:
- checkout
- android/accept-licenses
- setup-bazel:
bazel-version: <<parameters.bazel-version>>
- run:
name: Run tests
command: << parameters.bazel-version >> test //app/src:unit_tests # Depending on your Bazel package and target
Conclusion and next steps
I hope this tutorial has given you an idea of how to get a Bazel Android application running and building in your CI/CD pipeline. Next steps could be expanding the pipeline even further with automatic deployment to a testing service, or even directly distributing the app on an app store.