diff --git a/.github/actions/setup-mavlink-tooling/action.yml b/.github/actions/setup-mavlink-tooling/action.yml new file mode 100644 index 0000000..fb2ef49 --- /dev/null +++ b/.github/actions/setup-mavlink-tooling/action.yml @@ -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/ diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..da1b283 --- /dev/null +++ b/.github/dependabot.yml @@ -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: + - "*" diff --git a/.github/scripts/check_dialect_policy.py b/.github/scripts/check_dialect_policy.py new file mode 100755 index 0000000..435e31c --- /dev/null +++ b/.github/scripts/check_dialect_policy.py @@ -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)) diff --git a/.github/scripts/check_wire_compat.py b/.github/scripts/check_wire_compat.py new file mode 100755 index 0000000..89c6866 --- /dev/null +++ b/.github/scripts/check_wire_compat.py @@ -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 `` 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 +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)) diff --git a/.github/workflows/generate_c_lib.yml b/.github/workflows/generate_c_lib.yml index 22f7b6b..eeaddc7 100644 --- a/.github/workflows/generate_c_lib.yml +++ b/.github/workflows/generate_c_lib.yml @@ -1,64 +1,63 @@ name: Generate MAVLink C Headers +# The one writing workflow in this repository: it keeps the pre-generated +# headers under generated/ in step with military.xml after a change lands +# on main. Everything PR-side lives in validate_dialect.yml and only reads. + on: push: branches: [main] paths: - "military.xml" - ".github/workflows/generate_c_lib.yml" + - ".github/actions/setup-mavlink-tooling/**" workflow_dispatch: +permissions: + contents: read + +# One regeneration at a time and never cancelled mid-flight: a run killed +# between commit and push would just be rerun, but overlapping runs could +# race each other's rebase. +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + jobs: generate: + name: Regenerate committed C headers runs-on: ubuntu-latest + timeout-minutes: 15 permissions: contents: write steps: - - name: Checkout this repository - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.x" - - - name: Checkout mavlink repository (dialect definitions) - uses: actions/checkout@v4 - with: - repository: mavlink/mavlink - path: mavlink - depth: 1 + - name: Check out this repository + # Credentials stay in place on this checkout alone: the final + # step pushes the regenerated headers back to main with the + # job-scoped token. + # zizmor: ignore[artipacked] + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Checkout pymavlink repository (code generator) - uses: actions/checkout@v4 - with: - repository: ArduPilot/pymavlink - path: pymavlink - depth: 1 - - - name: Install pymavlink dependencies - run: | - pip install future lxml - - - name: Place dialect definition alongside other message definitions - run: | - cp military.xml mavlink/message_definitions/v1.0/ + - name: Set up MAVLink generation tooling + uses: ./.github/actions/setup-mavlink-tooling - name: Generate C headers + # Validation stays on: the pinned definitions and generator are a + # pair proven against each other, so a validation failure here is + # a real dialect defect, not upstream drift. The output tree is + # cleared first so a message removed from the dialect cannot leave + # a stale header behind. + env: + PYTHONPATH: ${{ github.workspace }} run: | - # --no-validate: common.xml on mavlink master currently fails the - # pymavlink master schema (superseded tag placement), which makes - # mavgen exit before generating. The dialect files themselves are - # schema-checked before merge. - python pymavlink/tools/mavgen.py \ - --lang=C \ - --wire-protocol=2.0 \ - --no-validate \ + rm -rf generated/include/mavlink/v2.0 + python3 -m pymavlink.tools.mavgen \ + --lang=C --wire-protocol=2.0 --strict-units \ --output=generated/include/mavlink/v2.0 \ mavlink/message_definitions/v1.0/military.xml - - name: Commit and push generated headers + - name: Commit and push regenerated headers run: | git config user.name "auterion-ci" git config user.email "ci@auterion.com" diff --git a/.github/workflows/validate_dialect.yml b/.github/workflows/validate_dialect.yml new file mode 100644 index 0000000..1bcd973 --- /dev/null +++ b/.github/workflows/validate_dialect.yml @@ -0,0 +1,152 @@ +name: Validate Dialect + +# Every check here reads the dialect definitions and proves they hold +# together; none of them writes to the repository, publishes an artifact, +# or exercises dialect behavior. Generated bindings exist only inside the +# runner and are discarded with it. + +on: + pull_request: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + schema: + name: Schema validation + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Check out this repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up MAVLink generation tooling + uses: ./.github/actions/setup-mavlink-tooling + + - name: Install xmllint + run: | + sudo apt-get update -qq + sudo apt-get install -y -qq libxml2-utils + + - name: Validate the dialect files against the MAVLink schema + # military_extensions.xml is a template, excluded from generation + # by design, so this is the one gate that keeps it well-formed. + run: | + xmllint --noout --schema pymavlink/generator/mavschema.xsd \ + military.xml military_extensions.xml + + policy: + name: ID allocation and naming policy + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Check out this repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Check the 53xxx allocation and MAVLink naming conventions + run: python3 .github/scripts/check_dialect_policy.py + + wire-compat: + name: Wire-compatibility report + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Check out this repository (full history for the base) + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Set up MAVLink generation tooling + uses: ./.github/actions/setup-mavlink-tooling + + - name: Compare wire identity against the PR base + # Advisory: it reports in the step summary and never fails the + # check, because the dialect is under development and replacing a + # message body is a decision for review, not for a bot. Flip to + # WIRE_COMPAT_ENFORCE=1 once message IDs freeze. + env: + PYTHONPATH: ${{ github.workspace }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + git show "${BASE_SHA}:military.xml" > "${RUNNER_TEMP}/base_military.xml" + python3 .github/scripts/check_wire_compat.py \ + "${RUNNER_TEMP}/base_military.xml" military.xml + + generate-c: + name: Generate and compile C + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Check out this repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up MAVLink generation tooling + uses: ./.github/actions/setup-mavlink-tooling + + - name: Generate C bindings (validated, strict units) + env: + PYTHONPATH: ${{ github.workspace }} + run: | + python3 -m pymavlink.tools.mavgen \ + --lang=C --wire-protocol=2.0 --strict-units \ + --output=build/c \ + mavlink/message_definitions/v1.0/military.xml + + - name: Type-check the generated headers at -Wall -Wextra -Werror + # This repository carries definitions only, so no consumer or + # example code is committed and none is written here: the compiler + # parses the generated dialect header as an include and type-checks + # its inline helpers, without constructing or moving any message. + # -Wno-address-of-packed-member: mavgen's generated helpers take + # addresses of packed-struct members by design; it fires inside + # the generated common.xml headers, independent of this dialect. + run: | + echo '#include ' | \ + gcc -std=c11 -Wall -Wextra -Werror -Wno-address-of-packed-member \ + -fsyntax-only -I build/c -x c - + echo "generated C headers parse and type-check cleanly" + + generate-python: + name: Generate and import Python + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Check out this repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up MAVLink generation tooling + uses: ./.github/actions/setup-mavlink-tooling + + - name: Generate Python bindings (validated, strict units) + env: + PYTHONPATH: ${{ github.workspace }} + run: | + mkdir -p build/python + python3 -m pymavlink.tools.mavgen \ + --lang=Python --wire-protocol=2.0 --strict-units \ + --output=build/python/military.py \ + mavlink/message_definitions/v1.0/military.xml + + - name: Import the generated module + # Import proves the generated module initializes and its message + # map is complete; nothing is constructed and nothing is sent. + working-directory: build/python + run: | + python3 -c "import military; n = len(military.mavlink_map); assert n >= 26, n; print(n, 'messages in the generated map')" diff --git a/.github/workflows/workflow_lint.yml b/.github/workflows/workflow_lint.yml new file mode 100644 index 0000000..2d690c7 --- /dev/null +++ b/.github/workflows/workflow_lint.yml @@ -0,0 +1,68 @@ +name: Lint CI Workflows + +# The workflows police themselves: actionlint for correctness (expression +# types, runner labels, shellcheck on run blocks) and zizmor for security +# posture (credential persistence, injection, unpinned actions). + +on: + pull_request: + paths: + - ".github/**" + push: + branches: [main] + paths: + - ".github/**" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + actionlint: + name: actionlint + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Check out this repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Run actionlint (pinned release, checksum verified) + env: + ACTIONLINT_VERSION: 1.7.12 + ACTIONLINT_SHA256: 8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8 + run: | + curl -sSLo actionlint.tar.gz \ + "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" + echo "${ACTIONLINT_SHA256} actionlint.tar.gz" | sha256sum --check + tar -xzf actionlint.tar.gz actionlint + ./actionlint -color + + zizmor: + name: zizmor + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Check out this repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.x" + + - name: Run zizmor (pinned from PyPI) + # The token only reads public metadata for the online audits + # (impostor commits, known-vulnerable action versions). + env: + GH_TOKEN: ${{ github.token }} + run: | + python3 -m pip install --quiet zizmor==1.28.0 + zizmor --no-progress .