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:

  1. Install the Bazel apt repositories
  2. 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.