roschema
Catch breaking ROS 2 interface changes in the PR, not on the robot.
roschema detects breaking changes, lints and formats ROS 2 interface
definitions (.msg, .srv, .action) with zero ROS dependencies, so it runs
in a bare CI container.
Quickstart
Test it without installing (expects uvx is present):
# in a pull request, compare the working tree against the base branch
uvx roschema check --against origin/main
See the full rule reference for every check (RS***) and lint
(RSL***) rule.
Prefer a runnable example? The roschema-demo
repo has a robot_msgs package with a passing and a failing branch to compare,
plus a ready-to-copy GitHub Action workflow.
Installation
Requires Python 3.10+ (ROS installation not necessary).
pip install roschema # or: uv tool install roschema
pip install 'roschema[bag]' # optional: MCAP bag support (for bag-check)
Or run it without installing anything using uvx:
uvx roschema check --against origin/main
Using roschema as a GitHub Action needs no installation or setup - the action provisions it on the runner for you.
Common usage
# breaking-change gate in a PR (compare against the base branch)
roschema check --against origin/main
# check before pushing, against the last commit
roschema check --against HEAD
# scope to one package, or a subtree with a glob (and exclude vendored packages)
roschema check src/my_msgs --against origin/main
roschema check 'src/**' --against origin/main --exclude 'vendor_*'
# only fail on wire-level breaks (loosest gate)
roschema check --against origin/main --level wire
# machine-readable output for tooling
roschema check --against origin/main --format json
# style/naming check, and a formatting gate for CI
roschema lint
roschema fmt --check
# pin a release baseline, then check against it later (no git needed)
roschema freeze -o roschema.lock
roschema check --against roschema.lock
# is this recorded bag still compatible with the installed interfaces?
roschema bag-check recording.mcap
Checking
roschema check [TARGET] --against REF [--level LEVEL] [--format text|json|github]
[--deep] [--exit-zero] [--config PATH] [--exclude GLOB]
TARGET is a path, a package name, or a glob ('src/**') that expands to every
package underneath; omit it for the current workspace.
--against accepts a git ref (validated with git rev-parse), a directory, an
MCAP bag (.mcap) or a lockfile (roschema.lock); the type is auto-detected. A
git ref is preferred when a directory of the same name also exists -- prefix the
argument with ./ (e.g. --against ./main) to force the directory. Exit codes:
0 clean, 1 breaking changes found, 2 tool/usage error. --exit-zero forces
0. Warnings alone do not fail (exit 0).
CDR is positional -- fields are serialised in declaration order with no field
tags -- so adding, removing, reordering or retyping a field is wire-breaking
even when your code still compiles. That is why the taxonomy is structural, and
why the default source level already reports wire-level changes.
Freeze (non-git baselines)
roschema freeze [TARGET] -o roschema.lock
roschema check [TARGET] --against roschema.lock
freeze writes a lockfile holding each interface's canonical (post-fmt) text
plus a sha256, so a release tarball can carry its own baseline without git.
check --against roschema.lock reads it back (sha256-verified) and compares.
The lockfile records the roschema tool_version for provenance; upgrading
roschema therefore changes that one line on the next freeze even when the
interfaces are identical. It is not read during comparison -- check diffs only
the interface text -- so the churn is cosmetic.
Deep checking
check --deep resolves nested complex types through the workspace and ament
index and reports transitive breakage (RS110): an interface that embeds -
directly or through nested types - another whose wire layout changed is flagged
even when its own definition is unchanged, with the causal chain shown. Without
--deep, nested types are compared by name only.
Linting and formatting
roschema lint [TARGET] [--format text|json|github] [--rules RULE=SEV] [--exit-zero]
roschema fmt [TARGET] [--check]
lint checks a single tree (no baseline). Naming rules follow rosidl: field
names snake_case (RSL001), constants UPPER_SNAKE (RSL002), interface names
PascalCase (RSL003); it also flags deprecated char/scalar-byte (RSL005) and
request/response field shadowing in services (RSL010). RSL004 (missing doc) and
RSL006 (unbounded) are off by default; enable via [lint.rules] or per run with
--rules (e.g. --rules RSL004=warn --rules RSL001=off; a bare --rules RSL004
means =warn). --rules is repeatable and beats config.
fmt canonically formats interface files (single space between type and name,
= without spaces for constants, one trailing newline) while preserving
comments, blank lines and section separators. It is idempotent and never
changes what check reports; --check exits 1 if any file would change.
Inline # roschema: ignore= suppressions survive formatting.
Checking against a recorded bag
pip install 'roschema[bag]'
roschema bag-check recording.mcap [TARGETS...] [--format ...]
bag-check reads the interface definitions embedded in an MCAP bag and checks
whether they are still compatible with your current environment - the bag is
the baseline, so it flags changes that would stop current code from reading the
recorded data. By default the bag's types are resolved from the ament index
(AMENT_PREFIX_PATH), i.e. the packages installed in the sourced environment,
so system packages like sensor_msgs are compared against their installed
definitions rather than reported as missing.
Optional TARGETS (paths or package names) are overlaid on top of the
environment, so a package you are editing can be checked from source instead of
its installed copy. A bag type found in neither the environment nor any target
is reported as removed (RS040). ros2msg schemas (including concatenated
dependent definitions) are fully supported; ros2idl is a best-effort
conversion of the msg-equivalent subset.
Compatibility levels
--level selects a cumulative bundle of what is reported; the default is
source.
| Level | Reports |
|---|---|
wire |
CDR byte-stream compatibility only |
source |
wire + generated C++/Python API |
introspection |
source + name-based tooling (bags, echo, YAML) |
behavior |
introspection + semantics (defaults, constant values) |
Configuration
roschema.toml at the workspace root, a per-package roschema.toml, or
[tool.roschema] in pyproject.toml. Precedence: CLI > package > workspace >
defaults.
[check]
level = "source"
[check.rules]
RS008 = "off" # off | warn | error
[check.allow]
# intentional breaks need a fully-qualified target and a mandatory reason
"my_msgs/msg/State.old_field" = { rules = ["RS001"], reason = "removed in 2.0, see #142" }
Inline suppression: a trailing comment on the new-side member line, e.g.
float64 theta # roschema: ignore=RS003 (comma-separate multiple IDs).
Suppressed findings are counted, not failed.
JSON output
--format json emits a stable document (version: 1):
{
"version": 1,
"findings": [
{
"rule": "RS003",
"severity": "error",
"level": "wire",
"package": "my_msgs",
"interface": "my_msgs/msg/Pose",
"section": "message",
"target": "theta",
"message": "type changed float32 -> float64",
"old": { "file": "my_msgs/msg/Pose.msg", "line": 1 },
"new": { "file": "my_msgs/msg/Pose.msg", "line": 1 },
"suppressed": false
}
],
"summary": {
"total": 1,
"errors": 1,
"warnings": 0,
"suppressed": 0,
"advisories": { "my_msgs": "MAJOR" }
}
}
old/new are null when there is no corresponding side (e.g. a removed
interface has no new). --format github emits ::error/::warning
workflow-command annotations instead.
GitHub Action
A composite action wraps uvx roschema check and posts inline annotations on
the PR diff:
# .github/workflows/interfaces.yml
on: pull_request
jobs:
interfaces:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # needed for the git-ref baseline
- uses: justagist/roschema/action@v1
with:
against: origin/${{ github.base_ref }}
# level: source
# deep: "true"
# exclude: "vendor_msgs* legacy_msgs/msg/Old"
against takes the same values as the CLI (git ref, directory, .mcap,
roschema.lock). A lockfile or directory baseline needs no fetch-depth: 0
because it is a plain file in the checkout.
Rule reference
Every rule, its levels, default severity and rationale is documented in
docs/rules.md, generated from the rule metadata with
python -m roschema.rules.gendocs.
Non-goals
roschema deliberately does not:
- generate migration or translation rules;
- parse ROS 1
.msg(md5 semantics differ); - parse hand-written
.idlbeyond the MCAP-embedded subset; - provide any registry or server component;
- parse
CMakeLists.txtfor discovery (directory convention + ament index only).
Relationship to REP-2011 and type hashes
These are complementary layers, not alternatives:
- roschema prevents accidental breaks at PR time, before anything ships.
- RIHS type hashes (REP-2011) detect a pub/sub mismatch at runtime -- after the fact, on the robot.
- REP-2011 type-description distribution / translation mitigates intentional evolution by carrying enough information to translate between versions.
roschema is the earliest gate: catch the break in review so the later layers never have to.
Metadata
Release files for roschema 0.1.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| roschema-0.1.1.tar.gz | 67.4 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| roschema-0.1.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 136.5 kB
Release files / roschema-0.1.1.tar.gz
| Download URL | roschema-0.1.1.tar.gz |
|---|---|
| Size | 67.4 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
0c8712b759b6de35b6558f9f09a4dc740a5a4f71802cf383e9a961bee9ef5f11
|
|
BLAKE2b-256 checksum How to use checksums |
f0c2129a7ef7b8121546c7f442b44d9c32ef23d17b6616c6788824f05c32c9c4
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Jul 25, 2026.
Transparency logRelease files / roschema-0.1.1-py3-none-any.whl
| Download URL | roschema-0.1.1-py3-none-any.whl |
|---|---|
| Size | 69.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
a46228bac38cee73c97019906e29363ff2fb52d7c0e1e0f9541aaae3aa36aa39
|
|
BLAKE2b-256 checksum How to use checksums |
5655830c16fb34bf24d9c3ef4ad3b43312a94e1eab164c86f15a3887932c5214
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Jul 25, 2026.
Transparency log