rules_julia
Overview
This repository implements Bazel rules for the Julia programming language.
Setup
To begin using the rules, add the following to your MODULE.bazel file.
bazel_dep(name = "rules_julia", version = "{version}")
julia = use_extension("@rules_julia//julia:extensions.bzl", "julia")
use_repo(julia, "julia_toolchains")
register_toolchains(
"@julia_toolchains//:all",
)
The default Julia version is always registered. To make additional versions
available, request them and select one with the version setting:
julia.toolchain(version = "1.11.9")
bazel build --@rules_julia//julia/settings:version=1.11.9 //...
Compilation mode
Optimization level, bounds checking and debug info follow --compilation_mode
through the toolchain's opt_level, check_bounds and debug_info
attributes, and apply uniformly to precompilation, system images and the
binaries that load them, since Julia rejects compile caches built with other
values. By default fastbuild and opt use Julia's own defaults, which also
match the standard library caches Julia ships, and dbg builds with -O0,
--check-bounds=yes and -g2. Per-target julia_flags carry everything
else, such as --threads.
Package layout
Targets follow Julia's conventions. A julia_library named Foo is loaded
with using Foo from src/Foo.jl (or Foo.jl next to the BUILD.bazel
file). When the package directory also holds a Project.toml, list it in
data so Julia loads the package through it. The package then has the name
and UUID recorded there, which packages such as Preferences and package
extensions depend on, and its dependencies resolve from the [deps] table:
julia_library(
name = "Foo",
srcs = glob(["src/**/*.jl"]),
data = ["Project.toml"],
deps = ["@deps//:JSON"],
)
Packages installed by the pkg extension are always set up this way.
System images
A system image is Julia's own ahead-of-time format: the shared library
julia --sysimage loads in place of the stock one, with every included module
already compiled, so loading them costs nothing at startup. julia_sysimage
produces one from a set of packages and their dependencies as an ordinary,
cached target:
julia_sysimage(
name = "image",
deps = ["@deps//:JSON", "//my/lib"],
)
julia --sysimage="$(bazel cquery --output=files //:image)" -e 'using JSON'
julia_standalone_binary builds on the same actions to produce an executable
around a package with a julia_main function.
External dependencies
Third party packages are declared the way Julia expects: a Project.toml
with a resolved Manifest.toml next to it. The pkg module extension turns
every package in the manifest into a julia_library target.
pkg = use_extension("@rules_julia//julia/pkg:extensions.bzl", "pkg")
pkg.from_manifest(
name = "deps",
manifest = "//:Manifest.toml",
)
use_repo(pkg, "deps")
Packages are then available as @deps//:<PackageName>:
julia_binary(
name = "app",
srcs = ["app.jl"],
deps = ["@deps//:JSON"],
)
Package tarballs are fetched from the Julia package server using the UUID and
git tree hash recorded in the manifest. On Bazel 9 and newer the integrity value
of each tarball is stored in the facts section of MODULE.bazel.lock the first
time it is needed, so no additional lockfile has to be maintained. Keep
--lockfile_mode at its default (update) so the facts are persisted.
A julia_pkg_resolve target resolves manifests from Project.toml without
leaving Bazel, and julia_pkg_resolve_test fails when they drift apart. Pkg resolves
against one Julia version, so each manifest is declared with the Julia minor
version it is for and is resolved and verified under a toolchain of that
version, whatever the version setting selects elsewhere:
load("@rules_julia//julia/pkg:defs.bzl", "julia_pkg_resolve", "julia_pkg_resolve_test")
julia_pkg_resolve(
name = "pkg_update",
project_toml = "Project.toml",
manifests = {"Manifest.toml": "1.12"},
)
julia_pkg_resolve_test(
name = "pkg_test",
resolver = ":pkg_update",
)
bazel run //:pkg_update
bazel run //:pkg_update -- --add JSON
Several Julia versions
A manifest is only valid for the Julia minor version that resolved it: the
standard libraries, compat bounds and Pkg itself all move with the minor
version. A project supporting several versions carries one manifest per
version, following Pkg's Manifest-v<major>.<minor>.toml convention, and one
run of the resolver updates all of them:
julia_pkg_resolve(
name = "pkg_update",
project_toml = "Project.toml",
manifests = {
"Manifest-v1.10.toml": "1.10",
"Manifest-v1.12.toml": "1.12",
},
)
pkg.from_manifest(
name = "deps",
manifests = [
"//:Manifest-v1.10.toml",
"//:Manifest-v1.12.toml",
],
)
The extension reads the Julia version recorded in each manifest and builds a
package graph per version. @deps//:<PackageName> selects the graph matching
@rules_julia//julia/settings:version, and every package is marked
incompatible with other versions, so targets depending on a package without a
manifest for the selected Julia are skipped by wildcard builds and fail with
Bazel's standard incompatibility message when built explicitly.
Artifacts
Many packages depend on prebuilt binaries or data shipped as Julia
artifacts, most visibly the
_jll packages wrapping C libraries. These are built by
BinaryBuilder inside a
cross compilation environment that cannot be reproduced in a Bazel action, so
the published tarballs are consumed as-is, pinned by the SHA-256 and git tree
hash recorded in each package's Artifacts.toml.
The pkg extension defines a repository per artifact variant and chooses in
two steps, so only relevant variants are ever downloaded. Operating system and
CPU come from the target platform and are decided by select(). The remaining
tags, the libgfortran ABI and the C++ std::string ABI, describe the
runtimes bundled with the Julia distribution rather than the machine, so they
are decided against the resolved toolchain's libgfortran_version and
cxxstring_abi attributes, which default to what official Julia binaries
ship. Users pick a platform and a Julia version; nothing else needs setting.
On Linux the C library is assumed to be glibc, which is what official Julia
binaries are built against; musl variants are not selected. This will follow
the libc constraint from platforms_contrib once it is available. Artifacts
for other operating systems carry no libc tag.
Variants that differ only in tags the rules do not model (for example
libstdcxx_version) resolve to the newest one, matching what Pkg picks on a
current Julia. A package whose artifact has no variant for the target platform
fails to analyze with a message listing the platforms it is available for.
Lazy artifacts, which Julia itself only downloads on first use, are the
exception: they are fetched like any other where a variant exists, and simply
omitted on platforms without one, so a package that never touches the artifact
there still builds. Code that does touch it fails at the point of use, as it
would under Julia without network access.
Artifact metadata is recorded next to the package integrity values, in
MODULE.bazel.lock facts or in Manifest.bazel.json, so reading
Artifacts.toml happens once per package version.
Publishing a module
Facts are only persisted in the root module's lockfile. A module consumed by
other Bazel modules should instead ship a Manifest.bazel.json so consumers
never download tarballs just to learn their hashes. Set manifest_bazel_json
on julia_pkg_resolve to generate it and pass it to pkg.from_manifest as
lockfile instead of manifest.
pkg.from_manifest(
name = "deps",
lockfile = "//:Manifest.bazel.json",
)
Publishing artifacts
Julia distributes binaries and large data as
artifacts: gzipped tarballs
identified by the git tree hash of their contents, listed in a package's
Artifacts.toml with a download URL and SHA-256. There is no registry for
them; any URL works, and GitHub releases are the usual host.
julia_artifact builds one from Bazel targets, so a cc_binary, a
generated data set or any other output becomes an artifact in exactly the form
Pkg expects. The tarball is packed with the Tar and 7z Julia ships and
both hashes come from the code Pkg uses to verify downloads:
load("@rules_julia//julia:defs.bzl", "julia_artifact")
julia_artifact(
name = "libfoo",
srcs = ["include/foo.h"],
files = {":libfoo_shared": "lib/libfoo.so"},
platform_tags = ["os", "arch", "libc"],
url = "https://github.com/me/Foo.jl/releases/download/v1.0.0/{file}",
)
This produces libfoo.tar.gz and libfoo.Artifacts.toml:
[[libfoo]]
arch = "x86_64"
git-tree-sha1 = "..."
libc = "glibc"
os = "linux"
[[libfoo.download]]
sha256 = "..."
url = "https://github.com/me/Foo.jl/releases/download/v1.0.0/libfoo.tar.gz"
Upload the tarball to the URL and merge the entry into the package's
Artifacts.toml. Platform tags are read from the target platform and the
@rules_julia//julia/settings flags, so building the same target under
several --platforms gives one entry per platform to concatenate. Leave
platform_tags empty for platform independent artifacts. Without url the
entry has no download table, for filling in once the file is hosted.
Packages themselves need no publishing step: Pkg installs a package from
its git tree, so a repository built with these rules is registered with
Registrator or LocalRegistry exactly like any other Julia package.