Skip to content

Exercise 2: Build ROS packages with Pixi

30 minutes, hands-on

Work in: exercises/02-ros-package/ · Solution: solutions/02-ros-package/ · After: Building ROS packages with Pixi

Goal: hand the build to Pixi. The same C++ node from Exercise 1, plus a Python one, both built and installed from their package.xml: no colcon build, no source install/setup.bash, no build/ directory.

Exercise 1 built your node the ROS way: colcon in the workspace, an install/ overlay, and sourcing before every run. Pixi can build the package itself and put it in the environment next to turtlesim, so none of that is needed anymore: no colcon, no overlay, no sourcing, not even the build tools in your workspace. This exercise gets you there one piece at a time, until what is left is edit and run.

Same format as before: each step says what to do, the commands are folded away underneath, and the cheat sheet helps with the flags.

Work inside the workshop repository

cd exercises/02-ros-package

This is where Exercise 1 left off, trimmed to Lyrical only: pixi.toml builds src/turtle_dancer/ with colcon and sources the overlay on activation. The Kilted environment and the PyTorch node are left out so the diff in this exercise stays about the build. The initial build selects only turtle_dancer, with its overlay in install/default/. The Python package stays unbuilt until 2.6.

Run the colcon way once, so you have the before in front of you:

pixi run build      # colcon builds the workspace
ls                  # build/ install/ log/ appeared next to src/

Run this initial build before pixi run dance: activation needs the overlay to exist before the command starts.

2.1 Turn on Pixi build

Building packages from source is a preview feature in Pixi, so a workspace opts in. It also needs a build backend, the piece that knows the build system. For ROS that is pixi-build-ros, and since every package in the workspace uses the same one, you declare it once, in [workspace.dependencies].

Your turn

  1. Enable the pixi-build preview in the [workspace] table.
  2. Add pixi-build-ros = ">=0.7.5" to [workspace.dependencies].
Solution
# 1. add the preview
pixi workspace preview add pixi-build
# 2. add the backend to the pool
pixi workspace dependencies add "pixi-build-ros >=0.7.5"
exercises/02-ros-package/pixi.toml
[workspace]
name = "02-ros-package"
channels = ["https://prefix.dev/robostack-lyrical", "conda-forge"]
platforms = [
  "linux-64",
  { platform = "osx-arm64", macos = "14.0" },
  "win-64",
]
version = "0.1.0"
preview = ["pixi-build"]

[workspace.dependencies]
pixi-build-ros = ">=0.7.5"

[workspace.dependencies] is a pool of shared specs, not an install list: nothing lands in the environment because of it. Packages pick entries out of the pool, which is the next step, and bumping the backend version later is one line.

2.2 Give the package a manifest

Until now every pixi.toml you wrote had a [workspace] table. There is a second main table which describes a single package:

Manifest role Marked by Describes In this exercise
Workspace [workspace] your environment: channels, platforms, dependencies, tasks pixi.toml
Package [package] how to build one package src/turtle_dancer/pixi.toml

A workspace is what you pixi run. A package is what Pixi turns into a conda package, through the backend. pixi-build-ros reads package.xml for the name, version, dependencies and build type, maps the dependencies to their RoboStack names, and runs the ament_cmake or ament_python build you already have. So the package manifest has very little to say.

Your turn

  1. Create src/turtle_dancer/pixi.toml that names pixi-build-ros as its build backend, taking the version from the workspace pool. Hint: workspace = true.
  2. Open package.xml and CMakeLists.txt. Which lines does the backend need, and which one makes ros2 run find the executable? Change nothing.
Solution
exercises/02-ros-package/src/turtle_dancer/pixi.toml
[package.build.backend]
name = "pixi-build-ros"
workspace = true

Two lines: which backend, and "the version is in the workspace pool". The ROS distro comes from the robostack-lyrical channel of the workspace, and everything else comes from package.xml.

From package.xml the backend takes <name>, <version>, <depend> and <build_type>. From CMakeLists.txt the line that matters is install(TARGETS dance DESTINATION lib/${PROJECT_NAME}): ros2 run looks in lib/<package>/, and that is where the backend installs whatever your CMake installs. Both files are the ones colcon used a minute ago, unchanged.

