Preventing production code depending on experiments

At Google we had an /experimental folder in source control. This is a nice way to be able to check in code in a monorepo that you're just "spiking" on, with all the usual developer ergonomics and access to shared library code. There's also no expectation that you have to maintain code in this folder.

These could go on an experimental feature branch. However, having this on the main branch lets you get CI feedback and easily point co-workers at your experiments, and collaborate with others on them, without having anyone forced to rebase the experimental branch on the latest main and deal with merge conflicts.

You can go a step further and configure the code review and merging requirements to be relaxed for commits that only modify experimental/ so that this folder has much faster iteration times, with correspondingly lower quality expectations.

The danger of having this low-quality code in the main branch of a Bazel-built monorepo is that it's so easy for a production service to take a (transitive) dependency on it. This article presents a simple way to prevent that.

Bazel can disallow dependency edges

We'll build on top of a Bazel feature called testonly. Bazel enforces that production code cannot depend on tests. It also prints a good error message when a developer violates the policy. So our first step is to mark all our experimental code as testonly - that is, you're free to use that code for your testing, but it won't be allowed to depend on it from anything that's not marked with testonly.

There's a convenient tool to machine-edit Bazel's BUILD files, buildozer. We'll use that to mechanically set all packages beneath /experimental to default their targets to be testonly. We'll also add a helpful comment, since developers might not be used to seeing this configuration.

# Download buildozer if needed: https://github.com/bazelbuild/buildtools/releases/
buildozer 'set default_testonly True' //experimental/...:__pkg__
buildozer 'comment code\ in\ experimental\ may\ only\ be\ used\ for\ testing' //experimental/...:__pkg__

As a result, all BUILD files under experimental will now contain

# code in experimental may only be used for testing
package(default_testonly = True)

Now we can check what happens when production code tries to add a dep on something in this folder:

$ bazel build --nobuild //myservice/...
ERROR: proj/myservice/BUILD.bazel:3:18:
 in js_binary rule server: 
non-test target '//myservice:server' depends on testonly target '//experimental/subdir:proto_test' 
and doesn't have testonly attribute set

Making sure it stays this way

That's a good start, but our setup is brittle for a couple reasons:

  • we set the default_testonly property for each package, but individual targets can override that with testonly=False

  • new packages can be introduced under /experimental and the authors won't know to apply this again

As a fix, we should setup a CI task. It simply needs to query for any non-testonly targets under /experimental like so:

bazel query 'attr(testonly, 0, //experimental/...)'

If there's any stdout from this command, then we report that as a failure in CI.

Also, let's give developers an easy way to repair a red build if they get one. We can just print those same buildozer commands we used above - they are idempotent and safe to re-run on all the packages to update newly-added BUILD files.