Typically, when writing Bazel rules, we output files as the final step, and that is what we need most of the time. However, today I want to make a case for outputting directories—or, as Bazel calls them, “tree artifacts.”
There are many reasons to reach for a tree artifact instead of, say, a ZIP file. For one, zipping can be expensive and time-consuming, depending on the size and contents of the output. Every downstream action that needs the directory then has to unzip it again, adding more CPU work, disk I/O, and temporary files.
Archives are also opaque blobs. By contrast, Bazel represents a tree artifact as a directory whose individual files are stored in the content-addressable store. The tree is still treated as a single declared output for dependency and action-cache purposes, but remote caching and remote execution can deduplicate the files it contains instead of repeatedly storing and transferring one large archive.
In the context of Apple-platform apps, we typically need .app bundles during intermediate build steps—not ZIP or .ipa archives. Signing, validation, installation, and other tools naturally operate on the bundle directory. Keeping the .app as a tree artifact means that we spend much less time zipping and unzipping it.
This is especially noticeable when developing locally through rules_xcodeproj, where unnecessary archive and extraction steps slow down the edit-build-run cycle.
There are other advantages too, particularly for remote execution, but it is hard to enumerate them all. The general idea is simple: keep structured outputs as directories for as long as possible, and create an archive only at the boundary where something actually requires one—for example, when producing the final .ipa.
Creating a tree artifact
It is quite easy, and perhaps not worthy of an entire blog post—but here we are.
In the rule implementation function, we simply invoke:
output = ctx.actions.declare_directory(
ctx.label.name + ".app",
)
The returned value is a File, just like the value returned by ctx.actions.declare_file(), and it can be passed to actions and downstream rules in much the same way:
ctx.actions.run(
executable = ctx.executable.tool,
arguments = ["--output", output.path],
outputs = [output],
)
The action producing the tree artifact must create the declared directory and place all of its contents inside it.
The main thing to remember is that Bazel does not know the tree artifact’s contents during the analysis phase. You cannot inspect the directory and turn arbitrary files inside it into regular declared outputs. Its contents are normally available only at execution time.
One newer option is map_directory, which lets Bazel expand actions over the contents of directories. It is still fairly new, however, and is not yet widely used.
Conclusion
My advice is to always evaluate whether an output really needs to be an archive. If it does not, it is usually better to declare it as a tree artifact.