2.3 Depend on your own package

A workspace depends on a local package like on anything else, except the specifier is a path to the package directory. The dependency name is the package.xml name with the distro prefix and hyphens: ros-lyrical-turtle-dancer, the name RoboStack would give it if it were published there.

Your turn

  1. Add ros-lyrical-turtle-dancer as a path dependency on src/turtle_dancer.
  2. Install, and watch what happens.
  3. Find your package in pixi list.
Solution
pixi add --path src/turtle_dancer ros-lyrical-turtle-dancer
exercises/02-ros-package/pixi.toml
[dependencies]
ros-lyrical-turtle-dancer = { path = "src/turtle_dancer" }
ros-lyrical-ros-base = ">=0.11"
ros-lyrical-turtlesim = ">=1.8"
ros-dev-tools = ">=1.0"
# 2
pixi install

A Running build for recipe: ros-lyrical-turtle-dancer-0.1.0-... block scrolls by. The first install fetches the backend and a build environment (CMake, the compilers, the ROS libraries from package.xml) into .pixi/bld/, compiles the node and installs the result into the environment as a conda package. On Windows, compiler activation uses the Visual Studio tools you installed in Before you start. Later builds are incremental.

$ pixi list turtle          # 3
ros-lyrical-turtle-dancer                                       conda  src/turtle_dancer
ros-lyrical-turtlesim   1.10.9   np2py314h1e5664e_22   417.69 KiB   conda  https://prefix.dev/robostack-lyrical

Where other packages show a channel, yours shows the path it was built from. On Windows, findstr turtle instead of grep turtle.

2.4 Delete the colcon machinery

Right now the node is in the environment twice: once in colcon's install/, once built by Pixi. Everything colcon needed can go.

Your turn

  1. Remove the depends-on of dance and both activation tables, then remove the build task and the ros-dev-tools dependency.
  2. Delete build/, install/ and log/.
  3. Run the node. In a second terminal, run the simulator and watch it go.
Solution
# 1. in your editor: drop `depends-on` from dance and delete both [target.*.activation] tables
pixi task remove build
pixi remove ros-dev-tools
# 2 (PowerShell: Remove-Item -Recurse build, install, log)
rm -rf build install log
# 3, in two terminals
pixi run sim
pixi run dance

Resulting pixi.toml:

exercises/02-ros-package/pixi.toml
[workspace]
name = "02-ros-package"
channels = ["https://prefix.dev/robostack-lyrical", "conda-forge"]
platforms = [
  "linux-64",
  { platform = "osx-arm64", macos = "14.0" },
  "win-64",
]
version = "0.1.0"
preview = ["pixi-build"]

[workspace.dependencies]
pixi-build-ros = ">=0.7.2"

[dependencies]
ros-lyrical-turtle-dancer = { path = "src/turtle_dancer" }
ros-lyrical-ros-base = ">=0.11"
ros-lyrical-turtlesim = ">=1.8"

[tasks]
sim = "ros2 run turtlesim turtlesim_node"
teleop = "ros2 run turtlesim turtle_teleop_key"
topics = "ros2 topic list"
dance = "ros2 run turtle_dancer dance"

pixi run ros2 run turtle_dancer dance works as well, from a fresh terminal, with nothing sourced. The node is a package in the environment now, the same as turtlesim.

2.5 Live in the edit-run loop

In Exercise 1 a change to the node meant edit, pixi run build, run, with the sourcing hidden in an activation script. See what it means now.

Your turn

  1. Open src/turtle_dancer/src/dance.cpp and change the default angular speed from 0.8 to 3.0.
  2. Run the node again, with the simulator still open.
Solution
src/turtle_dancer/src/dance.cpp
angular_speed_ = declare_parameter("angular_speed", 3.0);
# 2
pixi run dance      # the build output scrolls by, then the turtle turns in tight circles

pixi run checks the inputs of every source dependency before it runs anything: package.xml, CMakeLists.txt, the source files. One of them changed, so the package was rebuilt and reinstalled first. Change it back to 0.8 and run again: same thing, the other way.

What counts as an input

The backend watches C and C++ sources, package.xml, CMakeLists.txt, setup.py, launch files, messages and a few more by default. Anything else goes in extra-input-globs under [package.build.config] in the package manifest.

