Skip to main content

roschema

CI PyPI Python versions License: Apache-2.0 Ruff Checked with mypy

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.

roschema flagging a breaking interface change

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 .idl beyond the MCAP-embedded subset;
  • provide any registry or server component;
  • parse CMakeLists.txt for 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

roschema-0.1.1.tar.gz (67.4 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

roschema-0.1.1-py3-none-any.whl (69.1 kB view details)

Uploaded Python 3

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

Hashes for roschema-0.1.1.tar.gz
Algorithm Hash digest
SHA256 0c8712b759b6de35b6558f9f09a4dc740a5a4f71802cf383e9a961bee9ef5f11
MD5 45d8ba87a03e027ec5237bd2aed324c7
BLAKE2b-256 f0c2129a7ef7b8121546c7f442b44d9c32ef23d17b6616c6788824f05c32c9c4

See more details on using hashes here.

Provenance

The following attestation bundles were made for roschema-0.1.1.tar.gz:

Publisher: release.yml on justagist/roschema

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

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

Hashes for roschema-0.1.1-py3-none-any.whl
Algorithm Hash digest
SHA256 a46228bac38cee73c97019906e29363ff2fb52d7c0e1e0f9541aaae3aa36aa39
MD5 af60a98bff7a10c85b3ffe3a9c0578ed
BLAKE2b-256 5655830c16fb34bf24d9c3ef4ad3b43312a94e1eab164c86f15a3887932c5214

See more details on using hashes here.

Provenance

The following attestation bundles were made for roschema-0.1.1-py3-none-any.whl:

Publisher: release.yml on justagist/roschema

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

This release

0.1.1 This release

2 files

0.1.0

2 files

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page