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.