Design choices¶
This page is targeted at those who are interested in learning more about the key design decisions that the team made when developing the ROS Central Registry.
Versioning¶
Let’s say we are creating a new release for kilted/2026-01-22 release tag. In this release the rclcpp package moved from 29.5.5-1 to 29.5.6-1. Bootstrapping will result in a new Bazel module for rclcpp with the version kilted.29.5.6-1. Assuming we have to fix some build errors as a result of the code change, the released version will be kilted.29.5.6-1.rcr.1, with .rcr.1 signalling that a patch was needed, and this will be tagged in the first release of package rosdistro with version kilted.2026.01.22.rcr.1.
multiple_version_override(
module_name = "sensor_msgs",
versions = [
"jazzy.29.5.6-1.rcr.1",
"kilted.29.5.6-1.rcr.1",
],
)
multiple_version_override(
module_name = "sensor_msgs",
versions = [
"jazzy.29.5.6-1.rcr.1",
"kilted.29.5.6-1.rcr.1",
],
)
Interfaces¶
The ROS Central Registry adopts many of the aspects of rules_ros2 with a few key differences. The most obvious one is that interface dependencies are declared at the message level, not the package level. As an example, consider adding the following new message ExampleMessage.msg that depends on two common interfaces, sensor_msgs/CompressedImage and std_msgs/String.
std_msgs/String message
sensor_msgs/CompressedImage back_left
sensor_msgs/CompressedImage back_right
sensor_msgs/CompressedImage front_left
sensor_msgs/CompressedImage front_right
When you create a Bazel target for this interface, you must declare the dependencies at the message level, and not the package level. This allows the IDL generators – which convert this language-neutral interface description into language-specific code – to generate exactly what is needed for the target, without any extra code that is not used. This makes for faster builds and smaller build products.
load("@ros//:rules.bzl", "ros_interface")
ros_interface(
name = "ExampleMessage",
src = "ExampleMessage.msg",
deps = [
"@sensor_msgs//msg:CompressedImage",
"@std_msgs//msg:String",
],
)
Other differences are that we have – as best as possible – copied the rule semantics from protocol buffers (for example cc_ros_library is the C++ bindings for ROS interface in the same way cc_proto_library is the C++ bindings for protocol buffer messages) and used bare language rules (cc_binary in stead of ros_cc_binary) to avoid any opaqueness in our design.
load("@rosidl_generator_cpp//:rules.bzl", "cc_ros_library")
load("@rules_cc//cc:defs.bzl", "cc_binary")
cc_ros_library(
name = "example_ros_cc_msgs",
deps = [
"//msg:ExampleMessage",
],
)
cc_binary(
name = "example_ros_publisher_cc",
srcs = ["ros/example_ros_publisher.cc"],
deps = [
"@rclcpp",
":example_ros_cc_msgs"
],
)
Runtime data¶
One of the key differences between a CMake-based build and a Bazel-based build for ROS is how runtime data is handled. In a CMake build, runtime data is typically installed to a fixed location (for example to the install folder in a ROS workspace) with an INSTALL function. When you source install/setup.bash it automatically adds the install folder to your AMENT_PREFIX_PATH environment variable. Each client library then relies on a language-specific ament_index package to provide something like a get_package_share_directory(package_name) function to access data.
The issue with the CMake approach is that unrelated packages (not just dependencies and the current package) can install data into the same directory. This can cause conflicts and couples the behavior of the node to an execution context (it not hermetic) which can in turn cause nondeterministic failures.
Bazel resolves this problem using runfiles. What this looks like in practice is that whenever a target is built, every data dependency it needs is listed in its data attribute. This data attribute is then used to create a runfiles tree, which is essentially a set of symbolic links to the underlying data fails. This makes the build hermetic and prevents conflicts between unrelated packages.
To avoid having to change every all code in ROS to understand Bazel runfiles, we have modified the ament_index package for each language to treat the root of the runfiles folder as the prefix path if the AMENT_PREFIX_PATH environment variable is unset. For some example_project we encapsulate data with a ros_data rule, which serves to recreate the ament index folder layout within the runfiles tree of a specific binary.
load("@ros//:rules.bzl", "ros_data")
load("@rules_cc//cc:defs.bzl", "cc_library")
cc_library(
name = "util_library",
hdrs = ["include/example_project/util.hh"],
srcs = ["src/util.cc"],
includes = ["include"],
)
ros_data(
name = "data",
data = [
"config/default.yaml",
":util_library",
"@gtsam//:gtsam",
],
)
cc_binary(
name = "example_ros_publisher_cc",
srcs = ["ros/example_ros_publisher.cc"],
data = [
":data",
"@ament_index_cpp"
],
)
When you run bazel run //:example_ros_publisher_cc the example_ros_publisher_cc.runfiles directory is created, which organizes the libraries and binaries into a common lib folder, and the headers and shared data into include and share folders respectively, organized by package.
example_project/
└── bazel-bin/
├── example_ros_publisher_cc
└─── example_ros_publisher_cc.runfiles/
├── include/
│ └── example_project/
│ └── util.hh
├── lib/
│ ├── libutil_library.so
│ └── gtsam
└── share/
└── example_project/
└── config/
└── default.yaml
This illustrates the principle. In practice the rule is more complex because it also has to traverse the dependency chain to bring in files for the teansitive dependencies of the includes.
Python modules¶
The standard way to handle Python dependencies in Bazel is to use the pip.bzl extension from rules_python to parse a requirements_lock.txt file and download, compile and expose Python modules as Bazel targets. This is done using the pip_install rule. In practice, this involves adding something like this from within a MODULE.bazel file:
bazel_dep(name = "rules_python", version = "1.8.3")
...
pip = use_extension("@rules_python//python/extensions:pip.bzl", "pip")
pip.parse(
hub_name = "pip_deps",
python_version = "3.12",
requirements_lock = "//:requirements.txt",
)
use_repo(pip, "pip_deps")
Then, from any build file you can access the Python modules as if they were regular Bazel targets.
load("@rules_python//python:defs.bzl", "py_binary")
load("@pip_deps//:requirements.bzl", "requirement")
py_binary(
name = "baz",
srcs = ["baz.py"],
main = "baz.py",
deps = [
requirement("numpy"),
],
)
Relying on modules having their own requirements_lock.txt is file antithetical to the ROS philosophy of having a single source of truth for operating system and Python dependencies.
More importantly, it is a recipe for disaster because it means that different Bazel modules can import different versions of the same Python module. At runtime both Python module versions will be available in the runfile tree of any target that depends on both Bazel modules. The version that is ultimately selected is the one which appears last in the PYTHONPATH environment variable. This can cause conflicts and nondeterministic failures, and should be avoided.
To fix this issue we have centralized the Python dependency management in the rosdistro Bazel module. This means that each ROS release has its own global requirements_lock.txt file, which is used to download, compile and expose Python modules as Bazel targets for the entire ROS package ecosystem at the time of release. It then provides this simple extension:
load("@rules_python//python/extensions:pip.bzl", "pip")
def _pip_ros_impl(ctx):
# This is a 'no-op' logic-wise, but it allows bar to see
# the repos that this extension 'claims' to provide.
pass
# This extension essentially acts as a bridge
pip_ros = module_extension(implementation = _pip_ros_impl)
Now that this extension is available, we can modify any ROS package to reference a specific rosdistro release and use the “pass-through” module extension to access the pip deps:
bazel_dep(name = "rosdistro", version = "rolling/2026-01-26")
pip_ros = use_extension("@rosdistro//bazel/python:extensions.bzl", "pip_ros")
use_repo(pip_ros, "pip_ros")
This make usage similar to how it would look if a module-specific pip repository was being used:
load("@rules_python//python:defs.bzl", "py_binary")
load("@pip_ros//:requirements.bzl", "requirement")
py_binary(
name = "baz",
srcs = ["baz.py"],
main = "baz.py",
deps = [
requirement("numpy"),
],
)