Using CI¶
How to run AirStack CI on your pull request and read what comes back: what runs
automatically, how to select simulation campaigns with /pytest, and where the
results land. For how the pipeline itself works — the orchestrator, ephemeral
OSMO pods, caching, and security — see CI/CD Pipeline on OSMO.
Triggering a run¶
The three entry points¶
| Trigger | When it fires | What it runs |
|---|---|---|
unit-tests.yml pull request |
PR to main/develop opened, synchronized, or reopened (including forks) |
pytest tests/ -m unit on ubuntu-latest |
system-tests.yml pull request |
PR opened, synchronized, or reopened, same-repo branches only | -m build_packages on an OSMO worker |
/pytest PR comment |
Any time, from a user with OWNER/MEMBER/COLLABORATOR association |
Whatever args you put on the first line of the comment |
workflow_dispatch |
Manual, from the Actions tab | The form inputs: marks, sim, num_robots, stress_iterations, stable_duration, baseline_run_id |
PR pushes re-run the fast unit gate and the pull-only build_packages gate.
GPU-intensive simulations do not run automatically; select the campaign
whose policy or integration changed with /pytest.
Comment syntax¶
The first line is parsed with shlex; everything after it is free-form notes.
/pytest -m liveliness --sim msairsim --num-robots 1 --stress-iterations 1
Checking whether the DDS bridge fix holds under 3 robots — see thread above.
-m build_packages is pull-only: it retags floating cache_* images onto the PR VERSION tag and never runs images build (and does not pull Isaac Sim). Use that when iterating on colcon/pytest failures. For other marks, add --no-image-build to skip the bake:
The workflow replies on the thread with the exact pytest command it resolved
and a link to the run, and opens a Check Run pinned to the PR head SHA so
comment-triggered runs still show up in the PR's Checks tab.
build_packages is prepended for you
Whenever you pass -m, the workflow rewrites the expression to
build_packages or <your marks>. Launch tests are useless against a stale
install/ tree, and this removes the most common way to waste a 40-minute
GPU run. It is skipped when you already named build_packages, and when you
pass no marks at all (pytest then runs everything anyway).
What the pipeline tests, and what that catches¶
Tests are selected with pytest marks. Collection order is fixed in
tests/conftest.py so cheap and prerequisite suites always run first — a
colcon break fails in minutes instead of after a sim bring-up.
flowchart LR
u["unit<br/>seconds, no Docker"] --> bd["build_docker<br/>image builds"]
bd --> bp["build_packages<br/>colcon build in containers"]
bp --> lv["liveliness<br/>stack comes up"]
lv --> sn["sensors<br/>streams flow at rate"]
sn --> th["takeoff_hover_land<br/>flight chain"]
th --> au["autonomy<br/>trajectory tracking"]
The table below focuses on what each suite catches; the authoritative mark
list and per-mark reference live in
tests/README.md.
| Mark | Module | What it verifies | Bugs it is good at catching |
|---|---|---|---|
unit |
<pkg>/test/ (co-located) |
Hermetic Python/numpy logic co-located with each ROS 2 package | Off-by-one and boundary errors in filters, converters, validators; regressions in pure algorithm code |
build_docker |
system/test_build_docker.py |
Every image builds; records image sizes | Broken Dockerfiles, deleted apt packages, upstream base-image drift, accidental image bloat |
build_packages |
system/test_build_packages.py |
colcon build inside robot, GCS, and ms-airsim workspaces |
Missing package.xml dependencies, uninstalled launch/config files, C++ breakage on a clean tree |
liveliness |
system/test_liveliness.py |
Containers reach Running, /clock publishes, tmux panes alive, sentinel ROS 2 nodes present, compute snapshot, stability poll |
Launch files that crash on start, nodes that die after 30 s, ROBOT_NAME/domain-ID misconfiguration, runaway CPU or memory |
wiring |
system/test_wiring_snapshot.py |
Observed wiring snapshot of the running ROS graph, drift-checked against the stack's committed stacks/<name>/wiring.md |
Topic remaps that silently disconnect, nodes publishing into the void, launch-file edits that change the graph without updating wiring.md |
sensors |
system/test_sensors.py |
Stereo and depth publish rates on both sim and robot side, filtered LiDAR liveness plus geometry sanity, sim real-time factor, time-series stability | Broken sim-to-ROS bridges, sensor Hz that silently halves, RTF collapse from a heavy new node, LiDAR filter range regressions |
takeoff_hover_land |
system/test_takeoff_hover_land.py |
Four-phase chain per (sim, robots, iteration, velocity): PX4 ready → takeoff to 10 m → hover → land | Controller tuning regressions, altitude overshoot, hover drift, state-estimation bias against ground truth, PX4/MAVROS handshake breakage |
autonomy |
system/test_fixed_trajectory.py |
Same chain with a Circle / Figure8 / Racetrack / Line pattern in the middle; records cross-track error and path RMSE | Path-tracker regressions, trajectory-library math errors, velocity/acceleration limit violations that show up as corner-cutting |
waypoint_flight |
system/test_waypoint_flight.py |
Same chain with an ordered NavigateTask waypoint route in the middle; the odometry track is judged by the standalone waypoint_checker.py (in-order corridor arrival, final-goal tolerance, per-waypoint timeout) |
Waypoint sequencing regressions, behavior-tree/navigation task breakage, routes that skip or stall at a waypoint |
The flight chain¶
Both flight suites run as an ordered chain per parametrization, so the drone always ends on the ground before the next configuration starts:
flowchart LR
r["test_px4_ready<br/>MAVROS + EKF"] --> t["test_takeoff<br/>within 10% of 10 m"]
t --> x["test_hover or test_fixed_trajectory"]
x --> l["test_landing<br/>final altitude < 0.5 m"]
r -. "failure" .-> s["remaining phases skipped"]
t -. "failure" .-> s
x -. "failure still lands" .-> l
A failure in the middle phase (test_hover or test_fixed_trajectory) does
not skip landing — a bad tracker must not leave a drone stuck in the air
blocking the rest of the sweep. A failure in test_px4_ready or test_takeoff
does skip the remaining phases for that configuration.
Bring-up scope, and why mark selection costs money¶
airstack_env is class-scoped and parametrized over
(sim, num_robots, iteration). Each test class does its own airstack up and
airstack down. Selecting two suites with or therefore performs two full
stack cycles per tuple:
-m liveliness → 1 bring-up per (sim, robots, iter)
-m "liveliness or sensors" → 2 bring-ups per (sim, robots, iter)
--sim msairsim → opt in; both sims doubles all of the above
--num-robots 1,3 → doubles it again
Run one mark at a time unless you genuinely need both.
Reading the results¶
The PR comment¶
After run-tests finishes — pass or fail — a report job on ubuntu-latest
downloads the current artifact plus a baseline artifact and runs
parse_metrics.py
in diff mode only when both artifacts have the same complete simulation
campaign fingerprint (selected tests and parameters).
| Run type | Baseline used |
|---|---|
PR opened or /pytest |
Latest system-tests.yml artifact on the PR's base branch |
workflow_dispatch with baseline_run_id |
That specific run |
workflow_dispatch without it |
Latest artifact on main |
For a complete simulation campaign, the comment has pass rates plus a flat
Metrics table, a Sim publishing rates pivot (topic Hz aggregates from
the sensors mark), and a Compute usage pivot (CPU / memory / GPU per
container). Regressions are marked with a red circle, improvements with a
green one. These numeric deltas are advisory: they inform review but do not
fail the PR.
run_meta.json separates those policy results from CI failures. A collection
error, zero-test selection, internal pytest error, cancellation, or timeout is
reported as simulation metrics are not comparable. Pass-rate and regression
tables are suppressed in that case; the infrastructure problem cannot appear as
a false 0% policy score. A policy assertion that runs and fails remains a real
simulation result and keeps its recorded error metrics.
The artifact¶
test-results-<sha>-<run_id>, retained 90 days:
tests/results/2026-08-06_14-30-00/
├── summary.txt # human-readable per-chain summary — open this first
├── results.xml # JUnit XML: durations, pass/fail per test
├── run_meta.json # schema-v2 completion/failure class + exact campaign config
├── metrics.json # every recorded metric, including time series
└── diagnostics/ # on failure: bounded config, panes, logs, ROS/GPU/command ring
There are no per-test log files. Live output streams to the Actions log via
pytest's log_cli, and failed assertions embed the tail of the relevant
docker or ros2 subprocess output directly in the failure message.
Regenerate a report locally from a downloaded artifact:
python tests/parse_metrics.py \
--current path/to/current-run/ \
--baseline path/to/baseline-run/ \
--threshold 20
Using CI well while developing¶
The pipeline is expensive at the far end and nearly free at the near end. Push each class of failure as far left as it will go.
flowchart TD
q{"What did you change?"}
q -- "Pure Python / numpy logic" --> u["airstack test -m unit<br/>seconds, no GPU"]
q -- "Dockerfile / dependency" --> b["airstack test -m build_docker or build_packages<br/>minutes, no GPU"]
q -- "Launch file / new node" --> l["airstack test -m liveliness --sim msairsim --num-robots 1"]
q -- "Sensor or bridge" --> s["airstack test -m sensors --sim isaacsim --num-robots 1"]
q -- "Controller / planner" --> a["airstack test -m autonomy --sim msairsim --trajectory-types Circle"]
u --> pr["Push branch, open PR"]
b --> pr
l --> pr
s --> pr
a --> pr
pr --> fast["unit-tests.yml on ubuntu-latest"]
pr --> ci["build_packages on an ephemeral OSMO pod"]
fast --> rep["Read automatic check results"]
ci --> rep
rep --> iter["/pytest with the relevant simulation mark"]
iter --> metrics["Read like-for-like policy metrics"]
Practical rules that follow from how the system is built:
- Reproduce CI locally with the same command.
airstack testand CI both callpytest tests/with the same flags. If a run fails in CI, copy the resolved command from the acknowledgment comment and run it on any GPU box — including an interactive OSMO dev pod if you do not have a local GPU. - Narrow before you re-run. A
/pytestwith no args re-runs everything./pytest -m autonomy --sim msairsim --trajectory-types Circlere-runs the one chain you are fixing, in a fraction of the time. - Never trust a green launch test against a stale build. This is why
build_packagesis auto-prepended; keep it that way when writing your own/pytestline. - Read
summary.txtbefore the raw log. It groups each flight chain with per-phase wall times and status, so the failing phase is obvious without scrolling a 40-minute log. - Treat a like-for-like metrics diff as a review artifact. The reporter compares only identical selected simulation campaigns; a PR that turns a metric red needs an explanation even when every assertion passed.
- Bump
VERSIONin.envwhen image content changes.check-version-increment.ymlgates the PR on a strictly-greater semver, and merging that bump is what triggers the release build.
Troubleshooting¶
The failures a CI user can act on from the GitHub side:
| Symptom | Layer | First thing to check |
|---|---|---|
/pytest comment produced no run |
Trigger guard | You need OWNER/MEMBER/COLLABORATOR association, and the PR must come from a same-repo branch — fork PRs are blocked from the GPU runners |
system-tests.yml never ran on a fork PR |
Trigger guard | Expected: the pull_request path only runs for same-repo branches; only the ubuntu-latest unit gate runs on forks |
| Runner registered, then pytest failed | Tests | A real test failure — the GitHub Actions log and summary.txt are canonical |
| Report says “simulation metrics are not comparable” | Collection/infrastructure | Read the run outcome and pytest exit status in run_meta.json; no policy regression was scored |
| Metrics report job failed with no test failures | Report | Report generation or artifact integrity failed; numeric metric deltas are advisory and do not cause this conclusion |
Job sits queued forever, no runner appears |
Orchestrator / pod | Not fixable from the PR — an admin needs to inspect the orchestrator and pod; see the pipeline troubleshooting table |
Orchestrator-, OSMO-, and pod-level failures (auth errors, dockerd did not
become ready, disk exhaustion, …) require orchestrator host access — work
through CI/CD Pipeline on OSMO → Troubleshooting.
See also¶
- CI/CD Pipeline on OSMO — architecture, job lifecycle, pod anatomy, cache strategy, security model.
- System Tests — marks, fixtures, metrics, and every CLI flag.
- CI/CD Orchestrator — admin setup, rotation, and break-glass procedures.