Traditionally, when setting up a developer machine, instructions include something like “install the following tools using Homebrew”. What if we could always have tools available without asking developers to install anything but Bazel?
This is easily achievable with Bazel since it gives us a way to download and execute binaries. Before diving into the implementation, let’s first explore the downsides of asking developers to install tools on their own.
Problems with Homebrew for developer tools
brew install …
When developing on macOS, the “default” package manager is Homebrew, so we install tools like linters and formatters using it. However, it is not great for versioning in this use case. By default, we usually end up installing whatever version Homebrew currently resolves, unless we specifically do extra work to avoid that.
This is the first problem: we can’t expect people to ensure that they have exactly the same version of a tool as everybody else, especially if the organization is large.
Distributing tools
Just like we can’t easily enforce versions, we also can’t easily enforce tool replacement. Say that we built an internal linter and we want everybody to use it. What is the distribution mechanism? Perhaps an internal Brew formula? It works, but it is tedious to set up and maintain.
Using Bazel and rules_multitool
There have been a couple of community posts about rules_multitool, and I want to give my take on it.
How it works
This ruleset provides a convenient way to tell Bazel to download a binary and expose it as a target under the @multitool//tools/{TOOL_NAME} label. It takes in multitool.lock.json files and uses information from the lockfile to invoke Bazel repository rules, download the specified binary, and expose it as a runnable tool.
Setting it up
Declare the dependency in
MODULE.bazelas described here.Call its module extension from
MODULE.bazel:
multitool = use_extension("@rules_multitool//multitool:extension.bzl", "multitool")
multitool.hub(lockfile = "//tools:multitool.lock.json")
use_repo(multitool, "multitool")
Of course, you can put your multitool.lock.json wherever you like. I tend to keep it under the tools/ package.
- Add
multitool.lock.jsonand give it some tools to work with, e.g.:
{
"$schema": "https://raw.githubusercontent.com/theoremlp/rules_multitool/main/lockfile.schema.json",
"bb": {
"binaries": [
{
"kind": "file",
"url": "https://github.com/buildbuddy-io/bazel/releases/download/5.0.350/bazel-5.0.350-linux-x86_64",
"sha256": "d14e6a240dc5e8bc3ebb625ff7c139ba8e380f1440f9e2f60e9c1d7850d012c9",
"os": "linux",
"cpu": "x86_64"
},
{
"kind": "file",
"url": "https://github.com/buildbuddy-io/bazel/releases/download/5.0.350/bazel-5.0.350-darwin-arm64",
"sha256": "f16cc54449eb62ee65ac7ec3b45d7bce7922225a3c004925cbb25982faa8a9cd",
"os": "macos",
"cpu": "arm64"
}
]
},
"yq": {
"binaries": [
{
"kind": "file",
"url": "https://github.com/mikefarah/yq/releases/download/v4.52.4/yq_linux_arm64",
"sha256": "4c2cc022a129be5cc1187959bb4b09bebc7fb543c5837b93001c68f97ce39a5d",
"os": "linux",
"cpu": "arm64"
},
{
"kind": "file",
"url": "https://github.com/mikefarah/yq/releases/download/v4.52.4/yq_linux_amd64",
"sha256": "0c4d965ea944b64b8fddaf7f27779ee3034e5693263786506ccd1c120f184e8c",
"os": "linux",
"cpu": "x86_64"
},
{
"kind": "file",
"url": "https://github.com/mikefarah/yq/releases/download/v4.52.4/yq_darwin_arm64",
"sha256": "6bfa43a439936644d63c70308832390c8838290d064970eaada216219c218a13",
"os": "macos",
"cpu": "arm64"
}
]
}
}
This is it. You can now execute bazel run @multitool//tools/yq or bazel run @multitool//tools/bb, and Bazel will download and execute them just fine.
Making it more convenient
Typing out the label from above will quickly get tedious. What if we could instead run the yq tool as easily as ./tools/yq?
To achieve that, we could do the following trick:
- Create a script at
tools/_run_tool.shwith the following content:
#!/usr/bin/env bash
target="@multitool//tools/$(basename "$0")"
bazel run \
--run_in_cwd \
--noshow_progress \
--show_result=0 \
--ui_event_filters=-info \
"$target" -- "$@"
- Create a symlink for every tool with its name, e.g. a
yqsymlink that links totools/_run_tool.sh.
That will expand $(basename "$0") in tools/_run_tool.sh to the name of the symlink and allow you to execute ./tools/yq. Or, if you put the symlink at the root, it gets even more convenient: ./yq.
Conclusion
I think the trick with symlinks is quite powerful, and you don’t really have to use rules_multitool if you don’t want to. The same idea can work with any Bazel target that exposes a runnable tool.
Overall, I like this setup very much because it requires almost nothing from developers. They can just use the tools provided to them while not even thinking about versions.