From cd7a08761662ba1cab104dec67065360424551a9 Mon Sep 17 00:00:00 2001 From: TSC21 Date: Sat, 1 Aug 2026 11:28:47 -0700 Subject: [PATCH 1/4] ci(scripts): add dialect policy and wire-compatibility checks The repository documents an allocation policy (README.md, IDMAPPING.md, the military.xml header) and a wire contract (message IDs are stable once assigned; CRC_EXTRA is what a peer checks before accepting a message), but nothing machine-checks either one, so a pull request can allocate outside the 53xxx reservation, collide with the private 53900-53999 block, or change a message body without the wire impact being visible in review. check_dialect_policy.py enforces the documented allocation over both dialect files: military.xml draws messages and MAV_CMD entries from the shared 53000-53899 window, military_extensions.xml only from the private block, names follow MAVLink case conventions, and the template cannot collide with a shared ID or name. It runs on the Python standard library alone so it needs nothing checked out besides this repository. Structure beyond the policy (schema conformance, field types, duplicates against common.xml) stays owned by the schema check and by mavgen, which parse the full include tree. check_wire_compat.py parses two versions of military.xml with pymavlink's generator, the same CRC_EXTRA computation every consumer runs, and reports removals, renames and CRC changes per message ID. The report is advisory by default because the dialect is under active development and replacing a message body is a decision for review, not for a bot; WIRE_COMPAT_ENFORCE=1 turns findings into a failure once the dialect freezes. Changes confined to extension fields keep CRC_EXTRA stable and are wire-compatible by design, so they are not reported. Validation: the policy check passes on the current tree (26 messages and 4 MAV_CMD entries in the shared window, 2 template messages in the private block) and a synthetic tree with 11 planted violations reports all 11 and exits 1. The wire check on identical inputs reports zero changes; against the head of pull request #10 it reports exactly the eleven messages that PR touches, with FIRES at CRC_EXTRA 124, matching the message-entry table published in that PR's description. --- .github/scripts/check_dialect_policy.py | 174 ++++++++++++++++++++++++ .github/scripts/check_wire_compat.py | 106 +++++++++++++++ 2 files changed, 280 insertions(+) create mode 100755 .github/scripts/check_dialect_policy.py create mode 100755 .github/scripts/check_wire_compat.py 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)) From 59d0658a26bb5e4ff32e5bf553420a11b34fd489 Mon Sep 17 00:00:00 2001 From: TSC21 Date: Sat, 1 Aug 2026 11:29:04 -0700 Subject: [PATCH 2/4] ci: validate the dialect definitions on every pull request A change to military.xml gets its first machine check after it merges: the header-regeneration workflow is the only one configured, it runs on push to main, and the open dialect pull requests show no checks at all. Schema errors, allocation mistakes and invisible wire breaks therefore reach review with no tooling behind the reviewer. validate_dialect.yml runs on every pull request and push to main, and every job in it reads: nothing writes to the repository, publishes an artifact, or exercises dialect behavior, keeping CI inside the same boundary the README draws for the dialect itself. The jobs: - schema: xmllint against pymavlink's mavschema.xsd for both dialect files. The extensions template is excluded from generation by design, so this is the one gate that keeps it well-formed. - policy: the documented 53xxx allocation and MAVLink naming conventions, via check_dialect_policy.py. - wire-compat (pull requests only): CRC_EXTRA diff against the PR base via check_wire_compat.py, advisory, written to the run's step summary. - generate-c: mavgen C with schema validation and --strict-units on, then a syntax-and-type pass over the generated header at -Wall -Wextra -Werror. No consumer or example code exists in the repository and none is written in CI: the compiler parses the header as an include, it does not construct or move a message. -Wno-address-of-packed-member is required by design of the generator's helpers and fires inside the generated common.xml headers, independent of this dialect. - generate-python: mavgen Python with the same validation, then an import that proves the module initializes with a complete message map. The setup-mavlink-tooling composite action is the single source of truth for the toolchain: mavlink/mavlink 92c81a5 for the upstream definitions military.xml includes, ArduPilot/pymavlink 839c381 for the generator. The two are a pair proven against each other, so generation runs with schema validation on. Actions are pinned by commit SHA, jobs carry timeouts, workflow permissions are contents: read, checkouts drop credentials, and superseded pull request runs are cancelled. Validation, local, with the pinned pair and the exact job commands: xmllint validates both dialect files; mavgen C and Python generate cleanly with validation and strict units (260 messages across 4 XML files); the C header type-check passes; the Python import reports 260 messages in the map; the C CRC table regenerated by the pinned pair is identical to the committed generated/ tree. --- .../actions/setup-mavlink-tooling/action.yml | 49 ++++++ .github/workflows/validate_dialect.yml | 152 ++++++++++++++++++ 2 files changed, 201 insertions(+) create mode 100644 .github/actions/setup-mavlink-tooling/action.yml create mode 100644 .github/workflows/validate_dialect.yml 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/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')" From 1bcd7d01e7f23cd48ca6230b6138fae8ed6fb505 Mon Sep 17 00:00:00 2001 From: TSC21 Date: Sat, 1 Aug 2026 11:29:24 -0700 Subject: [PATCH 3/4] ci(generate): pin the toolchain that regenerates the C headers The regeneration workflow tracks mavlink/mavlink and ArduPilot/pymavlink at master, so its output and its ability to run at all depend on the day it fires, and generation carries --no-validate because the two moving heads drift out of schema agreement, which waives validation for the dialect itself along with them. Checkout actions float on major-version tags, the job holds a blanket contents: write, and nothing bounds its runtime. The workflow keeps its one purpose, holding generated/ in step with military.xml on main, and draws its toolchain through the same setup-mavlink-tooling composite action as the validation suite: one pinned mavlink/pymavlink pair, proven against each other, so schema validation and --strict-units stay on and a failure here is a real dialect defect, not upstream drift. The output tree is cleared before generation so a message removed in the dialect cannot leave a stale header behind. Permissions are contents: read at the workflow with contents: write scoped to the one job that pushes, actions are pinned by commit SHA, the job carries a timeout, and a concurrency group serializes regenerations without cancelling one mid-push. The self checkout alone keeps its credentials because the push step needs the job token; the zizmor waiver on that line records the reason. Validation: regenerating with the pinned pair reproduces the committed CRC table byte-identical, so the first run on main refreshes generator banner lines only, not wire content. --- .github/workflows/generate_c_lib.yml | 73 ++++++++++++++-------------- 1 file changed, 36 insertions(+), 37 deletions(-) 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" From c33749887603f3530dfb6d290a7c0f5e20d4a4be Mon Sep 17 00:00:00 2001 From: TSC21 Date: Sat, 1 Aug 2026 11:29:24 -0700 Subject: [PATCH 4/4] ci(lint): make the workflows police themselves Workflow definitions are code that runs with credentials, and this repository reviews them by eye alone. Two linters close that gap on every change under .github/: actionlint for correctness (expression types, runner labels, shellcheck over run blocks) and zizmor for security posture (credential persistence, template injection, unpinned actions). actionlint is fetched as a pinned release binary and verified against a recorded sha256 before it runs; zizmor installs version-pinned from PyPI. Both gate pull requests and main. dependabot.yml keeps the SHA-pinned actions current with one grouped pull request a month behind a seven-day cooldown, the right noise floor for a definitions repository. The mavlink/pymavlink generator pins are checkout refs inside the composite action, outside dependabot's reach, and move by hand, together, behind a green validation run. Validation: actionlint 1.7.12 exits clean over the three workflows; zizmor 1.28.0 reports no findings across the workflows, the composite action and dependabot.yml, with the one artipacked waiver justified in-file. --- .github/dependabot.yml | 29 ++++++++++++ .github/workflows/workflow_lint.yml | 68 +++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/workflow_lint.yml 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/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 .