In this post, we’ll cover what Bazel is, how to use it, and why I chose to use it.

What is Bazel?

Bazel is a build-system released by Google in 2015. It actually is derived from the internal build-system Google uses internally for most of its own code-base, called Blaze.

Building at scale

Bazel has a huge focus on hermetic builds, and reproducibility. Every build step is, from a really broad perspective, defined as a list of inputs, tools, and outputs. This allows for efficient and robust caching (if no inputs nor tools changed, then this target doesn’t need to be rebuilt, and this cascades through the whole build graph). Let’s see a sample definition of a C++ library, as well as a C++ binary depending on it:

BUILD

cc_library(
    name = "my_feature"
    srcs = [
        "feature_impl.cpp",
        "utils.cpp",
    ],
    hdrs = [
        "feature.hpp",
        "utils.hpp",
    ],
)

cc_binary(
    name = "my_app",
    srcs = ["main.cpp"],
    deps = [
        ":my_feature",
    ],
)

cc_library and cc_binary are both depending an implicit dependency on a C++ toolchain (I won’t enter into any language-specific features in this post, but if you don’t tell Bazel to use a specific C++ toolchain, it will try to use your system compiler - which is convenient, but loses a bit of hermeticity and reproducibility). Everything else is pretty obvious here: we defined two different build targets, one of them being a library called my_feature, and the other one a binary called my_app, depending on my_feature. If we build my_app, Bazel will automatically build my_feature first as you would expect, and then proceed to build my_app. If you change the main.cpp and re-build my_app, it will skip the compilation of my_feature entirely, as nothing changed.

Bazel’s cache handling is really reliable. During the past few months, I’ve done a lot of diverse things (writing my own rules, compiling a bunch of different languages, depending on third-party libraries and rules…), and never had a single time to run bazel clean. Now I didn’t use a lot of other build systems in the recent past, but from someone who has been using Gradle for Android previously, this feels really weird.

Integrating tools and other languages

Another great aspect of Bazel is its extensibility. It works with rules defined in a language called Starlark, which syntax is a subset of Python’s. It comes without a lot of standard Python features, as I/O, mutable collections, or anything that could affect build hermeticity. While this isn’t the focus of this article (I will cover the writing of a rule to run a simple tool in a later article), here is what an example rule can look like (from Bazel’s samples):

rules.bzl

def _convert_to_uppercase_impl(ctx):
    # Both the input and output files are specified by the BUILD file.
    in_file = ctx.file.input
    out_file = ctx.outputs.output
    ctx.actions.run_shell(
        outputs = [out_file],
        inputs = [in_file],
        arguments = [in_file.path, out_file.path],
        command = "tr '[:lower:]' '[:upper:]' < \"$1\" > \"$2\"",
    )
    # No need to return anything telling Bazel to build `out_file` when
    # building this target -- It's implied because the output is declared
    # as an attribute rather than with `declare_file()`.

convert_to_uppercase = rule(
    implementation = _convert_to_uppercase_impl,
    attrs = {
        "input": attr.label(
            allow_single_file = True,
            mandatory = True,
            doc = "The file to transform",
        ),
        "output": attr.output(doc = "The generated file"),
    },
    doc = "Transforms a text file by changing its characters to uppercase.",
)

Once it’s defined, it’s re-usable to define actual build targets in a simple way:

BUILD

load(":rules.bzl", "convert_to_uppercase")

convert_to_uppercase(
    name = "foo_but_uppercase",
    input = "foo.txt",
    output = "upper_foo.txt",
)

As a result of this simple extensibility, while Bazel ships only with C++ and Java support (which are actually getting removed and rewritten in Starlark, to decouple them from Bazel itself), a lot of rules have been written either by the Bazel team or by the community, to integrate languages and tools. You can find rules for NodeJS, Go, Rust, packaging (generating debs, zips…), generating Docker images, deploying stuff on Kubernetes, and a bunch of other things. And if there are no rules to run/build what you want, you can write your own!

A three-steps build

Bazel runs in three distinct phases. Each of them has a specific role, and specific capabilities.

Loading

The loading phase is parsing and evaluating all the BUILD files required to build the requested target(s). This is typically the step during witch any third-party dependency would be fetched (just downloaded and/or extracted, nothing more yet).

Analysis

The second phase is validating any involved build rule, to generate the actual build graph. Note that both of those two first phases are entirely cached, and if the build graph doesn’t change from one build to another (e.g. you just changed some source files), they will be skipped entirely.

Execution

This is the phase that checks for any out-of-date output (either non-existent, or its inputs changed), and runs the matching actions.

Great tooling

Bazel comes with some really cool tools. Without spending too much time on that, here’s a list of useful things:

  • ibazel is a filesystem-watcher that will rebuild a target as soon as its inputs files or dependencies changed.
  • query is a built-in sub-command that helps to analyse the build graph. It’s incredibly feature-packed.
  • buildozer is a tool to edit BUILD files at across a whole repository. It can be used to add dependencies to specific targets, changing target visibilities, adding comments…
  • unused_deps is detecting unused dependencies for Java targets, and displays buildozer commands to remove them.
  • Integration with different IDEs.
  • A set of APIs for remote caching and execution, with a few implementations, as well as an upcoming service on Google Cloud called Remote Build Execution, leveraging GCP to build remotely. The loading and analysis phases are still running locally, while the execution phase is running remotely.

Choosing a build system

At the time I started thinking about working on this blog again, I had a small private repository with a bunch of stuff, all compiled with Bazel. I also noticed a set of Starlark rules integrating Hugo. While I didn’t need a build system, Bazel seemed to be interesting for multiple aspects:

  • I could leverage my existing CI system
  • While Hugo comes with a bunch of features to e.g. pre-process Sass files, it has some kind of lock-in effect. What if I eventually realise that Hugo doesn’t fill my need? What’s the cost of migrating to a new static site generator? The less I rely on Hugo-specific features, the easier this would be
  • I could integrate some custom asset pipelines. For example, I could have a diagram written with PlantUML or Mermaid and have it part of the Bazel graph, as a dependency of this blog
  • Bazel would be able to handle packaging and deployment
  • It sounded stupid enough to be a fun experiment? (Let’s be honest, that’s the only real reason here.)

Closing thoughts

Bazel is quite complex, and this article only scratches the surface. The goal was not to teach you how to use Bazel (there are a lot of existing resources for that already), but to give a quick overview of the core ideas behind it.

If you found it interesting, here are some useful links:

In the next article, we’ll see how to build a simple Kotlin app with Bazel, from scratch all the way to running it.