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:

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:

ValueBehavior
unset (default)Fail the build on a newly introduced conflict
warnPrint the conflict, build anyway
offSkip the check entirely

Manifest fields

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.