Tutorial
Managing Dependencies
Airfield packages list dependencies by name in airfield.yaml. Each name
resolves in one of two ways: a matching manifest in
dependencies/<target-device>/ (or dependencies/xplatform/ for
architecture-independent recipes), found locally or in the shared Airfield
packages repository; or, when no manifest exists, a peer package under the
project’s packages/ folder, whose source is mounted and built from source
alongside the dependent package. This page is about the manifest path;
peer packages are covered in Create a Package.
Use local dependency manifests while developing a package, then contribute them to the shared repository when they’re ready for other packages to reuse.
Why a shared repository?
The shared Airfield repository (github.com/airfield/packages) does not hold ROS source code. Your packages stay in their own repositories. What it holds is the dependency manifests: the small recipes that describe how to install each dependency inside a container. Think of it as a package manager’s index, closer to a Homebrew tap or an apt sources list than to a source monorepo. It is a shared cookbook of “how to install dependency X in a ROS container.”
Centralizing the recipes instead of the code has practical benefits:
- The first person to work out the exact apt/pip commands and version pins for a tricky driver writes its manifest once. Every other package lists that dependency by name and inherits the working recipe, so nobody re-derives install commands across projects.
- When a dependency’s install changes (a new apt repo, a renamed package),
you fix the manifest in one place, and everyone picks up the change on
their next
dependencies pullinstead of chasing down stale copies. - A dependency can carry separate
x86_64andarm64manifests, or a single sharedxplatformrecipe, so the “installs differently on the Jetson than on a laptop” case lives in the shared repo rather than in every consumer. - You keep working locally without waiting on anyone: hold a manifest locally while developing and promote it upstream only when it is ready, using the workflow below.
1. Add a local manifest
For a project package, place manifests at the project root:
my_robot/
dependencies/
x86_64/
my_driver.yaml
For a standalone package, place manifests at the package root:
nav_stack/
dependencies/
x86_64/
my_driver.yaml
Example manifest:
name: my_driver
version: 1.0.0
ros_versions:
- jazzy
system:
- apt-get update && apt-get install -y libmy-driver-dev && rm -rf /var/lib/apt/lists/*
Then reference it from the package:
dependencies:
- my_driver
A pip-based manifest declares requirements under pip: — package names, not
commands:
name: tqdm
version: 1.0.0
pip:
- tqdm
Names rather than commands matters more than it looks: see
Why pip: is a list of names
below for what that buys you and when you still need a raw command.
2. Check for shared-repository conflicts
Run the check from the package or project directory:
airfield package dependencies check . --target-device x86_64
Or pass the directory explicitly:
airfield package dependencies check ./packages/nav_stack --target-device x86_64
Airfield prints the local dependency root, the shared repository root, and any manifest names that already exist upstream. If a conflict is reported, rename the local manifest or use the existing shared dependency.
3. Copy manifests into the shared package repository
airfield package dependencies upstream . --target-device x86_64
Airfield asks before copying local manifest files into the shared packages
repository. After copying, create a feature branch, commit, push, and open a
pull request in https://github.com/airfield/packages.
4. Repeat for each target device
Dependency manifests are target-specific. If the dependency needs different install commands per architecture, check and upstream each target:
airfield package dependencies check . --target-device x86_64
airfield package dependencies upstream . --target-device x86_64
airfield package dependencies check . --target-device arm64
airfield package dependencies upstream . --target-device arm64
If the same install commands work on both architectures, place a single
manifest in dependencies/xplatform/ instead; it resolves for every target
device.
5. Pull the latest shared manifests
To update your machine’s copy of the shared packages repository:
airfield package dependencies pull
Why pip: is a list of names, not commands
Every pip: entry across all of a package’s dependencies is collected into a
single pip install. That is the whole point of the field.
Run separately, pip solves each install in isolation and has no idea the others
are coming. A later install will happily uninstall a version an earlier one
needs, print ERROR: about it, and still exit 0 — so the image builds
green and fails at runtime instead:
pip install opencv-python-headless # pulls numpy 2.x
pip install "some-package" # needs numpy <2, rips out 2.x
ERROR: opencv ... requires numpy>=2, but you have 1.26.4
Successfully installed ... # exit 0. Build passes.
Handed to pip together, the resolver searches for a set that satisfies everyone. Usually it finds one and you never hear about it; when no such set exists it fails the build with the actual conflict named. You do not pin versions to get this — declaring the plain name is what makes it work.
Airfield does not need a version from you, but it accepts one when you have a real constraint:
pip:
- numpy>=1.24
- flask
When you still need a raw command
pip: only covers what pip can express as a requirement. Anything else — a
custom index, an install that branches on GPU vs CPU — stays a command under
user: or system:. Put such a pip command in user:: those run after the
batched install, so a deliberate choice gets the last word over the generic
resolve. (system: commands run earlier, as root, before the batch.) On Ubuntu
24.04 base images (ROS jazzy and newer) pip refuses to install into the system
Python unless told otherwise, so hand-written pip commands need
--break-system-packages:
user:
- python3 -m pip install --break-system-packages torch --index-url https://download.pytorch.org/whl/cu121
The tradeoff is that these installs sit outside the shared resolve, which is what the build-time check below is for.
The build-time consistency check
Batching protects what went through the batch. Three things sit outside it and
can still collide in the one Python environment the container has: packages
installed by apt manifests, manifests running their own pip command, and
whatever the base image shipped.
So every build runs pip check twice — once before any dependency installs to
record what the base image already had broken, once at the end. Only newly
broken packages fail the build. A conflict that arrived with a vendor base
image is not yours to fix, so Airfield does not blame you for it.
[airfield] Dependency conflict introduced by this package's installs:
requests 2.25.1 has requirement urllib3<1.27, but you have urllib3 2.7.0.
Control it with AIRFIELD_PIP_CHECK:
| Value | Behavior |
|---|---|
| unset (default) | Fail the build on a newly introduced conflict |
warn | Print the conflict, build anyway |
off | Skip the check entirely |
Manifest fields
namemust match the YAML filename without.yaml.versionversions the dependency manifest.ros_versionsis optional and limits the manifest to specific ROS distributions.piplists Python requirements by name (not commands). Entries from every dependency are batched into onepip installso the resolver solves them together; prefer this over a hand-written pip command.systemcommands run as root during image build, before the batchedpip install.usercommands run as the package user, after it.host_dependenciescan declare host-side requirements such as GPU drivers.
Shared package definitions
Besides dependency manifests, the shared repository can hold whole package
definitions: a manifest that declares kind: package and describes a
complete Airfield package, optionally with a source: {url, ref} pointing at
a git repository. These are for tools a project runs as-is but does not
develop, such as a visualization bridge or a fiducial detector.
When a command or plan pane names a package that does not exist under
packages/ but has a shared definition, Airfield materializes it: it clones
the declared source (or scaffolds an empty source folder for config-only
tools), writes the definition as the package’s airfield.yaml, and adds the
directory to the project’s .gitignore. From then on it behaves like any
local package.
The materialized directory is disposable by design. It is reproducible from the shared definition, so a fresh checkout of the project regains it on first use, and deleting the directory forces a re-materialization with the latest definition. Packages a team actively develops belong in the project itself, not in the shared repository.