2.6 Add the Python package

src/turtle_choreographer/ is a second node, in Python this time, an ament_python package provided pre-written. It gets the same treatment: a package manifest, a path dependency, a task.

Your turn

  1. Create src/turtle_choreographer/pixi.toml: the same two lines as the C++ one.
  2. Add ros-lyrical-turtle-choreographer as a path dependency.
  3. Add a choreograph task that runs ros2 run turtle_choreographer choreograph.
  4. Run it, with the simulator open.
Solution
exercises/02-ros-package/src/turtle_choreographer/pixi.toml
[package.build.backend]
name = "pixi-build-ros"
workspace = true
exercises/02-ros-package/pixi.toml
[dependencies]
# Our own packages, built from source. The names are the package.xml names with
# the ROS distro prefix that RoboStack uses, hyphens instead of underscores.
ros-lyrical-turtle-dancer = { path = "src/turtle_dancer" }
ros-lyrical-turtle-choreographer = { path = "src/turtle_choreographer" }
# What we need to run them. The libraries the packages themselves need come
# from their package.xml.
ros-lyrical-ros-base = ">=0.11"
ros-lyrical-turtlesim = ">=1.8"
# 3
pixi task add choreograph "ros2 run turtle_choreographer choreograph"
# 4
pixi run choreograph     # the turtle draws a figure of eight

One workspace, two languages, one lockfile.

setup.cfg is what makes ros2 run find a Python node

Have a look at src/turtle_choreographer/setup.cfg: install_scripts=$base/lib/turtle_choreographer. That puts the entry point in lib/<package>/, where ros2 run looks, instead of bin/. ros2 pkg create --build-type ament_python generates this file for you. Hand-written packages sometimes lack it; the backend then fills in the same default, but it is better to have the file.

2.7 Publish the packages locally

So far the packages are source dependencies: every workspace that wants them needs the source tree. Publishing turns the build result into normal .conda packages. For the workshop we publish to a local folder, not to Prefix.dev, so you can see exactly what would be uploaded.

Pixi only publishes packages that opt in, to avoid accidentally publishing every package it finds in a workspace. That opt-in lives in each package manifest.

Your turn

  1. Add publish = true to the [package] table in both package manifests: src/turtle_dancer/pixi.toml and src/turtle_choreographer/pixi.toml.
  2. Publish the workspace packages to a local folder.
  3. Inspect the packages Pixi wrote.
Solution
src/turtle_dancer/pixi.toml
[package]
publish = true

[package.build.backend]
name = "pixi-build-ros"
workspace = true
src/turtle_choreographer/pixi.toml
[package]
publish = true

[package.build.backend]
name = "pixi-build-ros"
workspace = true
# 2. build both opted-in packages and publish them to a local channel
pixi publish --target-channel output

# 3. inspect what was created
find output -name "*.conda" -print

You should see one package for each node, under a platform subdirectory such as linux-64/, osx-arm64/ or win-64/:

output/<platform>/ros-lyrical-turtle-dancer-0.1.0-<build>.conda
output/<platform>/ros-lyrical-turtle-choreographer-0.1.0-<build>.conda

That output/ directory is a real local conda channel, including the channel metadata Pixi needs. Another workspace could install from it with a file channel. By using ./output as the channel URL, you can install from the local channel without publishing to Prefix.dev.

The same command can publish to a Prefix.dev channel too:

pixi auth login prefix.dev
pixi publish --target-channel https://prefix.dev/<your-channel>

You need to be logged in, and you need permission to publish to that channel.

The full pixi.toml

solutions/02-ros-package/pixi.toml
# Solution to Exercise 2: Build ROS packages with Pixi.
#
# The same C++ package Exercise 1 built with colcon, plus a Python one, now both
# built and installed by Pixi straight from their package.xml:
#
#     pixi run sim            # in one terminal
#     pixi run dance          # in another, C++
#     pixi run choreograph    # ...or Python
#
# Compare with Exercise 1: no `colcon build`, no `source install/setup.bash`, no
# build/ install/ log/ directories, no activation scripts. Edit a source file
# and `pixi run` again: Pixi rebuilds what changed before it runs.

