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.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file roschema-0.1.1.tar.gz.
File metadata
- Download URL: roschema-0.1.1.tar.gz
- Upload date:
- Size: 67.4 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
0c8712b759b6de35b6558f9f09a4dc740a5a4f71802cf383e9a961bee9ef5f11
|
|
| MD5 |
45d8ba87a03e027ec5237bd2aed324c7
|
|
| BLAKE2b-256 |
f0c2129a7ef7b8121546c7f442b44d9c32ef23d17b6616c6788824f05c32c9c4
|
Provenance
The following attestation bundles were made for roschema-0.1.1.tar.gz:
Publisher:
release.yml on justagist/roschema
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
roschema-0.1.1.tar.gz -
Subject digest:
0c8712b759b6de35b6558f9f09a4dc740a5a4f71802cf383e9a961bee9ef5f11 - Sigstore transparency entry: 2245546836
- Sigstore integration time:
-
Permalink:
justagist/roschema@0cff098e95ed5aaa5cca0ba81f576ca1f900828d -
Branch / Tag:
refs/tags/v0.1.1 - Owner: https://github.com/justagist
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@0cff098e95ed5aaa5cca0ba81f576ca1f900828d -
Trigger Event:
push
-
Statement type:
File details
Details for the file roschema-0.1.1-py3-none-any.whl.
File metadata
- Download URL: roschema-0.1.1-py3-none-any.whl
- Upload date:
- Size: 69.1 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
a46228bac38cee73c97019906e29363ff2fb52d7c0e1e0f9541aaae3aa36aa39
|
|
| MD5 |
af60a98bff7a10c85b3ffe3a9c0578ed
|
|
| BLAKE2b-256 |
5655830c16fb34bf24d9c3ef4ad3b43312a94e1eab164c86f15a3887932c5214
|
Provenance
The following attestation bundles were made for roschema-0.1.1-py3-none-any.whl:
Publisher:
release.yml on justagist/roschema
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
roschema-0.1.1-py3-none-any.whl -
Subject digest:
a46228bac38cee73c97019906e29363ff2fb52d7c0e1e0f9541aaae3aa36aa39 - Sigstore transparency entry: 2245547033
- Sigstore integration time:
-
Permalink:
justagist/roschema@0cff098e95ed5aaa5cca0ba81f576ca1f900828d -
Branch / Tag:
refs/tags/v0.1.1 - Owner: https://github.com/justagist
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@0cff098e95ed5aaa5cca0ba81f576ca1f900828d -
Trigger Event:
push
-
Statement type: