Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions .github/actions/setup-mavlink-tooling/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
name: Set up MAVLink generation tooling
description: >-
Check out mavlink/mavlink (upstream message definitions) and
ArduPilot/pymavlink (the generator) at pinned commits, install the
generator's dependencies, and place this repository's dialect files
beside the upstream definitions so includes resolve. The two pins below
are the single source of truth for every workflow in this repository:
they are a pair proven against each other (the pinned common.xml passes
the pinned generator's schema validation), so move both together and
only after the validation workflow passes on the new pair.

inputs:
python-version:
description: Python version used for the generator
required: false
default: "3.x"

runs:
using: composite
steps:
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: ${{ inputs.python-version }}

- name: Check out mavlink/mavlink (upstream message definitions)
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: mavlink/mavlink
ref: 92c81a5ff7533f01f4ebaa9fd8aebb5b24a69982 # master, 2026-07-29
path: mavlink
sparse-checkout: message_definitions/v1.0
persist-credentials: false

- name: Check out ArduPilot/pymavlink (generator)
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: ArduPilot/pymavlink
ref: 839c381792b03396bd48f3d5783f5e67eb72bc43 # master, 2026-07-31
path: pymavlink
persist-credentials: false

- name: Install generator dependencies
shell: bash
run: python3 -m pip install --quiet future lxml

- name: Place the dialect beside the upstream definitions
shell: bash
run: cp military.xml military_extensions.xml mavlink/message_definitions/v1.0/
29 changes: 29 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Keeps the SHA-pinned actions current. One grouped PR a month is the
# right noise floor for a definitions repository; the mavlink/pymavlink
# generator pins live in .github/actions/setup-mavlink-tooling/action.yml
# as checkout refs and move by hand, together, behind a green validation
# run.
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "monthly"
cooldown:
default-days: 7
groups:
github-actions:
patterns:
- "*"

# Composite actions are scanned per-directory, not via the workflows.
- package-ecosystem: "github-actions"
directory: "/.github/actions/setup-mavlink-tooling"
schedule:
interval: "monthly"
cooldown:
default-days: 7
groups:
github-actions:
patterns:
- "*"
174 changes: 174 additions & 0 deletions .github/scripts/check_dialect_policy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
#!/usr/bin/env python3
"""Check the dialect files against this repository's allocation policy.

The policy enforced here is the one the repository documents in README.md,
IDMAPPING.md and the military.xml header, not a new one:

- Upstream MAVLink (all.xml) reserves message IDs 53000-53999 for this
dialect.
- military.xml, the shared dialect, allocates messages and MAV_CMD entries
from the shared window 53000-53899 (53000-53099 active, 53100-53899
reserved for future shared growth).
- 53900-53999 is the private/downstream block: military.xml must never
allocate from it, and military_extensions.xml, the downstream template,
must allocate only from it.
- Message, enum and enum-entry names are UPPER_SNAKE_CASE and field names
are lower_snake_case, matching MAVLink conventions.
- No duplicate message IDs or names inside a file, and the template must
not collide with a shared message or enum name.

Structure beyond this policy (schema conformance, field types, duplicates
against common.xml) is owned by the schema check and by mavgen, which parse
the full include tree. This script runs on the Python standard library only
so it needs no checkout besides this repository.

Exit status: 0 when every check passes, 1 with one line per violation.
"""

from __future__ import annotations

import re
import sys
import xml.etree.ElementTree as ET
from pathlib import Path

REPO_ROOT = Path(__file__).resolve().parents[2]

DIALECT_RESERVATION = range(53000, 54000) # upstream all.xml reservation
SHARED_ALLOCATION = range(53000, 53900) # military.xml draws from here
PRIVATE_ALLOCATION = range(53900, 54000) # military_extensions.xml only

UPPER_SNAKE = re.compile(r"^[A-Z][A-Z0-9_]*$")
LOWER_SNAKE = re.compile(r"^[a-z][a-z0-9_]*$")


class Dialect:
"""The names and IDs one dialect file defines (includes not followed)."""

def __init__(self, path: Path):
self.path = path
root = ET.parse(path).getroot()
self.messages = [
(int(m.get("id")), m.get("name")) for m in root.findall("./messages/message")
]
self.enums = root.findall("./enums/enum")
self.commands = [
(int(e.get("value")), e.get("name"))
for enum in self.enums
if enum.get("name") == "MAV_CMD"
for e in enum.findall("entry")
]
self.fields = [
(m.get("name"), f.get("name"))
for m in root.findall("./messages/message")
for f in m.findall("field")
]


def check_allocation(d: Dialect, allocation: range, label: str) -> list[str]:
errors = []
for kind, entries in (("message", d.messages), ("MAV_CMD entry", d.commands)):
for value, name in entries:
if value not in DIALECT_RESERVATION:
errors.append(
f"{d.path.name}: {kind} {name} uses ID {value}, outside the "
f"53000-53999 block upstream all.xml reserves for this dialect"
)
elif value not in allocation:
errors.append(
f"{d.path.name}: {kind} {name} uses ID {value}, outside the "
f"{label} allocation {allocation.start}-{allocation.stop - 1}"
)
return errors


def check_duplicates(d: Dialect) -> list[str]:
errors = []
for kind, entries in (("message ID", [i for i, _ in d.messages]),
("message name", [n for _, n in d.messages]),
("MAV_CMD value", [v for v, _ in d.commands])):
seen: set = set()
for item in entries:
if item in seen:
errors.append(f"{d.path.name}: duplicate {kind} {item}")
seen.add(item)
return errors


def check_naming(d: Dialect) -> list[str]:
errors = []
for _, name in d.messages:
if not UPPER_SNAKE.match(name or ""):
errors.append(f"{d.path.name}: message name {name} is not UPPER_SNAKE_CASE")
for enum in d.enums:
enum_name = enum.get("name") or ""
if not UPPER_SNAKE.match(enum_name):
errors.append(f"{d.path.name}: enum name {enum_name} is not UPPER_SNAKE_CASE")
for entry in enum.findall("entry"):
entry_name = entry.get("name") or ""
if not UPPER_SNAKE.match(entry_name):
errors.append(
f"{d.path.name}: enum entry {entry_name} is not UPPER_SNAKE_CASE"
)
for message_name, field_name in d.fields:
if not LOWER_SNAKE.match(field_name or ""):
errors.append(
f"{d.path.name}: field {message_name}.{field_name} is not lower_snake_case"
)
return errors


def check_template_collisions(shared: Dialect, template: Dialect) -> list[str]:
errors = []
shared_ids = {i for i, _ in shared.messages}
shared_names = {n for _, n in shared.messages}
shared_enums = {e.get("name") for e in shared.enums} - {"MAV_CMD"}
for msg_id, name in template.messages:
if msg_id in shared_ids:
errors.append(
f"{template.path.name}: message {name} reuses shared dialect ID {msg_id}"
)
if name in shared_names:
errors.append(
f"{template.path.name}: message name {name} collides with the shared dialect"
)
for enum in template.enums:
if enum.get("name") in shared_enums:
errors.append(
f"{template.path.name}: enum {enum.get('name')} collides with the shared dialect"
)
return errors


def main(argv: list[str]) -> int:
root = Path(argv[1]) if len(argv) > 1 else REPO_ROOT
shared = Dialect(root / "military.xml")
template = Dialect(root / "military_extensions.xml")

errors = []
errors += check_allocation(shared, SHARED_ALLOCATION, "shared")
errors += check_allocation(template, PRIVATE_ALLOCATION, "private/downstream")
for dialect in (shared, template):
errors += check_duplicates(dialect)
errors += check_naming(dialect)
errors += check_template_collisions(shared, template)

for error in errors:
print(f"ERROR: {error}", file=sys.stderr)
if errors:
print(f"FAIL: {len(errors)} policy violation(s)", file=sys.stderr)
return 1

print(
f"PASS: {shared.path.name}: {len(shared.messages)} messages and "
f"{len(shared.commands)} MAV_CMD entries inside "
f"{SHARED_ALLOCATION.start}-{SHARED_ALLOCATION.stop - 1}; "
f"{template.path.name}: {len(template.messages)} template messages inside "
f"{PRIVATE_ALLOCATION.start}-{PRIVATE_ALLOCATION.stop - 1}; "
"naming and duplicate checks clean"
)
return 0


if __name__ == "__main__":
sys.exit(main(sys.argv))
106 changes: 106 additions & 0 deletions .github/scripts/check_wire_compat.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
#!/usr/bin/env python3
"""Report wire-format differences between two versions of military.xml.

A message's wire identity is its ID, its name and its CRC_EXTRA. CRC_EXTRA
is computed from the message name and the name, type and order of every
non-extension field, so any change a peer notices on the wire surfaces as a
CRC_EXTRA change, and a peer with a different CRC_EXTRA silently drops the
message. Both versions are parsed with pymavlink's own generator, the same
computation every consumer of this dialect runs, and the diff reports:

- messages removed, or renamed on an existing ID (renames also change the
CRC, so the rename is called out by name)
- messages whose CRC_EXTRA changes: a field added, removed, retyped,
reordered or renamed in the non-extension part of the payload
- messages added (informational; the policy check owns the allocation)

Changes confined to `<extensions>` fields keep CRC_EXTRA stable and are the
wire-compatible evolution path by design, so they are not reported.

The report is advisory by default: the dialect is under active development
and replacing a message body is at times the right call, so the job makes
the wire impact visible in the run log and step summary instead of vetoing
it. Set WIRE_COMPAT_ENFORCE=1 to turn breaking findings into a failure once
the dialect freezes.

Usage: check_wire_compat.py <base military.xml> <head military.xml>
Requires pymavlink importable, e.g. PYTHONPATH pointing at the directory
that contains a pymavlink checkout. Includes are not followed: CRC_EXTRA
depends only on what the file itself defines.
"""

from __future__ import annotations

import os
import sys
from pathlib import Path

from pymavlink.generator import mavparse


def wire_map(path: str) -> dict[int, tuple[str, int]]:
xml = mavparse.MAVXML(path, wire_protocol_version=mavparse.PROTOCOL_2_0)
return {m.id: (m.name, m.crc_extra) for m in xml.message}


def main(argv: list[str]) -> int:
if len(argv) != 3:
print(__doc__, file=sys.stderr)
return 2
base = wire_map(argv[1])
head = wire_map(argv[2])

removed = [(i, base[i][0]) for i in sorted(base.keys() - head.keys())]
added = [(i, head[i][0]) for i in sorted(head.keys() - base.keys())]
breaking = []
for msg_id in sorted(base.keys() & head.keys()):
(base_name, base_crc), (head_name, head_crc) = base[msg_id], head[msg_id]
if base_crc == head_crc and base_name == head_name:
continue
what = f"CRC_EXTRA {base_crc} -> {head_crc}"
if base_name != head_name:
what = f"renamed to {head_name}, {what}"
breaking.append((msg_id, base_name, what))

lines = []
for msg_id, name in removed:
lines.append(f"BREAKING: message {msg_id} {name} removed")
for msg_id, name, what in breaking:
lines.append(f"BREAKING: message {msg_id} {name}: {what}")
for msg_id, name in added:
lines.append(f"INFO: message {msg_id} {name} added")

for line in lines:
prefix = "::warning title=wire-format change::" if "BREAKING" in line else ""
print(prefix + line)

total_breaks = len(removed) + len(breaking)
verdict = (
f"{total_breaks} wire-breaking change(s), {len(added)} addition(s), "
f"{len(base.keys() & head.keys()) - len(breaking)} message(s) untouched on the wire"
)
print(verdict)

summary_path = os.environ.get("GITHUB_STEP_SUMMARY")
if summary_path:
with Path(summary_path).open("a", encoding="utf-8") as summary:
summary.write("## Wire-compatibility report\n\n")
if lines:
summary.write("\n".join(f"- {line}" for line in lines))
summary.write("\n\n")
summary.write(f"{verdict}\n\n")
if total_breaks:
summary.write(
"A peer built from the base definitions silently drops these "
"messages. Deliberate while the dialect is under development; "
"downstream consumers pinning this repository must regenerate.\n"
)

if total_breaks and os.environ.get("WIRE_COMPAT_ENFORCE") == "1":
print("WIRE_COMPAT_ENFORCE=1: failing on wire-breaking changes", file=sys.stderr)
return 1
return 0


if __name__ == "__main__":
sys.exit(main(sys.argv))
Loading