[workspace]
channels = ["https://prefix.dev/robostack-lyrical", "conda-forge"]
platforms = ["linux-64", { platform = "osx-arm64", macos = "14.0" }, "win-64"]
# Building packages from source is a preview feature, so it is opt-in.
preview = ["pixi-build"]

# Share the same backend version for all packages in this workspace.
[workspace.dependencies]
pixi-build-ros = ">=0.7.2"

[dependencies]
# Our own packages, built from source. The names are the package.xml names with
# the ROS distro prefix that RoboStack uses, hyphens instead of underscores.
ros-lyrical-turtle-dancer = { path = "src/turtle_dancer" }
ros-lyrical-turtle-choreographer = { path = "src/turtle_choreographer" }
# What we need to run them. The libraries the packages themselves need come
# from their package.xml.
ros-lyrical-ros-base = ">=0.11"
ros-lyrical-turtlesim = ">=1.8"

[tasks.sim]
cmd = "ros2 run turtlesim turtlesim_node"
description = "Start the turtlesim window"

[tasks.teleop]
cmd = "ros2 run turtlesim turtle_teleop_key"
description = "Drive the turtle with the arrow keys"

[tasks.topics]
cmd = "ros2 topic list"
description = "List the ROS topics"

[tasks.dance]
cmd = "ros2 run turtle_dancer dance"
description = "Run the C++ node; Pixi builds it first if the source changed"

[tasks.choreograph]
cmd = "ros2 run turtle_choreographer choreograph"
description = "Run the Python node; same story"

[tasks.executables]
cmd = "ros2 pkg executables turtle_dancer && ros2 pkg executables turtle_choreographer"
description = "Show that the ROS tooling sees our nodes, exactly as after a colcon build"

[tasks.test]
cmd = "python -c \"import turtle_choreographer.choreograph as c; assert c.main; print('packages: ok')\""
description = "Prove both packages were built and installed: what CI runs"
solutions/02-ros-package/src/turtle_dancer/pixi.toml
# The package manifest: how to build this one package. There is no [workspace]
# table here on purpose, that role belongs to the pixi.toml two levels up.
#
# Name, version, description, maintainer and dependencies all come out of
# package.xml, which stays the single source of truth. CMakeLists.txt is
# untouched too: the backend runs the ament_cmake build you already have.
[package]
publish = true

[package.build.backend]
name = "pixi-build-ros"
workspace = true
solutions/02-ros-package/src/turtle_choreographer/pixi.toml
# The package manifest for the Python package. Same backend, same shape as the
# C++ one: the build type (ament_python) is read from package.xml, and setup.py
# and setup.cfg are used as they are.

[package]
publish = true

[package.build.backend]
name = "pixi-build-ros"
workspace = true

Check your work

pixi run dance          # C++ node, no sourcing
pixi run choreograph    # Python node
pixi run executables    # ros2 sees both nodes, exactly as after a colcon build
ls                      # src/, pixi.toml and output/, no build/ install/ log/

executables is one more task to add: ros2 pkg executables turtle_dancer && ros2 pkg executables turtle_choreographer. It should print:

turtle_dancer dance
turtle_choreographer choreograph

Going further

Finished early? Try these.

  • Compare what you started with against what you have: git diff --stat exercises/02-ros-package/pixi.toml.
  • Install one of your locally published packages from output/ in a fresh scratch workspace. That is the same kind of channel RoboStack serves, just on your disk.
  • Trim the workspace: replace ros-lyrical-ros-base with ros-lyrical-ros2run. Everything the nodes need comes through their package.xml now, so pixi list gets a lot shorter.
  • Add a third package with a custom .msg, and use it from both nodes. Interface generation works through this backend.

With your own workspace

Pick one leaf package from your repository, one that does not depend on your other packages. Put the two-table pixi.toml from 2.2 next to its package.xml, add the path dependency, pixi install. Expect it to fail the first time, and read the error: usually a package.xml dependency that is not on RoboStack under that name, which extra-package-mappings in the backend config fixes, or one that is not packaged at all. This is the ten minutes of the workshop we most want to spend with you, so raise a hand.


Next: Collaboration, CI/CD & Docker.