diff --git a/.clang-tidy b/.clang-tidy
index 9293603c71..2f4fa78c09 100644
--- a/.clang-tidy
+++ b/.clang-tidy
@@ -141,39 +141,28 @@ Checks: '*,
-cppcoreguidelines-avoid-goto,
-hicpp-avoid-goto,
-bugprone-branch-clone,
- -bugprone-unhandled-self-assignment,
- -cert-oop54-cpp,
-performance-enum-size,
-readability-avoid-nested-conditional-operator,
-cppcoreguidelines-prefer-member-initializer,
-cppcoreguidelines-explicit-virtual-functions,
- -cppcoreguidelines-virtual-class-destructor,
-readability-convert-member-functions-to-static,
-readability-make-member-function-const,
- -bugprone-assignment-in-if-condition,
-bugprone-implicit-widening-of-multiplication-result,
- -bugprone-incorrect-roundings,
- -bugprone-macro-parentheses,
-bugprone-multi-level-implicit-pointer-conversion,
-bugprone-signed-char-misuse,
- -bugprone-too-small-loop-variable,
-cppcoreguidelines-avoid-non-const-global-variables,
-cppcoreguidelines-use-default-member-init,
-hicpp-multiway-paths-covered,
-hicpp-named-parameter,
- -misc-header-include-cycle,
-misc-no-recursion,
-performance-no-int-to-ptr,
-readability-avoid-return-with-void-value,
-readability-avoid-unconditional-preprocessor-if,
-readability-delete-null-pointer,
- -readability-duplicate-include,
-readability-redundant-casting,
-readability-redundant-member-init,
-readability-reference-to-constructed-temporary,
-readability-simplify-boolean-expr,
- -bugprone-unsafe-functions,
- -cert-msc24-c,
-cert-msc32-c,
-cert-msc33-c,
-cert-msc51-cpp,
diff --git a/.claude/skills/commit/SKILL.md b/.claude/skills/commit/SKILL.md
new file mode 100644
index 0000000000..bf8e2deab1
--- /dev/null
+++ b/.claude/skills/commit/SKILL.md
@@ -0,0 +1,34 @@
+---
+name: commit
+description: Create a conventional commit for PX4 changes
+argument-hint: "[optional: description of changes]"
+allowed-tools: Bash, Read, Glob, Grep
+---
+
+# PX4 Conventional Commit
+
+Create a git commit in conventional-commit format: `type(scope): description`.
+
+- **type:** `feat`, `fix`, `refactor`, `perf`, `docs`, `style`, `test`,
+ `build`, `ci`, `chore`, `revert`. Append `!` before `:` for breaking changes.
+- **scope:** the module/driver/area affected — derive from the directory
+ path of the changed files (`src/modules/ekf2/` → `ekf2`,
+ `src/drivers/imu/invensense/icm42688p/` → `drivers/icm42688p`,
+ `.github/workflows/` → `ci`).
+- **description:** imperative, concise, ≥5 chars.
+
+**NEVER add Co-Authored-By Claude Code. No Claude attribution.**
+
+## Steps
+
+1. Check branch (`git branch --show-current`). If on `main`, create a feature
+ branch `/` where `` comes from
+ `gh api user --jq .login`.
+2. Run `git status` and `git diff --staged`. If nothing staged, ask what to stage.
+3. Run `make format` (or `./Tools/astyle/fix_code_style.sh `) on changed
+ C/C++ files.
+4. Body (if needed): explain **why**, not what.
+5. Check GPG signing: `git config --get user.signingkey`. If set,
+ `git commit -S -s`; else `git commit -s`.
+
+If the user provided arguments, use them as context: $ARGUMENTS
diff --git a/.claude/skills/pr/SKILL.md b/.claude/skills/pr/SKILL.md
new file mode 100644
index 0000000000..6ef9ba0da9
--- /dev/null
+++ b/.claude/skills/pr/SKILL.md
@@ -0,0 +1,31 @@
+---
+name: pr
+description: Create a pull request with conventional commit title and description
+argument-hint: "[optional: target branch or description]"
+allowed-tools: Bash, Read, Glob, Grep
+---
+
+# PX4 Pull Request
+
+**No Claude attribution anywhere (no Co-Authored-By, no "Generated with Claude").**
+
+## Steps
+
+1. Check branch. If on `main`, create a feature branch `/`
+ where `` comes from `gh api user --jq .login`.
+2. Gather context: `git status`, `git log --oneline main..HEAD`,
+ `git diff main...HEAD --stat`, check for remote tracking branch.
+3. Sanity-build the targets we care about. Fix any build errors before opening
+ the PR:
+ - `make px4_fmu-v6x` — hardware target
+ - `make px4_sitl` — simulation
+4. PR **title:** `type(scope): description` — under 72 chars, covers the
+ overall change across all commits. This becomes the squash-merge commit
+ message.
+5. PR **body:** start with a plain leading paragraph explaining what the PR does and why. No headings (`## Summary`, `## Test plan`, etc.), no boilerplate, no Claude attribution. Use bullet lists only to enumerate discrete changes; don't force prose into bullets. Describe testing inline if relevant, no separate test plan section. Use markdown (links, code blocks, lists) only when warranted. Keep it concise and well-formatted.
+
+6. Push with `-u` if needed, then `gh pr create`. Default base is `main`
+ unless user says otherwise.
+7. Return the PR URL.
+
+If the user provided arguments, use them as context: $ARGUMENTS
diff --git a/.claude/skills/rebase-onto-main/SKILL.md b/.claude/skills/rebase-onto-main/SKILL.md
new file mode 100644
index 0000000000..04e57882f2
--- /dev/null
+++ b/.claude/skills/rebase-onto-main/SKILL.md
@@ -0,0 +1,73 @@
+---
+name: rebase-onto-main
+description: Rebase a branch onto main, handling squash-merged parent branches cleanly
+argument-hint: "[optional: branch name, defaults to current branch]"
+allowed-tools: Bash, Read, Glob, Grep, Agent
+---
+
+# Rebase Branch onto Main
+
+Rebase the current (or specified) branch onto `main`, correctly handling the case where the branch was built on top of another branch that has since been squash-merged into `main`.
+
+## Background
+
+When a parent branch is squash-merged, its individual commits become a single new commit on `main` with a different hash. A normal `git rebase main` will try to replay the parent's original commits, causing messy conflicts. The fix is to **cherry-pick only the commits unique to this branch** onto a fresh branch from `main`.
+
+## Steps
+
+1. **Identify the branch.** Use `$ARGUMENTS` if provided, otherwise use the current branch.
+
+2. **Fetch and update main:**
+ ```
+ git fetch origin main:main
+ ```
+
+3. **Find the merge base** between the branch and `main`:
+ ```
+ git merge-base main
+ ```
+
+4. **List all commits** on the branch since the merge base:
+ ```
+ git log --oneline ..
+ ```
+
+5. **Identify which commits are unique to this branch** vs. inherited from a parent branch. Look for:
+ - Squash-merged commits on `main` that correspond to a group of commits at the bottom of the branch's history (check PR titles, commit message keywords).
+ - The boundary commit: the first commit that belongs to *this* branch's work, not the parent's.
+ - If ALL commits are unique (no parent branch), just do a normal `git rebase main` and skip the rest.
+
+6. **Create a fresh branch from `main`:**
+ ```
+ git checkout -b -rebase main
+ ```
+
+7. **Cherry-pick only the unique commits** (oldest first):
+ ```
+ git cherry-pick ^..
+ ```
+ The `A^..B` range means "from the parent of A through B inclusive."
+
+8. **Handle conflicts** if any arise during cherry-pick. Resolve and `git cherry-pick --continue`.
+
+9. **Replace the old branch:**
+ ```
+ git branch -m -old
+ git branch -m -rebase
+ ```
+
+10. **Verify** the result:
+ ```
+ git log --oneline main..
+ ```
+ Confirm only the expected commits are present.
+
+11. **Ask the user** before force-pushing. When approved:
+ ```
+ git push origin --force-with-lease
+ ```
+
+12. **Clean up** the old branch:
+ ```
+ git branch -D -old
+ ```
diff --git a/.claude/skills/review-pr/SKILL.md b/.claude/skills/review-pr/SKILL.md
new file mode 100644
index 0000000000..c05189b9ba
--- /dev/null
+++ b/.claude/skills/review-pr/SKILL.md
@@ -0,0 +1,207 @@
+---
+name: review-pr
+description: Review a pull request with structured, domain-aware feedback
+argument-hint: ""
+allowed-tools: Bash, Read, Glob, Grep, Agent
+---
+
+# PX4 Pull Request Review
+
+Review a pull request with domain-aware checks based on which files are changed.
+
+**No Claude attribution anywhere.**
+
+## Steps
+
+1. **Fetch PR context.** Run these in parallel:
+ - `gh pr view --json number,title,body,baseRefName,headRefName,files,commits,reviewRequests,reviews,author`
+ - `gh pr checks ` (exit code 8 means some checks are pending, this is normal, not an error)
+ - `gh pr diff ` -- if this fails with HTTP 406 (300+ files), do NOT retry. Instead use `gh api repos/OWNER/REPO/pulls/NUMBER/files --paginate` to get the full file list in one call, then fetch patches for key infrastructure files individually and sample representative changes from each domain touched.
+ - `gh api repos/OWNER/REPO/pulls/NUMBER/comments --paginate --jq '.[] | {user: .user.login, body: .body, path: .path, created_at: .created_at}'` to get inline review comments
+ - `gh api repos/OWNER/REPO/issues/NUMBER/comments --paginate --jq '.[] | {user: .user.login, body: .body, created_at: .created_at}'` to get PR conversation comments
+
+ From the PR metadata, note:
+ - **Assigned reviewers**: who has been requested to review (from `reviewRequests`)
+ - **Existing reviews**: who has already reviewed and their verdict (from `reviews` -- approved, changes_requested, commented, dismissed)
+ - **PR comments and inline comments**: read all existing feedback to avoid duplicating points already raised by other reviewers, and to build on their discussion rather than ignoring it
+
+2. **Check CI status.** From the `gh pr checks` output in step 1, summarize pass/fail/pending. If there are failures, fetch logs with `gh run view --log-failed`. Include CI status in the output.
+
+3. **Recommend merge strategy.** Analyze the commit history and recommend squash or rebase merge. This decision informs all subsequent commit hygiene feedback.
+
+ **Recommend rebase merge** when:
+ - Commits are atomic, each builds/works independently
+ - Each commit has a proper `type(scope): description` message
+ - The PR intentionally separates logical changes (e.g., refactor + feature, or one commit per module)
+ - The commit history tells a useful story that would be lost by squashing
+
+ **Recommend squash merge** when:
+ - There are WIP, fixup, or review-response commits
+ - Commit messages are messy or inconsistent
+ - The PR is a single logical change spread across multiple commits
+ - There are "oops" or "make format" commits mixed in
+
+ Include the recommendation in the output. If recommending rebase, flag any commits that break atomicity or have bad messages. If recommending squash, don't bother flagging individual commit messages (they'll be discarded) but ensure the PR title is correct since it becomes the squash commit message.
+
+4. **Check conventional commit title.** Verify the PR title follows `type(scope): description` per CONTRIBUTING.md. The PR title becomes the commit message on squash-merge, so it must be accurate and descriptive. Verify the scope matches the primary area of changed files. If the PR introduces breaking changes, the title must include `!` before the colon. If rebase merge was recommended in step 3, also scan individual commit messages for anti-patterns: vague messages ("fix", "update"), missing type prefix, review-response noise ("apply suggestions from code review", "do make format"), or WIP markers. Flag these for rewording.
+
+5. **Identify domains touched.** Classify changed files into domains based on paths (a PR may touch multiple):
+ - **Estimation**: `src/modules/ekf2/`, `src/lib/wind_estimator/`, `src/lib/world_magnetic_model/`
+ - **Control**: `src/modules/mc_*control*/`, `src/modules/fw_*control*/`, `src/modules/flight_mode_manager/`, `src/lib/rate_control/`, `src/lib/npfg/`, `src/modules/vtol_att_control/`
+ - **Drivers/CAN**: `src/drivers/`, `src/modules/cyphal/`, `src/drivers/uavcan*/`
+ - **Simulation**: `src/modules/simulation/`, `Tools/simulation/`
+ - **System**: `src/modules/commander/`, `src/modules/logger/`, `src/systemcmds/`, `platforms/`, `src/modules/dataman/`
+ - **Board Addition**: `boards/{manufacturer}/{board}/` (new directories only, not modifications to existing boards)
+ - **CI/Build**: `.github/`, `CMakeLists.txt`, `Makefile`, `cmake/`, `Tools/`, `Kconfig`
+ - **Messages/Protocol**: `msg/`, `src/modules/mavlink/`, `src/modules/uxrce_dds_client/`
+
+6. **Apply core checks** (always):
+ - **Correctness**: logic errors, off-by-ones, unhandled edge cases
+ - **Type safety**: int16 overflow, float/double promotion, unsigned subtraction, use `uint64_t` for absolute time
+ - **Initialization**: uninitialized variables, missing default construction
+ - **Buffer safety**: unchecked array access, stack allocation of large buffers, snprintf bounds
+ - **Magic numbers**: every numeric literal needs a named constant or justification
+ - **Framework reuse**: use PX4_ERR/WARN/INFO, existing libraries (AlphaFilter, SlewRate, RateControl), MAVLink constants from the library
+ - **Naming**: accurate, no unjustified abbreviations, current terminology (GPS -> GNSS for new code)
+ - **Unnecessary complexity**: can code be removed instead of added? Is there a simpler pattern?
+ - **Test coverage**: new features should include unit or integration tests; bug fixes should include regression tests where practical. When automated testing is infeasible (hardware-specific), require a flight log link from https://logs.px4.io or bench test evidence.
+ - **PR hygiene**: focused scope, no unrelated formatting, no stale submodule changes. Commits should be atomic and independently revertable. Multiple WIP or review-response commits should be squashed. Clean, logical commits will be preserved individually on main via rebase merge. **Do NOT assume PRs are squash-merged. Both squash and rebase merge are enabled; merge commits are disabled.** Verify the PR targets `main` unless it is a backport or release-specific fix.
+ - **Formatting**: `make format` / `make check_format` (astyle) for C/C++ files; `clang-tidy` clean. Python files checked with `mypy` and `flake8`. PRs failing CI format or lint checks will not be merged.
+ - **Coding style**: C/C++ must follow the [PX4 coding style](https://docs.px4.io/main/en/contribute/code.html)
+ - **Necessity**: challenge every addition with "Why?" Is this actually needed or just copied? Can we change a default instead of adding runtime detection?
+ - **Root cause vs symptom**: is this fixing the real problem or masking it?
+ - **Ecosystem impact**: what does this change mean for QGC users, log analysis tools, and third-party integrations?
+ - **Sustainability**: who will maintain this? Does it create long-term burden?
+ - **Architecture fit**: does the code live in the module that naturally owns the data? Are there unnecessary cross-module dependencies?
+ - **End user impact**: will parameters confuse less-technical users? Are error messages actionable in QGC?
+
+7. **Apply domain checks** based on step 5:
+
+ **Estimation:**
+ - Singularities in aerospace math (euler angles near gimbal lock, sideslip at low airspeed)
+ - Aliasing from downsampling sensor data without filtering
+ - Kalman filter correctness (Joseph form, innovation variance, covariance symmetry)
+ - CPU cost on embedded targets (avoid unnecessary sqrt, limit fusion rate)
+ - Frame/coordinate system correctness (FRD vs NED, body vs earth)
+
+ **Control:**
+ - Phase margin: output filters consume margin for no benefit; prefer adjusting gyro/d-gyro cutoffs
+ - Circular dependencies: sensor data feeding back into its own control loop (e.g., throttle-based airspeed in TECS)
+ - NaN propagation in flight-critical math; check `PX4_ISFINITE` before magnitude checks
+ - Setpoint generation vs output-stage hacks: prefer proper setpoint smoothing over controller output filtering
+ - Yaw control edge cases: heading lock, drift, setpoint propagation
+ - Flight task inheritance chain: correct base class for the desired behavior
+ - Control allocation: actuator function ordering, motor index mapping
+
+ **Drivers/CAN:**
+ - CAN bus devices behave differently from serial/SPI; check driver assumptions
+ - ESC index mapping: telemetry index != channel when motors are disabled
+ - ESC hardware quirks: 4-in-1 ESCs may report current on only one channel
+ - device_id correctness and I2CSPIDriver patterns
+ - Time representation: prefer `hrt_abstime` over iteration counts
+
+ **Simulation:**
+ - Physics fidelity: noise models should match reality (GPS noise is not Gaussian)
+ - Keep gz_bridge generic; vehicle-specific logic belongs in plugins
+ - Prefer gz-transport over ROS2 dependencies when possible
+ - Wrench commands for physics correctness vs kinematic constraints
+ - Library generic/specific boundary: only base classes in common libs
+
+ **System:**
+ - Race conditions and concurrency: no partial fixes, demand complete solutions
+ - Semaphore/scheduling edge cases; understand RTOS guarantees
+ - State machine sequential-logic bugs (consecutive RTL, armed/disarmed alternation)
+ - uORB-driven scheduling (`SubscriptionCallback`), not extra threads
+ - param_set triggers auto-save; no redundant param_save_default
+ - Flash/memory efficiency: avoid `std::string` on embedded, minimize SubscriptionData usage
+ - Constructor initialization order matters
+
+ **CI/Build:**
+ - Pipeline race conditions (tag + branch push double-trigger, git describe correctness)
+ - Container image size (check layer bloat)
+ - Ubuntu LTS support policy (latest + one prior only)
+ - Build time impact
+ - CMake preferred over Makefiles
+
+ **Messages/Protocol:**
+ - Backwards compatibility: will this break QGC, post-flight tools, or uLog parsers?
+ - uORB: `timestamp` for publication metadata, `timestamp_sample` close to physical sample, include `device_id`
+ - Don't version messages unless strictly needed
+ - Parameter UX: will this confuse users in a GCS? Every new param is a configuration burden
+ - MAVLink: use library constants, don't implement custom stream rates
+
+ **Board Addition:**
+ - **Flight logs**: require a link to https://logs.px4.io demonstrating basic operation for the vehicle type (hover for multicopters, stable flight for fixed-wing, driving for rovers, etc.); short bench-only logs are insufficient
+ - **Documentation**: require a docs page in `docs/en/flight_controller/` with pinout, where-to-buy, connector types, version badge, and manufacturer-supported notice block
+ - **USB VID/PID**: must not reuse another manufacturer's Vendor ID; manufacturer must use their own
+ - **Board naming**: directory is `boards/{manufacturer}/{board}/`, both lowercase, hyphens for board name
+ - **Unique board_id**: registered in `boards/boards.json`, no collisions
+ - **Copied code cleanup**: check for leftover files, configs, or comments from the template board; "Is this real or leftover?"
+ - **RC configuration**: prefer `CONFIG_DRIVERS_COMMON_RC` over legacy `CONFIG_DRIVERS_RC_INPUT`
+ - **No board-specific custom modules**: reject copy-pasted drivers (e.g., custom heater) when existing infrastructure works
+ - **Bootloader**: expect a bootloader defconfig (`nuttx-config/bootloader/defconfig`) or explanation of shared bootloader
+ - **CI integration**: board must be added to CI compile workflows so it builds on every PR
+ - **Flash constraints**: verify enabled modules fit in flash; we are running low across all board targets
+ - **Port labels**: serial port labels must match what is physically printed on the board
+ - **Hardware availability**: for unknown manufacturers, verify the product exists and is purchasable (no vaporware)
+
+8. **Format output** as:
+ - **CI status**: pass/fail summary, link to failed runs if any
+ - **Merge strategy**: recommend squash or rebase merge with reasoning
+ - **Title check**: pass/fail with suggestion
+ - **Review status**: list assigned reviewers and any existing reviews (who approved, who requested changes, key points already raised). Note if your review would duplicate feedback already given.
+ - **Domains detected**: list which domain checks were applied
+ - **Summary**: one paragraph on what the PR does and whether the approach is sound
+ - **Issues**: numbered list, each with file:line, severity (blocker/warning/nit), and explanation. Skip issues already raised by other reviewers unless you have something to add.
+ - **Verdict**: approve, request changes, or needs discussion
+
+ After the structured output, also display a **draft PR comment** formatted using the PR comment formatting rules from step 9. This gives the user a preview of what would be posted.
+
+9. **Interactive dialog.** After displaying the review, present the user with these options:
+
+ Present options based on the verdict:
+
+ If verdict is **approve**:
+ ```
+ What would you like to do?
+ 1. Chat about this PR (ask questions, explore code) [default]
+ 2. Approve this PR and post the review comment
+ 3. Adjust the review or draft (tell me what to change)
+ 4. Done for now
+ ```
+
+ If verdict is **request changes**:
+ ```
+ What would you like to do?
+ 1. Chat about this PR (ask questions, explore code) [default]
+ 2. Request changes on this PR and post the review comment
+ 3. Adjust the review or draft (tell me what to change)
+ 4. Done for now
+ ```
+
+ If verdict is **needs discussion**:
+ ```
+ What would you like to do?
+ 1. Chat about this PR (ask questions, explore code) [default]
+ 2. Post the review as a comment (no approval or rejection)
+ 3. Adjust the review or draft (tell me what to change)
+ 4. Done for now
+ ```
+
+ Wait for the user to choose before proceeding. If they pick:
+ - **1 (chat)**: enter a free-form conversation about the PR. The user can ask about specific files, code paths, or decisions. When done, loop back to the options. This is the default if the user just presses enter.
+ - **2 (submit)**: use the draft PR comment already shown. Before posting, check if you have review permissions: run `gh api repos/OWNER/REPO/collaborators/$(gh api user --jq .login)/permission --jq .permission` -- if `admin` or `write`, submit as a formal review with `gh pr review --approve --body "..."` or `gh pr review --request-changes --body "..."` based on the verdict. If no write access, fall back to `gh pr comment --body "..."`. Always confirm with the user before posting.
+ - **3 (adjust)**: ask what to change, update the review and draft, then loop back to the options.
+ - **4 (done)**: stop.
+
+ **PR comment formatting rules** (for the draft):
+ When writing the GitHub comment, rewrite the review to sound like a human reviewer, not a structured report. Do NOT include the full skill output. Instead:
+ - Drop most meta-sections (CI status, title check, domains detected, severity labels) but keep the merge strategy recommendation (e.g., "I'd suggest a rebase merge here since the commits are clean and atomic" or "This should be squash-merged, the commit history is messy")
+ - Write conversationally: "Nice work on this. A few things I noticed:" not "Issues: 1. file:line (warning):"
+ - Lead with a brief take on the overall change (1-2 sentences)
+ - List only actionable feedback as natural review comments, not numbered checklists
+ - Skip nits unless they are particularly useful
+ - End with a clear stance: looks good to merge, needs a few changes, or let's discuss X
+ - Post with `gh pr comment --body "$(cat <<'EOF' ... EOF)"`. Do not post without explicit confirmation.
+
+If the user provided arguments, use them as context: $ARGUMENTS
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000000..567609b123
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1 @@
+build/
diff --git a/.github/ISSUE_TEMPLATE/docs_bug_report.yml b/.github/ISSUE_TEMPLATE/docs_bug_report.yml
index 8e53821e7e..efe1d79032 100644
--- a/.github/ISSUE_TEMPLATE/docs_bug_report.yml
+++ b/.github/ISSUE_TEMPLATE/docs_bug_report.yml
@@ -1,7 +1,7 @@
name: 📑 Documentation Bug report
description: Create a report to help us improve the docs
-title: "[Docs] [Bug] "
-labels: ["Documentation 📑"]
+title: "docs(bug): "
+labels: ["scope:docs"]
body:
- type: textarea
attributes:
diff --git a/.github/actions/build-deb/action.yml b/.github/actions/build-deb/action.yml
new file mode 100644
index 0000000000..ca774a53b4
--- /dev/null
+++ b/.github/actions/build-deb/action.yml
@@ -0,0 +1,115 @@
+name: Build PX4 .deb Package
+description: Build PX4 SITL, run cpack, validate the .deb, and upload artifact
+
+inputs:
+ target:
+ description: 'Build target: default or sih'
+ required: true
+ artifact-name:
+ description: Name for the uploaded artifact
+ required: true
+ ccache-key-prefix:
+ description: Prefix for ccache cache keys
+ default: deb-ccache
+ ccache-max-size:
+ description: Maximum ccache size
+ default: 400M
+
+runs:
+ using: composite
+ steps:
+ - name: Restore ccache
+ id: ccache-restore
+ uses: actions/cache/restore@v5
+ with:
+ path: ~/.ccache
+ key: ${{ inputs.ccache-key-prefix }}-${{ github.ref_name }}-${{ github.sha }}
+ restore-keys: |
+ ${{ inputs.ccache-key-prefix }}-${{ github.ref_name }}-
+ ${{ inputs.ccache-key-prefix }}-${{ github.base_ref || 'main' }}-
+ ${{ inputs.ccache-key-prefix }}-
+
+ - name: Configure ccache
+ shell: bash
+ run: |
+ mkdir -p ~/.ccache
+ echo "base_dir = ${GITHUB_WORKSPACE}" > ~/.ccache/ccache.conf
+ echo "compression = true" >> ~/.ccache/ccache.conf
+ echo "compression_level = 6" >> ~/.ccache/ccache.conf
+ echo "max_size = ${{ inputs.ccache-max-size }}" >> ~/.ccache/ccache.conf
+ echo "hash_dir = false" >> ~/.ccache/ccache.conf
+ echo "compiler_check = content" >> ~/.ccache/ccache.conf
+ ccache -s
+ ccache -z
+
+ - name: Build PX4 SITL
+ shell: bash
+ run: make px4_sitl_${{ inputs.target }}
+
+ - name: ccache stats
+ if: always()
+ shell: bash
+ run: ccache -s
+
+ - name: Save ccache
+ uses: actions/cache/save@v5
+ if: always()
+ with:
+ path: ~/.ccache
+ key: ${{ inputs.ccache-key-prefix }}-${{ github.ref_name }}-${{ github.sha }}
+
+ - name: Build .deb package
+ shell: bash
+ run: |
+ cd build/px4_sitl_${{ inputs.target }}
+ cpack -G DEB
+
+ - name: Print package info and contents
+ shell: bash
+ run: |
+ cd build/px4_sitl_${{ inputs.target }}
+ echo "--- Package info ---"
+ dpkg-deb -I *.deb
+ echo "--- Package contents ---"
+ dpkg-deb -c *.deb
+
+ - name: Validate sih package
+ if: inputs.target == 'sih'
+ shell: bash
+ run: |
+ cd build/px4_sitl_sih
+ echo "--- Verify NO Gazebo resources ---"
+ ! dpkg-deb -c px4_*.deb | grep share/gz > /dev/null && echo "PASS: no Gazebo" || { echo "FAIL: Gazebo found"; exit 1; }
+ echo "--- Install test ---"
+ dpkg -i px4_*.deb
+ test -x /opt/px4/bin/px4 || { echo "FAIL: px4 binary not found"; exit 1; }
+ test -L /usr/bin/px4 || { echo "FAIL: symlink not created"; exit 1; }
+ test ! -d /opt/px4/share/gz || { echo "FAIL: Gazebo dir should not exist"; exit 1; }
+ echo "--- Smoke test ---"
+ /opt/px4/bin/px4 -h
+ echo "PASS: sih package validation successful"
+
+ - name: Validate gazebo package
+ if: inputs.target == 'default'
+ shell: bash
+ run: |
+ cd build/px4_sitl_default
+ echo "--- Verify Gazebo resources in package ---"
+ dpkg-deb -c px4-gazebo_*.deb | grep share/gz/models > /dev/null || { echo "FAIL: models missing"; exit 1; }
+ dpkg-deb -c px4-gazebo_*.deb | grep share/gz/worlds > /dev/null || { echo "FAIL: worlds missing"; exit 1; }
+ echo "--- Install test ---"
+ dpkg -i px4-gazebo_*.deb
+ test -x /opt/px4-gazebo/bin/px4 || { echo "FAIL: px4 binary not found"; exit 1; }
+ test -x /opt/px4-gazebo/bin/px4-gazebo || { echo "FAIL: wrapper not found"; exit 1; }
+ test -L /usr/bin/px4-gazebo || { echo "FAIL: symlink not created"; exit 1; }
+ test -d /opt/px4-gazebo/share/gz/models || { echo "FAIL: Gazebo models not installed"; exit 1; }
+ echo "--- Smoke test ---"
+ /opt/px4-gazebo/bin/px4 -h
+ echo "PASS: gazebo package validation successful"
+
+ - name: Upload .deb artifacts
+ uses: actions/upload-artifact@v7
+ with:
+ name: ${{ inputs.artifact-name }}
+ path: build/px4_sitl_${{ inputs.target }}/*.deb
+ if-no-files-found: error
diff --git a/.github/actions/build-gazebo-sitl/action.yml b/.github/actions/build-gazebo-sitl/action.yml
new file mode 100644
index 0000000000..aae5a9565a
--- /dev/null
+++ b/.github/actions/build-gazebo-sitl/action.yml
@@ -0,0 +1,21 @@
+name: Build Gazebo Classic SITL
+description: Build PX4 firmware and Gazebo Classic plugins with ccache stats
+
+runs:
+ using: composite
+ steps:
+ - name: Build - PX4 Firmware (SITL)
+ shell: bash
+ run: make px4_sitl_default
+
+ - name: Cache - Stats after PX4 Firmware
+ shell: bash
+ run: ccache -s
+
+ - name: Build - Gazebo Classic Plugins
+ shell: bash
+ run: make px4_sitl_default sitl_gazebo-classic
+
+ - name: Cache - Stats after Gazebo Plugins
+ shell: bash
+ run: ccache -s
diff --git a/.github/actions/save-ccache/action.yml b/.github/actions/save-ccache/action.yml
new file mode 100644
index 0000000000..6c477c70e0
--- /dev/null
+++ b/.github/actions/save-ccache/action.yml
@@ -0,0 +1,22 @@
+name: Save ccache
+description: Print ccache stats and save to cache
+
+inputs:
+ cache-primary-key:
+ description: Primary cache key from setup-ccache output
+ required: true
+
+runs:
+ using: composite
+ steps:
+ - name: Cache - Stats
+ if: always()
+ shell: bash
+ run: ccache -s
+
+ - name: Cache - Save ccache
+ if: always()
+ uses: actions/cache/save@v5
+ with:
+ path: ~/.ccache
+ key: ${{ inputs.cache-primary-key }}
diff --git a/.github/actions/setup-ccache/action.yml b/.github/actions/setup-ccache/action.yml
new file mode 100644
index 0000000000..04b4bd64ef
--- /dev/null
+++ b/.github/actions/setup-ccache/action.yml
@@ -0,0 +1,56 @@
+name: Setup ccache
+description: Restore ccache from cache and configure ccache.conf
+
+inputs:
+ cache-key-prefix:
+ description: Cache key prefix (e.g. ccache-sitl)
+ required: true
+ max-size:
+ description: Max ccache size (e.g. 300M)
+ required: false
+ default: '300M'
+ base-dir:
+ description: ccache base_dir value
+ required: false
+ default: '${GITHUB_WORKSPACE}'
+ install-ccache:
+ description: Install ccache via apt before configuring
+ required: false
+ default: 'false'
+
+outputs:
+ cache-primary-key:
+ description: Primary cache key (pass to save-ccache)
+ value: ${{ steps.restore.outputs.cache-primary-key }}
+
+runs:
+ using: composite
+ steps:
+ - name: Cache - Install ccache
+ if: inputs.install-ccache == 'true'
+ shell: bash
+ run: apt-get update && apt-get install -y ccache
+
+ - name: Cache - Restore ccache
+ id: restore
+ uses: actions/cache/restore@v5
+ with:
+ path: ~/.ccache
+ key: ${{ inputs.cache-key-prefix }}-${{ github.ref_name }}-${{ github.sha }}
+ restore-keys: |
+ ${{ inputs.cache-key-prefix }}-${{ github.ref_name }}-
+ ${{ inputs.cache-key-prefix }}-${{ github.base_ref || 'main' }}-
+ ${{ inputs.cache-key-prefix }}-
+
+ - name: Cache - Configure ccache
+ shell: bash
+ run: |
+ mkdir -p ~/.ccache
+ echo "base_dir = ${{ inputs.base-dir }}" > ~/.ccache/ccache.conf
+ echo "compression = true" >> ~/.ccache/ccache.conf
+ echo "compression_level = 6" >> ~/.ccache/ccache.conf
+ echo "max_size = ${{ inputs.max-size }}" >> ~/.ccache/ccache.conf
+ echo "hash_dir = false" >> ~/.ccache/ccache.conf
+ echo "compiler_check = content" >> ~/.ccache/ccache.conf
+ ccache -s
+ ccache -z
diff --git a/.github/instructions/board-addition.instructions.md b/.github/instructions/board-addition.instructions.md
new file mode 100644
index 0000000000..7fe83f4bfb
--- /dev/null
+++ b/.github/instructions/board-addition.instructions.md
@@ -0,0 +1,21 @@
+---
+applyTo: "boards/**"
+---
+
+# Board Addition Review Guidelines
+
+In addition to the core code review guidelines, when reviewing new board additions:
+
+- **Flight logs**: require a link to https://logs.px4.io demonstrating basic operation for the vehicle type (hover for multicopters, stable flight for fixed-wing, driving for rovers, etc.); short bench-only logs are insufficient
+- **Documentation**: require a docs page in `docs/en/flight_controller/` with pinout, where-to-buy, connector types, version badge, and manufacturer-supported notice block
+- **USB VID/PID**: must not reuse another manufacturer's Vendor ID; manufacturer must use their own
+- **Board naming**: directory is `boards/{manufacturer}/{board}/`, both lowercase, hyphens for board name
+- **Unique board_id**: registered in `boards/boards.json`, no collisions
+- **Copied code cleanup**: check for leftover files, configs, or comments from the template board. Ask "Is this real or leftover?"
+- **RC configuration**: prefer `CONFIG_DRIVERS_COMMON_RC` over legacy `CONFIG_DRIVERS_RC_INPUT`
+- **No board-specific custom modules**: reject copy-pasted drivers (e.g., custom heater) when existing infrastructure works
+- **Bootloader**: expect a bootloader defconfig (`nuttx-config/bootloader/defconfig`) or explanation of shared bootloader
+- **CI integration**: board must be added to CI compile workflows so it builds on every PR
+- **Flash constraints**: verify enabled modules fit in flash; we are running low across all board targets
+- **Port labels**: serial port labels must match what is physically printed on the board
+- **Hardware availability**: for unknown manufacturers, verify the product exists and is purchasable (no vaporware)
diff --git a/.github/instructions/ci-build.instructions.md b/.github/instructions/ci-build.instructions.md
new file mode 100644
index 0000000000..4d7f61ef97
--- /dev/null
+++ b/.github/instructions/ci-build.instructions.md
@@ -0,0 +1,13 @@
+---
+applyTo: ".github/**,cmake/**,Makefile,CMakeLists.txt,Tools/**,**/Kconfig"
+---
+
+# CI/Build Review Guidelines
+
+In addition to the core code review guidelines:
+
+- Check for pipeline race conditions (tag + branch push double-trigger, git describe correctness)
+- Container image size: check for layer bloat
+- Ubuntu LTS support policy: only latest + one prior LTS version
+- Consider build time impact of changes
+- Prefer CMake over Makefiles
diff --git a/.github/instructions/code-review.instructions.md b/.github/instructions/code-review.instructions.md
new file mode 100644
index 0000000000..469d741933
--- /dev/null
+++ b/.github/instructions/code-review.instructions.md
@@ -0,0 +1,32 @@
+---
+applyTo: "src/**,boards/**,platforms/**,msg/**,cmake/**,Makefile,CMakeLists.txt,Tools/**,.github/**"
+---
+
+# PX4 Code Review Guidelines
+
+## Conventions
+
+- PR titles must follow conventional commits: `type(scope): description` (see CONTRIBUTING.md)
+- Types: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `ci`, `chore`, `revert`
+- Scope should match the primary area of changed files
+- Append `!` before the colon for breaking changes
+- Both squash merge and rebase merge are enabled; merge commits are disabled
+- Commits should be atomic and independently revertable
+- WIP or review-response commits should be squashed before merge
+
+## Core Checks (always apply)
+
+- **Correctness**: logic errors, off-by-ones, unhandled edge cases
+- **Type safety**: int16 overflow, float/double promotion, unsigned subtraction, use `uint64_t` for absolute time
+- **Initialization**: uninitialized variables, missing default construction
+- **Buffer safety**: unchecked array access, stack allocation of large buffers, snprintf bounds
+- **Magic numbers**: every numeric literal needs a named constant or justification
+- **Framework reuse**: use PX4_ERR/WARN/INFO, existing libraries (AlphaFilter, SlewRate, RateControl), MAVLink constants from the library
+- **Naming**: accurate, no unjustified abbreviations, current terminology (GPS -> GNSS for new code)
+- **Unnecessary complexity**: can code be removed instead of added? Is there a simpler pattern?
+- **Test coverage**: new features should include unit or integration tests; bug fixes should include regression tests where practical
+- **Formatting**: `make format` / `make check_format` (astyle) for C/C++ files; `clang-tidy` clean
+- **Coding style**: C/C++ must follow the PX4 coding style (https://docs.px4.io/main/en/contribute/code.html)
+- **Necessity**: challenge every addition. Is this actually needed or just copied?
+- **Architecture fit**: does the code live in the module that naturally owns the data? No unnecessary cross-module dependencies
+- **Ecosystem impact**: consider QGC users, log analysis tools, and third-party integrations
diff --git a/.github/instructions/control.instructions.md b/.github/instructions/control.instructions.md
new file mode 100644
index 0000000000..1afa1f55c0
--- /dev/null
+++ b/.github/instructions/control.instructions.md
@@ -0,0 +1,15 @@
+---
+applyTo: "src/modules/mc_*control*/**,src/modules/fw_*control*/**,src/modules/flight_mode_manager/**,src/lib/rate_control/**,src/lib/npfg/**,src/modules/vtol_att_control/**"
+---
+
+# Control Review Guidelines
+
+In addition to the core code review guidelines:
+
+- Phase margin: output filters consume margin for no benefit; prefer adjusting gyro/d-gyro cutoffs
+- Check for circular dependencies: sensor data feeding back into its own control loop (e.g., throttle-based airspeed in TECS)
+- NaN propagation in flight-critical math; check `PX4_ISFINITE` before magnitude checks
+- Prefer proper setpoint smoothing over controller output filtering (setpoint generation vs output-stage hacks)
+- Check yaw control edge cases: heading lock, drift, setpoint propagation
+- Verify flight task inheritance chain uses the correct base class for desired behavior
+- Control allocation: verify actuator function ordering and motor index mapping
diff --git a/.github/instructions/drivers.instructions.md b/.github/instructions/drivers.instructions.md
new file mode 100644
index 0000000000..9fd6f630f7
--- /dev/null
+++ b/.github/instructions/drivers.instructions.md
@@ -0,0 +1,13 @@
+---
+applyTo: "src/drivers/**,src/modules/cyphal/**"
+---
+
+# Drivers/CAN Review Guidelines
+
+In addition to the core code review guidelines:
+
+- CAN bus devices behave differently from serial/SPI; check driver assumptions
+- ESC index mapping: telemetry index != channel when motors are disabled
+- ESC hardware quirks: 4-in-1 ESCs may report current on only one channel
+- Verify device_id correctness and I2CSPIDriver patterns
+- Time representation: prefer `hrt_abstime` over iteration counts
diff --git a/.github/instructions/estimation.instructions.md b/.github/instructions/estimation.instructions.md
new file mode 100644
index 0000000000..16c477d85c
--- /dev/null
+++ b/.github/instructions/estimation.instructions.md
@@ -0,0 +1,13 @@
+---
+applyTo: "src/modules/ekf2/**,src/lib/wind_estimator/**,src/lib/world_magnetic_model/**"
+---
+
+# Estimation Review Guidelines
+
+In addition to the core code review guidelines:
+
+- Check for singularities in aerospace math (euler angles near gimbal lock, sideslip at low airspeed)
+- Flag aliasing from downsampling sensor data without proper filtering
+- Verify Kalman filter correctness (Joseph form, innovation variance, covariance symmetry)
+- Consider CPU cost on embedded targets (avoid unnecessary sqrt, limit fusion rate)
+- Verify frame/coordinate system correctness (FRD vs NED, body vs earth frame)
diff --git a/.github/instructions/messages.instructions.md b/.github/instructions/messages.instructions.md
new file mode 100644
index 0000000000..add36ebb65
--- /dev/null
+++ b/.github/instructions/messages.instructions.md
@@ -0,0 +1,13 @@
+---
+applyTo: "msg/**,src/modules/mavlink/**,src/modules/uxrce_dds_client/**"
+---
+
+# Messages/Protocol Review Guidelines
+
+In addition to the core code review guidelines:
+
+- Backwards compatibility: will this break QGC, post-flight tools, or uLog parsers?
+- uORB: `timestamp` for publication metadata, `timestamp_sample` close to physical sample, include `device_id`
+- Don't version messages unless strictly needed
+- Parameter UX: will this confuse users in a GCS? Every new param is a configuration burden
+- MAVLink: use library constants, don't implement custom stream rates
diff --git a/.github/instructions/simulation.instructions.md b/.github/instructions/simulation.instructions.md
new file mode 100644
index 0000000000..ca26e87c52
--- /dev/null
+++ b/.github/instructions/simulation.instructions.md
@@ -0,0 +1,13 @@
+---
+applyTo: "src/modules/simulation/**,Tools/simulation/**"
+---
+
+# Simulation Review Guidelines
+
+In addition to the core code review guidelines:
+
+- Physics fidelity: noise models should match reality (GPS noise is not Gaussian)
+- Keep gz_bridge generic; vehicle-specific logic belongs in plugins
+- Prefer gz-transport over ROS2 dependencies when possible
+- Use wrench commands for physics correctness vs kinematic constraints
+- Library generic/specific boundary: only base classes in common libs
diff --git a/.github/instructions/system.instructions.md b/.github/instructions/system.instructions.md
new file mode 100644
index 0000000000..626efd9f18
--- /dev/null
+++ b/.github/instructions/system.instructions.md
@@ -0,0 +1,15 @@
+---
+applyTo: "src/modules/commander/**,src/modules/logger/**,src/systemcmds/**,platforms/**,src/modules/dataman/**"
+---
+
+# System Review Guidelines
+
+In addition to the core code review guidelines:
+
+- Race conditions and concurrency: no partial fixes, demand complete solutions
+- Semaphore/scheduling edge cases; understand RTOS guarantees
+- State machine sequential-logic bugs (consecutive RTL, armed/disarmed alternation)
+- Use uORB-driven scheduling (`SubscriptionCallback`), not extra threads
+- `param_set` triggers auto-save; no redundant `param_save_default`
+- Flash/memory efficiency: avoid `std::string` on embedded, minimize SubscriptionData usage
+- Constructor initialization order matters
diff --git a/.github/labeler.yml b/.github/labeler.yml
index e4d891c5c6..2449bbcae7 100644
--- a/.github/labeler.yml
+++ b/.github/labeler.yml
@@ -3,8 +3,331 @@
# Docs for the syntax in this file can be found at
# https://github.com/actions/labeler
-
-# Add 'Documentation' label to any changes within 'docs' folder or any subfolders
-"Documentation 📑":
+"scope:boards":
- changed-files:
- - any-glob-to-any-file: docs/**
+ - any-glob-to-any-file:
+ - boards/**
+ - src/drivers/px4io/**
+ - src/modules/px4iofirmware/**
+
+"scope:build-system":
+- changed-files:
+ - any-glob-to-any-file:
+ - CMakeLists.txt
+ - Makefile
+ - Kconfig
+ - cmake/**
+ - platforms/*/cmake/**
+ - platforms/*/Kconfig
+ - boards/**/Kconfig
+ - boards/**/*.cmake
+ - boards/**/*.px4board
+
+"scope:commander":
+- changed-files:
+ - any-glob-to-any-file:
+ - src/modules/commander/**
+
+"scope:control":
+- changed-files:
+ - any-glob-to-any-file:
+ - src/modules/*_att_control/**
+ - src/modules/*_rate_control/**
+ - src/modules/*_pos_control/**
+ - src/modules/*_mode_manager/**
+ - src/modules/control_allocator/**
+ - src/modules/flight_mode_manager/**
+ - src/modules/fw_lateral_longitudinal_control/**
+ - src/modules/mc_hover_thrust_estimator/**
+ - src/modules/mc_nn_control/**
+ - src/modules/mc_raptor/**
+ - src/modules/rover_*/**
+ - src/drivers/actuators/**
+ - src/drivers/dshot/**
+ - src/drivers/pwm_out/**
+ - src/drivers/pca9685_pwm_out/**
+ - src/drivers/tap_esc/**
+
+"scope:dependencies":
+- changed-files:
+ - any-glob-to-any-file:
+ - .gitmodules
+ - package.xml
+ - src/modules/mavlink/mavlink/**
+ - src/modules/uxrce_dds_client/Micro-XRCE-DDS-Client/**
+ - src/lib/crypto/monocypher/**
+ - src/lib/heatshrink/heatshrink/**
+ - platforms/nuttx/NuttX/**
+
+"scope:docs":
+- changed-files:
+ - any-glob-to-any-file:
+ - docs/**
+ - .github/instructions/docs*.md
+
+"scope:drivers":
+- changed-files:
+ - any-glob-to-any-file:
+ - src/drivers/**
+
+"scope:estimation":
+- changed-files:
+ - any-glob-to-any-file:
+ - src/modules/ekf2/**
+ - src/modules/local_position_estimator/**
+ - src/modules/attitude_estimator_q/**
+ - src/modules/landing_target_estimator/**
+ - src/modules/mag_bias_estimator/**
+ - src/modules/gyro_calibration/**
+ - src/modules/gyro_fft/**
+ - Tools/ecl_ekf/**
+
+"scope:infrastructure":
+- changed-files:
+ - any-glob-to-any-file:
+ - .devcontainer/**
+ - .github/**
+ - .vscode/**
+ - .clang-tidy
+ - .dockerignore
+ - Jenkinsfile
+ - Tools/ci/**
+ - Tools/docker/**
+
+"scope:logging":
+- changed-files:
+ - any-glob-to-any-file:
+ - src/modules/logger/**
+ - src/modules/replay/**
+ - src/modules/events/**
+ - src/modules/hardfault_stream/**
+ - src/lib/events/**
+ - Tools/flight_review/**
+ - Tools/log_encryption/**
+
+"scope:mavlink":
+- changed-files:
+ - any-glob-to-any-file:
+ - src/modules/mavlink/**
+ - src/drivers/telemetry/**
+ - src/drivers/transponder/**
+ - Tools/HIL/**
+
+"scope:middleware":
+- changed-files:
+ - any-glob-to-any-file:
+ - src/modules/muorb/**
+ - src/modules/uxrce_dds_client/**
+ - src/modules/zenoh/**
+ - src/drivers/cyphal/**
+ - src/drivers/uavcan/**
+ - src/drivers/uavcannode/**
+ - platforms/ros2/**
+ - msg/translation_node/**
+
+"scope:navigation":
+- changed-files:
+ - any-glob-to-any-file:
+ - src/modules/navigator/**
+ - src/modules/dataman/**
+ - src/modules/land_detector/**
+ - src/modules/payload_deliverer/**
+ - src/lib/collision_prevention/**
+ - src/lib/geofence/**
+ - src/lib/landing_slope/**
+ - src/lib/takeoff/**
+ - src/lib/weather_vane/**
+
+"scope:offboard":
+- changed-files:
+ - any-glob-to-any-file:
+ - src/modules/mavlink/mavlink_offboard.cpp
+ - src/modules/mavlink/mavlink_receiver.*
+ - src/modules/mavlink/streams/TRAJECTORY_REPRESENTATION_*.hpp
+ - src/modules/uxrce_dds_client/**
+ - src/modules/zenoh/**
+ - msg/OffboardControlMode.msg
+ - msg/TrajectorySetpoint.msg
+ - msg/versioned/TrajectorySetpoint.msg
+ - msg/versioned/VehicleCommand.msg
+ - msg/versioned/VehicleCommandAck.msg
+
+"scope:parameters":
+- changed-files:
+ - any-glob-to-any-file:
+ - "**/*_params.c"
+ - "**/*_params.cpp"
+ - "**/*_params.yaml"
+ - "**/parameters.c"
+ - "**/parameters.cpp"
+ - "**/module.yaml"
+ - src/lib/parameters/**
+ - src/modules/param/**
+ - Tools/param_metadata/**
+ - ROMFS/px4fmu_common/init.d*/**/*.params
+
+"scope:release":
+- changed-files:
+ - any-glob-to-any-file:
+ - docs/**/releases/**
+ - docs/**/release_process.md
+ - Tools/packaging/**
+ - platforms/**/package*.sh
+
+"scope:sensors":
+- changed-files:
+ - any-glob-to-any-file:
+ - src/modules/sensors/**
+ - src/modules/airspeed_selector/**
+ - src/modules/battery_status/**
+ - src/modules/esc_battery/**
+ - src/modules/temperature_compensation/**
+ - src/drivers/adc/**
+ - src/drivers/barometer/**
+ - src/drivers/batt_smbus/**
+ - src/drivers/differential_pressure/**
+ - src/drivers/distance_sensor/**
+ - src/drivers/gnss/**
+ - src/drivers/gps/**
+ - src/drivers/hygrometer/**
+ - src/drivers/imu/**
+ - src/drivers/ins/**
+ - src/drivers/irlock/**
+ - src/drivers/magnetometer/**
+ - src/drivers/optical_flow/**
+ - src/drivers/power_monitor/**
+ - src/drivers/pps_capture/**
+ - src/drivers/rpm/**
+ - src/drivers/rpm_capture/**
+ - src/drivers/smart_battery/**
+ - src/drivers/tattu_can/**
+ - src/drivers/temperature_sensor/**
+ - src/drivers/uwb/**
+ - src/drivers/wind_sensor/**
+
+"scope:simulation":
+- changed-files:
+ - any-glob-to-any-file:
+ - launch/**
+ - posix-configs/**
+ - src/modules/simulation/**
+ - Tools/simulation/**
+ - ROMFS/px4fmu_common/init.d-posix/**
+ - platforms/posix/**
+
+"scope:testing":
+- changed-files:
+ - any-glob-to-any-file:
+ - integrationtests/**
+ - test/**
+ - test_data/**
+ - validation/**
+ - src/systemcmds/tests/**
+ - src/examples/**
+ - "**/*[Tt]est.*"
+ - "**/*_test.*"
+ - "**/test_*.*"
+ - .github/workflows/*test*.yml
+ - .github/workflows/checks.yml
+ - .github/workflows/fuzzing.yml
+
+"scope:tools":
+- changed-files:
+ - any-glob-to-any-file:
+ - Tools/**
+ - msg/tools/**
+ - src/templates/**
+
+"scope:uorb":
+- changed-files:
+ - any-glob-to-any-file:
+ - msg/**
+ - srv/**
+ - platforms/common/uORB/**
+ - src/lib/uORB/**
+ - Tools/msg/**
+ - Tools/uorb_graph/**
+
+"kind:test":
+- changed-files:
+ - any-glob-to-any-file:
+ - integrationtests/**
+ - test/**
+ - test_data/**
+ - validation/**
+ - src/systemcmds/tests/**
+ - "**/*[Tt]est.*"
+ - "**/*_test.*"
+ - "**/test_*.*"
+
+"risk:safety-critical":
+- changed-files:
+ - any-glob-to-any-file:
+ - src/modules/commander/**
+ - src/modules/navigator/**
+ - src/modules/ekf2/**
+ - src/modules/sensors/**
+ - src/modules/*_control/**
+ - src/modules/control_allocator/**
+ - src/modules/land_detector/**
+ - src/modules/flight_mode_manager/**
+ - src/drivers/actuators/**
+ - src/drivers/imu/**
+ - src/drivers/ins/**
+ - src/drivers/pwm_out/**
+ - src/lib/collision_prevention/**
+ - src/lib/flight_tasks/**
+ - src/lib/geofence/**
+
+"risk:security":
+- changed-files:
+ - any-glob-to-any-file:
+ - SECURITY.md
+ - src/drivers/stub_keystore/**
+ - src/drivers/sw_crypto/**
+ - src/lib/crypto/**
+ - Tools/log_encryption/**
+ - Tools/test_keys/**
+
+"vehicle:airship":
+- changed-files:
+ - any-glob-to-any-file:
+ - src/modules/airship_att_control/**
+ - docs/**/frames_airship/**
+
+"vehicle:copter":
+- changed-files:
+ - any-glob-to-any-file:
+ - src/modules/mc_*/**
+ - docs/**/frames_multicopter/**
+ - docs/**/complete_vehicles_mc/**
+
+"vehicle:fixed-wing":
+- changed-files:
+ - any-glob-to-any-file:
+ - src/modules/fw_*/**
+ - docs/**/frames_plane/**
+ - docs/**/flying/fixed_wing*
+
+"vehicle:rover":
+- changed-files:
+ - any-glob-to-any-file:
+ - src/modules/rover_*/**
+ - docs/**/frames_rover/**
+
+"vehicle:spacecraft":
+- changed-files:
+ - any-glob-to-any-file:
+ - src/modules/spacecraft/**
+
+"vehicle:uuv":
+- changed-files:
+ - any-glob-to-any-file:
+ - src/modules/uuv_*/**
+ - docs/**/frames_sub/**
+
+"vehicle:vtol":
+- changed-files:
+ - any-glob-to-any-file:
+ - src/modules/vtol_att_control/**
+ - docs/**/frames_vtol/**
diff --git a/.github/workflows/build_all_targets.yml b/.github/workflows/build_all_targets.yml
index 4c0bc0fcb9..7c30d8bf24 100644
--- a/.github/workflows/build_all_targets.yml
+++ b/.github/workflows/build_all_targets.yml
@@ -69,32 +69,31 @@ jobs:
runs-on: [runs-on,runner=1cpu-linux-x64,image=ubuntu24-full-x64,"run-id=${{ github.run_id }}",spot=false]
outputs:
matrix: ${{ steps.set-matrix.outputs.matrix }}
+ seeders: ${{ steps.set-seeders.outputs.seeders }}
timestamp: ${{ steps.set-timestamp.outputs.timestamp }}
branchname: ${{ steps.set-branch.outputs.branchname }}
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
- name: Cache Python pip
- uses: actions/cache@v4
+ uses: actions/cache@v5
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**./Tools/setup/requirements.txt') }}
restore-keys: |
${{ runner.os }}-pip-
- - name: Update python packaging to avoid canonicalize_version() error
- run: |
- pip3 install -U packaging
-
- name: Install Python Dependencies
- uses: py-actions/py-dependency-install@v4
- with:
- path: "./Tools/setup/requirements.txt"
+ run: pip3 install -U packaging -r ./Tools/setup/requirements.txt
- id: set-matrix
name: Generate Build Matrix
run: echo "matrix=$(./Tools/ci/generate_board_targets_json.py --group)" >> $GITHUB_OUTPUT
+ - id: set-seeders
+ name: Generate Seeder Matrix
+ run: echo "seeders=$(./Tools/ci/generate_board_targets_json.py --group --seeders)" >> $GITHUB_OUTPUT
+
- id: set-timestamp
name: Save Current Timestamp
run: echo "timestamp=$(date +"%Y%m%d%H%M%S")" >> $GITHUB_OUTPUT
@@ -116,11 +115,52 @@ jobs:
echo "${{ steps.set-branch.outputs.branchname }}"
echo "$(./Tools/ci/generate_board_targets_json.py --group --verbose)"
+ # ===========================================================================
+ # CACHE SEEDER JOBS
+ # ===========================================================================
+ # Build one representative target per chip family to warm the ccache.
+ # Matrix jobs fall back to these caches via restore-keys when no
+ # group-specific cache exists yet. If any seeder fails, the build matrix
+ # does not start, catching common build errors early.
+ # ===========================================================================
+
+ seed:
+ name: Seed [${{ matrix.chip_family }}]
+ needs: group_targets
+ runs-on: [runs-on,"runner=8cpu-linux-${{ matrix.runner }}","image=ubuntu24-full-${{ matrix.runner }}","run-id=${{ github.run_id }}",spot=false,extras=s3-cache]
+ strategy:
+ matrix: ${{ fromJson(needs.group_targets.outputs.seeders) }}
+ fail-fast: false
+ container:
+ image: ${{ matrix.container }}
+ credentials:
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+ steps:
+ - uses: runs-on/action@v2
+ - uses: actions/checkout@v6
+ with:
+ fetch-depth: 1
+ - name: Configure Git Safe Directory
+ run: git config --system --add safe.directory '*'
+ - uses: ./.github/actions/setup-ccache
+ id: ccache
+ with:
+ cache-key-prefix: ccache-${{ matrix.chip_family }}-${{ matrix.runner }}-seeder
+ max-size: 400M
+ - name: Build seed target
+ run: make ${{ matrix.target }}
+ - uses: ./.github/actions/save-ccache
+ if: always()
+ with:
+ cache-primary-key: ${{ steps.ccache.outputs.cache-primary-key }}
+
setup:
name: Build [${{ matrix.runner }}][${{ matrix.group }}]
# runs-on: ubuntu-latest
- runs-on: [runs-on,"runner=8cpu-linux-${{ matrix.runner }}","image=ubuntu24-full-${{ matrix.runner }}","run-id=${{ github.run_id }}",spot=false]
- needs: group_targets
+ runs-on: [runs-on,"runner=4cpu-linux-${{ matrix.runner }}","image=ubuntu24-full-${{ matrix.runner }}","run-id=${{ github.run_id }}",spot=false,extras=s3-cache]
+ needs: [group_targets, seed]
+ if: "!failure() && !cancelled()"
strategy:
matrix: ${{ fromJson(needs.group_targets.outputs.matrix) }}
fail-fast: false
@@ -131,41 +171,35 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: runs-on/action@v2
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
with:
fetch-depth: 0
-
- - name: Git ownership workaround
+ - name: Configure Git Safe Directory
run: git config --system --add safe.directory '*'
- # ccache key breakdown:
- # ccache----
- # ccache----
- # ccache----
- - name: Cache Restore from Key
- id: cc_restore
- uses: actions/cache/restore@v4
+ - name: Cache - Restore ccache
+ id: ccache-restore
+ uses: actions/cache/restore@v5
with:
path: ~/.ccache
- key: ${{ format('ccache-{0}-{1}-{2}', runner.os, matrix.runner, matrix.group) }}
+ key: ccache-${{ matrix.chip_family }}-${{ matrix.runner }}-${{ matrix.group }}-${{ github.ref_name }}-${{ github.sha }}
restore-keys: |
- ccache-${{ runner.os }}-${{ matrix.runner }}-${{ matrix.group }}-
- ccache-${{ runner.os }}-${{ matrix.runner }}-
- ccache-${{ runner.os }}-${{ matrix.runner }}-
- ccache-${{ runner.os }}-
- ccache-
+ ccache-${{ matrix.chip_family }}-${{ matrix.runner }}-${{ matrix.group }}-${{ github.ref_name }}-
+ ccache-${{ matrix.chip_family }}-${{ matrix.runner }}-${{ matrix.group }}-${{ github.base_ref || 'main' }}-
+ ccache-${{ matrix.chip_family }}-${{ matrix.runner }}-${{ matrix.group }}-
+ ccache-${{ matrix.chip_family }}-${{ matrix.runner }}-
- - name: Cache Config and Stats
+ - name: Cache - Configure ccache
run: |
- mkdir -p ~/.ccache
- echo "base_dir = ${GITHUB_WORKSPACE}" > ~/.ccache/ccache.conf
- echo "compression = true" >> ~/.ccache/ccache.conf
- echo "compression_level = 6" >> ~/.ccache/ccache.conf
- echo "max_size = 120M" >> ~/.ccache/ccache.conf
- echo "hash_dir = false" >> ~/.ccache/ccache.conf
- echo "compiler_check = content" >> ~/.ccache/ccache.conf
- ccache -s
- ccache -z
+ mkdir -p ~/.ccache
+ echo "base_dir = ${GITHUB_WORKSPACE}" > ~/.ccache/ccache.conf
+ echo "compression = true" >> ~/.ccache/ccache.conf
+ echo "compression_level = 6" >> ~/.ccache/ccache.conf
+ echo "max_size = ${{ matrix.cache_size }}" >> ~/.ccache/ccache.conf
+ echo "hash_dir = false" >> ~/.ccache/ccache.conf
+ echo "compiler_check = content" >> ~/.ccache/ccache.conf
+ ccache -s
+ ccache -z
- name: Building Artifacts for [${{ matrix.targets }}]
run: |
@@ -176,23 +210,15 @@ jobs:
./Tools/ci/package_build_artifacts.sh
- name: Upload Build Artifacts
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v7
with:
name: px4_${{matrix.group}}_build_artifacts
path: artifacts/
- - name: Cache Post Build Stats
- if: always()
- run: |
- ccache -s
- ccache -z
-
- - name: Cache Save
- if: always()
- uses: actions/cache/save@v4
+ - uses: ./.github/actions/save-ccache
+ if: success()
with:
- path: ~/.ccache
- key: ${{ steps.cc_restore.outputs.cache-primary-key }}
+ cache-primary-key: ${{ steps.ccache-restore.outputs.cache-primary-key }}
# ===========================================================================
# ARTIFACT UPLOAD JOB
@@ -211,7 +237,7 @@ jobs:
uploadlocation: ${{ steps.upload-location.outputs.uploadlocation }}
steps:
- name: Download Artifacts
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v8
with:
path: artifacts/
merge-multiple: true
@@ -265,5 +291,8 @@ jobs:
with:
draft: true
prerelease: ${{ steps.upload-location.outputs.is_prerelease == 'true' }}
- files: artifacts/*.px4
+ files: |
+ artifacts/*.px4
+ artifacts/*.deb
+ artifacts/**/*.sbom.spdx.json
name: ${{ steps.upload-location.outputs.uploadlocation }}
diff --git a/.github/workflows/build_deb_package.yml b/.github/workflows/build_deb_package.yml
new file mode 100644
index 0000000000..2ec0203f36
--- /dev/null
+++ b/.github/workflows/build_deb_package.yml
@@ -0,0 +1,218 @@
+name: SITL Packages and Containers
+
+on:
+ push:
+ tags: ['v*']
+ pull_request:
+ paths:
+ - 'cmake/package.cmake'
+ - 'platforms/posix/CMakeLists.txt'
+ - 'Tools/packaging/**'
+ - 'boards/px4/sitl/sih.px4board'
+ - '.github/workflows/build_deb_package.yml'
+ - '.github/actions/build-deb/**'
+ workflow_dispatch:
+ inputs:
+ deploy_containers:
+ description: 'Push container images to registry'
+ required: false
+ type: boolean
+ default: false
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+permissions:
+ contents: read
+ packages: write
+
+jobs:
+
+ # ---------------------------------------------------------------------------
+ # Setup: extract version and determine whether to push containers
+ # ---------------------------------------------------------------------------
+ setup:
+ name: Setup
+ runs-on: [runs-on,"runner=1cpu-linux-x64","image=ubuntu24-full-x64","run-id=${{ github.run_id }}",extras=s3-cache,spot=false]
+ outputs:
+ px4_version: ${{ steps.version.outputs.px4_version }}
+ should_push: ${{ steps.push.outputs.should_push }}
+ steps:
+ - uses: runs-on/action@v2
+ - uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+ fetch-tags: true
+
+ - name: Set PX4 version
+ id: version
+ run: echo "px4_version=$(git describe --tags --match 'v[0-9]*')" >> $GITHUB_OUTPUT
+
+ - name: Check if we should push containers
+ id: push
+ run: |
+ if [[ "${{ startsWith(github.ref, 'refs/tags/') }}" == "true" ]] || \
+ [[ "${{ github.event_name }}" == "workflow_dispatch" && "${{ github.event.inputs.deploy_containers }}" == "true" ]]; then
+ echo "should_push=true" >> $GITHUB_OUTPUT
+ else
+ echo "should_push=false" >> $GITHUB_OUTPUT
+ fi
+
+ # ---------------------------------------------------------------------------
+ # Build .deb packages (all distros, arches, targets)
+ # ---------------------------------------------------------------------------
+ build-deb:
+ name: "Build .deb (${{ matrix.target }}/${{ matrix.codename }}/${{ matrix.arch }})"
+ needs: setup
+ runs-on: [runs-on,"runner=4cpu-linux-${{ matrix.runner }}","image=ubuntu24-full-${{ matrix.runner }}","run-id=${{ github.run_id }}",extras=s3-cache,spot=false]
+ container:
+ image: ${{ matrix.container }}
+ volumes:
+ - /github/workspace:/github/workspace
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - { codename: noble, arch: amd64, runner: x64, container: "ubuntu:24.04", target: default, setup_flags: "" }
+ - { codename: noble, arch: arm64, runner: arm64, container: "ubuntu:24.04", target: default, setup_flags: "" }
+ - { codename: jammy, arch: amd64, runner: x64, container: "ubuntu:22.04", target: default, setup_flags: "" }
+ - { codename: jammy, arch: arm64, runner: arm64, container: "ubuntu:22.04", target: default, setup_flags: "" }
+ - { codename: noble, arch: amd64, runner: x64, container: "ubuntu:24.04", target: sih, setup_flags: "--no-sim-tools" }
+ - { codename: noble, arch: arm64, runner: arm64, container: "ubuntu:24.04", target: sih, setup_flags: "--no-sim-tools" }
+ - { codename: jammy, arch: amd64, runner: x64, container: "ubuntu:22.04", target: sih, setup_flags: "--no-sim-tools" }
+ - { codename: jammy, arch: arm64, runner: arm64, container: "ubuntu:22.04", target: sih, setup_flags: "--no-sim-tools" }
+ env:
+ RUNS_IN_DOCKER: true
+ steps:
+ - uses: runs-on/action@v2
+
+ - name: Fix git in container
+ run: |
+ apt-get update && apt-get install -y git
+ git config --global --add safe.directory $(realpath .)
+
+ - uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+ fetch-tags: true
+
+ - name: Use AWS regional apt mirror
+ if: startsWith(runner.name, 'runs-on--')
+ run: ./Tools/ci/use_aws_apt_mirror.sh
+
+ - name: Cache apt packages
+ uses: actions/cache@v5
+ with:
+ path: /var/cache/apt/archives
+ key: apt-${{ matrix.target }}-${{ matrix.codename }}-${{ matrix.arch }}-${{ hashFiles('Tools/setup/ubuntu.sh') }}
+ restore-keys: apt-${{ matrix.target }}-${{ matrix.codename }}-${{ matrix.arch }}-
+
+ - name: Install dependencies
+ run: ./Tools/setup/ubuntu.sh --no-nuttx ${{ matrix.setup_flags }}
+
+ - name: Build and package .deb
+ uses: ./.github/actions/build-deb
+ with:
+ target: ${{ matrix.target }}
+ artifact-name: px4-sitl-debs-${{ matrix.target }}-${{ matrix.codename }}-${{ matrix.arch }}
+ ccache-key-prefix: deb-ccache-${{ matrix.target }}-${{ matrix.codename }}-${{ matrix.arch }}
+
+ # ---------------------------------------------------------------------------
+ # Build Docker images from Noble .debs
+ # ---------------------------------------------------------------------------
+ build-docker:
+ name: "Build Image (${{ matrix.image }}/${{ matrix.arch }})"
+ needs: [setup, build-deb]
+ runs-on: [runs-on,"runner=4cpu-linux-${{ matrix.runner }}","image=ubuntu24-full-${{ matrix.runner }}","run-id=${{ github.run_id }}",extras=s3-cache,spot=false]
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - { image: sih, repo: px4-sitl, target: sih, arch: amd64, runner: x64, platform: "linux/amd64", dockerfile: Dockerfile.sih }
+ - { image: sih, repo: px4-sitl, target: sih, arch: arm64, runner: arm64, platform: "linux/arm64", dockerfile: Dockerfile.sih }
+ - { image: gazebo, repo: px4-sitl-gazebo, target: default, arch: amd64, runner: x64, platform: "linux/amd64", dockerfile: Dockerfile.gazebo }
+ - { image: gazebo, repo: px4-sitl-gazebo, target: default, arch: arm64, runner: arm64, platform: "linux/arm64", dockerfile: Dockerfile.gazebo }
+ steps:
+ - uses: runs-on/action@v2
+ - uses: actions/checkout@v6
+ with:
+ fetch-depth: 1
+
+ - name: Download Noble .deb artifact
+ uses: actions/download-artifact@v8
+ with:
+ name: px4-sitl-debs-${{ matrix.target }}-noble-${{ matrix.arch }}
+ path: docker-context
+
+ - name: Prepare build context
+ run: cp Tools/packaging/px4-entrypoint.sh docker-context/
+
+ - name: Login to registries
+ if: needs.setup.outputs.should_push == 'true'
+ run: |
+ echo "${{ secrets.DOCKERHUB_TOKEN }}" | docker login -u "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
+ echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v4
+ with:
+ driver: docker-container
+ platforms: ${{ matrix.platform }}
+
+ - name: Build and push container image
+ uses: docker/build-push-action@v7
+ with:
+ context: docker-context
+ file: Tools/packaging/${{ matrix.dockerfile }}
+ tags: |
+ px4io/${{ matrix.repo }}:${{ needs.setup.outputs.px4_version }}-${{ matrix.arch }}
+ px4io/${{ matrix.repo }}:latest-${{ matrix.arch }}
+ ghcr.io/px4/${{ matrix.repo }}:${{ needs.setup.outputs.px4_version }}-${{ matrix.arch }}
+ ghcr.io/px4/${{ matrix.repo }}:latest-${{ matrix.arch }}
+ platforms: ${{ matrix.platform }}
+ load: false
+ push: ${{ needs.setup.outputs.should_push == 'true' }}
+ provenance: false
+ cache-from: type=gha,scope=sitl-${{ matrix.image }}-${{ matrix.arch }}
+ cache-to: type=gha,mode=max,scope=sitl-${{ matrix.image }}-${{ matrix.arch }}
+
+ # ---------------------------------------------------------------------------
+ # Deploy: create multi-arch manifests and push to registries
+ # ---------------------------------------------------------------------------
+ deploy:
+ name: "Deploy (${{ matrix.image }})"
+ needs: [setup, build-docker]
+ if: needs.setup.outputs.should_push == 'true'
+ runs-on: [runs-on,"runner=1cpu-linux-x64","image=ubuntu24-full-x64","run-id=${{ github.run_id }}",extras=s3-cache,spot=false]
+ strategy:
+ matrix:
+ include:
+ - { image: sih, repo: px4-sitl }
+ - { image: gazebo, repo: px4-sitl-gazebo }
+ steps:
+ - uses: runs-on/action@v2
+
+ - name: Login to registries
+ run: |
+ echo "${{ secrets.DOCKERHUB_TOKEN }}" | docker login -u "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
+ echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin
+
+ - name: Create and push multi-arch manifests
+ run: |
+ VERSION="${{ needs.setup.outputs.px4_version }}"
+
+ for REGISTRY in px4io ghcr.io/px4; do
+ IMAGE="${REGISTRY}/${{ matrix.repo }}"
+
+ for TAG in ${VERSION} latest; do
+ docker manifest create ${IMAGE}:${TAG} \
+ --amend ${IMAGE}:${TAG}-arm64 \
+ --amend ${IMAGE}:${TAG}-amd64
+
+ docker manifest annotate ${IMAGE}:${TAG} ${IMAGE}:${TAG}-arm64 --arch arm64
+ docker manifest annotate ${IMAGE}:${TAG} ${IMAGE}:${TAG}-amd64 --arch amd64
+
+ docker manifest push ${IMAGE}:${TAG}
+ done
+ done
diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml
index e43feee8b3..9013ab11f7 100644
--- a/.github/workflows/checks.yml
+++ b/.github/workflows/checks.yml
@@ -12,49 +12,88 @@ on:
paths-ignore:
- 'docs/**'
+permissions:
+ contents: read
+
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
- build:
- runs-on: ubuntu-latest
-
+ gate_checks:
+ name: Gate Checks [${{ matrix.check }}]
+ runs-on: [runs-on,runner=2cpu-linux-x64,image=ubuntu24-full-x64,"run-id=${{ github.run_id }}",spot=false,extras=s3-cache]
container:
- image: px4io/px4-dev:v1.16.0-rc1-258-g0369abd556
-
+ image: ghcr.io/px4/px4-dev:v1.17.0-rc2
strategy:
- fail-fast: false
+ fail-fast: true
matrix:
check: [
"check_format",
"check_newlines",
- "tests",
- "tests_coverage",
- "px4_fmu-v2_default stack_check",
"validate_module_configs",
"shellcheck_all",
- "NO_NINJA_BUILD=1 px4_fmu-v5_default",
- "NO_NINJA_BUILD=1 px4_sitl_default",
- "px4_sitl_allyes",
"module_documentation",
]
-
steps:
- - uses: actions/checkout@v4
+ - uses: runs-on/action@v2
+ - uses: actions/checkout@v6
with:
- fetch-depth: 0
+ fetch-depth: 1
+ - name: Configure Git Safe Directory
+ run: git config --system --add safe.directory '*'
- name: Building [${{ matrix.check }}]
- run: |
- cd "$GITHUB_WORKSPACE"
- git config --global --add safe.directory "$GITHUB_WORKSPACE"
- make ${{ matrix.check }}
+ env:
+ PX4_SBOM_DISABLE: 1
+ run: make ${{ matrix.check }}
- - name: Uploading Coverage to Codecov.io
- if: contains(matrix.check, 'coverage')
- uses: codecov/codecov-action@v1
+ tests:
+ name: Unit Tests
+ runs-on: [runs-on,runner=8cpu-linux-x64,image=ubuntu24-full-x64,"run-id=${{ github.run_id }}",spot=false,extras=s3-cache]
+ container:
+ image: ghcr.io/px4/px4-dev:v1.17.0-rc2
+ permissions:
+ contents: write
+ env:
+ GIT_COMMITTER_EMAIL: bot@px4.io
+ GIT_COMMITTER_NAME: PX4BuildBot
+ steps:
+ - uses: runs-on/action@v2
+ - uses: actions/checkout@v6
with:
- token: ${{ secrets.CODECOV_TOKEN }}
- flags: unittests
- file: coverage/lcov.info
+ fetch-depth: 1
+ - name: Configure Git Safe Directory
+ run: git config --system --add safe.directory '*'
+
+ - uses: ./.github/actions/setup-ccache
+ id: ccache
+ with:
+ cache-key-prefix: ccache-sitl
+ max-size: 300M
+
+ - name: Build and run unit tests
+ env:
+ PX4_SBOM_DISABLE: 1
+ run: make tests
+
+ - uses: ./.github/actions/save-ccache
+ if: always()
+ with:
+ cache-primary-key: ${{ steps.ccache.outputs.cache-primary-key }}
+
+ - name: Auto-update EKF change indication baselines
+ if: github.event_name == 'push'
+ uses: stefanzweifel/git-auto-commit-action@v7
+ with:
+ file_pattern: 'src/modules/ekf2/test/change_indication/*.csv'
+ commit_user_name: ${{ env.GIT_COMMITTER_NAME }}
+ commit_user_email: ${{ env.GIT_COMMITTER_EMAIL }}
+ commit_message: |
+ [AUTO COMMIT] update EKF change indication
+
+ See .github/workflows/checks.yml for more details
+
+ - name: Check for EKF functional changes
+ run: git diff --exit-code
+ working-directory: src/modules/ekf2/test/change_indication
diff --git a/.github/workflows/clang-tidy.yml b/.github/workflows/clang-tidy.yml
index fc3c5a46ea..e62718751a 100644
--- a/.github/workflows/clang-tidy.yml
+++ b/.github/workflows/clang-tidy.yml
@@ -16,14 +16,21 @@ permissions:
contents: read
jobs:
- clang_tidy:
+ # Push-to-main: unchanged historical behavior. Single clang build dir
+ # with BUILD_TESTING=OFF. `make clang-tidy` builds and analyzes every
+ # TU in compile_commands.json. Test files are not in the DB and
+ # therefore never analyzed.
+ clang_tidy_push:
name: Clang-Tidy
+ if: github.event_name != 'pull_request'
runs-on: [runs-on, runner=16cpu-linux-x64, "run-id=${{ github.run_id }}", "extras=s3-cache"]
container:
- image: px4io/px4-dev:v1.17.0-beta1
+ image: ghcr.io/px4/px4-dev:v1.17.0-rc2
+ permissions:
+ contents: read
steps:
- uses: runs-on/action@v2
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
with:
fetch-depth: 0
fetch-tags: true
@@ -31,39 +38,124 @@ jobs:
- name: Configure Git Safe Directory
run: git config --system --add safe.directory '*'
- - name: Restore Compiler Cache
- id: cc_restore
- uses: actions/cache/restore@v4
+ - uses: ./.github/actions/setup-ccache
+ id: ccache
with:
- path: ~/.ccache
- key: ccache-clang-tidy-${{ github.head_ref || github.ref_name }}
- restore-keys: |
- ccache-clang-tidy-${{ github.head_ref || github.ref_name }}-
- ccache-clang-tidy-main-
- ccache-clang-tidy-
+ cache-key-prefix: ccache-clang-tidy
+ max-size: 150M
- - name: Configure Compiler Cache
+ - name: Build and Analyze - Clang-Tidy
+ run: make -j$(nproc) clang-tidy
+
+ - uses: ./.github/actions/save-ccache
+ if: always()
+ with:
+ cache-primary-key: ${{ steps.ccache.outputs.cache-primary-key }}
+
+ # Pull request: diff-based analysis with a second BUILD_TESTING=ON
+ # build dir so test files in the PR diff can be linted by
+ # clang-tidy-diff-18.py with resolved gtest/fuzztest includes.
+ # Results are uploaded as a `pr-review` artifact for the PR Review
+ # Poster workflow to post as inline comments.
+ clang_tidy_pr:
+ name: Clang-Tidy
+ if: github.event_name == 'pull_request'
+ runs-on: [runs-on, runner=8cpu-linux-x64, "run-id=${{ github.run_id }}", "extras=s3-cache"]
+ container:
+ image: ghcr.io/px4/px4-dev:v1.17.0-rc2
+ permissions:
+ contents: read
+ pull-requests: read
+ steps:
+ - uses: runs-on/action@v2
+ - uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+ fetch-tags: true
+
+ - name: Configure Git Safe Directory
+ run: git config --system --add safe.directory '*'
+
+ - uses: ./.github/actions/setup-ccache
+ id: ccache
+ with:
+ cache-key-prefix: ccache-clang-tidy
+ max-size: 150M
+
+ # fuzztest (enabled via BUILD_TESTING in the -test build dir) pulls
+ # in abseil via FetchContent, and abseil runs a try_compile with
+ # fuzztest's -fsanitize=address flags. The px4-dev container ships
+ # clang but not the clang compiler-rt runtime, so that link fails
+ # and the configure reports a misleading "pthreads not found".
+ # libclang-rt-18-dev provides libclang_rt.asan and friends.
+ - name: Install clang compiler-rt
run: |
- mkdir -p ~/.ccache
- echo "base_dir = ${GITHUB_WORKSPACE}" > ~/.ccache/ccache.conf
- echo "compression = true" >> ~/.ccache/ccache.conf
- echo "compression_level = 6" >> ~/.ccache/ccache.conf
- echo "max_size = 120M" >> ~/.ccache/ccache.conf
- echo "hash_dir = false" >> ~/.ccache/ccache.conf
- echo "compiler_check = content" >> ~/.ccache/ccache.conf
- ccache -s
- ccache -z
+ apt-get update
+ apt-get install -y --no-install-recommends libclang-rt-18-dev
+
+ # `make clang-ci` prepares both clang build directories:
+ # - build/px4_sitl_default-clang: full build, BUILD_TESTING=OFF
+ # (used by run-clang-tidy-pr.py for whole-file analysis of
+ # changed production code)
+ # - build/px4_sitl_default-clang-test: configure-only, BUILD_TESTING=ON
+ # (used by clang-tidy-diff-18.py so test files are in the
+ # compilation database with resolved gtest/fuzztest includes)
+ - name: Build clang SITL
+ run: make -j$(nproc) clang-ci
- name: Run Clang-Tidy Analysis
- run: make -j16 clang-tidy
+ run: python3 Tools/ci/run-clang-tidy-pr.py origin/${{ github.base_ref }}
- - name: Compiler Cache Stats
+ # Produce a `pr-review` artifact for the PR Review Poster workflow
+ # to consume. clang-tidy-diff-18 emits a unified fixes.yml that
+ # the producer script translates into line-anchored review comments.
+ - name: Export clang-tidy fixes for PR review
if: always()
- run: ccache -s
+ run: |
+ mkdir -p pr-review
+ # Drop changed C/C++ source files that are not in
+ # compile_commands.json for the test-enabled build. Files not
+ # in the DB are platform-specific sources, vendored code, or
+ # submodule code we don't own. Feeding them to clang-tidy-diff
+ # produces false positives from unresolved headers.
+ python3 Tools/ci/clang-tidy-diff-filter.py \
+ --build-dir build/px4_sitl_default-clang-test \
+ --base-ref origin/${{ github.base_ref }} \
+ --out pr-review/diff.patch
+ if [ -s pr-review/diff.patch ]; then
+ clang-tidy-diff-18.py -p1 \
+ -path build/px4_sitl_default-clang-test \
+ -export-fixes pr-review/fixes.yml \
+ -j0 < pr-review/diff.patch || true
+ else
+ echo "No analyzable files in diff; skipping clang-tidy-diff"
+ fi
- - name: Save Compiler Cache
+ - name: Build pr-review artifact
if: always()
- uses: actions/cache/save@v4
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ python3 Tools/ci/clang-tidy-fixes-to-review.py \
+ --fixes pr-review/fixes.yml \
+ --repo-root "$GITHUB_WORKSPACE" \
+ --repo "$GITHUB_REPOSITORY" \
+ --pr-number "${{ github.event.pull_request.number }}" \
+ --commit-sha "${{ github.event.pull_request.head.sha }}" \
+ --out-dir pr-review \
+ --event COMMENT
+
+ - name: Upload pr-review artifact
+ if: always()
+ uses: actions/upload-artifact@v7
with:
- path: ~/.ccache
- key: ${{ steps.cc_restore.outputs.cache-primary-key }}
+ name: pr-review
+ path: |
+ pr-review/manifest.json
+ pr-review/comments.json
+ retention-days: 1
+
+ - uses: ./.github/actions/save-ccache
+ if: always()
+ with:
+ cache-primary-key: ${{ steps.ccache.outputs.cache-primary-key }}
diff --git a/.github/workflows/commit_checks.yml b/.github/workflows/commit_checks.yml
new file mode 100644
index 0000000000..b3bf0882b8
--- /dev/null
+++ b/.github/workflows/commit_checks.yml
@@ -0,0 +1,148 @@
+name: Commit Quality
+
+on:
+ pull_request:
+ types: [opened, edited, synchronize, reopened]
+
+permissions:
+ contents: read
+ pull-requests: write
+ issues: write
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+env:
+ PR_NUMBER: ${{ github.event.pull_request.number }}
+ IS_FORK: ${{ github.event.pull_request.head.repo.full_name != github.repository }}
+
+jobs:
+ pr-title:
+ name: PR Title
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+ with:
+ sparse-checkout: Tools/ci
+ fetch-depth: 1
+
+ - name: Check PR title
+ id: check
+ run: |
+ python3 Tools/ci/check_pr_title.py "${{ github.event.pull_request.title }}" --markdown-file comment.md && rc=0 || rc=$?
+ echo "exit_code=$rc" >> "$GITHUB_OUTPUT"
+
+ - name: Post or clear comment
+ if: env.IS_FORK == 'false'
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ if [ "${{ steps.check.outputs.exit_code }}" != "0" ]; then
+ python3 Tools/ci/pr_comment.py --marker pr-title --pr "$PR_NUMBER" --result fail < comment.md
+ else
+ python3 Tools/ci/pr_comment.py --marker pr-title --pr "$PR_NUMBER" --result pass
+ fi
+
+ - name: Result
+ if: steps.check.outputs.exit_code != '0'
+ run: |
+ echo "::error::PR title does not follow conventional commits format. See the PR comment for details."
+ exit 1
+
+ commit-messages:
+ name: Commit Messages
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+ with:
+ sparse-checkout: Tools/ci
+ fetch-depth: 1
+
+ - name: Check commit messages
+ id: check
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ gh api \
+ "repos/${{ github.repository }}/pulls/${PR_NUMBER}/commits?per_page=100" \
+ | python3 Tools/ci/check_commit_messages.py --markdown-file comment.md && rc=0 || rc=$?
+ echo "exit_code=$rc" >> "$GITHUB_OUTPUT"
+ # Check for warnings (non-empty markdown on exit 0)
+ if [ "$rc" -eq 0 ] && [ -s comment.md ]; then
+ echo "has_warnings=true" >> "$GITHUB_OUTPUT"
+ else
+ echo "has_warnings=false" >> "$GITHUB_OUTPUT"
+ fi
+
+ - name: Post or clear comment
+ if: env.IS_FORK == 'false'
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ if [ "${{ steps.check.outputs.exit_code }}" != "0" ]; then
+ python3 Tools/ci/pr_comment.py --marker commit-msgs --pr "$PR_NUMBER" --result fail < comment.md
+ elif [ "${{ steps.check.outputs.has_warnings }}" == "true" ]; then
+ python3 Tools/ci/pr_comment.py --marker commit-msgs --pr "$PR_NUMBER" --result warn < comment.md
+ else
+ python3 Tools/ci/pr_comment.py --marker commit-msgs --pr "$PR_NUMBER" --result pass
+ fi
+
+ - name: Result
+ if: steps.check.outputs.exit_code != '0'
+ run: |
+ echo "::error::Commit message errors found. See the PR comment for details."
+ exit 1
+
+ pr-body:
+ name: PR Description
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ if: env.IS_FORK == 'false'
+ uses: actions/checkout@v6
+ with:
+ sparse-checkout: Tools/ci
+ fetch-depth: 1
+
+ - name: Check PR body
+ id: check
+ env:
+ PR_BODY: ${{ github.event.pull_request.body }}
+ run: |
+ message=""
+ if [ -z "$PR_BODY" ]; then
+ message="PR description is empty. Please add a summary of the changes."
+ echo "::warning::PR description is empty."
+ else
+ cleaned=$(echo "$PR_BODY" | sed 's///g' | tr -d '[:space:]')
+ if [ -z "$cleaned" ]; then
+ message="PR description contains only template comments. Please fill in the details."
+ echo "::warning::PR description contains only template comments."
+ fi
+ fi
+ echo "message=$message" >> "$GITHUB_OUTPUT"
+
+ - name: Post or clear comment
+ if: env.IS_FORK == 'false'
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ if [ -n "${{ steps.check.outputs.message }}" ]; then
+ printf '%s\n' \
+ "## PR Description (advisory)" \
+ "" \
+ "This is **not blocking**, but your PR description appears to be empty or incomplete." \
+ "" \
+ "${{ steps.check.outputs.message }}" \
+ "" \
+ "A good PR description helps reviewers understand what changed and why." \
+ "" \
+ "---" \
+ "*This comment will be automatically removed once the issue is resolved.*" \
+ | python3 Tools/ci/pr_comment.py --marker pr-body --pr "$PR_NUMBER" --result warn
+ else
+ python3 Tools/ci/pr_comment.py --marker pr-body --pr "$PR_NUMBER" --result pass
+ fi
diff --git a/.github/workflows/compile_macos.yml b/.github/workflows/compile_macos.yml
index 5e011a5bf6..899d21e3a0 100644
--- a/.github/workflows/compile_macos.yml
+++ b/.github/workflows/compile_macos.yml
@@ -19,49 +19,51 @@ concurrency:
jobs:
build:
runs-on: macos-latest
- strategy:
- matrix:
- config: [
- px4_fmu-v5_default,
- px4_sitl
- ]
steps:
- name: install Python 3.10
- uses: actions/setup-python@v5
+ uses: actions/setup-python@v6
with:
python-version: "3.10"
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
+
+ - name: Cache - Restore Homebrew Packages
+ uses: actions/cache@v5
+ with:
+ path: ~/Library/Caches/Homebrew/downloads
+ key: macos-homebrew-${{ runner.arch }}-${{ hashFiles('Tools/setup/macos.sh') }}
+ restore-keys: |
+ macos-homebrew-${{ runner.arch }}-
+
+ - name: Cache - Restore pip Packages
+ uses: actions/cache@v5
+ with:
+ path: ~/Library/Caches/pip
+ key: macos-pip-${{ runner.arch }}-${{ hashFiles('Tools/setup/requirements.txt') }}
+ restore-keys: |
+ macos-pip-${{ runner.arch }}-
- name: setup
run: |
./Tools/setup/macos.sh
+ echo "${{ github.workspace }}/.venv/bin" >> $GITHUB_PATH
- - name: Prepare ccache timestamp
- id: ccache_cache_timestamp
- shell: cmake -P {0}
- run: |
- string(TIMESTAMP current_date "%Y-%m-%d-%H;%M;%S" UTC)
- file(APPEND "$ENV{GITHUB_OUTPUT}" "timestamp=${current_date}\n")
- - name: ccache cache files
- uses: actions/cache@v4
+ - uses: ./.github/actions/setup-ccache
+ id: ccache
with:
- path: ~/.ccache
- key: macos_${{matrix.config}}-ccache-${{steps.ccache_cache_timestamp.outputs.timestamp}}
- restore-keys: macos_${{matrix.config}}-ccache-
- - name: setup ccache
- run: |
- mkdir -p ~/.ccache
- echo "base_dir = ${GITHUB_WORKSPACE}" > ~/.ccache/ccache.conf
- echo "compression = true" >> ~/.ccache/ccache.conf
- echo "compression_level = 6" >> ~/.ccache/ccache.conf
- echo "max_size = 40M" >> ~/.ccache/ccache.conf
- echo "hash_dir = false" >> ~/.ccache/ccache.conf
- ccache -s
- ccache -z
+ cache-key-prefix: ccache-macos
+ max-size: 200M
- - name: make ${{matrix.config}}
- run: |
- ccache -z
- make ${{matrix.config}}
- ccache -s
+ - name: Build px4_sitl
+ run: make px4_sitl
+
+ - name: Cache - Stats after px4_sitl
+ run: ccache -s
+
+ - name: Build px4_fmu-v5_default
+ run: make px4_fmu-v5_default
+
+ - uses: ./.github/actions/save-ccache
+ if: always()
+ with:
+ cache-primary-key: ${{ steps.ccache.outputs.cache-primary-key }}
diff --git a/.github/workflows/compile_ubuntu.yml b/.github/workflows/compile_ubuntu.yml
index 019081b9f6..e7e9c45f31 100644
--- a/.github/workflows/compile_ubuntu.yml
+++ b/.github/workflows/compile_ubuntu.yml
@@ -4,9 +4,6 @@ on:
push:
branches:
- 'main'
- - 'stable'
- - 'beta'
- - 'release/**'
paths-ignore:
- 'docs/**'
pull_request:
@@ -29,12 +26,13 @@ jobs:
fail-fast: false
matrix:
version: ['ubuntu:22.04', 'ubuntu:24.04']
- runs-on: [runs-on,runner=4cpu-linux-x64,"image=ubuntu24-full-x64","run-id=${{ github.run_id }}",spot=false]
+ runs-on: [runs-on,runner=4cpu-linux-x64,"image=ubuntu24-full-x64","run-id=${{ github.run_id }}",spot=false,extras=s3-cache]
container:
image: ${{ matrix.version }}
volumes:
- /github/workspace:/github/workspace
steps:
+ - uses: runs-on/action@v2
- name: Fix git in container
run: |
@@ -47,11 +45,28 @@ jobs:
apt update && apt install git -y
git config --global --add safe.directory $(realpath .)
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
- - name: Install Deps, Build, and Make Quick Check
- run: |
- # we need to install dependencies and build on the same step
- # given the stateless nature of docker images
- ./Tools/setup/ubuntu.sh
- make quick_check
+ - name: Use AWS regional apt mirror
+ if: startsWith(runner.name, 'runs-on--')
+ run: ./Tools/ci/use_aws_apt_mirror.sh
+
+ - name: Install Deps
+ run: ./Tools/setup/ubuntu.sh
+
+ - uses: ./.github/actions/setup-ccache
+ id: ccache
+ with:
+ cache-key-prefix: ccache-ubuntu-${{ matrix.version }}
+ max-size: 200M
+
+ - name: Build px4_sitl_default
+ run: make px4_sitl_default
+
+ - name: Build px4_fmu-v5_default
+ run: make px4_fmu-v5_default
+
+ - uses: ./.github/actions/save-ccache
+ if: always()
+ with:
+ cache-primary-key: ${{ steps.ccache.outputs.cache-primary-key }}
diff --git a/.github/workflows/dev_container.yml b/.github/workflows/dev_container.yml
index 8922d636cd..715ddd3930 100644
--- a/.github/workflows/dev_container.yml
+++ b/.github/workflows/dev_container.yml
@@ -4,9 +4,6 @@ on:
push:
branches:
- 'main'
- - 'stable'
- - 'beta'
- - 'release/**'
tags:
- 'v*'
pull_request:
@@ -24,6 +21,11 @@ on:
description: 'Container tag (e.g. v1.16.0)'
required: true
type: string
+ build_ref:
+ description: 'Git ref to build from (branch, tag, or SHA). Leave empty to build from the dispatch ref.'
+ required: false
+ type: string
+ default: ''
deploy_to_registry:
description: 'Whether to push built images to the registry'
required: false
@@ -45,12 +47,12 @@ jobs:
meta_tags: ${{ steps.meta.outputs.tags }}
meta_labels: ${{ steps.meta.outputs.labels }}
steps:
- - uses: runs-on/action@v1
- - uses: actions/checkout@v4
+ - uses: runs-on/action@v2
+ - uses: actions/checkout@v6
with:
- fetch-tags: true
- submodules: false
+ ref: ${{ github.event.inputs.build_ref || github.ref }}
fetch-depth: 0
+ fetch-tags: true
# If manual dispatch, take the user‐provided input
- name: Set PX4 Tag Version
@@ -64,7 +66,7 @@ jobs:
- name: Extract metadata (tags, labels) for Docker
id: meta
- uses: docker/metadata-action@v5
+ uses: docker/metadata-action@v6
with:
images: |
ghcr.io/PX4/px4-dev
@@ -89,22 +91,22 @@ jobs:
runner: x64
runs-on: [runs-on,"runner=4cpu-linux-${{ matrix.runner }}","image=ubuntu24-full-${{ matrix.runner }}","run-id=${{ github.run_id }}",extras=s3-cache,spot=false]
steps:
- - uses: runs-on/action@v1
- - uses: actions/checkout@v4
+ - uses: runs-on/action@v2
+ - uses: actions/checkout@v6
with:
- fetch-tags: true
- submodules: false
+ ref: ${{ github.event.inputs.build_ref || github.ref }}
fetch-depth: 0
+ fetch-tags: true
- name: Login to Docker Hub
- uses: docker/login-action@v3
+ uses: docker/login-action@v4
if: ${{ startsWith(github.ref, 'refs/tags/') || (github.event_name == 'workflow_dispatch' && github.event.inputs.deploy_to_registry) }}
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
- uses: docker/login-action@v3
+ uses: docker/login-action@v4
if: ${{ startsWith(github.ref, 'refs/tags/') || (github.event_name == 'workflow_dispatch' && github.event.inputs.deploy_to_registry) }}
with:
registry: ghcr.io
@@ -112,13 +114,13 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v3
+ uses: docker/setup-buildx-action@v4
with:
driver: docker-container
platforms: ${{ matrix.platform }}
- name: Build and Load Container Image
- uses: docker/build-push-action@v6
+ uses: docker/build-push-action@v7
id: docker
with:
context: Tools/setup
@@ -130,8 +132,8 @@ jobs:
load: false
push: ${{ startsWith(github.ref, 'refs/tags/') || (github.event_name == 'workflow_dispatch' && github.event.inputs.deploy_to_registry) }}
provenance: false
- cache-from: type=gha,version=1,scope=${{ matrix.arch }}
- cache-to: type=gha,version=1,mode=max,scope=${{ matrix.arch }}
+ cache-from: type=gha,scope=${{ matrix.arch }}
+ cache-to: type=gha,mode=max,scope=${{ matrix.arch }},ignore-error=true
deploy:
name: Deploy To Registry
@@ -140,23 +142,26 @@ jobs:
packages: write
runs-on: [runs-on,"runner=4cpu-linux-x64","image=ubuntu24-full-x64","run-id=${{ github.run_id }}",extras=s3-cache,spot=false]
needs: [build, setup]
- if: ${{ startsWith(github.ref, 'refs/tags/') || (github.event_name == 'workflow_dispatch' && github.event.inputs.deploy_to_registry) }}
+ if: |
+ !cancelled() &&
+ needs.setup.result == 'success' &&
+ (startsWith(github.ref, 'refs/tags/') || (github.event_name == 'workflow_dispatch' && github.event.inputs.deploy_to_registry == 'true'))
steps:
- - uses: runs-on/action@v1
- - uses: actions/checkout@v4
+ - uses: runs-on/action@v2
+ - uses: actions/checkout@v6
with:
- fetch-tags: true
- submodules: false
+ ref: ${{ github.event.inputs.build_ref || github.ref }}
fetch-depth: 0
+ fetch-tags: true
- name: Login to Docker Hub
- uses: docker/login-action@v3
+ uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
- uses: docker/login-action@v3
+ uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -164,10 +169,10 @@ jobs:
- name: Verify Images Exist Before Creating Manifest
run: |
- docker manifest inspect px4io/px4-dev:${{ needs.setup.outputs.px4_version }}-arm64 || echo "⚠️ Warning: No ARM64 image found!"
- docker manifest inspect px4io/px4-dev:${{ needs.setup.outputs.px4_version }}-amd64 || echo "⚠️ Warning: No AMD64 image found!"
- docker manifest inspect ghcr.io/px4/px4-dev:${{ needs.setup.outputs.px4_version }}-arm64 || echo "⚠️ Warning: No ARM64 image found!"
- docker manifest inspect ghcr.io/px4/px4-dev:${{ needs.setup.outputs.px4_version }}-amd64 || echo "⚠️ Warning: No AMD64 image found!"
+ docker manifest inspect px4io/px4-dev:${{ needs.setup.outputs.px4_version }}-arm64
+ docker manifest inspect px4io/px4-dev:${{ needs.setup.outputs.px4_version }}-amd64
+ docker manifest inspect ghcr.io/px4/px4-dev:${{ needs.setup.outputs.px4_version }}-arm64
+ docker manifest inspect ghcr.io/px4/px4-dev:${{ needs.setup.outputs.px4_version }}-amd64
- name: Create and Push Multi-Arch Manifest for Docker Hub
run: |
diff --git a/.github/workflows/docs-orchestrator.yml b/.github/workflows/docs-orchestrator.yml
index ad4ef6c260..c725fa97ca 100644
--- a/.github/workflows/docs-orchestrator.yml
+++ b/.github/workflows/docs-orchestrator.yml
@@ -46,8 +46,8 @@ jobs:
source_changed: ${{ steps.changes.outputs.source }}
docs_changed: ${{ steps.changes.outputs.docs }}
steps:
- - uses: actions/checkout@v4
- - uses: dorny/paths-filter@v3
+ - uses: actions/checkout@v6
+ - uses: dorny/paths-filter@v4
id: changes
with:
filters: |
@@ -70,22 +70,19 @@ jobs:
contents: read
runs-on: [runs-on,runner=4cpu-linux-x64,image=ubuntu24-full-x64,"run-id=${{ github.run_id }}",spot=false,extras=s3-cache]
container:
- image: px4io/px4-dev:v1.17.0-beta1
+ image: ghcr.io/px4/px4-dev:v1.17.0-rc2
steps:
- - uses: runs-on/action@v1
-
- - name: Checkout
- uses: actions/checkout@v4
+ - uses: runs-on/action@v2
+ - uses: actions/checkout@v6
with:
fetch-depth: 0
submodules: recursive
-
- - name: Git ownership workaround
+ - name: Configure Git Safe Directory
run: git config --system --add safe.directory '*'
- name: Cache Restore - ccache
id: cache-ccache
- uses: actions/cache/restore@v4
+ uses: actions/cache/restore@v5
with:
path: ~/.ccache
key: ccache-docs-metadata-${{ github.sha }}
@@ -104,7 +101,7 @@ jobs:
CCACHE_DIR: ~/.ccache
- name: Cache Save - ccache
- uses: actions/cache/save@v4
+ uses: actions/cache/save@v5
if: always()
with:
path: ~/.ccache
@@ -116,7 +113,7 @@ jobs:
CCACHE_DIR: ~/.ccache
- name: Upload metadata artifact
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v7
with:
name: pr-metadata
path: docs/
@@ -132,12 +129,12 @@ jobs:
contents: write
runs-on: [runs-on,runner=4cpu-linux-x64,image=ubuntu24-full-x64,"run-id=${{ github.run_id }}",spot=false,extras=s3-cache]
container:
- image: px4io/px4-dev:v1.17.0-beta1
+ image: ghcr.io/px4/px4-dev:v1.17.0-rc2
steps:
- - uses: runs-on/action@v1
+ - uses: runs-on/action@v2
- name: Checkout
- uses: actions/checkout@v4
+ uses: actions/checkout@v6
with:
fetch-depth: 0
submodules: recursive
@@ -148,7 +145,7 @@ jobs:
- name: Cache Restore - ccache
id: cache-ccache
- uses: actions/cache/restore@v4
+ uses: actions/cache/restore@v5
with:
path: ~/.ccache
key: ccache-docs-metadata-${{ github.sha }}
@@ -167,7 +164,7 @@ jobs:
CCACHE_DIR: ~/.ccache
- name: Cache Save - ccache
- uses: actions/cache/save@v4
+ uses: actions/cache/save@v5
if: always()
with:
path: ~/.ccache
@@ -213,25 +210,24 @@ jobs:
if: always() && (github.event_name == 'pull_request')
permissions:
contents: read
- pull-requests: write
runs-on: ubuntu-latest
steps:
- name: Checkout
- uses: actions/checkout@v4
+ uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 0
- name: Download metadata artifact
if: needs.pr-metadata-regen.result == 'success'
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v8
with:
name: pr-metadata
path: docs/
- name: Get changed doc files
id: changed-files
- uses: tj-actions/changed-files@v46.0.5
+ uses: tj-actions/changed-files@v47
with:
json: true
write_output_files: true
@@ -248,13 +244,13 @@ jobs:
cat ./logs/prFiles.json
- name: Setup Node.js
- uses: actions/setup-node@v4
+ uses: actions/setup-node@v6
with:
node-version: 20
- name: Run filtered link checker (changed files)
run: |
- npm -g install markdown_link_checker_sc@0.0.138
+ npm -g install markdown_link_checker_sc@0.0.144
if [ "$(jq length ./logs/prFiles.json)" -gt 0 ]; then
markdown_link_checker_sc \
-r "$GITHUB_WORKSPACE" \
@@ -281,15 +277,35 @@ jobs:
> ./logs/link-check-results.md || true
cat ./logs/link-check-results.md
- - name: Post PR comment with link check results
- if: github.event.pull_request.head.repo.full_name == github.repository
- uses: marocchino/sticky-pull-request-comment@v2
+ - name: Prepare pr-comment artifact
+ id: prepare-pr-comment
+ run: |
+ if [ ! -s ./logs/filtered-link-check-results.md ]; then
+ echo "No link-check results file; skipping pr-comment artifact."
+ echo "prepared=false" >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+ mkdir -p pr-comment
+ cp ./logs/filtered-link-check-results.md pr-comment/body.md
+ cat > pr-comment/manifest.json <",
+ "mode": "upsert"
+ }
+ EOF
+ echo "prepared=true" >> "$GITHUB_OUTPUT"
+
+ - name: Upload pr-comment artifact
+ if: steps.prepare-pr-comment.outputs.prepared == 'true'
+ uses: actions/upload-artifact@v7
with:
- header: flaws
- path: ./logs/filtered-link-check-results.md
+ name: pr-comment
+ path: pr-comment/
+ retention-days: 1
- name: Upload link check results
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v7
with:
name: link-check-results
path: logs/
@@ -313,16 +329,14 @@ jobs:
branchname: ${{ steps.set-branch.outputs.branchname }}
releaseversion: ${{ steps.set-version.outputs.releaseversion }}
steps:
- - uses: runs-on/action@v1
-
- - name: Checkout
- uses: actions/checkout@v4
+ - uses: runs-on/action@v2
+ - uses: actions/checkout@v6
with:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
- name: Download metadata artifact (PR)
if: github.event_name == 'pull_request' && needs.pr-metadata-regen.result == 'success'
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v8
with:
name: pr-metadata
path: docs/
@@ -346,7 +360,7 @@ jobs:
echo "releaseversion=$version" >> $GITHUB_OUTPUT
- name: Setup Node
- uses: actions/setup-node@v4
+ uses: actions/setup-node@v6
with:
node-version: 20
cache: npm
@@ -365,7 +379,7 @@ jobs:
npm run docs:sitemap
- name: Upload artifact
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v7
with:
name: px4_docs_build
path: docs/.vitepress/dist/
@@ -387,7 +401,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Download Artifact
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v8
with:
name: px4_docs_build
path: ~/_book
diff --git a/.github/workflows/docs_crowdin_download.yml b/.github/workflows/docs_crowdin_download.yml
index 9fef0afb0e..3a07dbbc2f 100644
--- a/.github/workflows/docs_crowdin_download.yml
+++ b/.github/workflows/docs_crowdin_download.yml
@@ -22,7 +22,7 @@ jobs:
lc: [ko, uk, zh-CN] # Target languages https://developer.crowdin.com/language-codes/
steps:
- name: Checkout
- uses: actions/checkout@v4
+ uses: actions/checkout@v6
- name: Debug Environment Variables
run: |
echo "CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_DOCS_PROJECT_ID }}"
@@ -34,14 +34,14 @@ jobs:
upload_sources: false
upload_translations: false
download_translations: true
- commit_message: New Crowdin translations - ${{ matrix.lc }}
+ commit_message: 'docs(i18n): PX4 guide translations (Crowdin) - ${{ matrix.lc }}'
localization_branch_name: l10n_crowdin_docs_translations_${{ matrix.lc }}
crowdin_branch_name: main
create_pull_request: true
pull_request_base_branch_name: 'main'
- pull_request_title: New PX4 guide translations (Crowdin) - ${{ matrix.lc }}
- pull_request_body: 'New PX4 guide Crowdin translations by [Crowdin GH Action](https://github.com/crowdin/github-action) for ${{ matrix.lc }}'
- pull_request_labels: 'Documentation 📑'
+ pull_request_title: 'docs(i18n): PX4 guide translations (Crowdin) - ${{ matrix.lc }}'
+ pull_request_body: 'docs(i18n): PX4 guide Crowdin translations by [Crowdin GH Action](https://github.com/crowdin/github-action) for ${{ matrix.lc }}'
+ pull_request_labels: 'scope:docs'
pull_request_reviewers: hamishwillee
download_language: ${{ matrix.lc }}
env:
diff --git a/.github/workflows/docs_crowdin_upload.yml b/.github/workflows/docs_crowdin_upload.yml
index f5ec87a367..d55f459923 100644
--- a/.github/workflows/docs_crowdin_upload.yml
+++ b/.github/workflows/docs_crowdin_upload.yml
@@ -24,7 +24,7 @@ jobs:
steps:
- name: Checkout
- uses: actions/checkout@v4
+ uses: actions/checkout@v6
- name: crowdin push
uses: crowdin/github-action@v2
with:
diff --git a/.github/workflows/docs_deploy.yml b/.github/workflows/docs_deploy.yml
index bdf729b2b3..f5a2ccf3b7 100644
--- a/.github/workflows/docs_deploy.yml
+++ b/.github/workflows/docs_deploy.yml
@@ -22,12 +22,11 @@ jobs:
build:
runs-on: [runs-on,runner=4cpu-linux-x64,image=ubuntu24-full-x64,"run-id=${{ github.run_id }}",extras=s3-cache,spot=false]
steps:
- - uses: runs-on/action@v1
- - name: Checkout
- uses: actions/checkout@v4
+ - uses: runs-on/action@v2
+ - uses: actions/checkout@v6
- name: Setup Node
- uses: actions/setup-node@v4
+ uses: actions/setup-node@v6
with:
node-version: 20
cache: npm
@@ -46,7 +45,7 @@ jobs:
- name: Upload artifact
if: ${{ github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.merged) || github.event_name == 'workflow_dispatch' }}
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v7
with:
name: px4_docs_build
path: docs/.vitepress/dist/
@@ -59,7 +58,7 @@ jobs:
steps:
- name: Download Artifact
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v8
with:
name: px4_docs_build
path: ~/_book
diff --git a/.github/workflows/ekf_functional_change_indicator.yml b/.github/workflows/ekf_functional_change_indicator.yml
deleted file mode 100644
index 11c1970680..0000000000
--- a/.github/workflows/ekf_functional_change_indicator.yml
+++ /dev/null
@@ -1,35 +0,0 @@
-name: EKF Change Indicator
-
-on:
- pull_request:
- branches:
- - '**'
- paths-ignore:
- - 'docs/**'
-
-# If two events are triggered within a short time in the same PR, cancel the run of the oldest event
-concurrency:
- group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
- cancel-in-progress: true
-
-jobs:
- unit_tests:
- runs-on: ubuntu-latest
-
- container:
- image: px4io/px4-dev:v1.16.0-rc1-258-g0369abd556
-
- steps:
- - uses: actions/checkout@v4
- with:
- fetch-depth: 0
-
- - name: main test
- run: |
- cd "$GITHUB_WORKSPACE"
- git config --global --add safe.directory "$GITHUB_WORKSPACE"
- make tests TESTFILTER=EKF
-
- - name: Check if there is a functional change
- run: git diff --exit-code
- working-directory: src/modules/ekf2/test/change_indication
diff --git a/.github/workflows/ekf_update_change_indicator.yml b/.github/workflows/ekf_update_change_indicator.yml
deleted file mode 100644
index 6f6e1dde55..0000000000
--- a/.github/workflows/ekf_update_change_indicator.yml
+++ /dev/null
@@ -1,54 +0,0 @@
-name: EKF Update Change Indicator
-
-on:
- push:
- paths-ignore:
- - 'docs/**'
-
-jobs:
- unit_tests:
- runs-on: ubuntu-latest
-
- container:
- image: px4io/px4-dev:v1.16.0-rc1-258-g0369abd556
-
- env:
- GIT_COMMITTER_EMAIL: bot@px4.io
- GIT_COMMITTER_NAME: PX4BuildBot
-
- steps:
- - uses: actions/checkout@v4
- with:
- fetch-depth: 0
-
- - name: main test
- run: |
- cd "$GITHUB_WORKSPACE"
- git config --global --add safe.directory "$GITHUB_WORKSPACE"
- make tests TESTFILTER=EKF
-
- - name: Check if there exists diff and save result in variable
- id: diff-check
- working-directory: src/modules/ekf2/test/change_indication
- run: |
- if git diff --quiet; then
- echo "CHANGE_INDICATED=false" >> $GITHUB_OUTPUT
- else
- echo "CHANGE_INDICATED=true" >> $GITHUB_OUTPUT
- fi
-
- - name: auto-commit any changes to change indication
- if: steps.diff-check.outputs.CHANGE_INDICATED == 'true'
- uses: stefanzweifel/git-auto-commit-action@v4
- with:
- file_pattern: 'src/modules/ekf2/test/change_indication/*.csv'
- commit_user_name: ${{ env.GIT_COMMITTER_NAME }}
- commit_user_email: ${{ env.GIT_COMMITTER_EMAIL }}
- commit_message: |
- [AUTO COMMIT] update change indication
-
- See .github/workflows/ekf_update_change_indicator.yml for more details
-
- - name: if there is a functional change, fail check
- if: steps.diff-check.outputs.CHANGE_INDICATED == 'true'
- run: exit 1
diff --git a/.github/workflows/failsafe_sim.yml b/.github/workflows/failsafe_sim.yml
index 24cdb49550..161d018adb 100644
--- a/.github/workflows/failsafe_sim.yml
+++ b/.github/workflows/failsafe_sim.yml
@@ -18,7 +18,7 @@ concurrency:
jobs:
build:
- runs-on: ubuntu-latest
+ runs-on: [runs-on,runner=4cpu-linux-x64,image=ubuntu24-full-x64,"run-id=${{ github.run_id }}",spot=false,extras=s3-cache]
defaults:
run:
shell: bash
@@ -29,22 +29,30 @@ jobs:
"failsafe_web",
]
container:
- image: px4io/px4-dev:v1.16.0-rc1-258-g0369abd556
+ image: ghcr.io/px4/px4-dev:v1.17.0-rc2
options: --privileged --ulimit core=-1 --security-opt seccomp=unconfined
steps:
+ - uses: runs-on/action@v2
+ - uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+ - name: Configure Git Safe Directory
+ run: git config --system --add safe.directory '*'
+
- name: Install Node v20.18.0
- uses: actions/setup-node@v4
+ uses: actions/setup-node@v6
with:
node-version: 20.18.0
- - uses: actions/checkout@v4
+ - name: Cache - Restore Emscripten SDK
+ id: cache-emsdk
+ uses: actions/cache@v5
with:
- fetch-depth: 0
-
- - name: Git ownership workaround
- run: git config --system --add safe.directory '*'
+ path: _emscripten_sdk
+ key: emsdk-4.0.15
- name: Install empscripten
+ if: steps.cache-emsdk.outputs.cache-hit != 'true'
run: |
git clone https://github.com/emscripten-core/emsdk.git _emscripten_sdk
cd _emscripten_sdk
diff --git a/.github/workflows/flash_analysis.yml b/.github/workflows/flash_analysis.yml
index 7f126352ab..861053843a 100644
--- a/.github/workflows/flash_analysis.yml
+++ b/.github/workflows/flash_analysis.yml
@@ -24,9 +24,9 @@ env:
jobs:
analyze_flash:
name: Analyzing ${{ matrix.target }}
- runs-on: [runs-on,runner=4cpu-linux-x64,image=ubuntu24-full-x64,"run-id=${{ github.run_id }}",spot=false]
+ runs-on: [runs-on,runner=4cpu-linux-x64,image=ubuntu24-full-x64,"run-id=${{ github.run_id }}",spot=false,extras=s3-cache]
container:
- image: px4io/px4-dev:v1.16.0-rc1-258-g0369abd556
+ image: ghcr.io/px4/px4-dev:v1.17.0-rc2
strategy:
matrix:
target: [px4_fmu-v5x, px4_fmu-v6x]
@@ -36,25 +36,58 @@ jobs:
px4_fmu-v6x-bloaty-output: ${{ steps.gen-output.outputs.px4_fmu-v6x-bloaty-output }}
px4_fmu-v6x-bloaty-summary-map: ${{ steps.gen-output.outputs.px4_fmu-v6x-bloaty-summary-map }}
steps:
- - uses: actions/checkout@v4
+ - uses: runs-on/action@v2
+ - uses: actions/checkout@v6
with:
fetch-depth: 0
submodules: recursive
-
- - name: Git ownership workaround
+ - name: Configure Git Safe Directory
run: git config --system --add safe.directory '*'
+ - name: Cache - Restore ccache (current)
+ id: cache_current
+ uses: actions/cache/restore@v5
+ with:
+ path: ~/.ccache
+ key: ccache-flash-${{ matrix.target }}-current-${{ github.ref_name }}-${{ github.sha }}
+ restore-keys: |
+ ccache-flash-${{ matrix.target }}-current-${{ github.ref_name }}-
+ ccache-flash-${{ matrix.target }}-current-
+
+ - name: Cache - Configure ccache
+ run: |
+ mkdir -p ~/.ccache
+ echo "base_dir = ${GITHUB_WORKSPACE}" > ~/.ccache/ccache.conf
+ echo "compression = true" >> ~/.ccache/ccache.conf
+ echo "compression_level = 6" >> ~/.ccache/ccache.conf
+ echo "max_size = 200M" >> ~/.ccache/ccache.conf
+ echo "hash_dir = false" >> ~/.ccache/ccache.conf
+ echo "compiler_check = content" >> ~/.ccache/ccache.conf
+ ccache -s
+ ccache -z
+
- name: Build Target
run: make ${{ matrix.target }}_flash-analysis
- name: Store the ELF with the change
run: cp ./build/**/*.elf ./with-change.elf
+ - name: Cache - Stats after Current Build
+ run: ccache -s
+
+ - name: Cache - Save ccache (current)
+ if: always()
+ uses: actions/cache/save@v5
+ with:
+ path: ~/.ccache
+ key: ${{ steps.cache_current.outputs.cache-primary-key }}
+
- name: Clean previous build
run: |
make clean
make distclean
make submodulesclean
+ ccache -C
- name: If it's a PR checkout the base branch
if: ${{ github.event.pull_request }}
@@ -68,12 +101,34 @@ jobs:
- name: Update submodules
run: make submodulesupdate
+ - name: Cache - Restore ccache (baseline)
+ id: cache_baseline
+ uses: actions/cache/restore@v5
+ with:
+ path: ~/.ccache
+ key: ccache-flash-${{ matrix.target }}-baseline-${{ github.sha }}
+ restore-keys: |
+ ccache-flash-${{ matrix.target }}-baseline-
+
+ - name: Cache - Reset ccache stats
+ run: ccache -z
+
- name: Build
run: make ${{ matrix.target }}_flash-analysis
- name: Store the ELF before the change
run: cp ./build/**/*.elf ./before-change.elf
+ - name: Cache - Stats after Baseline Build
+ run: ccache -s
+
+ - name: Cache - Save ccache (baseline)
+ if: always()
+ uses: actions/cache/save@v5
+ with:
+ path: ~/.ccache
+ key: ${{ steps.cache_baseline.outputs.cache-primary-key }}
+
- name: bloaty-action
uses: PX4/bloaty-action@v1.0.0
id: bloaty-step
@@ -93,9 +148,6 @@ jobs:
echo '${{ steps.bloaty-step.outputs.bloaty-summary-map }}' >> $GITHUB_OUTPUT
echo "$EOF" >> $GITHUB_OUTPUT
- # TODO:
- # This part of the workflow is causing errors for forks. We should find a way to fix this and enable it again for forks.
- # Track this issue https://github.com/PX4/PX4-Autopilot/issues/24408
post_pr_comment:
name: Publish Results
runs-on: [runs-on,runner=1cpu-linux-x64,image=ubuntu24-full-x64,"run-id=${{ github.run_id }}"]
@@ -105,22 +157,22 @@ jobs:
V5X-SUMMARY-MAP-PERC: ${{ fromJSON(fromJSON(needs.analyze_flash.outputs.px4_fmu-v5x-bloaty-summary-map).vm-percentage) }}
V6X-SUMMARY-MAP-ABS: ${{ fromJSON(fromJSON(needs.analyze_flash.outputs.px4_fmu-v6x-bloaty-summary-map).vm-absolute) }}
V6X-SUMMARY-MAP-PERC: ${{ fromJSON(fromJSON(needs.analyze_flash.outputs.px4_fmu-v6x-bloaty-summary-map).vm-percentage) }}
- if: github.event.pull_request && github.event.pull_request.head.repo.full_name == github.repository
+ if: github.event.pull_request
steps:
- name: Find Comment
- uses: peter-evans/find-comment@v3
+ uses: peter-evans/find-comment@v4
id: fc
with:
issue-number: ${{ github.event.pull_request.number }}
comment-author: 'github-actions[bot]'
- body-includes: FLASH Analysis
+ body-includes: ''
- name: Set Build Time
id: bt
run: |
echo "timestamp=$(date +'%Y-%m-%dT%H:%M:%S')" >> $GITHUB_OUTPUT
- - name: Create or update comment
+ - name: Write pr-comment artifact
# This can't be moved to the job-level conditions, as GH actions don't allow a job-level if condition to access the env.
if: |
steps.fc.outputs.comment-id != '' ||
@@ -128,27 +180,46 @@ jobs:
env.V5X-SUMMARY-MAP-ABS <= fromJSON(env.MIN_FLASH_NEG_DIFF_FOR_COMMENT) ||
env.V6X-SUMMARY-MAP-ABS >= fromJSON(env.MIN_FLASH_POS_DIFF_FOR_COMMENT) ||
env.V6X-SUMMARY-MAP-ABS <= fromJSON(env.MIN_FLASH_NEG_DIFF_FOR_COMMENT)
- uses: peter-evans/create-or-update-comment@v4
+ run: |
+ mkdir -p pr-comment
+ cat > pr-comment/manifest.json <",
+ "mode": "upsert"
+ }
+ EOF
+ cat > pr-comment/body.md <<'PR_COMMENT_BODY_EOF'
+
+ ## 🔎 FLASH Analysis
+
+ px4_fmu-v5x [Total VM Diff: ${{ env.V5X-SUMMARY-MAP-ABS }} byte (${{ env.V5X-SUMMARY-MAP-PERC}} %)]
+
+ ```
+ ${{ needs.analyze_flash.outputs.px4_fmu-v5x-bloaty-output }}
+ ```
+
+
+
+ px4_fmu-v6x [Total VM Diff: ${{ env.V6X-SUMMARY-MAP-ABS }} byte (${{ env.V6X-SUMMARY-MAP-PERC }} %)]
+
+ ```
+ ${{ needs.analyze_flash.outputs.px4_fmu-v6x-bloaty-output }}
+ ```
+
+
+ **Updated: _${{ steps.bt.outputs.timestamp }}_**
+ PR_COMMENT_BODY_EOF
+
+ - name: Upload pr-comment artifact
+ if: |
+ steps.fc.outputs.comment-id != '' ||
+ env.V5X-SUMMARY-MAP-ABS >= fromJSON(env.MIN_FLASH_POS_DIFF_FOR_COMMENT) ||
+ env.V5X-SUMMARY-MAP-ABS <= fromJSON(env.MIN_FLASH_NEG_DIFF_FOR_COMMENT) ||
+ env.V6X-SUMMARY-MAP-ABS >= fromJSON(env.MIN_FLASH_POS_DIFF_FOR_COMMENT) ||
+ env.V6X-SUMMARY-MAP-ABS <= fromJSON(env.MIN_FLASH_NEG_DIFF_FOR_COMMENT)
+ uses: actions/upload-artifact@v7
with:
- comment-id: ${{ steps.fc.outputs.comment-id }}
- issue-number: ${{ github.event.pull_request.number }}
- body: |
- ## 🔎 FLASH Analysis
-
- px4_fmu-v5x [Total VM Diff: ${{ env.V5X-SUMMARY-MAP-ABS }} byte (${{ env.V5X-SUMMARY-MAP-PERC}} %)]
-
- ```
- ${{ needs.analyze_flash.outputs.px4_fmu-v5x-bloaty-output }}
- ```
-
-
-
- px4_fmu-v6x [Total VM Diff: ${{ env.V6X-SUMMARY-MAP-ABS }} byte (${{ env.V6X-SUMMARY-MAP-PERC }} %)]
-
- ```
- ${{ needs.analyze_flash.outputs.px4_fmu-v6x-bloaty-output }}
- ```
-
-
- **Updated: _${{ steps.bt.outputs.timestamp }}_**
- edit-mode: replace
+ name: pr-comment
+ path: pr-comment/
+ retention-days: 1
diff --git a/.github/workflows/fuzzing.yml b/.github/workflows/fuzzing.yml
index 5fc26f8063..0248b3503e 100644
--- a/.github/workflows/fuzzing.yml
+++ b/.github/workflows/fuzzing.yml
@@ -12,19 +12,25 @@ env:
jobs:
Fuzzing:
- runs-on: ubuntu-latest
+ runs-on: [runs-on,runner=4cpu-linux-x64,image=ubuntu24-full-x64,"run-id=${{ github.run_id }}",spot=false,extras=s3-cache]
container:
image: px4io/px4-dev:v1.16.0-rc2-4-gb67c65bfe6
steps:
- - name: Install Dependencies
- run: |
- apt update && apt install -y clang
+ - uses: runs-on/action@v2
- - name: Fix git in Container
- run: |
- git config --global --add safe.directory $(realpath .)
+ - uses: actions/checkout@v6
- - uses: actions/checkout@v4
+ - name: Configure Git Safe Directory
+ run: git config --system --add safe.directory '*'
+
+ - name: Install clang
+ run: apt-get update && apt-get install -y clang
+
+ - uses: ./.github/actions/setup-ccache
+ id: ccache
+ with:
+ cache-key-prefix: ccache-sitl
+ max-size: 300M
- name: Build and Run Fuzz Tests
run: |
@@ -38,7 +44,11 @@ jobs:
./Tools/ci/run_fuzz_tests.sh $fuzz_binary 15m
done
- # Create a github issue in case of a failure
+ - uses: ./.github/actions/save-ccache
+ if: always()
+ with:
+ cache-primary-key: ${{ steps.ccache.outputs.cache-primary-key }}
+
- name: Create Issue
if: ${{ failure() }}
uses: JasonEtco/create-an-issue@v2
diff --git a/.github/workflows/issue_triage_label.yml b/.github/workflows/issue_triage_label.yml
new file mode 100644
index 0000000000..def41e0267
--- /dev/null
+++ b/.github/workflows/issue_triage_label.yml
@@ -0,0 +1,24 @@
+name: Issue Triage Label
+
+on:
+ issues:
+ types: [opened]
+
+permissions:
+ issues: write
+
+jobs:
+ add-triage-label:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Add status:needs-triage label to new issues
+ uses: actions/github-script@v7
+ with:
+ script: |
+ await github.rest.issues.addLabels({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: context.payload.issue.number,
+ labels: ['status:needs-triage'],
+ });
diff --git a/.github/workflows/itcm_check.yml b/.github/workflows/itcm_check.yml
index 767f69aa7c..242d986646 100644
--- a/.github/workflows/itcm_check.yml
+++ b/.github/workflows/itcm_check.yml
@@ -22,9 +22,9 @@ concurrency:
jobs:
check_itcm:
name: Checking ${{ matrix.target }}
- runs-on: [runs-on,runner=4cpu-linux-x64,image=ubuntu24-full-x64,"run-id=${{ github.run_id }}",spot=false]
+ runs-on: [runs-on,runner=4cpu-linux-x64,image=ubuntu24-full-x64,"run-id=${{ github.run_id }}",spot=false,extras=s3-cache]
container:
- image: px4io/px4-dev:v1.16.0-rc1-258-g0369abd556
+ image: ghcr.io/px4/px4-dev:v1.17.0-rc2
strategy:
fail-fast: false
matrix:
@@ -46,14 +46,19 @@ jobs:
boards/nxp/mr-tropic/nuttx-config/scripts/itcm_functions_includes.ld
boards/nxp/mr-tropic/nuttx-config/scripts/itcm_static_functions.ld
steps:
- - uses: actions/checkout@v4
+ - uses: runs-on/action@v2
+ - uses: actions/checkout@v6
with:
- fetch-depth: 0
- submodules: recursive
-
- - name: Git ownership workaround
+ fetch-depth: 1
+ - name: Configure Git Safe Directory
run: git config --system --add safe.directory '*'
+ - uses: ./.github/actions/setup-ccache
+ id: ccache
+ with:
+ cache-key-prefix: ccache-itcm-${{ matrix.target }}
+ max-size: 200M
+
- name: Build Target
run: make ${{ matrix.target }}
@@ -65,3 +70,8 @@ jobs:
- name: Execute the itcm-check
run: python3 Tools/itcm_check.py --elf-file built.elf --script-files ${{ matrix.scripts }}
+
+ - uses: ./.github/actions/save-ccache
+ if: always()
+ with:
+ cache-primary-key: ${{ steps.ccache.outputs.cache-primary-key }}
diff --git a/.github/workflows/label.yml b/.github/workflows/label.yml
index 1a69cfdbf5..f9341d8ca8 100644
--- a/.github/workflows/label.yml
+++ b/.github/workflows/label.yml
@@ -1,11 +1,13 @@
-# This workflow will triage pull requests and apply a label based on the
-# paths that are modified in the pull request.
-# The paths are set up in .github/labeler.yml
+# This workflow will triage pull requests and apply labels based on the
+# PR title prefix and paths that are modified in the pull request.
+# The path labels are set up in .github/labeler.yml
#
# See: https://github.com/actions/labeler
name: Labeler
-on: [pull_request_target]
+on:
+ pull_request_target:
+ types: [opened, edited, synchronize, reopened, ready_for_review]
jobs:
label:
@@ -13,9 +15,45 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: read
+ issues: write
pull-requests: write
steps:
- - uses: actions/labeler@v5
+ - name: Label PR title type
+ env:
+ GH_TOKEN: ${{ github.token }}
+ GH_REPO: ${{ github.repository }}
+ PR_NUMBER: ${{ github.event.pull_request.number }}
+ PR_TITLE: ${{ github.event.pull_request.title }}
+ run: |
+ set -euo pipefail
+
+ if [[ ! "$PR_TITLE" =~ ^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)\([a-zA-Z0-9_./-]+\)!?:\ .{5,}$ ]]; then
+ echo "No conventional commit type detected; Commit Quality validates title format."
+ exit 0
+ fi
+
+ type="${BASH_REMATCH[1]}"
+
+ case "$type" in
+ feat) label="kind:feature" ;;
+ fix) label="kind:bug" ;;
+ refactor) label="kind:refactor" ;;
+ perf) label="kind:improvement" ;;
+ test) label="kind:test" ;;
+ docs|style|build|ci|chore|revert) label="kind:chore" ;;
+ esac
+
+ current_labels="$(gh pr view "$PR_NUMBER" --json labels --jq '.labels[].name')"
+
+ if grep -Fxq "$label" <<< "$current_labels"; then
+ echo "PR already has $label."
+ exit 0
+ fi
+
+ echo "Adding $label based on PR title type '$type'."
+ gh pr edit "$PR_NUMBER" --add-label "$label"
+
+ - uses: actions/labeler@v6
with:
repo-token: "${{ secrets.GITHUB_TOKEN }}"
diff --git a/.github/workflows/mavros_mission_tests.yml b/.github/workflows/mavros_mission_tests.yml
deleted file mode 100644
index 0b1a84f7b3..0000000000
--- a/.github/workflows/mavros_mission_tests.yml
+++ /dev/null
@@ -1,45 +0,0 @@
-name: MAVROS Mission Tests
-
-on:
- push:
- branches:
- - 'main'
- paths-ignore:
- - 'docs/**'
- pull_request:
- branches:
- - '**'
- paths-ignore:
- - 'docs/**'
-
-concurrency:
- group: ${{ github.workflow }}-${{ github.ref }}
- cancel-in-progress: true
-
-jobs:
- build:
- runs-on: ubuntu-latest
-
- strategy:
- fail-fast: false
-
- steps:
- - uses: actions/checkout@v4
- with:
- fetch-depth: 0
-
- - name: Build SITL and Run Tests (inside old ROS container)
- run: |
- docker run --rm \
- -v "${GITHUB_WORKSPACE}:/workspace" \
- -w /workspace \
- px4io/px4-dev-ros-melodic:2021-09-08 \
- bash -c '
- git config --global --add safe.directory /workspace
- make px4_sitl_default
- make px4_sitl_default sitl_gazebo-classic
- ./test/rostest_px4_run.sh \
- mavros_posix_test_mission.test \
- mission:=MC_mission_box \
- vehicle:=iris
- '
diff --git a/.github/workflows/mavros_offboard_tests.yml b/.github/workflows/mavros_offboard_tests.yml
deleted file mode 100644
index dd6b750812..0000000000
--- a/.github/workflows/mavros_offboard_tests.yml
+++ /dev/null
@@ -1,44 +0,0 @@
-name: MAVROS Offboard Tests
-
-on:
- push:
- branches:
- - 'main'
- paths-ignore:
- - 'docs/**'
- pull_request:
- branches:
- - '**'
- paths-ignore:
- - 'docs/**'
-
-concurrency:
- group: ${{ github.workflow }}-${{ github.ref }}
- cancel-in-progress: true
-
-jobs:
- build:
- runs-on: ubuntu-latest
-
- strategy:
- fail-fast: false
-
- steps:
- - uses: actions/checkout@v4
- with:
- fetch-depth: 0
-
- - name: Build SITL and Run Tests (inside old ROS container)
- run: |
- docker run --rm \
- -v "${GITHUB_WORKSPACE}:/workspace" \
- -w /workspace \
- px4io/px4-dev-ros-melodic:2021-09-08 \
- bash -c '
- git config --global --add safe.directory /workspace
- make px4_sitl_default
- make px4_sitl_default sitl_gazebo-classic
- ./test/rostest_px4_run.sh \
- mavros_posix_tests_offboard_posctl.test \
- vehicle:=iris
- '
diff --git a/.github/workflows/mavros_tests.yml b/.github/workflows/mavros_tests.yml
new file mode 100644
index 0000000000..b8df2541d8
--- /dev/null
+++ b/.github/workflows/mavros_tests.yml
@@ -0,0 +1,73 @@
+name: MAVROS Tests
+
+on:
+ push:
+ branches:
+ - 'main'
+ paths-ignore:
+ - 'docs/**'
+ pull_request:
+ branches:
+ - '**'
+ paths-ignore:
+ - 'docs/**'
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ test:
+ name: "MAVROS ${{ matrix.config.name }}"
+ runs-on: [runs-on,runner=4cpu-linux-x64,image=ubuntu24-full-x64,"run-id=${{ github.run_id }}",spot=false,extras=s3-cache]
+ permissions:
+ contents: read
+ container:
+ image: px4io/px4-dev-ros-noetic:2021-09-08
+ env:
+ PX4_SBOM_DISABLE: 1
+ strategy:
+ fail-fast: false
+ matrix:
+ config:
+ - {name: "Mission", test_file: "mavros_posix_test_mission.test", params: "mission:=MC_mission_box vehicle:=iris"}
+ - {name: "Offboard", test_file: "mavros_posix_tests_offboard_posctl.test", params: "vehicle:=iris"}
+ steps:
+ - uses: runs-on/action@v2
+ - uses: actions/checkout@v6
+ with:
+ fetch-depth: 1
+ - name: Configure Git Safe Directory
+ run: git config --system --add safe.directory '*'
+
+ - name: Setup - Install Python Test Dependencies
+ run: pip3 install -r Tools/setup/requirements.txt
+
+ - uses: ./.github/actions/setup-ccache
+ id: ccache
+ with:
+ cache-key-prefix: ccache-sitl-gazebo-classic
+ max-size: 350M
+
+ - uses: ./.github/actions/build-gazebo-sitl
+
+ - name: Test - MAVROS ${{ matrix.config.name }}
+ run: |
+ ./test/rostest_px4_run.sh \
+ ${{ matrix.config.test_file }} \
+ ${{ matrix.config.params }}
+ timeout-minutes: 10
+
+ - uses: ./.github/actions/save-ccache
+ if: always()
+ with:
+ cache-primary-key: ${{ steps.ccache.outputs.cache-primary-key }}
+
+ - name: Upload - Failed Test Logs
+ if: failure()
+ uses: actions/upload-artifact@v7
+ with:
+ name: failed-mavros-${{ matrix.config.name }}-logs.zip
+ path: |
+ logs/**/**/**/*.log
+ logs/**/**/**/*.ulg
diff --git a/.github/workflows/nuttx_env_config.yml b/.github/workflows/nuttx_env_config.yml
deleted file mode 100644
index f05b456bb6..0000000000
--- a/.github/workflows/nuttx_env_config.yml
+++ /dev/null
@@ -1,46 +0,0 @@
-name: Nuttx Target with extra env config
-
-on:
- push:
- branches:
- - 'main'
- paths-ignore:
- - 'docs/**'
- pull_request:
- branches:
- - '**'
- paths-ignore:
- - 'docs/**'
-
-concurrency:
- group: ${{ github.workflow }}-${{ github.ref }}
- cancel-in-progress: true
-
-jobs:
- build:
- runs-on: ubuntu-latest
-
- container:
- image: px4io/px4-dev:v1.16.0-rc1-258-g0369abd556
-
- strategy:
- matrix:
- config:
- - px4_fmu-v5_default
-
- steps:
- - uses: actions/checkout@v4
- with:
- fetch-depth: 0
-
- - name: Build PX4 and Run Test [${{ matrix.config }}]
- run: |
- cd "$GITHUB_WORKSPACE"
- git config --global --add safe.directory "$GITHUB_WORKSPACE"
- export PX4_EXTRA_NUTTX_CONFIG='CONFIG_NSH_LOGIN_PASSWORD="test";CONFIG_NSH_CONSOLE_LOGIN=y'
- echo "PX4_EXTRA_NUTTX_CONFIG: $PX4_EXTRA_NUTTX_CONFIG"
-
- make ${{ matrix.config }} nuttx_context
-
- echo "Check that the config option is set"
- grep CONFIG_NSH_LOGIN_PASSWORD build/${{ matrix.config }}/NuttX/nuttx/.config
diff --git a/.github/workflows/pr-comment-poster.yml b/.github/workflows/pr-comment-poster.yml
new file mode 100644
index 0000000000..eed79c0443
--- /dev/null
+++ b/.github/workflows/pr-comment-poster.yml
@@ -0,0 +1,155 @@
+name: PR Comment Poster
+
+# Generic PR comment poster. Any analysis workflow (clang-tidy, flash_analysis,
+# fuzz coverage, SITL perf, etc.) can produce a `pr-comment` artifact and this
+# workflow will post or update a sticky PR comment with its contents. Designed
+# so that analysis jobs running on untrusted fork PRs can still get their
+# results posted back to the PR.
+#
+# ==============================================================================
+# SECURITY INVARIANTS
+# ==============================================================================
+# This workflow runs on `workflow_run` which means it runs in the BASE REPO
+# context with a WRITE token, even when the triggering PR comes from a fork.
+# That is the entire reason it exists, and also the reason it is a loaded
+# footgun. Anyone modifying this file MUST preserve the following invariants:
+#
+# 1. NEVER check out PR code. No `actions/checkout` with a ref. No git clone
+# of a fork branch. No execution of scripts from the downloaded artifact.
+# The ONLY things read from the artifact are `manifest.json` and `body.md`,
+# and both are treated as opaque data (JSON parsed by the poster script
+# and markdown posted verbatim via the GitHub API).
+#
+# 2. `pr_number` is validated to be a positive integer before use.
+# `marker` is validated to be printable ASCII only before use. Validation
+# happens inside Tools/ci/pr-comment-poster.py which is checked out from
+# the base branch, not from the artifact.
+#
+# 3. The comment body is passed to the GitHub API as a JSON field, never
+# interpolated into a shell command string.
+#
+# 4. This workflow file lives on the default branch. `workflow_run` only
+# loads workflow files from the default branch, so a fork cannot modify
+# THIS file as part of a PR. The fork CAN cause this workflow to fire
+# by triggering a producer workflow that uploads a `pr-comment` artifact.
+# That is intended.
+#
+# 5. The artifact-name filter (`pr-comment`) is the only gate on which
+# workflow runs get processed. Any workflow in this repo that uploads
+# an artifact named `pr-comment` is trusted to have written the
+# manifest and body itself, NOT copied fork-controlled content into
+# them. Producer workflows are responsible for that.
+#
+# 6. `actions/checkout@v6` below uses NO ref (so it pulls the base branch,
+# the default-branch commit this workflow file was loaded from) AND uses
+# sparse-checkout to materialize ONLY Tools/ci/pr-comment-poster.py and
+# its stdlib-only helper module Tools/ci/_github_helpers.py. The rest of
+# the repo never touches the workspace. This is safe: the only files the
+# job executes are base-repo Python scripts that were reviewed through
+# normal code review, never anything from the PR.
+#
+# ==============================================================================
+# ARTIFACT CONTRACT
+# ==============================================================================
+# Producers upload an artifact named exactly `pr-comment` containing:
+#
+# manifest.json:
+# {
+# "pr_number": 12345, // required, int > 0
+# "marker": "", // required, printable ASCII
+# "mode": "upsert" // optional, default "upsert"
+# }
+#
+# body.md: the markdown content of the comment. Posted verbatim.
+#
+# The `marker` string is used to find an existing comment to update. It MUST
+# be unique per producer (e.g. include the producer name). If no existing
+# comment contains the marker, a new one is created. If the marker is found
+# in an existing comment, that comment is edited in place.
+#
+# Producers MUST write `pr_number` from their own workflow context
+# (`github.event.pull_request.number`) and MUST NOT read it from any
+# fork-controlled source.
+
+on:
+ workflow_run:
+ # Producers that may upload a `pr-comment` artifact. When a new producer
+ # is wired up, add its workflow name here. Runs of workflows not in this
+ # list will never trigger the poster. Every run of a listed workflow will
+ # trigger the poster, which will no-op if no `pr-comment` artifact exists.
+ workflows:
+ - "FLASH usage analysis"
+ - "Docs - Orchestrator"
+ types:
+ - completed
+
+permissions:
+ pull-requests: write
+ actions: read
+ contents: read
+
+jobs:
+ post:
+ name: Post PR Comment
+ runs-on: ubuntu-latest
+ # Only run for pull_request producer runs. Push-to-main and other
+ # non-PR triggers would have no comment to post, and silently no-oping
+ # inside the script made it look like the poster was broken. Gating at
+ # the job level surfaces those as a clean "Skipped" in the UI instead.
+ if: >-
+ github.event.workflow_run.conclusion != 'cancelled'
+ && github.event.workflow_run.event == 'pull_request'
+ steps:
+ # Checkout runs first so the poster script is available AND so that
+ # actions/checkout@v6's default clean step does not delete the artifact
+ # zip that the next step writes into the workspace. Sparse-checkout
+ # restricts the materialized tree to just the poster script.
+ - name: Checkout poster script only
+ uses: actions/checkout@v6
+ with:
+ sparse-checkout: |
+ Tools/ci/pr-comment-poster.py
+ Tools/ci/_github_helpers.py
+ sparse-checkout-cone-mode: false
+
+ - name: Download pr-comment artifact
+ id: download
+ uses: actions/github-script@v9
+ with:
+ script: |
+ const artifacts = await github.rest.actions.listWorkflowRunArtifacts({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ run_id: context.payload.workflow_run.id,
+ });
+ const match = artifacts.data.artifacts.find(a => a.name === 'pr-comment');
+ if (!match) {
+ core.info('No pr-comment artifact on this run; nothing to post.');
+ core.setOutput('found', 'false');
+ return;
+ }
+ const download = await github.rest.actions.downloadArtifact({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ artifact_id: match.id,
+ archive_format: 'zip',
+ });
+ const fs = require('fs');
+ fs.writeFileSync('pr-comment.zip', Buffer.from(download.data));
+ core.setOutput('found', 'true');
+
+ - name: Unpack artifact
+ if: steps.download.outputs.found == 'true'
+ run: |
+ mkdir -p pr-comment
+ unzip -q pr-comment.zip -d pr-comment
+
+ - name: Validate artifact
+ if: steps.download.outputs.found == 'true'
+ run: python3 Tools/ci/pr-comment-poster.py validate pr-comment
+
+ - name: Upsert sticky comment
+ if: steps.download.outputs.found == 'true'
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: python3 Tools/ci/pr-comment-poster.py post pr-comment
diff --git a/.github/workflows/pr-review-poster.yml b/.github/workflows/pr-review-poster.yml
new file mode 100644
index 0000000000..a5651fb239
--- /dev/null
+++ b/.github/workflows/pr-review-poster.yml
@@ -0,0 +1,179 @@
+name: PR Review Poster
+
+# Generic PR review-comment poster. Sibling of "PR Comment Poster": that
+# workflow posts sticky issue-style comments, this one posts line-anchored
+# review comments on the "Files changed" tab. Any analysis workflow that
+# wants to flag specific lines can produce a `pr-review` artifact and this
+# workflow will dismiss any stale matching review and post a fresh one.
+# Designed so analysis jobs running on untrusted fork PRs can still get
+# their inline annotations posted back to the PR.
+#
+# ==============================================================================
+# SECURITY INVARIANTS
+# ==============================================================================
+# This workflow runs on `workflow_run` which means it runs in the BASE REPO
+# context with a WRITE token, even when the triggering PR comes from a fork.
+# That is the entire reason it exists, and also the reason it is a loaded
+# footgun. Anyone modifying this file MUST preserve the following invariants:
+#
+# 1. NEVER check out PR code. No `actions/checkout` with a ref. No git clone
+# of a fork branch. No execution of scripts from the downloaded artifact.
+# The ONLY things read from the artifact are `manifest.json` and
+# `comments.json`, and both are treated as opaque data (JSON parsed by
+# the poster script and the comment fields posted via the GitHub API).
+#
+# 2. `pr_number` is validated to be a positive integer before use.
+# `marker` is validated to be printable ASCII only before use.
+# `commit_sha` is validated to be 40 lowercase hex characters.
+# `event` is validated against an allowlist of `COMMENT` only.
+# `APPROVE` and `REQUEST_CHANGES` are intentionally forbidden:
+# bots should not approve PRs, and REQUEST_CHANGES reviews cannot
+# be dismissed by the GITHUB_TOKEN under branch protection rules.
+# Validation happens inside
+# Tools/ci/pr-review-poster.py which is checked out from the base
+# branch, not from the artifact.
+#
+# 3. Comment bodies and the optional summary are passed to the GitHub API
+# as JSON fields, never interpolated into a shell command string.
+#
+# 4. This workflow file lives on the default branch. `workflow_run` only
+# loads workflow files from the default branch, so a fork cannot modify
+# THIS file as part of a PR. The fork CAN cause this workflow to fire
+# by triggering a producer workflow that uploads a `pr-review`
+# artifact. That is intended.
+#
+# 5. The artifact-name filter (`pr-review`) is the only gate on which
+# workflow runs get processed. Any workflow in this repo that uploads
+# an artifact named `pr-review` is trusted to have written the
+# manifest and comments itself, NOT copied fork-controlled content
+# into them. Producer workflows are responsible for that.
+#
+# 6. `actions/checkout@v6` below uses NO ref (so it pulls the base branch,
+# the default-branch commit this workflow file was loaded from) AND
+# uses sparse-checkout to materialize ONLY
+# Tools/ci/pr-review-poster.py and its stdlib-only helper module
+# Tools/ci/_github_helpers.py. The rest of the repo never touches the
+# workspace. This is safe: the only files the job executes are
+# base-repo Python scripts that were reviewed through normal code
+# review, never anything from the PR.
+#
+# 7. Stale-review dismissal is restricted to reviews whose AUTHOR is
+# `github-actions[bot]` AND whose body contains the producer's
+# marker. A fork PR cannot impersonate the bot login, and cannot
+# inject the marker into a human reviewer's body without API
+# access. Both filters together prevent the poster from ever
+# dismissing a human review.
+#
+# ==============================================================================
+# ARTIFACT CONTRACT
+# ==============================================================================
+# Producers upload an artifact named exactly `pr-review` containing:
+#
+# manifest.json:
+# {
+# "pr_number": 12345, // required, int > 0
+# "marker": "", // required, printable ASCII
+# "event": "COMMENT", // required, "COMMENT" only
+# "commit_sha": "0123456789abcdef0123456789abcdef01234567", // required, 40 hex chars
+# "summary": "Optional review summary text" // optional
+# }
+#
+# comments.json: JSON array of line-anchored review comment objects:
+# [
+# {"path": "src/foo.cpp", "line": 42, "side": "RIGHT", "body": "..."},
+# {"path": "src/bar.hpp", "start_line": 10, "line": 15,
+# "side": "RIGHT", "start_side": "RIGHT", "body": "..."}
+# ]
+#
+# The `marker` string is used to find an existing matching review to
+# dismiss before posting a new one. It MUST be unique per producer (e.g.
+# include the producer name).
+#
+# Producers MUST write `pr_number` and `commit_sha` from their own
+# workflow context (`github.event.pull_request.number` and
+# `github.event.pull_request.head.sha`) and MUST NOT read either from any
+# fork-controlled source.
+
+on:
+ workflow_run:
+ # Producers that may upload a `pr-review` artifact. When a new
+ # producer is wired up, add its workflow name here. Runs of workflows
+ # not in this list will never trigger the poster. Every run of a
+ # listed workflow will trigger the poster, which will no-op if no
+ # `pr-review` artifact exists.
+ workflows:
+ - "Static Analysis"
+ types:
+ - completed
+
+permissions:
+ pull-requests: write
+ actions: read
+ contents: read
+
+jobs:
+ post:
+ name: Post PR Review
+ runs-on: ubuntu-latest
+ # Only run for pull_request producer runs. Push-to-main and other
+ # non-PR triggers have no review to post, so gating at the job level
+ # surfaces those as a clean "Skipped" in the UI instead of a
+ # silent no-op buried inside the script.
+ if: >-
+ github.event.workflow_run.conclusion != 'cancelled'
+ && github.event.workflow_run.event == 'pull_request'
+ steps:
+ # Checkout runs first so the poster scripts are available AND so
+ # that actions/checkout@v6's default clean step does not delete the
+ # artifact zip that the next step writes into the workspace.
+ # Sparse-checkout restricts the materialized tree to just the
+ # poster script and its stdlib helper module.
+ - name: Checkout poster script only
+ uses: actions/checkout@v6
+ with:
+ sparse-checkout: |
+ Tools/ci/pr-review-poster.py
+ Tools/ci/_github_helpers.py
+ sparse-checkout-cone-mode: false
+
+ - name: Download pr-review artifact
+ id: download
+ uses: actions/github-script@v9
+ with:
+ script: |
+ const artifacts = await github.rest.actions.listWorkflowRunArtifacts({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ run_id: context.payload.workflow_run.id,
+ });
+ const match = artifacts.data.artifacts.find(a => a.name === 'pr-review');
+ if (!match) {
+ core.info('No pr-review artifact on this run; nothing to post.');
+ core.setOutput('found', 'false');
+ return;
+ }
+ const download = await github.rest.actions.downloadArtifact({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ artifact_id: match.id,
+ archive_format: 'zip',
+ });
+ const fs = require('fs');
+ fs.writeFileSync('pr-review.zip', Buffer.from(download.data));
+ core.setOutput('found', 'true');
+
+ - name: Unpack artifact
+ if: steps.download.outputs.found == 'true'
+ run: |
+ mkdir -p pr-review
+ unzip -q pr-review.zip -d pr-review
+
+ - name: Validate artifact
+ if: steps.download.outputs.found == 'true'
+ run: python3 Tools/ci/pr-review-poster.py validate pr-review
+
+ - name: Post PR review
+ if: steps.download.outputs.found == 'true'
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: python3 Tools/ci/pr-review-poster.py post pr-review
diff --git a/.github/workflows/python_checks.yml b/.github/workflows/python_checks.yml
index c0fdc73e8b..481cb16194 100644
--- a/.github/workflows/python_checks.yml
+++ b/.github/workflows/python_checks.yml
@@ -14,20 +14,23 @@ on:
jobs:
build:
- runs-on: ubuntu-24.04
+ runs-on: [runs-on,runner=1cpu-linux-x64,image=ubuntu24-full-x64,"run-id=${{ github.run_id }}"]
steps:
- - uses: actions/checkout@v4
+ - uses: runs-on/action@v2
+ - uses: actions/checkout@v6
with:
- fetch-depth: 0
+ fetch-depth: 1
- - name: Install Python3
- run: sudo apt-get install python3 python3-setuptools python3-pip -y
+ - name: Setup Python
+ uses: actions/setup-python@v6
+ with:
+ python-version: "3.10"
- name: Install tools
- run: python3 -m pip install mypy types-requests flake8 --break-system-packages
+ run: pip install mypy types-requests flake8
- name: Check MAVSDK test scripts with mypy
- run: $HOME/.local/bin/mypy --strict test/mavsdk_tests/*.py
+ run: mypy --strict test/mavsdk_tests/*.py
- name: Check MAVSDK test scripts with flake8
- run: $HOME/.local/bin/flake8 test/mavsdk_tests/*.py
+ run: flake8 test/mavsdk_tests/*.py
diff --git a/.github/workflows/ros_integration_tests.yml b/.github/workflows/ros_integration_tests.yml
index bbcce560d8..14ac57cc97 100644
--- a/.github/workflows/ros_integration_tests.yml
+++ b/.github/workflows/ros_integration_tests.yml
@@ -23,16 +23,18 @@ concurrency:
jobs:
build:
- runs-on: [runs-on,runner=4cpu-linux-x64,image=ubuntu22-full-x64,"run-id=${{ github.run_id }}",spot=false]
+ runs-on: [runs-on,runner=8cpu-linux-x64,image=ubuntu24-full-x64,"run-id=${{ github.run_id }}",spot=false,extras=s3-cache]
container:
image: px4io/px4-dev-ros2-galactic:2021-09-08
options: --privileged --ulimit core=-1 --security-opt seccomp=unconfined
+ env:
+ PX4_SBOM_DISABLE: 1
steps:
- - uses: actions/checkout@v4
+ - uses: runs-on/action@v2
+ - uses: actions/checkout@v6
with:
- fetch-depth: 0
-
- - name: Git Ownership Workaround
+ fetch-depth: 1
+ - name: Configure Git Safe Directory
run: git config --system --add safe.directory '*'
- name: Update ROS Keys
@@ -45,30 +47,21 @@ jobs:
run: |
apt update && apt install -y gazebo11 libgazebo11-dev gstreamer1.0-plugins-bad gstreamer1.0-plugins-base gstreamer1.0-plugins-good gstreamer1.0-plugins-ugly libgstreamer-plugins-base1.0-dev
- - name: Prepare ccache timestamp
- id: ccache_cache_timestamp
- shell: cmake -P {0}
- run: |
- string(TIMESTAMP current_date "%Y-%m-%d-%H;%M;%S" UTC)
- message("::set-output name=timestamp::${current_date}")
- - name: ccache cache files
- uses: actions/cache@v4
+ - uses: ./.github/actions/setup-ccache
+ id: ccache
with:
- path: ~/.ccache
- key: ros_integration_tests-${{matrix.config.build_type}}-ccache-${{steps.ccache_cache_timestamp.outputs.timestamp}}
- restore-keys: ros_integration_tests-${{matrix.config.build_type}}-ccache-
- - name: setup ccache
- run: |
- mkdir -p ~/.ccache
- echo "base_dir = ${GITHUB_WORKSPACE}" > ~/.ccache/ccache.conf
- echo "compression = true" >> ~/.ccache/ccache.conf
- echo "compression_level = 6" >> ~/.ccache/ccache.conf
- echo "max_size = 300M" >> ~/.ccache/ccache.conf
- echo "hash_dir = false" >> ~/.ccache/ccache.conf
- ccache -s
- ccache -z
+ cache-key-prefix: ccache-ros-integration
+ max-size: 400M
- - name: Get and build micro-xrce-dds-agent
+ - name: Cache - Restore Micro-XRCE-DDS Agent
+ id: cache-xrce-agent
+ uses: actions/cache@v5
+ with:
+ path: /opt/Micro-XRCE-DDS-Agent
+ key: xrce-agent-v2.2.1-fastdds-2.8.2-galactic-2021-09-08
+
+ - name: Build - Micro-XRCE-DDS Agent (v2.2.1)
+ if: steps.cache-xrce-agent.outputs.cache-hit != 'true'
run: |
cd /opt
git clone --recursive https://github.com/eProsima/Micro-XRCE-DDS-Agent.git
@@ -79,17 +72,35 @@ jobs:
cd build
cmake ..
make -j2
- - name: ccache post-run micro-xrce-dds-agent
- run: ccache -s
- - name: Get and build the ros2 interface library
+ - name: Cache - Restore PX4 ROS 2 Interface Library Workspace
+ id: cache-px4-ros2-ws
+ uses: actions/cache@v5
+ with:
+ path: /opt/px4_ws
+ # Bump 'v1' when the cached workspace layout changes in a way
+ # that is not captured by the message/service hash below.
+ key: px4-ros2-ws-v1-galactic-2021-09-08-${{ hashFiles('msg/*.msg', 'msg/versioned/*.msg', 'srv/*.srv') }}
+
+ - name: Build - PX4 ROS 2 Interface Library
+ if: steps.cache-px4-ros2-ws.outputs.cache-hit != 'true'
shell: bash
run: |
PX4_DIR="$(pwd)"
. /opt/ros/galactic/setup.bash
mkdir -p /opt/px4_ws/src
cd /opt/px4_ws/src
- git clone --recursive https://github.com/Auterion/px4-ros2-interface-lib.git
+ # On a PR, target the branch we're merging into (main or release/X.Y).
+ # On a direct push, fall back to the branch we're running on.
+ BRANCH="${GITHUB_BASE_REF:-$GITHUB_REF_NAME}"
+ REPO_URL="https://github.com/Auterion/px4-ros2-interface-lib.git"
+ if git ls-remote --heads "$REPO_URL" "$BRANCH" | grep -q "$BRANCH"; then
+ echo "Cloning px4-ros2-interface-lib with matching branch: $BRANCH"
+ git clone --recursive --branch "$BRANCH" "$REPO_URL"
+ else
+ echo "Branch '$BRANCH' not found in px4-ros2-interface-lib, using default (main)"
+ git clone --recursive "$REPO_URL"
+ fi
# Ignore python packages due to compilation issue (can be enabled when updating ROS)
touch px4-ros2-interface-lib/px4_ros2_py/COLCON_IGNORE || true
touch px4-ros2-interface-lib/examples/python/COLCON_IGNORE || true
@@ -98,17 +109,8 @@ jobs:
"${PX4_DIR}/Tools/copy_to_ros_ws.sh" "$(pwd)"
rm -rf src/translation_node src/px4_msgs_old
colcon build --symlink-install
- - name: ccache post-run ros workspace
- run: ccache -s
- - name: Build PX4
- run: make px4_sitl_default
- - name: ccache post-run px4/firmware
- run: ccache -s
- - name: Build SITL Gazebo
- run: make px4_sitl_default sitl_gazebo-classic
- - name: ccache post-run sitl_gazebo-classic
- run: ccache -s
+ - uses: ./.github/actions/build-gazebo-sitl
- name: Core dump settings
run: |
@@ -120,12 +122,17 @@ jobs:
run: |
. /opt/px4_ws/install/setup.bash
/opt/Micro-XRCE-DDS-Agent/build/MicroXRCEAgent udp4 localhost -p 8888 -v 0 &
- test/ros_test_runner.py --verbose --model iris --upload --force-color
+ test/ros_test_runner.py --verbose --model iris --force-color
timeout-minutes: 45
+ - uses: ./.github/actions/save-ccache
+ if: always()
+ with:
+ cache-primary-key: ${{ steps.ccache.outputs.cache-primary-key }}
+
- name: Upload failed logs
if: failure()
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v7
with:
name: failed-logs.zip
path: |
diff --git a/.github/workflows/ros_translation_node.yml b/.github/workflows/ros_translation_node.yml
index 64bae13f83..6ba3f181eb 100644
--- a/.github/workflows/ros_translation_node.yml
+++ b/.github/workflows/ros_translation_node.yml
@@ -10,6 +10,9 @@ on:
- '**'
paths-ignore:
- 'docs/**'
+permissions:
+ contents: read
+
defaults:
run:
shell: bash
@@ -20,8 +23,8 @@ concurrency:
jobs:
build_and_test:
- name: Build and test
- runs-on: [runs-on,runner=4cpu-linux-x64,image=ubuntu24-full-x64,"run-id=${{ github.run_id }}",spot=false]
+ name: Build and test [${{ matrix.config.ros_version }}]
+ runs-on: [runs-on,runner=4cpu-linux-x64,image=ubuntu24-full-x64,"run-id=${{ github.run_id }}",spot=false,extras=s3-cache]
strategy:
fail-fast: false
matrix:
@@ -29,33 +32,43 @@ jobs:
- {ros_version: "humble", ubuntu: "jammy"}
- {ros_version: "jazzy", ubuntu: "noble"}
container:
- image: rostooling/setup-ros-docker:ubuntu-${{ matrix.config.ubuntu }}-latest
+ image: ros:${{ matrix.config.ros_version }}-ros-base-${{ matrix.config.ubuntu }}
steps:
- - name: Setup ROS 2 (${{ matrix.config.ros_version }})
- uses: ros-tooling/setup-ros@v0.7
- with:
- required-ros-distributions: ${{ matrix.config.ros_version }}
- - name: Checkout repository
- uses: actions/checkout@v4
+ - uses: runs-on/action@v2
+ - uses: actions/checkout@v6
with:
fetch-depth: 0
-
- # Workaround for https://github.com/actions/runner/issues/2033
- - name: ownership workaround
+ - name: Configure Git Safe Directory
run: git config --system --add safe.directory '*'
+ - uses: ./.github/actions/setup-ccache
+ id: ccache
+ with:
+ cache-key-prefix: ccache-ros-translation-${{ matrix.config.ros_version }}
+ max-size: 150M
+ base-dir: /ros_ws
+ install-ccache: 'true'
+
- name: Check .msg file versioning
if: github.event_name == 'pull_request'
run: |
./Tools/ci/check_msg_versioning.sh ${{ github.event.pull_request.base.sha }} ${{github.event.pull_request.head.sha}}
- - name: Build and test
+ - name: Build - Translation Node
run: |
ros_ws=/ros_ws
mkdir -p $ros_ws/src
./Tools/copy_to_ros_ws.sh $ros_ws
cd $ros_ws
source /opt/ros/${{ matrix.config.ros_version }}/setup.sh
- colcon build --cmake-args -DCMAKE_BUILD_TYPE=Release --symlink-install --event-handlers=console_cohesion+
- source ./install/setup.sh
- ./build/translation_node/translation_node_unit_tests
+ colcon build --cmake-args -DCMAKE_BUILD_TYPE=Release -DCMAKE_CXX_COMPILER_LAUNCHER=ccache -DCMAKE_C_COMPILER_LAUNCHER=ccache --symlink-install --event-handlers=console_cohesion+
+
+ - name: Test - Translation Node Unit Tests
+ run: |
+ source /ros_ws/install/setup.sh
+ /ros_ws/build/translation_node/translation_node_unit_tests
+
+ - uses: ./.github/actions/save-ccache
+ if: always()
+ with:
+ cache-primary-key: ${{ steps.ccache.outputs.cache-primary-key }}
diff --git a/.github/workflows/sbom_license_check.yml b/.github/workflows/sbom_license_check.yml
new file mode 100644
index 0000000000..aeaf1153c2
--- /dev/null
+++ b/.github/workflows/sbom_license_check.yml
@@ -0,0 +1,42 @@
+name: SBOM License Check
+
+on:
+ push:
+ branches:
+ - 'main'
+ - 'release/**'
+ - 'stable'
+ paths:
+ - '.gitmodules'
+ - 'Tools/ci/license-overrides.yaml'
+ - 'Tools/ci/generate_sbom.py'
+ pull_request:
+ branches:
+ - '**'
+ paths:
+ - '.gitmodules'
+ - 'Tools/ci/license-overrides.yaml'
+ - 'Tools/ci/generate_sbom.py'
+
+permissions:
+ contents: read
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ verify-licenses:
+ runs-on: ubuntu-24.04
+
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ fetch-depth: 1
+ submodules: recursive
+
+ - name: Install PyYAML
+ run: pip install pyyaml --break-system-packages
+
+ - name: Verify submodule licenses
+ run: python3 Tools/ci/generate_sbom.py --verify-licenses --source-dir .
diff --git a/.github/workflows/sbom_monthly_audit.yml b/.github/workflows/sbom_monthly_audit.yml
new file mode 100644
index 0000000000..051dd9afb6
--- /dev/null
+++ b/.github/workflows/sbom_monthly_audit.yml
@@ -0,0 +1,132 @@
+name: SBOM Monthly Audit
+
+on:
+ schedule:
+ # First Monday of each month at 09:00 UTC
+ - cron: '0 9 1-7 * 1'
+ workflow_dispatch:
+ inputs:
+ branch:
+ description: 'Branch to audit (leave empty for current)'
+ required: false
+ type: string
+
+permissions:
+ contents: read
+ issues: write
+
+jobs:
+ audit:
+ runs-on: ubuntu-24.04
+
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ ref: ${{ inputs.branch || github.ref }}
+ fetch-depth: 1
+ submodules: recursive
+
+ - name: Install PyYAML
+ run: pip install pyyaml --break-system-packages
+
+ - name: Run license verification
+ id: verify
+ continue-on-error: true
+ run: |
+ python3 Tools/ci/generate_sbom.py --verify-licenses --source-dir . 2>&1 | tee /tmp/sbom-verify.txt
+ echo "exit_code=$?" >> "$GITHUB_OUTPUT"
+
+ - name: Check for issues
+ id: check
+ run: |
+ if grep -q "<-- UNRESOLVED" /tmp/sbom-verify.txt; then
+ echo "has_issues=true" >> "$GITHUB_OUTPUT"
+ # Extract only genuinely unresolved license lines
+ grep "<-- UNRESOLVED" /tmp/sbom-verify.txt > /tmp/sbom-issues.txt || true
+ # Extract copyleft lines
+ sed -n '/Copyleft licenses detected/,/^$/p' /tmp/sbom-verify.txt > /tmp/sbom-copyleft.txt || true
+ else
+ echo "has_issues=false" >> "$GITHUB_OUTPUT"
+ fi
+
+ - name: Create issue if problems found
+ if: steps.check.outputs.has_issues == 'true'
+ uses: actions/github-script@v9
+ with:
+ script: |
+ const fs = require('fs');
+
+ const fullOutput = fs.readFileSync('/tmp/sbom-verify.txt', 'utf8');
+ let issueLines = '';
+ try {
+ issueLines = fs.readFileSync('/tmp/sbom-issues.txt', 'utf8');
+ } catch (e) {
+ issueLines = 'No specific NOASSERTION lines captured.';
+ }
+ let copyleftLines = '';
+ try {
+ copyleftLines = fs.readFileSync('/tmp/sbom-copyleft.txt', 'utf8');
+ } catch (e) {
+ copyleftLines = '';
+ }
+
+ const date = new Date().toISOString().split('T')[0];
+ const branch = '${{ inputs.branch || github.ref_name }}';
+
+ // Check for existing open issue to avoid duplicates
+ const existing = await github.rest.issues.listForRepo({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ labels: 'sbom-audit',
+ state: 'open',
+ });
+
+ if (existing.data.length > 0) {
+ // Update existing issue with new findings
+ await github.rest.issues.createComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: existing.data[0].number,
+ body: `## Monthly audit update (${date})\n\nIssues still present:\n\n\`\`\`\n${issueLines}\n\`\`\`\n${copyleftLines ? `\n### Copyleft warnings\n\`\`\`\n${copyleftLines}\n\`\`\`` : ''}`,
+ });
+ return;
+ }
+
+ await github.rest.issues.create({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ title: `chore(sbom): license audit found NOASSERTION entries on ${branch} (${date})`,
+ labels: ['sbom-audit'],
+ assignees: ['mrpollo'],
+ body: [
+ `## SBOM Monthly Audit -- ${branch} -- ${date}`,
+ '',
+ 'The automated SBOM license audit found submodules with unresolved licenses.',
+ '',
+ '### NOASSERTION entries',
+ '',
+ '```',
+ issueLines,
+ '```',
+ '',
+ copyleftLines ? `### Copyleft warnings\n\n\`\`\`\n${copyleftLines}\n\`\`\`\n` : '',
+ '### How to fix',
+ '',
+ '1. Check the submodule repo for a LICENSE file',
+ '2. Add an override to `Tools/ci/license-overrides.yaml`',
+ '3. Run `python3 Tools/ci/generate_sbom.py --verify-licenses --source-dir .` to confirm',
+ '',
+ '### Full output',
+ '',
+ '',
+ 'Click to expand
',
+ '',
+ '```',
+ fullOutput,
+ '```',
+ '',
+ ' ',
+ '',
+ 'cc @mrpollo',
+ ].join('\n'),
+ });
diff --git a/.github/workflows/sitl_tests.yml b/.github/workflows/sitl_tests.yml
index c114b05cb3..ce44f3d879 100644
--- a/.github/workflows/sitl_tests.yml
+++ b/.github/workflows/sitl_tests.yml
@@ -24,7 +24,7 @@ concurrency:
jobs:
build:
name: Testing PX4 ${{ matrix.config.model }}
- runs-on: [runs-on,runner=4cpu-linux-x64,image=ubuntu22-full-x64,"run-id=${{ github.run_id }}",spot=false]
+ runs-on: [runs-on,runner=8cpu-linux-x64,image=ubuntu24-full-x64,"run-id=${{ github.run_id }}",spot=false,extras=s3-cache]
container:
image: px4io/px4-dev-simulation-focal:2021-09-08
options: --privileged --ulimit core=-1 --security-opt seccomp=unconfined
@@ -37,52 +37,25 @@ jobs:
# transitions). Re-enable once the test infrastructure is stabilized.
# - {model: "tailsitter" , latitude: "29.660316", longitude: "-82.316658", altitude: "30", build_type: "RelWithDebInfo" } # Florida
# - {model: "standard_vtol", latitude: "47.397742", longitude: "8.545594", altitude: "488", build_type: "Coverage" } # Zurich
+ env:
+ PX4_CMAKE_BUILD_TYPE: ${{ matrix.config.build_type }}
+ PX4_SBOM_DISABLE: 1
steps:
- - uses: actions/checkout@v4
+ - uses: runs-on/action@v2
+ - uses: actions/checkout@v6
with:
- fetch-depth: 0
-
- - name: Git Ownership Workaround
+ fetch-depth: 1
+ - name: Configure Git Safe Directory
run: git config --system --add safe.directory '*'
- - id: set-timestamp
- name: Set timestamp for cache
- run: echo "::set-output name=timestamp::$(date +"%Y%m%d%H%M%S")"
-
- - name: Cache Key Config
- uses: actions/cache@v4
+ - uses: ./.github/actions/setup-ccache
+ id: ccache
with:
- path: ~/.ccache
- key: sitl-ccache-${{ steps.set-timestamp.outputs.timestamp }}
- restore-keys: sitl-ccache-${{ steps.set-timestamp.outputs.timestamp }}
+ cache-key-prefix: ccache-sitl-gazebo-classic
+ max-size: 350M
- - name: Cache Conf Config
- run: |
- mkdir -p ~/.ccache
- echo "base_dir = ${GITHUB_WORKSPACE}" > ~/.ccache/ccache.conf
- echo "compression = true" >> ~/.ccache/ccache.conf
- echo "compression_level = 6" >> ~/.ccache/ccache.conf
- echo "max_size = 120M" >> ~/.ccache/ccache.conf
- echo "hash_dir = false" >> ~/.ccache/ccache.conf
- ccache -s
- ccache -z
-
- - name: Build PX4
- env:
- PX4_CMAKE_BUILD_TYPE: ${{matrix.config.build_type}}
- run: make px4_sitl_default
-
- - name: Cache Post-Run [px4_sitl_default]
- run: ccache -s
-
- - name: Build SITL Gazebo
- env:
- PX4_CMAKE_BUILD_TYPE: ${{matrix.config.build_type}}
- run: make px4_sitl_default sitl_gazebo-classic
-
- - name: Cache Post-Run [sitl_gazebo-classic]
- run: ccache -s
+ - uses: ./.github/actions/build-gazebo-sitl
- name: Download MAVSDK
run: wget "https://github.com/mavlink/MAVSDK/releases/download/v$(cat test/mavsdk_tests/MAVSDK_VERSION)/libmavsdk-dev_$(cat test/mavsdk_tests/MAVSDK_VERSION)_ubuntu20.04_amd64.deb"
@@ -95,19 +68,19 @@ jobs:
PX4_HOME_LAT: ${{matrix.config.latitude}}
PX4_HOME_LON: ${{matrix.config.longitude}}
PX4_HOME_ALT: ${{matrix.config.altitude}}
- PX4_CMAKE_BUILD_TYPE: ${{matrix.config.build_type}}
run: |
export
ulimit -a
- name: Build PX4 / MAVSDK tests
env:
- PX4_CMAKE_BUILD_TYPE: ${{matrix.config.build_type}}
DONT_RUN: 1
run: make px4_sitl_default sitl_gazebo-classic mavsdk_tests
- - name: Cache Post-Run [px4_sitl_default sitl_gazebo-classic mavsdk_tests]
- run: ccache -s
+ - uses: ./.github/actions/save-ccache
+ if: always()
+ with:
+ cache-primary-key: ${{ steps.ccache.outputs.cache-primary-key }}
- name: Core Dump Settings
run: |
@@ -119,13 +92,12 @@ jobs:
PX4_HOME_LAT: ${{matrix.config.latitude}}
PX4_HOME_LON: ${{matrix.config.longitude}}
PX4_HOME_ALT: ${{matrix.config.altitude}}
- PX4_CMAKE_BUILD_TYPE: ${{matrix.config.build_type}}
run: test/mavsdk_tests/mavsdk_test_runner.py --speed-factor 10 --abort-early --model ${{matrix.config.model}} test/mavsdk_tests/configs/sitl.json --verbose --force-color
timeout-minutes: 45
- name: Upload failed logs
if: failure()
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v7
with:
name: failed-${{matrix.config.model}}-logs.zip
path: |
@@ -139,7 +111,7 @@ jobs:
- name: Upload PX4 coredump
if: failure() && ${{ hashFiles('px4.core') != '' }}
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v7
with:
name: coredump
path: px4.core
@@ -156,7 +128,7 @@ jobs:
- name: Upload Coverage Information to Codecov
if: contains(matrix.config.build_type, 'Coverage')
- uses: codecov/codecov-action@v4
+ uses: codecov/codecov-action@v6
with:
token: ${{ secrets.CODECOV_TOKEN }}
flags: mavsdk
diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml
index 96cfbc5799..0630b66891 100644
--- a/.github/workflows/stale.yml
+++ b/.github/workflows/stale.yml
@@ -2,6 +2,7 @@ name: 'Handle stale issues and PRs'
on:
schedule:
- cron: '30 1 * * *'
+ workflow_dispatch:
jobs:
stale:
@@ -9,7 +10,7 @@ jobs:
steps:
- uses: actions/stale@v10
with:
- operations-per-run: 250
+ operations-per-run: 1500
days-before-stale: 90
days-before-close: 30
stale-issue-label: 'stale'
diff --git a/.github/workflows/sync_release_to_ros2_interface_lib.yml b/.github/workflows/sync_release_to_ros2_interface_lib.yml
new file mode 100644
index 0000000000..6ac613a017
--- /dev/null
+++ b/.github/workflows/sync_release_to_ros2_interface_lib.yml
@@ -0,0 +1,43 @@
+name: Sync release branch to px4-ros2-interface-lib
+
+on:
+ create:
+ workflow_dispatch:
+ inputs:
+ branch:
+ description: 'Release branch name (e.g. release/1.18)'
+ required: true
+ type: string
+
+permissions: {}
+
+jobs:
+ notify-interface-lib:
+ if: >-
+ github.repository == 'PX4/PX4-Autopilot' &&
+ (
+ (github.event_name == 'create' && github.ref_type == 'branch' && startsWith(github.ref_name, 'release/')) ||
+ github.event_name == 'workflow_dispatch'
+ )
+ runs-on: ubuntu-latest
+ steps:
+ - name: Determine branch name
+ id: params
+ run: |
+ if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
+ BRANCH="${{ inputs.branch }}"
+ else
+ BRANCH="${{ github.ref_name }}"
+ fi
+ echo "branch=$BRANCH" >> "$GITHUB_OUTPUT"
+ echo "Dispatching for branch: $BRANCH"
+
+ - name: Dispatch release branch creation
+ run: |
+ BRANCH="${{ steps.params.outputs.branch }}"
+ curl -s -f -X POST \
+ -H "Authorization: token ${{ secrets.PX4BUILTBOT_PERSONAL_ACCESS_TOKEN }}" \
+ -H "Accept: application/vnd.github.v3+json" \
+ https://api.github.com/repos/Auterion/px4-ros2-interface-lib/dispatches \
+ -d "{\"event_type\":\"px4_release_branch\",\"client_payload\":{\"branch\":\"$BRANCH\"}}"
+ echo "Dispatched px4_release_branch event for $BRANCH"
diff --git a/.github/workflows/sync_to_px4_msgs.yml b/.github/workflows/sync_to_px4_msgs.yml
index eae00f3b3f..56966ff0e3 100644
--- a/.github/workflows/sync_to_px4_msgs.yml
+++ b/.github/workflows/sync_to_px4_msgs.yml
@@ -20,7 +20,7 @@ jobs:
runs-on: [runs-on,runner=4cpu-linux-x64,image=ubuntu22-full-x64,"run-id=${{ github.run_id }}",spot=false]
steps:
- name: Checkout PX4 repo
- uses: actions/checkout@v4
+ uses: actions/checkout@v6
- name: Setup git credentials
run: |
diff --git a/.github/workflows/tag_px4_msgs_from_px4_release_tag.yml b/.github/workflows/tag_px4_msgs_from_px4_release_tag.yml
new file mode 100644
index 0000000000..f189a98ab8
--- /dev/null
+++ b/.github/workflows/tag_px4_msgs_from_px4_release_tag.yml
@@ -0,0 +1,135 @@
+name: Tag px4_msgs from PX4 release tags
+
+on:
+ push:
+ tags:
+ - 'v*.*.*'
+ workflow_dispatch:
+ inputs:
+ tag_name:
+ description: 'PX4 tag to propagate (example: v1.17.0)'
+ required: true
+ type: string
+
+permissions:
+ contents: read
+
+jobs:
+ tag_px4_msgs:
+ if: github.repository == 'PX4/PX4-Autopilot'
+ runs-on: [runs-on,runner=4cpu-linux-x64,image=ubuntu22-full-x64,"run-id=${{ github.run_id }}",spot=false]
+ env:
+ TAG_NAME: ${{ github.event_name == 'workflow_dispatch' && inputs.tag_name || github.ref_name }}
+ steps:
+ - name: Checkout PX4 repo
+ uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+ fetch-tags: true
+
+ - name: Setup git credentials
+ run: |
+ git config --global user.name "${{ secrets.PX4BUILDBOT_USER }}"
+ git config --global user.email "${{ secrets.PX4BUILDBOT_EMAIL }}"
+
+ - name: Resolve release branch from tag
+ id: tag_info
+ shell: bash
+ run: |
+ set -euo pipefail
+
+ if [[ ! "${TAG_NAME}" =~ ^v([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
+ echo "Tag format is not stable vX.Y.Z, skipping: ${TAG_NAME}"
+ echo "should_run=false" >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+
+ echo "should_run=true" >> "$GITHUB_OUTPUT"
+
+ major="${BASH_REMATCH[1]}"
+ minor="${BASH_REMATCH[2]}"
+ release_branch="release/${major}.${minor}"
+
+ git show-ref --verify --quiet "refs/heads/${release_branch}" || {
+ echo "PX4 branch ${release_branch} not found"
+ exit 1
+ }
+
+ tag_date="$(git for-each-ref --format='%(creatordate:iso8601)' "refs/tags/${TAG_NAME}")"
+ if [[ -z "${tag_date}" ]]; then
+ echo "Unable to resolve tag date for ${TAG_NAME}"
+ exit 1
+ fi
+
+ echo "release_branch=${release_branch}" >> "$GITHUB_OUTPUT"
+ echo "tag_date=${tag_date}" >> "$GITHUB_OUTPUT"
+
+ - name: Clone px4_msgs repo
+ if: steps.tag_info.outputs.should_run == 'true'
+ run: |
+ git clone https://${{ secrets.PX4BUILTBOT_PERSONAL_ACCESS_TOKEN }}@github.com/PX4/px4_msgs.git
+
+ - name: Checkout matching px4_msgs release branch
+ if: steps.tag_info.outputs.should_run == 'true'
+ shell: bash
+ run: |
+ set -euo pipefail
+ cd px4_msgs
+
+ release_branch="${{ steps.tag_info.outputs.release_branch }}"
+ if git show-ref --verify --quiet "refs/remotes/origin/${release_branch}"; then
+ git checkout -B "${release_branch}" "origin/${release_branch}"
+ else
+ echo "px4_msgs branch ${release_branch} does not exist"
+ exit 1
+ fi
+
+ - name: Verify msg and srv trees are identical
+ if: steps.tag_info.outputs.should_run == 'true'
+ shell: bash
+ run: |
+ set -euo pipefail
+
+ release_branch="${{ steps.tag_info.outputs.release_branch }}"
+ git checkout "${release_branch}"
+
+ # Use the same synchronization logic as sync_to_px4_msgs.yml,
+ # then verify there are no changes in px4_msgs.
+ rm -f px4_msgs/msg/*.msg
+ rm -f px4_msgs/msg/versioned/*.msg
+ rm -f px4_msgs/srv/*.srv
+ rm -f px4_msgs/srv/versioned/*.srv
+ cp msg/*.msg px4_msgs/msg/
+ cp msg/versioned/*.msg px4_msgs/msg/ || true
+ cp srv/*.srv px4_msgs/srv/
+ cp srv/versioned/*.srv px4_msgs/srv/ || true
+
+ if ! git -C px4_msgs diff --exit-code -- msg srv; then
+ echo "Message/service definitions differ between PX4 ${release_branch} and px4_msgs ${release_branch}"
+ exit 1
+ fi
+
+ - name: Create and push tag in px4_msgs
+ if: steps.tag_info.outputs.should_run == 'true'
+ shell: bash
+ run: |
+ set -euo pipefail
+ cd px4_msgs
+
+ target="$(git rev-parse HEAD)"
+ existing_target="$(git rev-parse "refs/tags/${TAG_NAME}^{}" 2>/dev/null || true)"
+
+ if [[ -n "${existing_target}" ]]; then
+ if [[ "${existing_target}" == "${target}" ]]; then
+ echo "Tag ${TAG_NAME} already exists on ${target}; nothing to do"
+ exit 0
+ fi
+ echo "Tag ${TAG_NAME} already exists on ${existing_target}, expected ${target}"
+ exit 1
+ fi
+
+ GIT_COMMITTER_DATE="${{ steps.tag_info.outputs.tag_date }}" \
+ git tag -a "${TAG_NAME}" "${target}" \
+ -m "PX4 msgs and srvs definitions matching PX4 stable release ${TAG_NAME#v}"
+
+ git push origin "refs/tags/${TAG_NAME}"
diff --git a/.gitignore b/.gitignore
index fcf982c8df..9f78108fa2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -112,3 +112,9 @@ keys/
# metadata
_emscripten_sdk/
+
+# virtual Python environment
+.venv
+
+# Claude Code local settings
+.claude/settings.local.json
diff --git a/.gitmodules b/.gitmodules
index 86cb85c961..af800e888b 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -109,3 +109,12 @@
[submodule "src/lib/rl_tools/rl_tools"]
path = src/lib/rl_tools/rl_tools
url = https://github.com/rl-tools/rl-tools.git
+[submodule "libmodal-json"]
+ path = boards/modalai/voxl2/src/lib/mpa/libmodal-json
+ url = https://gitlab.com/voxl-public/voxl-sdk/core-libs/libmodal-json.git
+[submodule "libmodal-pipe"]
+ path = boards/modalai/voxl2/src/lib/mpa/libmodal-pipe
+ url = https://gitlab.com/voxl-public/voxl-sdk/core-libs/libmodal-pipe.git
+[submodule "src/modules/simulation/gz_plugins/optical_flow/PX4-OpticalFlow"]
+ path = src/modules/simulation/gz_plugins/optical_flow/PX4-OpticalFlow
+ url = https://github.com/PX4/PX4-OpticalFlow.git
diff --git a/.vscode/cmake-variants.yaml b/.vscode/cmake-variants.yaml
index 461ce2d95b..f41c0af8cb 100644
--- a/.vscode/cmake-variants.yaml
+++ b/.vscode/cmake-variants.yaml
@@ -336,6 +336,11 @@ CONFIG:
buildType: MinSizeRel
settings:
CONFIG: cuav_x25-evo_default
+ cuav_x25-super_default:
+ short: cuav_x25-super
+ buildType: MinSizeRel
+ settings:
+ CONFIG: cuav_x25-super_default
cubepilot_cubeorange_test:
short: cubepilot_cubeorange
buildType: MinSizeRel
diff --git a/CITATION.cff b/CITATION.cff
new file mode 100644
index 0000000000..c7fe49b33f
--- /dev/null
+++ b/CITATION.cff
@@ -0,0 +1,27 @@
+cff-version: 1.2.0
+title: "PX4 Autopilot"
+message: "If you use PX4 in your research, please cite it using this metadata."
+type: software
+authors:
+ - family-names: Meier
+ given-names: Lorenz
+ - name: "The PX4 Contributors"
+repository-code: "https://github.com/PX4/PX4-Autopilot"
+url: "https://px4.io"
+abstract: >-
+ PX4 is an open-source autopilot stack for drones and
+ unmanned vehicles. It supports multirotors, fixed-wing,
+ VTOL, rovers, and many more platforms. PX4 runs on both
+ RTOS and POSIX-compatible operating systems.
+keywords:
+ - autopilot
+ - drone
+ - uav
+ - flight-controller
+ - robotics
+ - ros2
+license: BSD-3-Clause
+identifiers:
+ - type: doi
+ value: "10.5281/zenodo.595432"
+ description: "Zenodo concept DOI (resolves to latest version)"
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000000..88355ff8b8
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,12 @@
+# PX4-Autopilot
+
+Safety-critical C/C++ flight control firmware for autopilots, plus SITL
+simulation and Python tooling.
+
+- **Commits:** use the `/commit` skill. Conventional commit format with
+ topic-based scope: `type(scope): description`.
+- **Pull requests:** use the `/pr` skill.
+- **No Claude attribution** — no `Co-Authored-By: Claude`, no "Generated
+ with Claude Code" footer.
+- **Style:** run `make format` on changed C/C++ before committing; CI
+ enforces `make check_format`.
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 7f4ff17ac4..351a209c45 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -229,6 +229,28 @@ endif()
#
project(px4 CXX C ASM)
+# Silence Apple ranlib "has no symbols" warnings. Several PX4 sources are
+# wrapped in #if defined(CONFIG_*) guards (e.g. platforms/common/i2c.cpp,
+# spi.cpp, board_common.c, pab_manifest.c, px4_log_history.cpp) and some
+# libraries carry a dummy.cpp placeholder, all of which legitimately produce
+# empty object files on POSIX/SITL. GNU ranlib ignores this; Apple's warns.
+#
+# The warning is actually emitted by `ar qc` (which implicitly builds a symbol
+# table), not by the standalone ranlib call. So we use `ar qcS` to skip the
+# implicit symbol table, then let CMAKE_*_ARCHIVE_FINISH run ranlib with the
+# -no_warning_for_no_symbols flag to add it quietly.
+if(APPLE)
+ set(CMAKE_C_ARCHIVE_CREATE " qcS ")
+ set(CMAKE_CXX_ARCHIVE_CREATE " qcS ")
+ set(CMAKE_ASM_ARCHIVE_CREATE " qcS ")
+ set(CMAKE_C_ARCHIVE_APPEND " qS ")
+ set(CMAKE_CXX_ARCHIVE_APPEND " qS ")
+ set(CMAKE_ASM_ARCHIVE_APPEND " qS ")
+ set(CMAKE_C_ARCHIVE_FINISH " -no_warning_for_no_symbols -c ")
+ set(CMAKE_CXX_ARCHIVE_FINISH " -no_warning_for_no_symbols -c ")
+ set(CMAKE_ASM_ARCHIVE_FINISH " -no_warning_for_no_symbols -c ")
+endif()
+
# CMake build type (Debug Release RelWithDebInfo MinSizeRel Coverage)
if(NOT CMAKE_BUILD_TYPE)
if(${PX4_PLATFORM} STREQUAL "nuttx")
@@ -240,8 +262,15 @@ if(NOT CMAKE_BUILD_TYPE)
set(CMAKE_BUILD_TYPE ${PX4_BUILD_TYPE} CACHE STRING "Build type" FORCE)
endif()
+if(CONFIG_BOARD_SUPPORT_FORTIFIED_TOOLCHAIN)
+ set(PX4_DEBUG_OPT_LEVEL -Og)
+ message(STATUS "fortified toolchain support enabled: PX4_DEBUG_OPT_LEVEL=${PX4_DEBUG_OPT_LEVEL}")
+else()
+ set(PX4_DEBUG_OPT_LEVEL -O0)
+endif()
+
if((CMAKE_BUILD_TYPE STREQUAL "Debug") OR (CMAKE_BUILD_TYPE STREQUAL "Coverage"))
- set(MAX_CUSTOM_OPT_LEVEL -O0)
+ set(MAX_CUSTOM_OPT_LEVEL ${PX4_DEBUG_OPT_LEVEL})
elseif(CMAKE_BUILD_TYPE MATCHES "Sanitizer")
set(MAX_CUSTOM_OPT_LEVEL -O1)
elseif(CMAKE_BUILD_TYPE MATCHES "Release")
@@ -484,6 +513,7 @@ include(bloaty)
include(metadata)
include(package)
+include(sbom)
# install python requirements using configured python
add_custom_target(install_python_requirements
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
index 34f16ba21e..c07620a69a 100644
--- a/CODE_OF_CONDUCT.md
+++ b/CODE_OF_CONDUCT.md
@@ -2,45 +2,82 @@
## Our Pledge
-In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation.
+We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation.
+
+We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community.
## Our Standards
-Examples of behavior that contributes to creating a positive environment include:
+Examples of behavior that contributes to a positive environment for our community include:
-* Using welcoming and inclusive language
-* Being respectful of differing viewpoints and experiences
-* Gracefully accepting constructive criticism
-* Focusing on what is best for the community
-* Showing empathy towards other community members
+* Demonstrating empathy and kindness toward other people
+* Being respectful of differing opinions, viewpoints, and experiences
+* Giving and gracefully accepting constructive feedback
+* Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience
+* Focusing on what is best not just for us as individuals, but for the overall community
-Examples of unacceptable behavior by participants include:
+Examples of unacceptable behavior include:
-* The use of sexualized language or imagery and unwelcome sexual attention or advances
-* Trolling, insulting/derogatory comments, and personal or political attacks
+* The use of sexualized language or imagery, and sexual attention or advances of any kind
+* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
-* Publishing others' private information, such as a physical or electronic address, without explicit permission
+* Publishing others' private information, such as a physical or email address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a professional setting
-## Our Responsibilities
+## Enforcement Responsibilities
-Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior.
+Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful.
-Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful.
+Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate.
## Scope
-This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers.
+This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event.
## Enforcement
-Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at lorenz@px4.io. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately.
+Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at coc@dronecode.org. All complaints will be reviewed and investigated promptly and fairly.
-Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership.
+All community leaders are obligated to respect the privacy and security of the reporter of any incident.
+
+## Enforcement Guidelines
+
+Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct:
+
+### 1. Correction
+
+**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community.
+
+**Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested.
+
+### 2. Warning
+
+**Community Impact**: A violation through a single incident or series of actions.
+
+**Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban.
+
+### 3. Temporary Ban
+
+**Community Impact**: A serious violation of community standards, including sustained inappropriate behavior.
+
+**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban.
+
+### 4. Permanent Ban
+
+**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals.
+
+**Consequence**: A permanent ban from any sort of public interaction within the community.
## Attribution
-This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version]
+This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
-[homepage]: http://contributor-covenant.org
-[version]: http://contributor-covenant.org/version/1/4/
+Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder][Mozilla CoC].
+
+For answers to common questions about this code of conduct, see the FAQ at [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at [https://www.contributor-covenant.org/translations][translations].
+
+[homepage]: https://www.contributor-covenant.org
+[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
+[Mozilla CoC]: https://github.com/mozilla/diversity
+[FAQ]: https://www.contributor-covenant.org/faq
+[translations]: https://www.contributor-covenant.org/translations
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index cce5340cc7..65604838da 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,44 +1,170 @@
-# Contributing to PX4 Firmware
+# Contributing to PX4-Autopilot
-We follow the [Github flow](https://guides.github.com/introduction/flow/) development model.
+We follow the [GitHub flow](https://guides.github.com/introduction/flow/) development model.
-### Fork the project, then clone your repo
+## Fork the project, then clone your repo
-First [fork and clone](https://help.github.com/articles/fork-a-repo) the project project.
+First [fork and clone](https://help.github.com/articles/fork-a-repo) the project.
-### Create a feature branch
+## Create a feature branch
-*Always* branch off main for new features.
+Always branch off `main` for new features.
```
git checkout -b mydescriptivebranchname
```
-### Edit and build the code
+## Edit and build the code
-The [developer guide](https://docs.px4.io/main/en/development/development.html) explains how to set up the development environment on Mac OS, Linux or Windows. Please take note of our [coding style](https://docs.px4.io/main/en/contribute/code.html) when editing files.
+The [developer guide](https://docs.px4.io/main/en/development/development.html) explains how to set up the development environment on Mac OS, Linux or Windows.
-### Commit your changes
+### Coding standards
-Always write descriptive commit messages and add a fixes or relates note to them with an [issue number](https://github.com/px4/Firmware/issues) (Github will link these then conveniently)
+All C/C++ code must follow the [PX4 coding style](https://docs.px4.io/main/en/contribute/code.html). Formatting is enforced by [astyle](http://astyle.sourceforge.net/) in CI (`make check_format`, ``make format`, `make format_changed`). Code quality checks run via [clang-tidy](https://clang.llvm.org/extra/clang-tidy/). Pull requests that fail either check will not be merged.
-**Example:**
+Python code is checked with [mypy](https://mypy-lang.org/) and [flake8](https://flake8.pycqa.org/).
+
+## Commit message convention
+
+PX4 uses [conventional commits](https://www.conventionalcommits.org/) for all commit messages and PR titles.
+
+### Format
```
-Change how the attitude controller works
-
-- Fixes rate feed forward
-- Allows a local body rate override
-
-Fixes issue #123
+type(scope): short description of the change
```
-### Test your changes
+| Part | Rule |
+|------|------|
+| **type** | Category of change (see types table below) |
+| **scope** | The module, driver, board, or area of PX4 affected |
+| **`!`** (optional) | Append before `:` to mark a breaking change |
+| **description** | What the change does, at least 5 characters, written in imperative form |
-Since we care about safety, we will regularly ask you for test results. Best is to do a test flight (or bench test where it applies) and upload the logfile from it (on the microSD card in the logs directory) to Google Drive or Dropbox and share the link.
+### Types
-### Push your changes
+| Type | Description |
+|------|-------------|
+| `feat` | A new feature |
+| `fix` | A bug fix |
+| `docs` | Documentation only changes |
+| `style` | Formatting, whitespace, no code change |
+| `refactor` | Code change that neither fixes a bug nor adds a feature |
+| `perf` | Performance improvement |
+| `test` | Adding or correcting tests |
+| `build` | Build system or external dependencies |
+| `ci` | CI configuration files and scripts |
+| `chore` | Other changes that don't modify src or test files |
+| `revert` | Reverts a previous commit |
-Push changes to your repo and send a [pull request](https://github.com/PX4/Firmware/compare/).
+### Scopes
+
+The scope identifies which part of PX4 is affected. Common scopes:
+
+| Scope | Area |
+|-------|------|
+| `ekf2` | Extended Kalman Filter (state estimation) |
+| `mavlink` | MAVLink messaging protocol |
+| `commander` | Commander and mode management |
+| `navigator` | Mission, RTL, Land, and other navigation modes |
+| `sensors` | Sensor drivers and processing |
+| `drivers` | Hardware drivers |
+| `boards/px4_fmu-v6x` | Board-specific changes (use the board name) |
+| `mc_att_control` | Multicopter attitude control |
+| `mc_pos_control` | Multicopter position control |
+| `fw_att_control` | Fixed-wing attitude control |
+| `vtol` | VTOL-specific logic |
+| `actuators` | Mixer and actuator output |
+| `battery` | Battery monitoring and estimation |
+| `logger` | On-board logging |
+| `param` | Parameter system |
+| `simulation` | SITL, Gazebo, SIH |
+| `ci` | Continuous integration and workflows |
+| `docs` | Documentation |
+| `build` | CMake, toolchain, build system |
+| `uorb` | Inter-module messaging |
+
+For changes spanning multiple subsystems, use the primary one affected. Look at the directory path of the files you changed to find the right scope: `src/modules/ekf2/` uses `ekf2`, `src/drivers/imu/` uses `drivers/imu`, `.github/workflows/` uses `ci`.
+
+### Breaking changes
+
+Append `!` before the colon to indicate a breaking change:
+
+```
+feat(ekf2)!: remove deprecated height fusion API
+```
+
+### Good commit messages
+
+```
+feat(ekf2): add height fusion timeout
+fix(mavlink): correct BATTERY_STATUS_V2 parsing
+refactor(navigator): simplify RTL altitude logic
+ci(workflows): migrate to reusable workflows
+docs(ekf2): update tuning guide
+feat(boards/px4_fmu-v6x)!: remove deprecated driver API
+perf(mc_rate_control): reduce loop latency
+```
+
+### Commits to avoid
+
+These will be flagged by CI and should be squashed or reworded before merging:
+
+```
+fix # too vague, no type or scope
+update # too vague, no type or scope
+ekf2: fix something # missing type prefix
+apply suggestions from code review # squash into parent commit
+do make format # squash into parent commit
+WIP: trying something # not ready for main
+oops # not descriptive
+```
+
+### PR titles
+
+The PR title follows the same `type(scope): description` format. This is enforced by CI and is especially important because the PR title becomes the commit message when a PR is squash-merged.
+
+### Merge policy
+
+Commits should be atomic and independently revertable. Squash at reviewer discretion for obvious cases (multiple WIP commits, messy review-response history). When your commits are clean and logical, they will be preserved as individual commits on `main`.
+
+### Cleaning up commits
+
+If CI flags your commit messages, you can fix them with an interactive rebase:
+
+```bash
+# Squash all commits into one:
+git rebase -i HEAD~N # replace N with the number of commits
+# mark all commits except the first as 'squash' or 'fixup'
+# reword the remaining commit to follow the format
+git push --force-with-lease
+
+# Or reword specific commits:
+git rebase -i HEAD~N
+# mark the bad commits as 'reword'
+git push --force-with-lease
+```
+
+## Test your changes
+
+PX4 is safety-critical software. All contributions must include adequate testing where practical:
+
+- **New features** must include unit tests and/or integration tests that exercise the new functionality, where practical. Hardware-dependent changes that cannot be tested in SITL should include bench test or flight test evidence.
+- **Bug fixes** must include a regression test where practical. When automated testing is not feasible (hardware-specific issues, race conditions, etc.), provide a link to a flight log demonstrating the fix and the reproduction steps for the original bug.
+- **Reviewers** will verify that tests or test evidence exist before approving a pull request.
+
+### Types of tests
+
+| Test type | When to use | How to run |
+|-----------|-------------|------------|
+| **Unit tests** (gtest) | Module-level logic, math, parsing | `make tests` |
+| **SITL integration tests** (MAVSDK) | Flight behavior, failsafes, missions | `test/mavsdk_tests/` |
+| **Bench tests / flight logs** | Hardware-dependent changes | Upload logs to [Flight Review](https://logs.px4.io) |
+
+Since we care about safety, we will regularly ask you for test results. Best is to do a test flight (or bench test where it applies) and upload the log file from it (on the microSD card in the logs directory) to Google Drive or Dropbox and share the link.
+
+## Push your changes
+
+Push changes to your repo and send a [pull request](https://github.com/PX4/PX4-Autopilot/compare/).
Make sure to provide some testing feedback and if possible the link to a flight log file. Upload flight log files to [Flight Review](http://logs.px4.io) and link the resulting report.
diff --git a/Jenkinsfile b/Jenkinsfile
deleted file mode 100644
index b6f6d3c98a..0000000000
--- a/Jenkinsfile
+++ /dev/null
@@ -1,268 +0,0 @@
-#!/usr/bin/env groovy
-
-pipeline {
- agent none
- stages {
-
- stage('Analysis') {
- when {
- anyOf {
- branch 'main'
- branch 'master' // should be removed, but in case there is something going on...
- branch 'pr-jenkins' // for testing
- }
- }
- parallel {
-
- stage('Airframe') {
- agent {
- docker { image 'px4io/px4-dev-base-focal:2021-08-18' }
- }
- steps {
- sh 'make distclean; git clean -ff -x -d .'
- sh 'git fetch --all --tags'
- sh 'make airframe_metadata'
- dir('build/px4_sitl_default/docs') {
- archiveArtifacts(artifacts: 'airframes.md, airframes.xml')
- stash includes: 'airframes.md, airframes.xml', name: 'metadata_airframes'
- }
- }
- post {
- always {
- sh 'make distclean; git clean -ff -x -d .'
- }
- }
- }
-
- stage('Parameter') {
- agent {
- docker { image 'px4io/px4-dev-base-focal:2021-08-18' }
- }
- steps {
- sh 'make distclean; git clean -ff -x -d .'
- sh 'git fetch --all --tags'
- sh 'make parameters_metadata'
- dir('build/px4_sitl_default/docs') {
- archiveArtifacts(artifacts: 'parameters.md, parameters.xml, parameters.json.xz')
- stash includes: 'parameters.md, parameters.xml, parameters.json.xz', name: 'metadata_parameters'
- }
- }
- post {
- always {
- sh 'make distclean; git clean -ff -x -d .'
- }
- }
- }
-
- stage('Module') {
- agent {
- docker { image 'px4io/px4-dev-base-focal:2021-08-18' }
- }
- steps {
- sh 'make distclean; git clean -ff -x -d .'
- sh 'git fetch --all --tags'
- sh 'make module_documentation'
- dir('build/px4_sitl_default/docs') {
- archiveArtifacts(artifacts: 'modules/*.md')
- stash includes: 'modules/*.md', name: 'metadata_module_documentation'
- }
- }
- post {
- always {
- sh 'make distclean; git clean -ff -x -d .'
- }
- }
- }
-
- stage('msg file docs') {
- agent {
- docker { image 'px4io/px4-dev-base-focal:2021-08-18' }
- }
- steps {
- sh 'mkdir -p build/msg_docs; ./Tools/msg/generate_msg_docs.py -d build/msg_docs'
- dir('build') {
- archiveArtifacts(artifacts: 'msg_docs/*.md')
- stash includes: 'msg_docs/*.md', name: 'msg_documentation'
- }
- }
- post {
- always {
- sh 'make distclean; git clean -ff -x -d .'
- }
- }
- }
-
- stage('failsafe docs') {
- agent {
- docker { image 'px4io/px4-dev-nuttx-focal:2022-08-12' }
- }
- steps {
- sh '''#!/bin/bash -l
- echo $0;
- git clone https://github.com/emscripten-core/emsdk.git _emscripten_sdk;
- cd _emscripten_sdk;
- git checkout 4.0.15;
- ./emsdk install latest;
- ./emsdk activate latest;
- cd ..;
- . ./_emscripten_sdk/emsdk_env.sh;
- git fetch --all --tags;
- make failsafe_web;
- cd build/px4_sitl_default_failsafe_web;
- mkdir -p failsafe_sim;
- cp index.* parameters.json failsafe_sim;
- '''
- dir('build/px4_sitl_default_failsafe_web') {
- archiveArtifacts(artifacts: 'failsafe_sim/*')
- stash includes: 'failsafe_sim/*', name: 'failsafe_sim'
- }
- }
- post {
- always {
- sh 'make distclean; git clean -ff -x -d .'
- }
- }
- }
-
- stage('uORB graphs') {
- agent {
- docker {
- image 'px4io/px4-dev-nuttx-focal:2022-08-12'
- args '-e CCACHE_BASEDIR=$WORKSPACE -v ${CCACHE_DIR}:${CCACHE_DIR}:rw'
- }
- }
- steps {
- sh 'export'
- sh 'make distclean; git clean -ff -x -d .'
- sh 'git fetch --all --tags'
- sh 'make uorb_graphs'
- dir('Tools/uorb_graph') {
- archiveArtifacts(artifacts: 'graph_*.json')
- stash includes: 'graph_*.json', name: 'uorb_graph'
- }
- }
- post {
- always {
- sh 'make distclean; git clean -ff -x -d .'
- }
- }
- }
-
- } // parallel
- } // stage: Generate Metadata
-
- stage('Deploy') {
-
- parallel {
-
- stage('Userguide') {
- agent {
- docker { image 'px4io/px4-dev-base-focal:2021-08-18' }
- }
- steps {
- sh('export')
- unstash 'metadata_airframes'
- unstash 'metadata_parameters'
- unstash 'metadata_module_documentation'
- unstash 'msg_documentation'
- unstash 'failsafe_sim'
- unstash 'uorb_graph'
- withCredentials([usernamePassword(credentialsId: 'px4buildbot_github_personal_token', passwordVariable: 'GIT_PASS', usernameVariable: 'GIT_USER')]) {
- sh('git clone https://${GIT_USER}:${GIT_PASS}@github.com/PX4/PX4-user_guide.git')
- sh('cp airframes.md PX4-user_guide/en/airframes/airframe_reference.md')
- sh('cp parameters.md PX4-user_guide/en/advanced_config/parameter_reference.md')
- sh('cp -R modules/*.md PX4-user_guide/en/modules/')
- sh('cp -R graph_*.json PX4-user_guide/public/middleware/') // vitepress
- sh('cp -R msg_docs/*.md PX4-user_guide/en/msg_docs/')
- sh('cp -R failsafe_sim/* PX4-user_guide/public/config/failsafe') // vitepress
- sh('cd PX4-user_guide; git status; git add .; git commit -a -m "Update PX4 Firmware metadata `date`" || true')
- sh('cd PX4-user_guide; git push origin main || true')
- sh('rm -rf PX4-user_guide')
- }
- }
- when {
- anyOf {
- branch 'main'
- branch 'master' // should be removed, but in case there is something going on...
- branch 'pr-jenkins' // for testing
- }
- }
- options {
- skipDefaultCheckout()
- }
- }
-
- stage('QGroundControl') {
- agent {
- docker { image 'px4io/px4-dev-base-focal:2021-08-18' }
- }
- steps {
- sh('export')
- unstash 'metadata_airframes'
- unstash 'metadata_parameters'
- withCredentials([usernamePassword(credentialsId: 'px4buildbot_github_personal_token', passwordVariable: 'GIT_PASS', usernameVariable: 'GIT_USER')]) {
- sh('git clone https://${GIT_USER}:${GIT_PASS}@github.com/mavlink/qgroundcontrol.git')
- sh('cp airframes.xml qgroundcontrol/src/AutoPilotPlugins/PX4/AirframeFactMetaData.xml')
- sh('cp parameters.xml qgroundcontrol/src/FirmwarePlugin/PX4/PX4ParameterFactMetaData.xml')
- sh('cd qgroundcontrol; git status; git add .; git commit -a -m "Update PX4 Firmware metadata `date`" || true')
- sh('cd qgroundcontrol; git push origin master || true')
- sh('rm -rf qgroundcontrol')
- }
- }
- when {
- anyOf {
- branch 'main'
- branch 'master' // should be removed, but in case there is something going on...
- branch 'pr-jenkins' // for testing
- }
- }
- options {
- skipDefaultCheckout()
- }
- }
-
- stage('S3') {
- agent {
- docker { image 'px4io/px4-dev-base-focal:2021-08-18' }
- }
- steps {
- sh('export')
- unstash 'metadata_airframes'
- unstash 'metadata_parameters'
- sh('ls')
- withAWS(credentials: 'px4_aws_s3_key', region: 'us-east-1') {
- s3Upload(acl: 'PublicRead', bucket: 'px4-travis', file: 'airframes.xml', path: 'Firmware/master/')
- s3Upload(acl: 'PublicRead', bucket: 'px4-travis', file: 'parameters.xml', path: 'Firmware/master/')
- s3Upload(acl: 'PublicRead', bucket: 'px4-travis', file: 'parameters.json.xz', path: 'Firmware/master/')
- }
- }
- when {
- anyOf {
- branch 'main'
- branch 'master' // should be removed, but in case there is something going on...
- branch 'pr-jenkins' // for testing
- }
- }
- options {
- skipDefaultCheckout()
- }
- }
-
- } // parallel
- } // stage: Generate Metadata
-
- } // stages
-
- environment {
- CCACHE_DIR = '/tmp/ccache'
- CI = true
- GIT_AUTHOR_EMAIL = "bot@px4.io"
- GIT_AUTHOR_NAME = "PX4BuildBot"
- GIT_COMMITTER_EMAIL = "bot@px4.io"
- GIT_COMMITTER_NAME = "PX4BuildBot"
- }
- options {
- buildDiscarder(logRotator(numToKeepStr: '20', artifactDaysToKeepStr: '30'))
- timeout(time: 90, unit: 'MINUTES')
- }
-}
diff --git a/Kconfig b/Kconfig
index 8e570545a1..4bc076893a 100644
--- a/Kconfig
+++ b/Kconfig
@@ -67,6 +67,16 @@ menu "Toolchain"
help
Enables Cmake Release for -O3 optimization
+ config BOARD_SUPPORT_FORTIFIED_TOOLCHAIN
+ bool "Fortified toolchain support"
+ default n
+ help
+ Enable compatibility with toolchains that define
+ _FORTIFY_SOURCE.
+
+ This switches PX4_DEBUG_OPT_LEVEL from -O0 to -Og. Keep this
+ disabled unless the fortified toolchain requires optimization.
+
config BOARD_ROMFSROOT
string "ROMFSROOT"
default "px4fmu_common"
@@ -192,6 +202,12 @@ menu "File paths"
config BOARD_PARAM_FILE
string "Parameter file"
default "/fs/mtd_params"
+
+ config BOARD_NO_SDCARD
+ bool "Board has no SD card"
+ default n
+ help
+ Disable the SD card arming check for boards without an SD card slot.
endmenu
menu "drivers"
@@ -210,6 +226,10 @@ menu "examples"
source "src/examples/Kconfig"
endmenu
+menu "templates"
+source "src/templates/Kconfig"
+endmenu
+
menu "platforms"
depends on PLATFORM_QURT || PLATFORM_POSIX || PLATFORM_NUTTX
source "platforms/Kconfig"
diff --git a/MAINTAINERS.md b/MAINTAINERS.md
index e1fcc89b5f..892c5f65c6 100644
--- a/MAINTAINERS.md
+++ b/MAINTAINERS.md
@@ -1,9 +1,11 @@
Maintainers
===========
-See [the documentation on Maintainers](https://docs.px4.io/main/en/contribute/maintainers.html) to learn about the role of the maintainers and the process to become one.
+PX4 is maintained by a group of contributors trusted to steward the project. All maintainers listed below are members of the @PX4/dev-team, have write access, and participate in maintainer decisions. We recognize two types: **Code Owners**, responsible for specific components, and **Reviewers**, who help across the project without a fixed component.
-**Active Maintainers**
+See [the documentation on Maintainers](https://docs.px4.io/main/en/contribute/maintainers) to learn about the role of the maintainers and the process to become one.
+
+**Code Owners**
| Name | Sector | GitHub | Chat | email
|-------------------------|--------|--------|------|----------------
@@ -23,6 +25,15 @@ See [the documentation on Maintainers](https://docs.px4.io/main/en/contribute/ma
| Jacob Dahl | Simulation | [@dakejahl](https://github.com/dakejahl) | dakejahl |
+**Reviewers**
+
+Reviewers help maintain PX4 across the project without ownership of a specific component.
+
+| Name | GitHub | Chat | email
+|------|--------|------|----------------------
+| Onur Ozkan | [@onur-ozkan](https://github.com/onur-ozkan) | onur_ozkan0126 |
+
+
**Documentation Maintainers**
| Name | GitHub | Chat | email
diff --git a/Makefile b/Makefile
index 3cbe67acc9..7ccdbc2b33 100644
--- a/Makefile
+++ b/Makefile
@@ -162,6 +162,12 @@ else
endif
+# Prefer the interpreter from an active Python virtual environment.
+# Otherwise leave PYTHON_EXECUTABLE unset and let CMake resolve Python.
+ifneq ($(strip $(VIRTUAL_ENV)),)
+ PYTHON_EXECUTABLE ?= $(VIRTUAL_ENV)/bin/python
+endif
+
# Pick up specific Python path if set
ifdef PYTHON_EXECUTABLE
override CMAKE_ARGS += -DPYTHON_EXECUTABLE=${PYTHON_EXECUTABLE}
@@ -226,9 +232,22 @@ CONFIG_TARGETS_DEFAULT := $(patsubst %_default,%,$(filter %_default,$(ALL_CONFIG
$(CONFIG_TARGETS_DEFAULT):
@$(call cmake-build,$@_default$(BUILD_DIR_SUFFIX))
+# Multi-processor boards: build all processor targets together
+# VOXL2 apps processor (default) depends on SLPI DSP being built first
+modalai_voxl2_default: modalai_voxl2_slpi
+modalai_voxl2: modalai_voxl2_slpi
+modalai_voxl2_deb: modalai_voxl2_slpi
+
all_config_targets: $(ALL_CONFIG_TARGETS)
all_default_targets: $(CONFIG_TARGETS_DEFAULT)
+# DEB package targets: builds _default config, then runs cpack.
+# Multi-processor boards (e.g. VOXL2) chain companion builds automatically
+# via existing cmake prerequisites.
+%_deb:
+ @$(call cmake-build,$(subst _deb,_default,$@)$(BUILD_DIR_SUFFIX))
+ @cd "$(SRC_DIR)/build/$(subst _deb,_default,$@)" && cpack -G DEB
+
updateconfig:
@./Tools/kconfig/updateconfig.py
@@ -332,6 +351,7 @@ bootloaders_update: \
cuav_7-nano_bootloader \
cuav_fmu-v6x_bootloader \
cuav_x25-evo_bootloader \
+ cuav_x25-super_bootloader \
cubepilot_cubeorange_bootloader \
cubepilot_cubeorangeplus_bootloader \
hkust_nxt-dual_bootloader \
@@ -384,7 +404,7 @@ px4_metadata: parameters_metadata airframe_metadata module_documentation extract
# Style
# --------------------------------------------------------------------
-.PHONY: check_format format check_newlines
+.PHONY: check_format format format_changed check_newlines
check_format:
$(call colorecho,'Checking formatting with astyle')
@@ -395,6 +415,10 @@ format:
$(call colorecho,'Formatting with astyle')
@"$(SRC_DIR)"/Tools/astyle/check_code_style_all.sh --fix
+format_changed:
+ $(call colorecho,'Formatting changed files with astyle')
+ @"$(SRC_DIR)"/Tools/astyle/check_code_style_all.sh --fix --diff-only
+
check_newlines:
$(call colorecho,'Checking for missing or duplicate newlines at the end of files')
@"$(SRC_DIR)"/Tools/astyle/check_newlines.sh
@@ -474,7 +498,7 @@ python_coverage:
# static analyzers (scan-build, clang-tidy, cppcheck)
# --------------------------------------------------------------------
-.PHONY: scan-build px4_sitl_default-clang clang-tidy clang-tidy-fix
+.PHONY: scan-build px4_sitl_default-clang px4_sitl_default-clang-test clang-ci clang-tidy clang-tidy-fix
.PHONY: cppcheck shellcheck_all validate_module_configs
scan-build:
@@ -492,6 +516,26 @@ px4_sitl_default-clang:
@cd "$(SRC_DIR)"/build/px4_sitl_default-clang && cmake "$(SRC_DIR)" $(CMAKE_ARGS) -G"$(PX4_CMAKE_GENERATOR)" -DCONFIG=px4_sitl_default -DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++
@$(PX4_MAKE) -C "$(SRC_DIR)"/build/px4_sitl_default-clang
+# Clang SITL configure with BUILD_TESTING=ON so test files land in
+# compile_commands.json with resolved gtest/fuzztest includes. Used by CI
+# to produce a compilation database for diff-based clang-tidy that can
+# lint test files. Configure only: we don't build the test binaries here,
+# just generate the database.
+px4_sitl_default-clang-test:
+ @mkdir -p "$(SRC_DIR)"/build/px4_sitl_default-clang-test
+ @cd "$(SRC_DIR)"/build/px4_sitl_default-clang-test && cmake "$(SRC_DIR)" $(CMAKE_ARGS) -G"$(PX4_CMAKE_GENERATOR)" -DCONFIG=px4_sitl_default -DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++ -DCMAKE_TESTING=ON
+
+# CI-oriented target that prepares both clang build directories used by
+# the Static Analysis workflow:
+# - px4_sitl_default-clang: full build, BUILD_TESTING=OFF.
+# Used by `make clang-tidy` (push-to-main) and run-clang-tidy-pr.py.
+# - px4_sitl_default-clang-test: configure-only, BUILD_TESTING=ON.
+# Used by clang-tidy-diff-18.py so test files are in the
+# compilation database with resolved gtest/fuzztest includes.
+# Running one target ensures both dirs exist before any clang-tidy
+# variant runs, and keeps the workflow free of raw cmake invocations.
+clang-ci: px4_sitl_default-clang px4_sitl_default-clang-test
+
# Paths to exclude from clang-tidy (auto-generated from .gitmodules + manual additions):
# - All submodules (external code we consume, not edit)
# - Test code (allowed looser style)
@@ -534,7 +578,8 @@ validate_module_configs:
-not -path "$(SRC_DIR)/src/modules/zenoh/zenoh-pico/*" \
-not -path "$(SRC_DIR)/src/lib/events/libevents/*" \
-not -path "$(SRC_DIR)/src/lib/cdrstream/*" \
- -not -path "$(SRC_DIR)/src/lib/crypto/libtommath/*" -print0 | \
+ -not -path "$(SRC_DIR)/src/lib/crypto/libtommath/*" \
+ -not -path "$(SRC_DIR)/src/lib/tensorflow_lite_micro/*" -print0 | \
xargs -0 "$(SRC_DIR)"/Tools/validate_yaml.py --schema-file "$(SRC_DIR)"/validation/module_schema.yaml
# Cleanup
diff --git a/README.md b/README.md
index bf4d74bb76..2e439ecb15 100644
--- a/README.md
+++ b/README.md
@@ -9,10 +9,16 @@
-
+
-
-
+
+
+
+
+
+
+
+
---
@@ -64,7 +70,17 @@ PX4 is an open-source autopilot stack for drones and unmanned vehicles. It suppo
…and many more: helicopters, autogyros, airships, submarines, boats, and other experimental platforms. These frames have basic support but are not part of the regular flight-test program. See the full airframe reference.
-## Quick Start
+## Try PX4
+
+Run PX4 in simulation with a single command. No build tools, no dependencies beyond Docker:
+
+```bash
+docker run --rm -it -p 14550:14550/udp px4io/px4-sitl:latest
+```
+
+Open [QGroundControl](https://qgroundcontrol.com) and fly. See [PX4 Simulation Quickstart](../dev_setup/px4_simulation_quickstart.md) for more options.
+
+## Build from Source
```bash
git clone https://github.com/PX4/PX4-Autopilot.git --recursive
@@ -98,6 +114,22 @@ make px4_sitl
We welcome contributions of all kinds — bug reports, documentation, new features, and code reviews. Please read the [Contribution Guide](https://docs.px4.io/main/en/contribute/) to get started.
+## Citation
+
+If you use PX4 in academic work, please cite it. BibTeX:
+
+```bibtex
+@software{px4_autopilot,
+ author = {Meier, Lorenz and {The PX4 Contributors}},
+ title = {{PX4 Autopilot}},
+ publisher = {Zenodo},
+ doi = {10.5281/zenodo.595432},
+ url = {https://px4.io}
+}
+```
+
+The DOI above is a Zenodo concept DOI that always resolves to the latest release. For a version-pinned citation, see the [Zenodo record](https://doi.org/10.5281/zenodo.595432) or our [`CITATION.cff`](CITATION.cff).
+
## Governance
The PX4 Autopilot project is hosted by the [Dronecode Foundation](https://www.dronecode.org/), a [Linux Foundation](https://www.linuxfoundation.org/) Collaborative Project. Dronecode holds all PX4 trademarks and serves as the project's legal guardian, ensuring vendor-neutral stewardship — no single company owns the name or controls the roadmap. The source code is licensed under the [BSD 3-Clause](LICENSE) license, so you are free to use, modify, and distribute it in your own projects.
diff --git a/ROMFS/CMakeLists.txt b/ROMFS/CMakeLists.txt
index 9b282339fd..53ee58a6c6 100644
--- a/ROMFS/CMakeLists.txt
+++ b/ROMFS/CMakeLists.txt
@@ -193,6 +193,7 @@ endif()
# board custom init files
set(OPTIONAL_BOARD_RC)
list(APPEND OPTIONAL_BOARD_RC
+ rc.board_early
rc.board_defaults
rc.board_sensors
rc.board_extras
diff --git a/ROMFS/px4fmu_common/init.d-posix/airframes/10040_sihsim_quadx b/ROMFS/px4fmu_common/init.d-posix/airframes/10040_sihsim_quadx
index 623ee41f1f..d043d9b82d 100644
--- a/ROMFS/px4fmu_common/init.d-posix/airframes/10040_sihsim_quadx
+++ b/ROMFS/px4fmu_common/init.d-posix/airframes/10040_sihsim_quadx
@@ -34,6 +34,7 @@ param set-default PWM_MAIN_FUNC2 102
param set-default PWM_MAIN_FUNC3 103
param set-default PWM_MAIN_FUNC4 104
-param set-default EKF2_GPS_DELAY 0
+param set-default SENS_GPS0_DELAY 0
+param set-default SENS_GPS1_DELAY 0
param set SIH_VEHICLE_TYPE 0
diff --git a/ROMFS/px4fmu_common/init.d-posix/airframes/10041_sihsim_airplane b/ROMFS/px4fmu_common/init.d-posix/airframes/10041_sihsim_airplane
index 100af65943..512818edf8 100644
--- a/ROMFS/px4fmu_common/init.d-posix/airframes/10041_sihsim_airplane
+++ b/ROMFS/px4fmu_common/init.d-posix/airframes/10041_sihsim_airplane
@@ -44,7 +44,8 @@ param set-default PWM_MAIN_FUNC2 202
param set-default PWM_MAIN_FUNC3 203
param set-default PWM_MAIN_FUNC4 101
-param set-default EKF2_GPS_DELAY 0
+param set-default SENS_GPS0_DELAY 0
+param set-default SENS_GPS1_DELAY 0
# Rate controllers
param set-default FW_RR_P 0.0500
diff --git a/ROMFS/px4fmu_common/init.d-posix/airframes/10042_sihsim_xvert b/ROMFS/px4fmu_common/init.d-posix/airframes/10042_sihsim_xvert
index ee5ea9401c..04551ecf6d 100644
--- a/ROMFS/px4fmu_common/init.d-posix/airframes/10042_sihsim_xvert
+++ b/ROMFS/px4fmu_common/init.d-posix/airframes/10042_sihsim_xvert
@@ -11,7 +11,8 @@
PX4_SIMULATOR=${PX4_SIMULATOR:=sihsim}
PX4_SIM_MODEL=${PX4_SIM_MODEL:=xvert}
-param set-default EKF2_GPS_DELAY 0
+param set-default SENS_GPS0_DELAY 0
+param set-default SENS_GPS1_DELAY 0
param set-default EKF2_FUSE_BETA 0 # side slip fusion is currently not supported for tailsitters
param set-default SENS_EN_GPSSIM 1
diff --git a/ROMFS/px4fmu_common/init.d-posix/airframes/10043_sihsim_standard_vtol b/ROMFS/px4fmu_common/init.d-posix/airframes/10043_sihsim_standard_vtol
index 4abc9de264..4ab7471752 100644
--- a/ROMFS/px4fmu_common/init.d-posix/airframes/10043_sihsim_standard_vtol
+++ b/ROMFS/px4fmu_common/init.d-posix/airframes/10043_sihsim_standard_vtol
@@ -27,7 +27,8 @@ param set-default SENS_EN_BAROSIM 1
param set-default SENS_EN_MAGSIM 1
param set-default SENS_EN_ARSPDSIM 1
-param set-default EKF2_GPS_DELAY 0
+param set-default SENS_GPS0_DELAY 0
+param set-default SENS_GPS1_DELAY 0
param set-default VT_TYPE 2
param set-default MPC_MAN_Y_MAX 60
@@ -92,5 +93,18 @@ param set SIH_IXZ 0
param set SIH_KDV 0.2
param set SIH_L_ROLL 0.2
+# pusher propeller model with advance ratio, model from UIUC APC 8x6"
+param set SIH_F_T_MAX 6
+param set SIH_F_Q_MAX 0.03
+# if SIH_F_CT0 > 0, SIH_F_T_MAX and SIH_F_Q_MAX will be overridden
+param set SIH_F_CT0 0.131
+param set SIH_F_CT1 0.004
+param set SIH_F_CT2 -0.146
+param set SIH_F_CP0 0.0777
+param set SIH_F_CP1 0.0498
+param set SIH_F_CP2 -0.11
+param set SIH_F_DIA_INCH 8
+param set SIH_F_RPM_MAX 9000
+
# sih as standard vtol
param set SIH_VEHICLE_TYPE 3
diff --git a/ROMFS/px4fmu_common/init.d-posix/airframes/10044_sihsim_hex b/ROMFS/px4fmu_common/init.d-posix/airframes/10044_sihsim_hex
index 36ae9a26d7..f99b200359 100644
--- a/ROMFS/px4fmu_common/init.d-posix/airframes/10044_sihsim_hex
+++ b/ROMFS/px4fmu_common/init.d-posix/airframes/10044_sihsim_hex
@@ -18,6 +18,7 @@ param set-default SENS_EN_BAROSIM 1
param set-default SENS_EN_MAGSIM 1
param set SIH_VEHICLE_TYPE 4
+param set-default MAV_TYPE 13
# Symmetric hexacopter X clockwise motor numbering
param set-default CA_ROTOR_COUNT 6
@@ -44,4 +45,7 @@ param set-default PWM_MAIN_FUNC4 104
param set-default PWM_MAIN_FUNC5 105
param set-default PWM_MAIN_FUNC6 106
-param set-default EKF2_GPS_DELAY 0
+param set-default SENS_GPS0_DELAY 0
+param set-default SENS_GPS1_DELAY 0
+
+param set-default THR_MDL_FAC 1 # simulated quadratic motor thrust
diff --git a/ROMFS/px4fmu_common/init.d-posix/airframes/4003_gz_rc_cessna b/ROMFS/px4fmu_common/init.d-posix/airframes/4003_gz_rc_cessna
index 58e09228eb..908392674d 100644
--- a/ROMFS/px4fmu_common/init.d-posix/airframes/4003_gz_rc_cessna
+++ b/ROMFS/px4fmu_common/init.d-posix/airframes/4003_gz_rc_cessna
@@ -44,8 +44,6 @@ param set-default FW_T_SINK_MIN 3
param set-default FW_W_EN 1
-param set-default FD_ESCS_EN 0
-
param set-default MIS_TAKEOFF_ALT 30
param set-default NAV_ACC_RAD 15
diff --git a/ROMFS/px4fmu_common/init.d-posix/airframes/4004_gz_standard_vtol b/ROMFS/px4fmu_common/init.d-posix/airframes/4004_gz_standard_vtol
index cc8bed693f..25c6d7a494 100644
--- a/ROMFS/px4fmu_common/init.d-posix/airframes/4004_gz_standard_vtol
+++ b/ROMFS/px4fmu_common/init.d-posix/airframes/4004_gz_standard_vtol
@@ -104,4 +104,3 @@ param set-default VT_FWD_THRUST_EN 4
param set-default VT_PITCH_MIN -5
param set-default VT_F_TRANS_THR 1
param set-default VT_TYPE 2
-param set-default FD_ESCS_EN 0
diff --git a/ROMFS/px4fmu_common/init.d-posix/airframes/4006_gz_px4vision b/ROMFS/px4fmu_common/init.d-posix/airframes/4006_gz_px4vision
index 4370a677ed..135bb93152 100644
--- a/ROMFS/px4fmu_common/init.d-posix/airframes/4006_gz_px4vision
+++ b/ROMFS/px4fmu_common/init.d-posix/airframes/4006_gz_px4vision
@@ -20,8 +20,8 @@ param set-default COM_DISARM_LAND 0.5
# EKF2 parameters
param set-default EKF2_DRAG_CTRL 1
param set-default EKF2_IMU_POS_X 0.02
-param set-default EKF2_GPS_POS_X 0.055
-param set-default EKF2_GPS_POS_Z -0.15
+param set-default SENS_GPS0_OFFX 0.055
+param set-default SENS_GPS0_OFFZ -0.15
param set-default EKF2_MIN_RNG 0.03
param set-default EKF2_OF_CTRL 1
param set-default EKF2_OF_POS_X 0.055
diff --git a/ROMFS/px4fmu_common/init.d-posix/airframes/60002_gz_uuv_bluerov2_heavy b/ROMFS/px4fmu_common/init.d-posix/airframes/60002_gz_uuv_bluerov2_heavy
index ef620441ac..681efbfc45 100644
--- a/ROMFS/px4fmu_common/init.d-posix/airframes/60002_gz_uuv_bluerov2_heavy
+++ b/ROMFS/px4fmu_common/init.d-posix/airframes/60002_gz_uuv_bluerov2_heavy
@@ -26,7 +26,6 @@ param set-default SENS_EN_GPSSIM 1
param set-default SENS_EN_BAROSIM 1
param set-default SENS_EN_MAGSIM 1
param set-default COM_ARM_CHK_ESCS 0 # We don't have ESCs
-param set-default FD_ESCS_EN 0 # We don't have ESCs - but maybe we need this later?
# Set proper failsafes
param set-default COM_ACT_FAIL_ACT 0
diff --git a/ROMFS/px4fmu_common/init.d-posix/airframes/70000_gz_atmos b/ROMFS/px4fmu_common/init.d-posix/airframes/70000_gz_atmos
index 49ef971307..b7a6a24bd1 100644
--- a/ROMFS/px4fmu_common/init.d-posix/airframes/70000_gz_atmos
+++ b/ROMFS/px4fmu_common/init.d-posix/airframes/70000_gz_atmos
@@ -28,7 +28,6 @@ param set-default SIM_GZ_EN 1
param set-default SENS_EN_MAGSIM 1
param set-default COM_ARM_CHK_ESCS 0 # We don't have ESCs
-param set-default FD_ESCS_EN 0
param set-default CA_AIRFRAME 14
param set-default MAV_TYPE 45
diff --git a/ROMFS/px4fmu_common/init.d-posix/airframes/70001_gz_atmos_dual b/ROMFS/px4fmu_common/init.d-posix/airframes/70001_gz_atmos_dual
index 9a63cd420e..f495a46e6a 100644
--- a/ROMFS/px4fmu_common/init.d-posix/airframes/70001_gz_atmos_dual
+++ b/ROMFS/px4fmu_common/init.d-posix/airframes/70001_gz_atmos_dual
@@ -28,7 +28,6 @@ param set-default SIM_GZ_EN 1
param set-default SENS_EN_MAGSIM 1
param set-default COM_ARM_CHK_ESCS 0 # We don't have ESCs
-param set-default FD_ESCS_EN 0
param set-default CA_AIRFRAME 14
param set-default MAV_TYPE 45
diff --git a/ROMFS/px4fmu_common/init.d-posix/px4-rc.mavlinksim b/ROMFS/px4fmu_common/init.d-posix/px4-rc.mavlinksim
index f2d571062c..195952eaf8 100644
--- a/ROMFS/px4fmu_common/init.d-posix/px4-rc.mavlinksim
+++ b/ROMFS/px4fmu_common/init.d-posix/px4-rc.mavlinksim
@@ -2,7 +2,8 @@
# shellcheck disable=SC2154
# EKF2 specifics
-param set-default EKF2_GPS_DELAY 10
+param set-default SENS_GPS0_DELAY 10
+param set-default SENS_GPS1_DELAY 10
param set-default EKF2_MULTI_IMU 3
param set-default SENS_IMU_MODE 0
diff --git a/ROMFS/px4fmu_common/init.d-posix/rcS b/ROMFS/px4fmu_common/init.d-posix/rcS
index 0e443d30c4..8ce56eb640 100644
--- a/ROMFS/px4fmu_common/init.d-posix/rcS
+++ b/ROMFS/px4fmu_common/init.d-posix/rcS
@@ -119,10 +119,11 @@ else
param set SYS_AUTOCONFIG 1
fi
-if param compare SYS_AUTOCONFIG 1
+# To trigger a parameter reset during boot SYS_AUTOCONFIG was set to 1 before
+if param greater SYS_AUTOCONFIG 0
then
- # Reset params except Airframe, RC calibration, sensor calibration, flight modes, total flight time, and next flight UUID.
- param reset_all SYS_AUTOSTART RC* CAL_* COM_FLTMODE* LND_FLIGHT* TC_* COM_FLIGHT*
+ # Reset parameters except airframe, parameter version, sensor calibration, total flight time, flight UUID
+ param reset_all SYS_AUTOSTART SYS_PARAM_VER CAL_* LND_FLIGHT* TC_* COM_FLIGHT*
set AUTOCNF yes
fi
@@ -197,10 +198,6 @@ if [ -n "$PX4_SIM_SPEED_FACTOR" ]; then
COM_OF_LOSS_T_LONGER=$(echo "$PX4_SIM_SPEED_FACTOR * 1.0" | bc)
echo "COM_OF_LOSS_T set to $COM_OF_LOSS_T_LONGER"
param set COM_OF_LOSS_T $COM_OF_LOSS_T_LONGER
-
- COM_OBC_LOSS_T_LONGER=$(echo "$PX4_SIM_SPEED_FACTOR * 5.0" | bc)
- echo "COM_OBC_LOSS_T set to $COM_OBC_LOSS_T_LONGER"
- param set COM_OBC_LOSS_T $COM_OBC_LOSS_T_LONGER
fi
# Autostart ID
@@ -248,7 +245,7 @@ fi
load_mon start
-if param compare SIM_BAT_ENABLE 1
+if param greater SIM_BAT_DRAIN 0
then
battery_simulator start
fi
diff --git a/ROMFS/px4fmu_common/init.d/airframes/1101_rc_plane_sih.hil b/ROMFS/px4fmu_common/init.d/airframes/1101_rc_plane_sih.hil
index 40e8110e04..4a802cef62 100644
--- a/ROMFS/px4fmu_common/init.d/airframes/1101_rc_plane_sih.hil
+++ b/ROMFS/px4fmu_common/init.d/airframes/1101_rc_plane_sih.hil
@@ -14,6 +14,7 @@
param set UAVCAN_ENABLE 0
+param set-default CA_AIRFRAME 1
param set-default CA_ROTOR_COUNT 1
param set-default CA_ROTOR0_PX 0.3
@@ -38,7 +39,6 @@ param set-default SYS_HITL 2
# - without real battery
param set-default CBRK_SUPPLY_CHK 894281
-param set SIH_T_MAX 6
param set SIH_MASS 0.3
param set SIH_IXX 0.00402
param set SIH_IYY 0.0144
@@ -48,3 +48,21 @@ param set SIH_KDV 0.2
param set SIH_VEHICLE_TYPE 1 # sih as fixed wing
param set RWTO_TKOFF 1 # enable takeoff from runway (as opposed to launched)
+
+# pusher propeller model with advance ratio, model from UIUC APC 8x6"
+param set SIH_F_T_MAX 6
+param set SIH_F_Q_MAX 0.03
+# if SIH_F_CT0 > 0, SIH_F_T_MAX and SIH_F_Q_MAX will be overridden
+param set SIH_F_CT0 0.131
+param set SIH_F_CT1 0.004
+param set SIH_F_CT2 -0.146
+param set SIH_F_CP0 0.0777
+param set SIH_F_CP1 0.0498
+param set SIH_F_CP2 -0.11
+param set SIH_F_DIA_INCH 8
+param set SIH_F_RPM_MAX 9000
+
+param set-default FW_AIRSPD_MIN 7
+param set-default FW_AIRSPD_TRIM 10
+param set-default FW_AIRSPD_MAX 12
+param set-default FW_PSP_OFF 0.5
diff --git a/ROMFS/px4fmu_common/init.d/airframes/1102_tailsitter_duo_sih.hil b/ROMFS/px4fmu_common/init.d/airframes/1102_tailsitter_duo_sih.hil
index e033c1a4b6..5a1a6c829f 100644
--- a/ROMFS/px4fmu_common/init.d/airframes/1102_tailsitter_duo_sih.hil
+++ b/ROMFS/px4fmu_common/init.d/airframes/1102_tailsitter_duo_sih.hil
@@ -28,6 +28,7 @@ param set-default VT_FW_DIFTHR_EN 1
param set-default VT_FW_DIFTHR_S_Y 0.3
param set-default MPC_MAN_Y_MAX 60
param set-default MC_PITCH_P 5
+param set-default FW_PSP_OFF 5
param set-default CA_AIRFRAME 4
param set-default CA_ROTOR_COUNT 2
@@ -56,7 +57,6 @@ param set-default HIL_ACT_REV 32
param set-default MAV_TYPE 19
-
# set SYS_HITL to 2 to start the SIH and avoid sensors startup
param set-default SYS_HITL 2
@@ -66,8 +66,9 @@ param set-default CBRK_SUPPLY_CHK 894281
param set-default SENS_DPRES_OFF 0.001
-param set SIH_T_MAX 2.0
-param set SIH_Q_MAX 0.0165
+# tailsitter is equipped with two forward propellers
+param set SIH_F_T_MAX 2
+param set SIH_F_Q_MAX 0.0165
param set SIH_MASS 0.2
# IXX and IZZ are inverted from the thesis as the body frame is pitched by 90 deg
param set SIH_IXX 0.00354
@@ -77,6 +78,19 @@ param set SIH_IXZ 0
param set SIH_KDV 0.2
param set SIH_L_ROLL 0.145
+# propeller diameter, rpm, and coeffs coming from the thesis
+# Modeling and control of a flying wing tailsitter unmanned aerial vehicle."
+# Chiappinelli, Romain, supervised by Nahon, Meyer, McGill University, Masters thesis, 2018.
+# if SIH_F_CT0 > 0, SIH_F_T_MAX and SIH_F_Q_MAX will be overridden
+param set SIH_F_CT0 0.1342
+param set SIH_F_CT1 -0.1196
+param set SIH_F_CT2 -0.1281
+param set SIH_F_CP0 0.0522
+param set SIH_F_CP1 -0.0146
+param set SIH_F_CP2 -0.0602
+param set SIH_F_DIA_INCH 5
+param set SIH_F_RPM_MAX 14000
+
# sih as tailsitter
param set SIH_VEHICLE_TYPE 2
diff --git a/ROMFS/px4fmu_common/init.d/airframes/1103_standard_vtol_sih.hil b/ROMFS/px4fmu_common/init.d/airframes/1103_standard_vtol_sih.hil
index 5ac9e6b1ab..26b8d092bb 100644
--- a/ROMFS/px4fmu_common/init.d/airframes/1103_standard_vtol_sih.hil
+++ b/ROMFS/px4fmu_common/init.d/airframes/1103_standard_vtol_sih.hil
@@ -56,6 +56,7 @@ param set-default CA_SV_CS2_TYPE 4 # rudder
param set-default FW_AIRSPD_MIN 7
param set-default FW_AIRSPD_TRIM 10
param set-default FW_AIRSPD_MAX 12
+param set-default VT_FWD_THRUST_EN 1
param set-default HIL_ACT_FUNC1 101
param set-default HIL_ACT_FUNC2 102
@@ -77,6 +78,7 @@ param set-default CBRK_SUPPLY_CHK 894281
param set-default SENS_DPRES_OFF 0.001
+# quadrotor propellers
param set SIH_T_MAX 2.0
param set SIH_Q_MAX 0.0165
param set SIH_MASS 0.2
@@ -88,5 +90,18 @@ param set SIH_IXZ 0
param set SIH_KDV 0.2
param set SIH_L_ROLL 0.2
+# pusher propeller model with advance ratio, model from UIUC APC 8x6"
+param set SIH_F_T_MAX 6
+param set SIH_F_Q_MAX 0.03
+# if SIH_F_CT0 > 0, SIH_F_T_MAX and SIH_F_Q_MAX will be overridden
+param set SIH_F_CT0 0.131
+param set SIH_F_CT1 0.004
+param set SIH_F_CT2 -0.146
+param set SIH_F_CP0 0.0777
+param set SIH_F_CP1 0.0498
+param set SIH_F_CP2 -0.11
+param set SIH_F_DIA_INCH 8
+param set SIH_F_RPM_MAX 9000
+
# sih as standard vtol
param set SIH_VEHICLE_TYPE 3
diff --git a/ROMFS/px4fmu_common/init.d/airframes/1105_rc_hexa_x_sih.hil b/ROMFS/px4fmu_common/init.d/airframes/1105_rc_hexa_x_sih.hil
new file mode 100644
index 0000000000..b5574c1db8
--- /dev/null
+++ b/ROMFS/px4fmu_common/init.d/airframes/1105_rc_hexa_x_sih.hil
@@ -0,0 +1,49 @@
+#!/bin/sh
+#
+# @name SIH Hexacopter X
+#
+# @type Simulation
+# @class Copter
+#
+# @maintainer Romain Chiappinelli
+#
+# @board px4_fmu-v2 exclude
+#
+
+. ${R}etc/init.d/rc.mc_defaults
+
+param set UAVCAN_ENABLE 0
+
+# set SYS_HITL to 2 to start the SIH and avoid sensors startup
+param set SYS_HITL 2
+
+# disable some checks to allow to fly:
+# - without real battery
+param set-default CBRK_SUPPLY_CHK 894281
+
+param set SIH_VEHICLE_TYPE 4
+
+# Symmetric hexacopter X clockwise motor numbering
+param set-default CA_ROTOR_COUNT 6
+param set-default CA_ROTOR0_PX 0.866
+param set-default CA_ROTOR0_PY 0.5
+param set-default CA_ROTOR1_PX 0
+param set-default CA_ROTOR1_PY 1
+param set-default CA_ROTOR1_KM -0.05
+param set-default CA_ROTOR2_PX -0.866
+param set-default CA_ROTOR2_PY 0.5
+param set-default CA_ROTOR3_PX -0.866
+param set-default CA_ROTOR3_PY -0.5
+param set-default CA_ROTOR3_KM -0.05
+param set-default CA_ROTOR4_PX 0
+param set-default CA_ROTOR4_PY -1
+param set-default CA_ROTOR5_PX 0.866
+param set-default CA_ROTOR5_PY -0.5
+param set-default CA_ROTOR5_KM -0.05
+
+param set-default HIL_ACT_FUNC1 101
+param set-default HIL_ACT_FUNC2 102
+param set-default HIL_ACT_FUNC3 103
+param set-default HIL_ACT_FUNC4 104
+param set-default HIL_ACT_FUNC5 105
+param set-default HIL_ACT_FUNC6 106
diff --git a/ROMFS/px4fmu_common/init.d/airframes/4016_holybro_px4vision b/ROMFS/px4fmu_common/init.d/airframes/4016_holybro_px4vision
index 1e699b5180..3934c44690 100644
--- a/ROMFS/px4fmu_common/init.d/airframes/4016_holybro_px4vision
+++ b/ROMFS/px4fmu_common/init.d/airframes/4016_holybro_px4vision
@@ -19,8 +19,8 @@ param set-default COM_DISARM_LAND 0.5
# EKF2 parameters
param set-default EKF2_DRAG_CTRL 1
param set-default EKF2_IMU_POS_X 0.02
-param set-default EKF2_GPS_POS_X 0.055
-param set-default EKF2_GPS_POS_Z -0.15
+param set-default SENS_GPS0_OFFX 0.055
+param set-default SENS_GPS0_OFFZ -0.15
param set-default EKF2_MIN_RNG 0.03
param set-default EKF2_OF_CTRL 1
param set-default EKF2_OF_POS_X 0.055
diff --git a/ROMFS/px4fmu_common/init.d/airframes/4020_holybro_px4vision_v1_5 b/ROMFS/px4fmu_common/init.d/airframes/4020_holybro_px4vision_v1_5
index b134cabd6c..173b21ed16 100644
--- a/ROMFS/px4fmu_common/init.d/airframes/4020_holybro_px4vision_v1_5
+++ b/ROMFS/px4fmu_common/init.d/airframes/4020_holybro_px4vision_v1_5
@@ -19,8 +19,8 @@ param set-default COM_DISARM_LAND 0.5
# EKF2 parameters
param set-default EKF2_DRAG_CTRL 1
param set-default EKF2_IMU_POS_X 0.02
-param set-default EKF2_GPS_POS_X 0.055
-param set-default EKF2_GPS_POS_Z -0.15
+param set-default SENS_GPS0_OFFX 0.055
+param set-default SENS_GPS0_OFFZ -0.15
param set-default EKF2_MIN_RNG 0.03
param set-default EKF2_OF_CTRL 1
param set-default EKF2_OF_POS_X 0.055
diff --git a/ROMFS/px4fmu_common/init.d/airframes/4052_holybro_qav250 b/ROMFS/px4fmu_common/init.d/airframes/4052_holybro_qav250
index 7ef37051e6..36c39f9a59 100644
--- a/ROMFS/px4fmu_common/init.d/airframes/4052_holybro_qav250
+++ b/ROMFS/px4fmu_common/init.d/airframes/4052_holybro_qav250
@@ -2,7 +2,7 @@
#
# @name HolyBro QAV250
#
-# @url https://docs.px4.io/main/en/frames_multicopter/holybro_qav250_pixhawk4_mini.html
+# @url https://docs.px4.io/main/en/frames_multicopter/holybro_qav250_pixhawk4_mini
#
# @type Quadrotor x
# @class Copter
diff --git a/ROMFS/px4fmu_common/init.d/airframes/4061_atl_mantis_edu b/ROMFS/px4fmu_common/init.d/airframes/4061_atl_mantis_edu
index 0d8ddaf2d8..9fcadae64d 100644
--- a/ROMFS/px4fmu_common/init.d/airframes/4061_atl_mantis_edu
+++ b/ROMFS/px4fmu_common/init.d/airframes/4061_atl_mantis_edu
@@ -47,8 +47,9 @@ param set-default EKF2_BCOEF_Y 25.5
param set-default EKF2_DRAG_CTRL 1
-param set-default EKF2_GPS_DELAY 100
-param set-default EKF2_GPS_POS_X 0.06
+param set-default SENS_GPS0_DELAY 100
+param set-default SENS_GPS1_DELAY 100
+param set-default SENS_GPS0_OFFX 0.06
param set-default EKF2_GPS_V_NOISE 0.5
param set-default EKF2_IMU_POS_X 0.06
diff --git a/ROMFS/px4fmu_common/init.d/airframes/50001_aion_robotics_r1_rover b/ROMFS/px4fmu_common/init.d/airframes/50001_aion_robotics_r1_rover
index 3063bd3647..fe6c09678c 100644
--- a/ROMFS/px4fmu_common/init.d/airframes/50001_aion_robotics_r1_rover
+++ b/ROMFS/px4fmu_common/init.d/airframes/50001_aion_robotics_r1_rover
@@ -2,7 +2,7 @@
#
# @name Aion Robotics R1 UGV
#
-# @url https://docs.px4.io/main/en/complete_vehicles_rover/aion_r1.html
+# @url https://docs.px4.io/main/en/complete_vehicles_rover/aion_r1
#
# @type Rover
# @class Rover
diff --git a/ROMFS/px4fmu_common/init.d/airframes/CMakeLists.txt b/ROMFS/px4fmu_common/init.d/airframes/CMakeLists.txt
index 8f13aa55b1..fd6827e509 100644
--- a/ROMFS/px4fmu_common/init.d/airframes/CMakeLists.txt
+++ b/ROMFS/px4fmu_common/init.d/airframes/CMakeLists.txt
@@ -49,6 +49,7 @@ if(CONFIG_MODULES_SIMULATION_PWM_OUT_SIM)
1101_rc_plane_sih.hil
1102_tailsitter_duo_sih.hil
1103_standard_vtol_sih.hil
+ 1105_rc_hexa_x_sih.hil
)
if(CONFIG_MODULES_ROVER_ACKERMANN)
px4_add_romfs_files(
diff --git a/ROMFS/px4fmu_common/init.d/rc.sensors b/ROMFS/px4fmu_common/init.d/rc.sensors
index ea6b8d4f1e..a7c6a5b774 100644
--- a/ROMFS/px4fmu_common/init.d/rc.sensors
+++ b/ROMFS/px4fmu_common/init.d/rc.sensors
@@ -238,7 +238,7 @@ then
fi
# Start TMP102 temperature sensor
-if param compare SENS_EN_TMP102 1
+if param compare -s SENS_EN_TMP102 1
then
tmp102 start -X
fi
diff --git a/ROMFS/px4fmu_common/init.d/rcS b/ROMFS/px4fmu_common/init.d/rcS
index 80749fbe8c..eb06764d4c 100644
--- a/ROMFS/px4fmu_common/init.d/rcS
+++ b/ROMFS/px4fmu_common/init.d/rcS
@@ -31,11 +31,20 @@ set PARAM_FILE ""
set PARAM_BACKUP_FILE ""
set RC_INPUT_ARGS ""
set STORAGE_AVAILABLE no
+set STORAGE_CHECK yes
set SDCARD_EXT_PATH /fs/microsd/ext_autostart
set SDCARD_FORMAT no
set STARTUP_TUNE 1
set VEHICLE_TYPE none
+# Fine-grained feature gates.
+set USE_HARDFAULT_LOG no
+set USE_EXTERNAL_AIRFRAMES no
+set USE_PARAM_BACKUPS no
+set USE_PARAM_IMPORT_DEBUG no
+set USE_TASK_WATCHDOG no
+set USE_ALT_UPDATE_DIRS no
+
# Airframe parameter versioning
# Value set to 1 by default but can optionally be overridden in the airframe configuration startup script.
# Airframe maintainers can ensure a reset to the airframe defaults during an update by increasing by one.
@@ -48,53 +57,81 @@ set PARAM_DEFAULTS_VER 1
ver all
#
-# Try to mount the microSD card.
+# Optional early board init: rc.board_early
+# Can be used for setting env vars for rcS.
#
-if [ -b "/dev/mmcsd0" ]
+set BOARD_RC_EARLY ${R}etc/init.d/rc.board_early
+if [ -f $BOARD_RC_EARLY ]
then
- if mount -t vfat /dev/mmcsd0 /fs/microsd
- then
- if [ -f "/fs/microsd/.format" ]
- then
- echo "INFO [init] format /dev/mmcsd0 requested (/fs/microsd/.format)"
- set SDCARD_FORMAT yes
- rm /fs/microsd/.format
- umount /fs/microsd
+ . $BOARD_RC_EARLY
+fi
+unset BOARD_RC_EARLY
- else
+#
+# Try to mount/check storage (rc.board_early can disable this).
+#
+if [ $STORAGE_CHECK = yes ]
+then
+ #
+ # Try to mount the microSD card.
+ #
+ if [ -b "/dev/mmcsd0" ]
+ then
+ if mount -t vfat /dev/mmcsd0 /fs/microsd
+ then
+ if [ -f "/fs/microsd/.format" ]
+ then
+ echo "INFO [init] format /dev/mmcsd0 requested (/fs/microsd/.format)"
+ set SDCARD_FORMAT yes
+ rm /fs/microsd/.format
+ umount /fs/microsd
+
+ else
+ set STORAGE_AVAILABLE yes
+ fi
+ fi
+
+ if [ $STORAGE_AVAILABLE = no -o $SDCARD_FORMAT = yes ]
+ then
+ echo "INFO [init] formatting /dev/mmcsd0"
+ set STARTUP_TUNE 15 # tune 15 = SD_ERROR (overridden to SD_INIT if format + mount succeeds)
+
+ if mkfatfs -F 32 /dev/mmcsd0
+ then
+ echo "INFO [init] card formatted"
+
+ if mount -t vfat /dev/mmcsd0 /fs/microsd
+ then
+ set STORAGE_AVAILABLE yes
+ set STARTUP_TUNE 14 # tune 14 = SD_INIT
+ else
+ echo "ERROR [init] card mount failed"
+ fi
+ else
+ echo "ERROR [init] format failed"
+ fi
+ fi
+ else
+ # Is there a device mounted for storage
+ if mft query -q -k MTD -s MTD_PARAMETERS -v /mnt/microsd
+ then
set STORAGE_AVAILABLE yes
fi
fi
-
- if [ $STORAGE_AVAILABLE = no -o $SDCARD_FORMAT = yes ]
- then
- echo "INFO [init] formatting /dev/mmcsd0"
- set STARTUP_TUNE 15 # tune 15 = SD_ERROR (overridden to SD_INIT if format + mount succeeds)
-
- if mkfatfs -F 32 /dev/mmcsd0
- then
- echo "INFO [init] card formatted"
-
- if mount -t vfat /dev/mmcsd0 /fs/microsd
- then
- set STORAGE_AVAILABLE yes
- set STARTUP_TUNE 14 # tune 14 = SD_INIT
- else
- echo "ERROR [init] card mount failed"
- fi
- else
- echo "ERROR [init] format failed"
- fi
- fi
-else
- # Is there a device mounted for storage
- if mft query -q -k MTD -s MTD_PARAMETERS -v /mnt/microsd
- then
- set STORAGE_AVAILABLE yes
- fi
fi
if [ $STORAGE_AVAILABLE = yes ]
+then
+ set USE_HARDFAULT_LOG yes
+ set USE_EXTERNAL_AIRFRAMES yes
+ set USE_PARAM_BACKUPS yes
+ set USE_PARAM_IMPORT_DEBUG yes
+ set USE_ALT_UPDATE_DIRS yes
+ set PARAM_FILE /fs/microsd/params
+ set PARAM_BACKUP_FILE "/fs/microsd/parameters_backup.bson"
+fi
+
+if [ $USE_HARDFAULT_LOG = yes ]
then
if hardfault_log check
then
@@ -104,7 +141,15 @@ then
hardfault_log reset
fi
fi
+fi
+if [ $USE_TASK_WATCHDOG = yes ]
+then
+ task_watchdog start
+fi
+
+if [ $USE_ALT_UPDATE_DIRS = yes ]
+then
# Check for an update of the ext_autostart folder, and replace the old one with it
if [ -e /fs/microsd/ext_autostart_new ]
then
@@ -112,9 +157,6 @@ then
rm -r $SDCARD_EXT_PATH
mv /fs/microsd/ext_autostart_new $SDCARD_EXT_PATH
fi
-
- set PARAM_FILE /fs/microsd/params
- set PARAM_BACKUP_FILE "/fs/microsd/parameters_backup.bson"
fi
#
@@ -155,8 +197,11 @@ else
if [ -d "/fs/microsd" ]
then
- # try to make a backup copy
- cp $PARAM_FILE /fs/microsd/param_import_fail.bson
+ if [ $USE_PARAM_IMPORT_DEBUG = yes ]
+ then
+ # save copy of the failed param file for debugging
+ cp $PARAM_FILE /fs/microsd/param_import_fail.bson
+ fi
# try importing from backup file
if [ -f $PARAM_BACKUP_FILE ]
@@ -174,11 +219,14 @@ else
param status
- dmesg >> /fs/microsd/param_import_fail.txt &
+ if [ $USE_PARAM_IMPORT_DEBUG = yes ]
+ then
+ dmesg >> /fs/microsd/param_import_fail.txt &
+ fi
fi
fi
- if [ $STORAGE_AVAILABLE = yes ]
+ if [ $USE_PARAM_BACKUPS = yes ]
then
param select-backup $PARAM_BACKUP_FILE
fi
@@ -188,11 +236,11 @@ else
netman update -i eth0
fi
- # To trigger a parameter reset during boot SYS_AUTCONFIG was set to 1 before
+ # To trigger a parameter reset during boot SYS_AUTOCONFIG was set to 1 before
if param greater SYS_AUTOCONFIG 0
then
- # Reset parameters except airframe, parameter version, RC calibration, sensor calibration, flight modes, total flight time, flight UUID
- param reset_all SYS_AUTOSTART SYS_PARAM_VER RC* CAL_* COM_FLTMODE* LND_FLIGHT* TC_* COM_FLIGHT*
+ # Reset parameters except airframe, parameter version, sensor calibration, total flight time, flight UUID
+ param reset_all SYS_AUTOSTART SYS_PARAM_VER CAL_* LND_FLIGHT* TC_* COM_FLIGHT*
fi
#
@@ -234,12 +282,12 @@ else
if [ ${VEHICLE_TYPE} = none ]
then
- # Run external airframe script on SD card
- if [ $STORAGE_AVAILABLE = yes ]
+ # Run external airframe script on SD card or EEPROM-backed storage
+ if [ $USE_EXTERNAL_AIRFRAMES = yes ]
then
. ${R}etc/init.d/rc.autostart_ext
else
- echo "ERROR [init] SD not mounted, skipping external airframe"
+ echo "ERROR [init] no external airframe storage, skipping"
fi
fi
@@ -633,12 +681,15 @@ else
#
# Start the VTX services.
#
- set RC_VTXTABLE ${R}etc/init.d/rc.vtxtable
- if [ -f ${RC_VTXTABLE} ]
+ if ! param compare -s VTX_SER_CFG 0
then
- . ${RC_VTXTABLE}
+ set RC_VTXTABLE ${R}etc/init.d/rc.vtxtable
+ if [ -f ${RC_VTXTABLE} ]
+ then
+ . ${RC_VTXTABLE}
+ fi
+ unset RC_VTXTABLE
fi
- unset RC_VTXTABLE
#
# Set additional parameters and env variables for selected AUTOSTART.
@@ -648,6 +699,10 @@ else
. ${R}etc/init.d/rc.autostart.post
fi
+ #
+ # Lock read-only parameters (if configured for this board).
+ #
+ param lock
set BOARD_BOOTLOADER_UPGRADE ${R}etc/init.d/rc.board_bootloader_upgrade
if [ -f $BOARD_BOOTLOADER_UPGRADE ]
@@ -676,9 +731,16 @@ unset PARAM_BACKUP_FILE
unset PARAM_DEFAULTS_VER
unset RC_INPUT_ARGS
unset STORAGE_AVAILABLE
+unset STORAGE_CHECK
unset SDCARD_EXT_PATH
unset SDCARD_FORMAT
unset STARTUP_TUNE
+unset USE_HARDFAULT_LOG
+unset USE_EXTERNAL_AIRFRAMES
+unset USE_PARAM_BACKUPS
+unset USE_PARAM_IMPORT_DEBUG
+unset USE_TASK_WATCHDOG
+unset USE_ALT_UPDATE_DIRS
unset VEHICLE_TYPE
#
diff --git a/SECURITY.md b/SECURITY.md
index ed99e771bb..cb27a808a7 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -2,24 +2,40 @@
## Supported Versions
-The following is a list of versions the development team is currently supporting.
+The following versions receive security updates:
| Version | Supported |
| ------- | ------------------ |
-| 1.4.x | :white_check_mark: |
-| 1.3.3 | :white_check_mark: |
-| < 1.3 | :x: |
+| 1.16.x | :white_check_mark: |
+| < 1.16 | :x: |
## Reporting a Vulnerability
-We currently only receive security vulnerability reports through GitHub.
+We receive security vulnerability reports through GitHub Security Advisories.
-To begin a report, please go to the top-level repository, for example, PX4/PX4-Autopilot,
-and click on the Security tab. If you are on mobile, click the ... dropdown menu, and then click Security.
+To begin a report, go to the [PX4/PX4-Autopilot](https://github.com/PX4/PX4-Autopilot) repository
+and click on the **Security** tab. If you are on mobile, click the **...** dropdown menu, then click **Security**.
-Click Report a Vulnerability to open the advisory form. Fill in the advisory details form.
-Make sure your title is descriptive, and the development team can find all of the relevant details needed
-to verify on the description box. We recommend you add as much data as possible. We welcome logs,
-screenshots, photos, and videos, anything that can help us verify and identify the issues being reported.
+Click **Report a Vulnerability** to open the advisory form. Fill in the advisory details form.
+Make sure your title is descriptive and the description contains all relevant details needed
+to verify the issue. We welcome logs, screenshots, photos, and videos.
-At the bottom of the form, click Submit report. The maintainer team will be notified and will get back to you ASAP.
+At the bottom of the form, click **Submit report**.
+
+## Response Process
+
+1. **Acknowledgment**: The maintainer team will acknowledge your report within **7 days**.
+2. **Triage**: We will assess severity and impact and communicate next steps.
+3. **Disclosure**: We coordinate disclosure with the reporter. We follow responsible disclosure practices and will credit reporters in the advisory unless they request anonymity.
+
+If you do not receive acknowledgment within 7 days, please follow up by emailing the [release managers](MAINTAINERS.md).
+
+## Secure Development Practices
+
+The PX4 development team applies the following practices to reduce security risk:
+
+- **Code review**: All changes require peer review before merging.
+- **Static analysis**: [clang-tidy](https://clang.llvm.org/extra/clang-tidy/) runs on every pull request with warnings treated as errors.
+- **Fuzzing**: A daily fuzzing pipeline using [Google fuzztest](https://github.com/google/fuzztest) tests MAVLink message handling and GNSS driver protocol parsing.
+- **Input validation**: All external inputs (MAVLink messages, RC signals, sensor data) are validated against expected ranges before use.
+- **Compiler hardening**: Builds use `-Wall -Werror`, stack protectors, and other hardening flags where supported by the target platform.
diff --git a/Tools/HIL/test_airframes.sh b/Tools/HIL/test_airframes.sh
index 24d20f07ec..3b26d47ed0 100755
--- a/Tools/HIL/test_airframes.sh
+++ b/Tools/HIL/test_airframes.sh
@@ -1,4 +1,4 @@
-#! /bin/bash
+#!/usr/bin/env bash
# exit when any command fails
set -e
diff --git a/Tools/Matlab/motors.m b/Tools/Matlab/motors.m
deleted file mode 100644
index 6d688a3077..0000000000
--- a/Tools/Matlab/motors.m
+++ /dev/null
@@ -1,58 +0,0 @@
-clear all;
-close all;
-
-% Measurement data
-% 1045 propeller
-% Robbe Roxxy Motor (1100 kV, data collected in 2010)
-data = [ 45, 7.4;...
- 38, 5.6;...
- 33, 4.3;...
- 26, 3.0;...
- 18, 2.0;...
- 10, 1.0 ];
-
-% Normalize the data, as we're operating later
-% anyways in normalized units
-data(:,1) = data(:,1) ./ max(data(:,1));
-data(:,2) = data(:,2) ./ max(data(:,2));
-
-% Fit a 2nd degree polygon to the data and
-% print the x2, x1, x0 coefficients
-p = polyfit(data(:,2), data(:,1),2)
-
-% Override the first coffefficient for testing
-% purposes
-pf = 0.62;
-
-% Generate plotting data
-px1 = linspace(0, max(data(:,2)));
-py1 = polyval(p, px1);
-
-pyt = zeros(size(data, 1), 1);
-corr = zeros(size(data, 1), 1);
-
-% Actual code test
-% the two lines below are the ones needed to be ported to C:
-% pf: Power factor parameter.
-% px1(i): The current normalized motor command (-1..1)
-% corr(i): The required correction. The motor speed is:
-% px1(i)
-for i=1:size(px1, 2)
-
- % The actual output throttle
- pyt(i) = -pf * (px1(i) * px1(i)) + (1 + pf) * px1(i);
-
- % Solve for input throttle
- % y = -p * x^2 + (1+p) * x;
- %
-end
-
-plot(data(:,2), data(:,1), '*r');
-hold on;
-plot(px1, py1, '*b');
-hold on;
-plot([0 px1(end)], [0 py1(end)], '-k');
-hold on;
-plot(px1, pyt, '-b');
-hold on;
-plot(px1, corr, '-m');
diff --git a/Tools/Matlab/plot_mag.m b/Tools/Matlab/plot_mag.m
deleted file mode 100644
index c9f0c29925..0000000000
--- a/Tools/Matlab/plot_mag.m
+++ /dev/null
@@ -1,77 +0,0 @@
-%
-% Tool for plotting mag data
-%
-% Reference values:
-% telem> [cal] mag #0 off: x:0.15 y:0.07 z:0.14 Ga
-% MATLAB: x:0.1581 y: 0.0701 z: 0.1439 Ga
-% telem> [cal] mag #0 scale: x:1.10 y:0.97 z:1.02
-% MATLAB: 0.5499, 0.5190, 0.4907
-%
-% telem> [cal] mag #1 off: x:-0.18 y:0.11 z:-0.09 Ga
-% MATLAB: x:-0.1827 y:0.1147 z:-0.0848 Ga
-% telem> [cal] mag #1 scale: x:1.00 y:1.00 z:1.00
-% MATLAB: 0.5122, 0.5065, 0.4915
-%
-%
-% User-guided values:
-%
-% telem> [cal] mag #0 off: x:0.12 y:0.09 z:0.14 Ga
-% telem> [cal] mag #0 scale: x:0.88 y:0.99 z:0.95
-% telem> [cal] mag #1 off: x:-0.18 y:0.11 z:-0.09 Ga
-% telem> [cal] mag #1 scale: x:1.00 y:1.00 z:1.00
-
-close all;
-clear all;
-
-plot_scale = 0.8;
-
-xmax = plot_scale;
-xmin = -xmax;
-ymax = plot_scale;
-ymin = -ymax;
-zmax = plot_scale;
-zmin = -zmax;
-
-mag0_raw = load('../../mag0_raw3.csv');
-mag1_raw = load('../../mag1_raw3.csv');
-
-mag0_cal = load('../../mag0_cal3.csv');
-mag1_cal = load('../../mag1_cal3.csv');
-
-fm0r = figure();
-
-mag0_x_scale = 0.88;
-mag0_y_scale = 0.99;
-mag0_z_scale = 0.95;
-
-plot3(mag0_raw(:,1), mag0_raw(:,2), mag0_raw(:,3), '*r');
-[mag0_raw_center, mag0_raw_radii, evecs, pars ] = ellipsoid_fit( [mag0_raw(:,1) mag0_raw(:,2) mag0_raw(:,3)] );
-mag0_raw_center
-mag0_raw_radii
-axis([xmin xmax ymin ymax zmin zmax])
-viscircles([mag0_raw_center(1), mag0_raw_center(2)], [mag0_raw_radii(1)]);
-
-fm1r = figure();
-plot3(mag1_raw(:,1), mag1_raw(:,2), mag1_raw(:,3), '*r');
-[center, radii, evecs, pars ] = ellipsoid_fit( [mag1_raw(:,1) mag1_raw(:,2) mag1_raw(:,3)] );
-center
-radii
-axis([xmin xmax ymin ymax zmin zmax])
-
-fm0c = figure();
-plot3(mag0_cal(:,1) .* mag0_x_scale, mag0_cal(:,2) .* mag0_y_scale, mag0_cal(:,3) .* mag0_z_scale, '*b');
-[mag0_cal_center, mag0_cal_radii, evecs, pars ] = ellipsoid_fit( [mag1_raw(:,1) .* mag0_x_scale mag1_raw(:,2) .* mag0_y_scale mag1_raw(:,3) .* mag0_z_scale] );
-mag0_cal_center
-mag0_cal_radii
-axis([xmin xmax ymin ymax zmin zmax])
-viscircles([0, 0], [mag0_cal_radii(3)]);
-
-fm1c = figure();
-plot3(mag1_cal(:,1), mag1_cal(:,2), mag1_cal(:,3), '*b');
-axis([xmin xmax ymin ymax zmin zmax])
-[center, radii, evecs, pars ] = ellipsoid_fit( [mag1_raw(:,1) mag1_raw(:,2) mag1_raw(:,3)] );
-viscircles([0, 0], [radii(3)]);
-
-mag0_x_scale_matlab = 1 / (mag0_cal_radii(1) / mag0_raw_radii(1))
-mag0_y_scale_matlab = 1 / (mag0_cal_radii(2) / mag0_raw_radii(2))
-mag0_z_scale_matlab = 1 / (mag0_cal_radii(3) / mag0_raw_radii(3))
diff --git a/Tools/astyle/check_code_style.sh b/Tools/astyle/check_code_style.sh
index c68b32a9a2..a25fe62e8b 100755
--- a/Tools/astyle/check_code_style.sh
+++ b/Tools/astyle/check_code_style.sh
@@ -16,7 +16,7 @@ if [ -n "$CHECK_FAILED" ]; then
if [[ $PX4_ASTYLE_FIX -eq 1 ]]; then
${DIR}/fix_code_style.sh $FILE
else
- echo 'to fix automatically run "make format" or "./Tools/astyle/fix_code_style.sh' $FILE'"'
+ echo 'to fix automatically run "make format", "make format_changed" or "./Tools/astyle/fix_code_style.sh' $FILE'"'
exit 1
fi
fi
diff --git a/Tools/astyle/check_code_style_all.sh b/Tools/astyle/check_code_style_all.sh
index 462a1358e3..871ab62256 100755
--- a/Tools/astyle/check_code_style_all.sh
+++ b/Tools/astyle/check_code_style_all.sh
@@ -36,9 +36,13 @@ fi
CI="${CI:-false}"
DIR=$(cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd)
-if [[ "$@" == "--fix" ]]; then
- export PX4_ASTYLE_FIX=1
-fi
+DIFF_ONLY=false
+for arg in "$@"; do
+ case "$arg" in
+ --fix) export PX4_ASTYLE_FIX=1 ;;
+ --diff-only) DIFF_ONLY=true ;;
+ esac
+done
# install git pre-commit hook
@@ -59,7 +63,24 @@ if [[ ! -f "$HOOK_FILE" && "$CI" != "true" && $- == *i* ]]; then
fi
fi
-${DIR}/files_to_check_code_style.sh | xargs -P 8 -I % ${DIR}/check_code_style.sh %
+FILE_LIST=$(${DIR}/files_to_check_code_style.sh)
+
+if [ "$DIFF_ONLY" = true ]; then
+ # --diff-filter=d: do not list deleted files (no need to format)
+ CHANGED=$(git -C "$DIR/../.." diff --name-only --diff-filter=d HEAD -- '*.c' '*.h' '*.cpp' '*.hpp')
+ if [ -z "$CHANGED" ]; then
+ FILE_LIST=""
+ else
+ FILE_LIST=$(echo "$FILE_LIST" | grep -Fx -f <(echo "$CHANGED") || true)
+ fi
+fi
+
+if [ -z "$FILE_LIST" ]; then
+ echo "No files to check"
+ exit 0
+fi
+
+echo "$FILE_LIST" | xargs -P 8 -I % ${DIR}/check_code_style.sh %
if [ $? -eq 0 ]; then
echo "Format checks passed"
diff --git a/Tools/astyle/files_to_check_code_style.sh b/Tools/astyle/files_to_check_code_style.sh
index b70766efa2..ca6b28b37f 100755
--- a/Tools/astyle/files_to_check_code_style.sh
+++ b/Tools/astyle/files_to_check_code_style.sh
@@ -28,6 +28,7 @@ exec find boards msg src platforms test \
-path src/modules/gyro_fft/CMSIS_5 -prune -o \
-path src/modules/mavlink/mavlink -prune -o \
-path src/modules/mc_raptor/blob -prune -o \
+ -path src/modules/simulation/gz_plugins/optical_flow/PX4-OpticalFlow -prune -o \
-path test/fuzztest -prune -o \
-path test/mavsdk_tests/catch2 -prune -o \
-path src/lib/crypto/monocypher -prune -o \
@@ -39,6 +40,8 @@ exec find boards msg src platforms test \
-path src/lib/cdrstream/rosidl -prune -o \
-path src/modules/zenoh/zenoh-pico -prune -o \
-path boards/modalai/voxl2/libfc-sensor-api -prune -o \
+ -path boards/modalai/voxl2/src/lib/mpa/libmodal-json -prune -o \
+ -path boards/modalai/voxl2/src/lib/mpa/libmodal-pipe -prune -o \
-path src/drivers/actuators/vertiq_io/iq-module-communication-cpp -prune -o \
-path src/lib/tensorflow_lite_micro/tflite_micro -prune -o \
-path src/drivers/ins/sbgecom/sbgECom -prune -o \
diff --git a/Tools/astyle/fix_code_style.sh b/Tools/astyle/fix_code_style.sh
index 73a7a39278..af3a968e38 100755
--- a/Tools/astyle/fix_code_style.sh
+++ b/Tools/astyle/fix_code_style.sh
@@ -1,4 +1,4 @@
-#!/bin/bash
+#!/usr/bin/env bash
if [[ $# -eq 0 ]] ; then
exit 0
diff --git a/Tools/auterion/remote_update_fmu.sh b/Tools/auterion/remote_update_fmu.sh
index d3777aebaf..ba181bc49e 100755
--- a/Tools/auterion/remote_update_fmu.sh
+++ b/Tools/auterion/remote_update_fmu.sh
@@ -1,4 +1,4 @@
-#!/bin/bash
+#!/usr/bin/env bash
# Flash PX4 to a device running AuterionOS in the local network
if [ "$1" == "-h" ] || [ "$1" == "--help" ] || [ $# -lt 2 ]; then
echo "Usage: $0 -f [-c ] -d [-u ] [-p ] [--revert]"
diff --git a/Tools/ci/_github_helpers.py b/Tools/ci/_github_helpers.py
new file mode 100644
index 0000000000..628e4dc4b4
--- /dev/null
+++ b/Tools/ci/_github_helpers.py
@@ -0,0 +1,172 @@
+#!/usr/bin/env python3
+"""
+Shared GitHub REST helpers for PX4 CI scripts.
+
+This module is imported by the PR poster scripts under Tools/ci/. It is
+NOT an executable entry point; do not run it directly.
+
+Provides:
+ - fail(msg) terminates the caller with a clear error
+ - GitHubClient(token) thin stdlib-only GitHub REST client with
+ single-request and paginated helpers
+
+Python stdlib only. No third-party dependencies.
+
+History: extracted from Tools/ci/pr-comment-poster.py so that
+pr-comment-poster.py and pr-review-poster.py share the same HTTP plumbing
+without duplicating ~100 lines of request/pagination/error-handling code.
+"""
+
+import json
+import sys
+import typing
+import urllib.error
+import urllib.request
+
+
+GITHUB_API = 'https://api.github.com'
+DEFAULT_USER_AGENT = 'px4-ci'
+API_VERSION = '2022-11-28'
+
+
+def fail(msg: str) -> typing.NoReturn:
+ """Print an error to stderr and exit with status 1.
+
+ Annotated NoReturn so static checkers understand control does not
+ continue past a fail() call.
+ """
+ print('error: {}'.format(msg), file=sys.stderr)
+ sys.exit(1)
+
+
+def _parse_next_link(link_header):
+ """Return the URL for rel="next" from an RFC 5988 Link header, or None.
+
+ The Link header is comma-separated entries of the form:
+ ; rel="next", ; rel="last"
+ We walk each entry and return the URL of the one whose rel attribute is
+ "next". Accept single-quoted rel values for robustness even though
+ GitHub always emits double quotes.
+ """
+ if not link_header:
+ return None
+ for part in link_header.split(','):
+ segs = part.strip().split(';')
+ if len(segs) < 2:
+ continue
+ url_seg = segs[0].strip()
+ if not (url_seg.startswith('<') and url_seg.endswith('>')):
+ continue
+ url = url_seg[1:-1]
+ for attr in segs[1:]:
+ attr = attr.strip()
+ if attr == 'rel="next"' or attr == "rel='next'":
+ return url
+ return None
+
+
+class GitHubClient:
+ """Minimal GitHub REST client backed by the Python stdlib.
+
+ Each instance holds a token and a user-agent so callers do not have to
+ thread them through every call. Methods return parsed JSON (or None for
+ empty responses) and raise RuntimeError with the server response body on
+ HTTP errors, so CI logs show what the API actually objected to.
+
+ Usage:
+ client = GitHubClient(token, user_agent='px4-pr-comment-poster')
+ body, headers = client.request('GET', 'repos/{o}/{r}/pulls/123')
+ for item in client.paginated('repos/{o}/{r}/pulls/123/reviews'):
+ ...
+ """
+
+ def __init__(self, token, user_agent=DEFAULT_USER_AGENT):
+ if not token:
+ raise ValueError('GitHub token is required')
+ self._token = token
+ self._user_agent = user_agent
+
+ def request(self, method, path_or_url, json_body=None):
+ """GET/POST/PATCH/PUT/DELETE a single API path or absolute URL.
+
+ `path_or_url` may be either a relative API path (e.g.
+ "repos/PX4/PX4-Autopilot/pulls/123") or an absolute URL such as the
+ next-page URL returned from paginated results. Relative paths are
+ prefixed with the GitHub API base.
+
+ Returns (parsed_json_or_none, headers_dict). Raises RuntimeError
+ on HTTP or transport errors.
+ """
+ url = self._resolve(path_or_url)
+ return self._do_request(method, url, json_body)
+
+ def paginated(self, path, per_page=100):
+ """GET a path and follow rel="next" Link headers.
+
+ Yields items from each page's JSON array. Bumps per_page to 100
+ (GitHub's max) so large result sets take fewer round-trips.
+ Raises RuntimeError if any page response is not a JSON array.
+ """
+ url = self._resolve(path)
+ sep = '&' if '?' in url else '?'
+ url = '{}{}per_page={}'.format(url, sep, per_page)
+ while url is not None:
+ body, headers = self._do_request('GET', url, None)
+ if body is None:
+ return
+ if not isinstance(body, list):
+ raise RuntimeError(
+ 'expected JSON array from {}, got {}'.format(
+ url, type(body).__name__))
+ for item in body:
+ yield item
+ url = _parse_next_link(headers.get('Link'))
+
+ def _resolve(self, path_or_url):
+ if path_or_url.startswith('http://') or path_or_url.startswith('https://'):
+ return path_or_url
+ return '{}/{}'.format(GITHUB_API.rstrip('/'), path_or_url.lstrip('/'))
+
+ def _do_request(self, method, url, json_body):
+ data = None
+ headers = {
+ 'Authorization': 'Bearer {}'.format(self._token),
+ 'Accept': 'application/vnd.github+json',
+ # Pin the API version so GitHub deprecations don't silently
+ # change the response shape under us.
+ 'X-GitHub-Api-Version': API_VERSION,
+ 'User-Agent': self._user_agent,
+ }
+ if json_body is not None:
+ data = json.dumps(json_body).encode('utf-8')
+ headers['Content-Type'] = 'application/json; charset=utf-8'
+
+ req = urllib.request.Request(
+ url, data=data, method=method, headers=headers)
+ try:
+ with urllib.request.urlopen(req) as resp:
+ raw = resp.read()
+ # HTTPMessage is case-insensitive on lookup but its items()
+ # preserves the original case. GitHub sends "Link" with a
+ # capital L, which is what _parse_next_link expects.
+ resp_headers = dict(resp.headers.items())
+ if not raw:
+ return None, resp_headers
+ return json.loads(raw.decode('utf-8')), resp_headers
+ except urllib.error.HTTPError as e:
+ # GitHub error bodies are JSON with a "message" field and often
+ # a "documentation_url". Dump the raw body into the exception so
+ # the CI log shows exactly what the API objected to. A bare
+ # "HTTP 422" tells us nothing useful.
+ try:
+ err_body = e.read().decode('utf-8', errors='replace')
+ except Exception:
+ err_body = '(no body)'
+ raise RuntimeError(
+ 'GitHub API {} {} failed: HTTP {} {}\n{}'.format(
+ method, url, e.code, e.reason, err_body))
+ except urllib.error.URLError as e:
+ # Network layer failure (DNS, TLS, connection reset). No HTTP
+ # response to parse; just surface the transport reason.
+ raise RuntimeError(
+ 'GitHub API {} {} failed: {}'.format(method, url, e.reason))
diff --git a/Tools/ci/build_all_config.yml b/Tools/ci/build_all_config.yml
new file mode 100644
index 0000000000..b53f8ff6c6
--- /dev/null
+++ b/Tools/ci/build_all_config.yml
@@ -0,0 +1,71 @@
+# Build All Targets CI Configuration
+#
+# Controls board grouping, cache sizes, runner specs, and seeder targets
+# for the build_all_targets workflow. Forks can customize this file to
+# adjust for their infrastructure (e.g., lower cache sizes for GitHub's
+# 10GB cache limit, fewer CPU cores for smaller runners).
+
+# Container images
+containers:
+ default: "ghcr.io/px4/px4-dev:v1.17.0-rc2"
+ voxl2: "ghcr.io/px4/px4-dev-voxl2:v1.7"
+
+# Runner specs
+runners:
+ seeder_cpu: 8
+ matrix_cpu: 4
+
+# Default ccache max-size for build groups
+cache:
+ default_size: "400M"
+ # Per-chip overrides for groups with many diverse boards
+ chip_sizes:
+ stm32h7: "800M"
+ stm32f4: "800M"
+ stm32f7: "800M"
+ imxrt: "800M"
+
+# Board grouping limits
+grouping:
+ # Max targets per group, tuned for ~10 min wall-clock with warm cache
+ chip_split_limits:
+ stm32h7: 10
+ stm32f7: 12
+ stm32f4: 20
+ stm32f1: 39
+ imxrt: 12
+ kinetis: 14
+ s32k: 17
+ rp2040: 10
+ special: 10
+ native: 17
+ default_split_limit: 12
+ # Minimum targets for a manufacturer to get a named group
+ lower_limit: 3
+ # If last chunk has fewer targets than this, merge into previous chunk
+ merge_back_threshold: 5
+
+# Labels that isolate builds into the "special" group
+special_labels:
+ - lto
+ - protected
+
+# NXP chip families are pooled under "nxp-{chip}" regardless of board directory
+nxp_chip_families:
+ - imxrt
+ - kinetis
+ - s32k
+
+# Seeder targets: one representative build per chip family
+seeders:
+ stm32h7: "px4_fmu-v6x_default"
+ stm32f7: "px4_fmu-v5_default"
+ stm32f4: "px4_fmu-v4_default"
+ stm32f1: "px4_io-v2_default"
+ imxrt: "nxp_mr-tropic_default"
+ kinetis: "nxp_fmuk66-v3_default"
+ s32k: "nxp_mr-canhubk3_default"
+ rp2040: "raspberrypi_pico_default"
+ special: "px4_fmu-v6x_default"
+ native: "px4_sitl_default"
+ voxl2: "modalai_voxl2_default"
diff --git a/Tools/ci/build_all_runner.sh b/Tools/ci/build_all_runner.sh
index bfb77e2623..a47d42dd87 100755
--- a/Tools/ci/build_all_runner.sh
+++ b/Tools/ci/build_all_runner.sh
@@ -1,9 +1,8 @@
-#!/bin/bash
+#!/usr/bin/env bash
# This script is meant to be used by the build_all.yml workflow in a github runner
# Please only modify if you know what you are doing
set -e
-echo "### :clock1: Build Times" >> $GITHUB_STEP_SUMMARY
targets=$1
for target in ${targets//,/ }
do
@@ -14,6 +13,5 @@ do
diff=$(($stop-$start))
build_time="$(($diff /60/60))h $(($diff /60))m $(($diff % 60))s elapsed"
echo -e "\033[0;32mBuild Time: [$build_time]"
- echo "* **$target** - $build_time" >> $GITHUB_STEP_SUMMARY
echo "::endgroup::"
done
diff --git a/Tools/ci/check_commit_messages.py b/Tools/ci/check_commit_messages.py
new file mode 100755
index 0000000000..5359f737cb
--- /dev/null
+++ b/Tools/ci/check_commit_messages.py
@@ -0,0 +1,331 @@
+#!/usr/bin/env python3
+"""Validate commit messages in a PR against conventional commits format.
+
+Reads a JSON array of GitHub commit objects from stdin (as returned by the
+GitHub API's /pulls/{n}/commits endpoint) and checks each message for
+blocking errors and advisory warnings.
+
+With --markdown, outputs a formatted PR comment body instead of plain text.
+"""
+
+import json
+import sys
+
+from conventional_commits import (
+ EXEMPT_PREFIXES,
+ parse_header,
+)
+
+# Blocking: prefixes that indicate unsquashed fixup commits
+FIXUP_PREFIXES = ('fixup!', 'squash!', 'amend!')
+
+# Blocking: single-word throwaway messages (case-insensitive exact match)
+THROWAWAY_WORDS = frozenset({
+ 'fix', 'fixed', 'fixes',
+ 'update', 'updated', 'updates',
+ 'test', 'tests', 'testing',
+ 'tmp', 'temp',
+ 'oops', 'wip',
+ 'debug', 'cleanup',
+})
+
+# Blocking: debug session leftovers
+DEBUG_KEYWORDS = ('tmate',)
+
+# Warning: review-response messages (case-insensitive substring match)
+REVIEW_RESPONSE_PATTERNS = (
+ 'address review',
+ 'apply suggestions from code review',
+ 'code review',
+)
+
+# Warning: formatter-only commits
+FORMATTER_PATTERNS = (
+ 'do make format',
+ 'make format',
+ 'run formatter',
+ 'apply format',
+)
+
+MIN_MESSAGE_LENGTH = 5
+
+
+def check_commit(message: str) -> tuple[list[str], list[str]]:
+ """Return (errors, warnings) for a single commit message."""
+ errors: list[str] = []
+ warnings: list[str] = []
+
+ first_line = message.split('\n', 1)[0].strip()
+ lower = first_line.lower()
+
+ # --- Blocking checks ---
+
+ for prefix in FIXUP_PREFIXES:
+ if lower.startswith(prefix):
+ errors.append(f'Unsquashed commit: starts with "{prefix}"')
+
+ if lower == 'wip' or lower.startswith('wip ') or lower.startswith('wip:'):
+ errors.append('WIP commit should not be merged')
+
+ if len(first_line) < MIN_MESSAGE_LENGTH:
+ errors.append(f'Message too short ({len(first_line)} chars, minimum {MIN_MESSAGE_LENGTH})')
+
+ if first_line.strip() and first_line.strip().lower() in THROWAWAY_WORDS:
+ errors.append(f'Single-word throwaway message: "{first_line.strip()}"')
+
+ for kw in DEBUG_KEYWORDS:
+ if kw in lower:
+ errors.append(f'Debug session leftover: contains "{kw}"')
+
+ # --- Warning checks ---
+
+ for pattern in REVIEW_RESPONSE_PATTERNS:
+ if pattern in lower:
+ warnings.append('Review-response commit')
+ break
+
+ for pattern in FORMATTER_PATTERNS:
+ if pattern in lower:
+ warnings.append('Formatter-only commit')
+ break
+
+ if not parse_header(first_line):
+ # Exempt merge commits
+ for prefix in EXEMPT_PREFIXES:
+ if first_line.startswith(prefix):
+ break
+ else:
+ warnings.append(
+ 'Missing conventional commit format '
+ '(e.g. "feat(ekf2): add something")'
+ )
+
+ return errors, warnings
+
+
+def suggest_commit(message: str) -> str | None:
+ """Suggest how to fix a bad commit message."""
+ first_line = message.split('\n', 1)[0].strip()
+ lower = first_line.lower()
+
+ for prefix in FIXUP_PREFIXES:
+ if lower.startswith(prefix):
+ return 'Squash this into the commit it fixes'
+
+ if lower == 'wip' or lower.startswith('wip ') or lower.startswith('wip:'):
+ return 'Reword with a descriptive message (e.g. "feat(scope): what changed")'
+
+ if len(first_line) < MIN_MESSAGE_LENGTH:
+ return 'Reword with a descriptive message (e.g. "feat(ekf2): what changed")'
+
+ if first_line.strip().lower() in THROWAWAY_WORDS:
+ return 'Reword with a descriptive message (e.g. "fix(scope): what changed")'
+
+ return None
+
+
+def format_plain(data: list) -> tuple[bool, bool]:
+ """Print plain text output. Returns (has_blocking, has_warnings)."""
+ has_blocking = False
+ has_warnings = False
+
+ for commit in data:
+ sha = commit.get('sha', '?')[:10]
+ message = commit.get('commit', {}).get('message', '')
+ first_line = message.split('\n', 1)[0].strip()
+
+ errors, warnings = check_commit(message)
+
+ if errors or warnings:
+ print(f"\n {sha} {first_line}")
+
+ for err in errors:
+ print(f" ERROR: {err}")
+ has_blocking = True
+
+ for warn in warnings:
+ print(f" WARNING: {warn}")
+ has_warnings = True
+
+ if has_blocking:
+ print(
+ "\n"
+ "ERROR = must fix before merging (CI will block the PR)\n"
+ "WARNING = advisory, not blocking, but recommended to fix\n"
+ "\n"
+ "See the contributing guide for details:\n"
+ " https://github.com/PX4/PX4-Autopilot/blob/main/CONTRIBUTING.md#commit-message-convention\n",
+ )
+
+ elif has_warnings:
+ print(
+ "\n"
+ "WARNING = advisory, not blocking, but recommended to fix\n"
+ "\n"
+ "See the contributing guide for details:\n"
+ " https://github.com/PX4/PX4-Autopilot/blob/main/CONTRIBUTING.md#commit-message-convention\n",
+ )
+
+ return has_blocking, has_warnings
+
+
+def format_markdown_blocking(data: list) -> str:
+ """Format a blocking error markdown comment."""
+ error_groups: dict[str, list[str]] = {}
+ unique_commits: list[tuple[str, str, list[str], str]] = []
+
+ for commit in data:
+ sha = commit.get('sha', '?')[:10]
+ message = commit.get('commit', {}).get('message', '')
+ first_line = message.split('\n', 1)[0].strip()
+
+ errors, _ = check_commit(message)
+ if not errors:
+ continue
+
+ suggestion = suggest_commit(message) or ''
+ unique_commits.append((sha, first_line, errors, suggestion))
+
+ for err in errors:
+ error_groups.setdefault(err, []).append(sha)
+
+ lines = [
+ "## \u274c Commit messages need attention before merging",
+ "",
+ ]
+
+ has_large_group = any(len(shas) > 3 for shas in error_groups.values())
+
+ if has_large_group:
+ lines.extend([
+ "**Issues found:**",
+ "",
+ ])
+ for err_msg, shas in error_groups.items():
+ if len(shas) > 3:
+ lines.append(f"- **{len(shas)} commits**: {err_msg} "
+ f"(`{shas[0]}`, `{shas[1]}`, ... `{shas[-1]}`)")
+ else:
+ sha_list = ', '.join(f'`{s}`' for s in shas)
+ lines.append(f"- {err_msg}: {sha_list}")
+
+ distinct_messages = {msg for _, msg, _, _ in unique_commits}
+ if len(distinct_messages) <= 5:
+ lines.extend(["", "**Affected commits:**", ""])
+ for sha, msg, errors, suggestion in unique_commits:
+ safe_msg = msg.replace('|', '\\|')
+ lines.append(f"- `{sha}` {safe_msg}")
+ else:
+ lines.extend([
+ "| Commit | Message | Issue | Suggested fix |",
+ "|--------|---------|-------|---------------|",
+ ])
+ for sha, msg, errors, suggestion in unique_commits:
+ issues = '; '.join(errors)
+ safe_msg = msg.replace('|', '\\|')
+ lines.append(f"| `{sha}` | {safe_msg} | {issues} | {suggestion} |")
+
+ lines.extend([
+ "",
+ "See [CONTRIBUTING.md](https://github.com/PX4/PX4-Autopilot/blob/main/CONTRIBUTING.md#commit-message-convention) "
+ "for how to clean up commits.",
+ "",
+ "---",
+ "*This comment will be automatically removed once the issues are resolved.*",
+ ])
+
+ return '\n'.join(lines)
+
+
+def format_markdown_advisory(data: list) -> str:
+ """Format an advisory warning markdown comment."""
+ lines = [
+ "## \U0001f4a1 Commit messages could be improved",
+ "",
+ "Not blocking, but these commit messages could use some cleanup.",
+ "",
+ "| Commit | Message | Suggestion |",
+ "|--------|---------|------------|",
+ ]
+
+ for commit in data:
+ sha = commit.get('sha', '?')[:10]
+ message = commit.get('commit', {}).get('message', '')
+ first_line = message.split('\n', 1)[0].strip()
+
+ _, warnings = check_commit(message)
+ if not warnings:
+ continue
+
+ suggestion = '; '.join(warnings)
+ safe_msg = first_line.replace('|', '\\|')
+ lines.append(f"| `{sha}` | {safe_msg} | {suggestion} |")
+
+ lines.extend([
+ "",
+ "See the [commit message convention](https://github.com/PX4/PX4-Autopilot/blob/main/CONTRIBUTING.md#commit-message-convention) "
+ "for details.",
+ "",
+ "---",
+ "*This comment will be automatically removed once the issues are resolved.*",
+ ])
+
+ return '\n'.join(lines)
+
+
+def main() -> None:
+ markdown_stdout = '--markdown' in sys.argv
+ markdown_file = None
+ for i, a in enumerate(sys.argv):
+ if a == '--markdown-file' and i + 1 < len(sys.argv):
+ markdown_file = sys.argv[i + 1]
+ elif a.startswith('--markdown-file='):
+ markdown_file = a.split('=', 1)[1]
+
+ try:
+ data = json.load(sys.stdin)
+ except json.JSONDecodeError as exc:
+ print(f"Failed to parse JSON input: {exc}", file=sys.stderr)
+ sys.exit(2)
+
+ if not isinstance(data, list):
+ print("Expected a JSON array of commit objects.", file=sys.stderr)
+ sys.exit(2)
+
+ # Always compute blocking/warning state
+ has_blocking = False
+ has_warnings = False
+ for commit in data:
+ message = commit.get('commit', {}).get('message', '')
+ errors, warnings = check_commit(message)
+ if errors:
+ has_blocking = True
+ if warnings:
+ has_warnings = True
+
+ # Generate markdown if needed
+ md = None
+ if has_blocking:
+ md = format_markdown_blocking(data)
+ elif has_warnings:
+ md = format_markdown_advisory(data)
+
+ if md:
+ if markdown_stdout:
+ print(md)
+ if markdown_file:
+ with open(markdown_file, 'w') as f:
+ f.write(md + '\n')
+ elif markdown_file:
+ with open(markdown_file, 'w') as f:
+ pass
+
+ # Plain text output to stderr for CI logs (always, unless --markdown only)
+ if not markdown_stdout:
+ has_blocking, _ = format_plain(data)
+
+ sys.exit(1 if has_blocking else 0)
+
+
+if __name__ == '__main__':
+ main()
diff --git a/Tools/ci/check_msg_versioning.sh b/Tools/ci/check_msg_versioning.sh
index 508a344ee6..da769dbeb3 100755
--- a/Tools/ci/check_msg_versioning.sh
+++ b/Tools/ci/check_msg_versioning.sh
@@ -1,4 +1,4 @@
-#! /bin/bash
+#!/usr/bin/env bash
# Copy a git diff between two commits if msg versioning is added
DIR=$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )
diff --git a/Tools/ci/check_pr_title.py b/Tools/ci/check_pr_title.py
new file mode 100755
index 0000000000..5ef2663f8a
--- /dev/null
+++ b/Tools/ci/check_pr_title.py
@@ -0,0 +1,163 @@
+#!/usr/bin/env python3
+"""Validate that a PR title follows conventional commits format.
+
+Format: type(scope): description
+
+Can output plain text for CI logs or markdown for PR comments.
+"""
+
+import re
+import sys
+
+from conventional_commits import (
+ CONVENTIONAL_TYPES,
+ EXEMPT_PREFIXES,
+ parse_header,
+ suggest_scope,
+ suggest_type,
+)
+
+
+def suggest_title(title: str) -> str | None:
+ """Try to suggest a corrected title in conventional commits format."""
+ stripped = title.strip()
+
+ # Remove common bracket prefixes like [docs], [CI], etc.
+ bracket_match = re.match(r'^\[([^\]]+)\]\s*(.+)', stripped)
+ if bracket_match:
+ prefix = bracket_match.group(1).strip().lower()
+ rest = bracket_match.group(2).strip()
+ rest = re.sub(r'^[\-:]\s*', '', rest).strip()
+ if len(rest) >= 5:
+ # Try to map bracket content to a type
+ commit_type = prefix if prefix in CONVENTIONAL_TYPES else suggest_type(rest)
+ scope = suggest_scope(rest)
+ if scope:
+ return f"{commit_type}({scope}): {rest}"
+
+ # Already has old-style "subsystem: description" format - convert it
+ colon_match = re.match(r'^([a-zA-Z][a-zA-Z0-9_/\-\. ]*): (.+)$', stripped)
+ if colon_match:
+ old_subsystem = colon_match.group(1).strip()
+ desc = colon_match.group(2).strip()
+ if len(desc) >= 5:
+ commit_type = suggest_type(desc)
+ # Use the old subsystem as scope (clean it up)
+ scope = old_subsystem.lower().replace(' ', '_')
+ return f"{commit_type}({scope}): {desc}"
+
+ # No format at all - try to guess both type and scope
+ commit_type = suggest_type(stripped)
+ scope = suggest_scope(stripped)
+ if scope:
+ desc = stripped[0].lower() + stripped[1:] if stripped else stripped
+ return f"{commit_type}({scope}): {desc}"
+
+ return None
+
+
+def check_title(title: str) -> bool:
+ title = title.strip()
+
+ if not title:
+ print("PR title is empty.", file=sys.stderr)
+ return False
+
+ for prefix in EXEMPT_PREFIXES:
+ if title.startswith(prefix):
+ return True
+
+ if parse_header(title):
+ return True
+
+ types_str = ', '.join(f'`{t}`' for t in CONVENTIONAL_TYPES.keys())
+ print(
+ f"PR title does not match conventional commits format.\n"
+ f"\n"
+ f" Title: {title}\n"
+ f"\n"
+ f"Expected format: type(scope): description\n"
+ f"\n"
+ f"Valid types: {types_str}\n"
+ f"\n"
+ f"Good examples:\n"
+ f" feat(ekf2): add height fusion timeout\n"
+ f" fix(mavlink): correct BATTERY_STATUS_V2 parsing\n"
+ f" ci(workflows): migrate to reusable workflows\n"
+ f" feat(boards/px4_fmu-v6x)!: remove deprecated driver API\n"
+ f"\n"
+ f"Bad examples:\n"
+ f" fix stuff\n"
+ f" Update file\n"
+ f" ekf2: fix something (missing type prefix)\n"
+ f"\n"
+ f"See the contributing guide for details:\n"
+ f" https://github.com/PX4/PX4-Autopilot/blob/main/CONTRIBUTING.md#commit-message-convention\n",
+ file=sys.stderr,
+ )
+ return False
+
+
+def format_markdown(title: str) -> str:
+ """Format a markdown PR comment body for a bad title."""
+ lines = [
+ "## \u274c PR title needs conventional commit format",
+ "",
+ "Expected format: `type(scope): description` "
+ "([conventional commits](https://www.conventionalcommits.org/)).",
+ "",
+ "**Your title:**",
+ f"> {title}",
+ "",
+ ]
+
+ suggestion = suggest_title(title)
+ if suggestion:
+ lines.extend([
+ "**Suggested fix:**",
+ f"> {suggestion}",
+ "",
+ ])
+
+ lines.extend([
+ "**To fix this:** click the ✏️ next to the PR title at the top "
+ "of this page and update it.",
+ "",
+ "See [CONTRIBUTING.md](https://github.com/PX4/PX4-Autopilot/blob/main/CONTRIBUTING.md#commit-message-convention) "
+ "for details.",
+ "",
+ "---",
+ "*This comment will be automatically removed once the issue is resolved.*",
+ ])
+
+ return '\n'.join(lines)
+
+
+def main() -> None:
+ import argparse
+ parser = argparse.ArgumentParser(description='Check PR title format')
+ parser.add_argument('title', help='The PR title to validate')
+ parser.add_argument('--markdown', action='store_true',
+ help='Output markdown to stdout on failure')
+ parser.add_argument('--markdown-file', metavar='FILE',
+ help='Write markdown to FILE on failure')
+ args = parser.parse_args()
+
+ passed = check_title(args.title)
+
+ if not passed:
+ md = format_markdown(args.title)
+ if args.markdown:
+ print(md)
+ if args.markdown_file:
+ with open(args.markdown_file, 'w') as f:
+ f.write(md + '\n')
+ elif args.markdown_file:
+ with open(args.markdown_file, 'w') as f:
+ pass
+
+ sys.exit(0 if passed else 1)
+
+
+if __name__ == '__main__':
+ main()
diff --git a/Tools/ci/clang-tidy-diff-filter.py b/Tools/ci/clang-tidy-diff-filter.py
new file mode 100644
index 0000000000..5f516c5d01
--- /dev/null
+++ b/Tools/ci/clang-tidy-diff-filter.py
@@ -0,0 +1,111 @@
+#!/usr/bin/env python3
+"""
+Filter a git diff for consumption by clang-tidy-diff.
+
+Produces a unified diff containing only files that clang-tidy can
+actually analyze against the current compilation database:
+
+ - C/C++ source files (.c, .cpp, .cc, .cxx, .m, .mm) must be present
+ in compile_commands.json. Files absent from the database are test
+ files, excluded code, or platform-specific sources that were not
+ compiled. Feeding them to clang-tidy-diff produces spurious
+ "header not found" errors (gtest/gtest.h in particular).
+
+ - Header files (.h, .hpp, .hxx) always pass through. clang-tidy
+ analyzes header changes via the TUs that include them; there is
+ no separate TU for a header to match against the database.
+
+ - All other files (CMakeLists.txt, .yml, .md, etc.) are dropped.
+
+Output is a unified diff suitable for piping into clang-tidy-diff.py.
+If nothing remains, the output file is empty.
+
+Used by .github/workflows/clang-tidy.yml as a pre-filter for the
+`pr-review` artifact producer. Python stdlib only.
+"""
+
+import argparse
+import json
+import os
+import subprocess
+import sys
+
+
+SOURCE_EXTS = {'.c', '.cpp', '.cc', '.cxx', '.m', '.mm'}
+HEADER_EXTS = {'.h', '.hpp', '.hxx'}
+
+
+def load_db_files(build_dir):
+ """Return the set of source paths (repo-relative) in compile_commands.json."""
+ path = os.path.join(build_dir, 'compile_commands.json')
+ with open(path) as f:
+ db = json.load(f)
+ root = os.path.abspath('.')
+ prefix = root + os.sep
+ paths = set()
+ for entry in db:
+ p = entry.get('file', '')
+ if p.startswith(prefix):
+ paths.add(p[len(prefix):])
+ else:
+ # Relative or external path; record as-is
+ paths.add(p)
+ return paths
+
+
+def changed_files(base_ref):
+ out = subprocess.check_output(
+ ['git', 'diff', '--name-only', '{}...HEAD'.format(base_ref)],
+ text=True,
+ )
+ return [line.strip() for line in out.splitlines() if line.strip()]
+
+
+def keep_file(path, db_files):
+ """Decide whether to keep this path in the filtered diff."""
+ ext = os.path.splitext(path)[1].lower()
+ if ext in HEADER_EXTS:
+ return True
+ if ext in SOURCE_EXTS:
+ return path in db_files
+ return False
+
+
+def filtered_diff(base_ref, keep_paths):
+ if not keep_paths:
+ return ''
+ cmd = ['git', 'diff', '-U0', '{}...HEAD'.format(base_ref), '--'] + sorted(keep_paths)
+ return subprocess.check_output(cmd, text=True)
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument('--build-dir', required=True,
+ help='CMake build dir containing compile_commands.json')
+ parser.add_argument('--base-ref', required=True,
+ help='Git ref to diff against (e.g. origin/main)')
+ parser.add_argument('--out', required=True,
+ help='Output path for the filtered unified diff')
+ args = parser.parse_args()
+
+ db_files = load_db_files(args.build_dir)
+ changed = changed_files(args.base_ref)
+
+ keep = [p for p in changed if keep_file(p, db_files)]
+ dropped = [p for p in changed if p not in keep]
+
+ print('clang-tidy-diff-filter: kept {} of {} changed files'.format(
+ len(keep), len(changed)))
+ if dropped:
+ print(' dropped (not in compile_commands.json or not source/header):')
+ for p in dropped:
+ print(' {}'.format(p))
+
+ diff = filtered_diff(args.base_ref, keep)
+ with open(args.out, 'w') as f:
+ f.write(diff)
+ return 0
+
+
+if __name__ == '__main__':
+ sys.exit(main())
diff --git a/Tools/ci/clang-tidy-fixes-to-review.py b/Tools/ci/clang-tidy-fixes-to-review.py
new file mode 100644
index 0000000000..3567ae4f76
--- /dev/null
+++ b/Tools/ci/clang-tidy-fixes-to-review.py
@@ -0,0 +1,567 @@
+#!/usr/bin/env python3
+#
+# clang-tidy-fixes-to-review.py
+#
+# Producer-side helper that converts a clang-tidy fixes.yml file into a
+# pr-review artifact (manifest.json + comments.json) suitable for
+# Tools/ci/pr-review-poster.py.
+#
+# This script runs inside the clang-tidy job's px4-dev container so it can
+# read the source tree directly and look up byte offsets in the original
+# files. The output it writes is a fully-baked array of review comments;
+# the poster never reads source files or fixes.yml.
+#
+# ----------------------------------------------------------------------------
+# ATTRIBUTION
+# ----------------------------------------------------------------------------
+# This script reuses the diagnostic-to-review-comment translation logic
+# from platisd/clang-tidy-pr-comments. The original work is:
+#
+# MIT License
+#
+# Copyright (c) 2021 Dimitris Platis
+#
+# Permission is hereby granted, free of charge, to any person obtaining a
+# copy of this software and associated documentation files (the
+# "Software"), to deal in the Software without restriction, including
+# without limitation the rights to use, copy, modify, merge, publish,
+# distribute, sublicense, and/or sell copies of the Software, and to
+# permit persons to whom the Software is furnished to do so, subject to
+# the following conditions:
+#
+# The above copyright notice and this permission notice shall be included
+# in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
+# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+# CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
+# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
+# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+#
+# Adapted parts:
+# - get_diff_line_ranges_per_file() and its inner change_to_line_range()
+# - generate_review_comments() and its nested helpers
+# (get_line_by_offset, validate_warning_applicability,
+# calculate_replacements_diff, markdown, markdown_url,
+# diagnostic_name_visual, generate_single_comment)
+# - reorder_diagnostics()
+#
+# Removed parts (handled by Tools/ci/pr-review-poster.py instead):
+# - post_review_comments / dismiss_change_requests / resolve_conversations
+# - the original argparse main and the requests-based HTTP layer
+#
+# Adaptation notes:
+# - The HTTP layer is rewritten on top of Tools/ci/_github_helpers.py so
+# this script does not depend on the third-party `requests` package.
+# - Conversation resolution (the GraphQL path) is intentionally dropped
+# for v1; revisit if it turns out to be missed.
+# - Clang-Tidy 8 upconvert is preserved verbatim.
+#
+# ----------------------------------------------------------------------------
+# Bounded assumptions (documented for future maintainers):
+# - Source files are UTF-8 (we read them as latin_1, matching clang-tidy's
+# own byte-offset model, and the offsets we surface are line counts)
+# - Source files use LF line endings
+# - Malformed entries in fixes.yml are skipped with a warning rather than
+# crashing the job
+#
+# Dependencies: pyyaml + Tools/ci/_github_helpers.py.
+# pyyaml is preinstalled in the px4-dev container; this script is intended
+# to run there, not on bare ubuntu-latest.
+"""Convert a clang-tidy fixes.yml into a pr-review artifact."""
+
+import argparse
+import difflib
+import json
+import os
+import posixpath
+import re
+import sys
+import urllib.parse
+
+import yaml
+
+import _github_helpers
+from _github_helpers import fail as _fail
+
+
+# Markers used inside the per-comment body to call out severity. Plain
+# strings rather than emojis to keep the file emoji-free per project
+# preferences; the rendered Markdown is unaffected.
+SINGLE_COMMENT_MARKERS = {
+ 'Error': '**[error]**',
+ 'Warning': '**[warning]**',
+ 'Remark': '**[remark]**',
+ 'fallback': '**[note]**',
+}
+
+
+# Diagnostics we never surface as PR review comments. `file not found` from
+# clang-diagnostic-error fires whenever clang-tidy analyzes a header whose
+# including TU is not in the active compile_commands.json (e.g. board-
+# specific NuttX headers against the SITL clang build). It is noise here,
+# not a signal: the build_all_targets CI matrix compiles those headers for
+# real and will fail loudly if an include is actually missing.
+DROPPED_DIAGNOSTIC_PATTERNS = (
+ ('clang-diagnostic-error', 'file not found'),
+)
+
+
+def is_dropped_diagnostic(diag_name, diag_message):
+ for name, needle in DROPPED_DIAGNOSTIC_PATTERNS:
+ if diag_name == name and needle in (diag_message or ''):
+ return True
+ return False
+
+
+# ---------------------------------------------------------------------------
+# Diff-range parsing (adapted from platisd)
+# ---------------------------------------------------------------------------
+
+def get_diff_line_ranges_per_file(pr_files):
+ """Return a dict mapping each PR file path to a list of line ranges
+ (the +new-side hunks) parsed from its patch."""
+
+ def change_to_line_range(change):
+ split_change = change.split(',')
+ start = int(split_change[0])
+ size = int(split_change[1]) if len(split_change) > 1 else 1
+ return range(start, start + size)
+
+ result = {}
+ for pr_file in pr_files:
+ # Removed binary files etc. have no patch section.
+ if 'patch' not in pr_file:
+ continue
+ file_name = pr_file['filename']
+ # Match lines like '@@ -101,8 +102,11 @@'
+ git_line_tags = re.findall(
+ r'^@@ -.*? +.*? @@', pr_file['patch'], re.MULTILINE)
+ changes = [
+ tag.replace('@@', '').strip().split()[1].replace('+', '')
+ for tag in git_line_tags
+ ]
+ result[file_name] = [
+ change_to_line_range(change) for change in changes
+ ]
+ return result
+
+
+def fetch_pull_request_files(client, repo, pr_number):
+ """Yield file metadata objects for each file modified by the PR."""
+ path = 'repos/{}/pulls/{}/files'.format(repo, pr_number)
+ for entry in client.paginated(path):
+ yield entry
+
+
+# ---------------------------------------------------------------------------
+# Diagnostic ordering (adapted from platisd)
+# ---------------------------------------------------------------------------
+
+def reorder_diagnostics(diags):
+ """Return diagnostics ordered Error -> Warning -> Remark -> other."""
+ errors = [d for d in diags if d.get('Level') == 'Error']
+ warnings = [d for d in diags if d.get('Level') == 'Warning']
+ remarks = [d for d in diags if d.get('Level') == 'Remark']
+ others = [
+ d for d in diags
+ if d.get('Level') not in {'Error', 'Warning', 'Remark'}
+ ]
+ if others:
+ print(
+ 'warning: some fixes have an unexpected Level (not Error, '
+ 'Warning, or Remark)', file=sys.stderr)
+ return errors + warnings + remarks + others
+
+
+# ---------------------------------------------------------------------------
+# Comment generation (adapted from platisd)
+# ---------------------------------------------------------------------------
+
+def generate_review_comments(clang_tidy_fixes, repository_root,
+ diff_line_ranges_per_file,
+ single_comment_markers):
+ """Yield review comment dicts for each clang-tidy diagnostic that
+ intersects the PR diff."""
+
+ def get_line_by_offset(file_path, offset):
+ # Clang-Tidy doesn't support multibyte encodings and measures
+ # offsets in bytes; latin_1 makes byte offsets and string offsets
+ # equivalent.
+ with open(repository_root + file_path, encoding='latin_1') as fh:
+ source = fh.read()
+ return source[:offset].count('\n') + 1
+
+ def validate_warning_applicability(file_path, start_line_num, end_line_num):
+ assert end_line_num >= start_line_num
+ for line_range in diff_line_ranges_per_file[file_path]:
+ assert line_range.step == 1
+ if (line_range.start <= start_line_num
+ and end_line_num < line_range.stop):
+ return True
+ return False
+
+ def calculate_replacements_diff(file_path, replacements):
+ # Apply replacements in reverse order so subsequent offsets do not
+ # shift.
+ replacements.sort(key=lambda item: (-item['Offset']))
+ with open(repository_root + file_path, encoding='latin_1') as fh:
+ source = fh.read()
+ changed = source
+ for replacement in replacements:
+ changed = (
+ changed[:replacement['Offset']]
+ + replacement['ReplacementText']
+ + changed[replacement['Offset'] + replacement['Length']:]
+ )
+ return difflib.Differ().compare(
+ source.splitlines(keepends=True),
+ changed.splitlines(keepends=True),
+ )
+
+ def markdown(s):
+ md_chars = '\\`*_{}[]<>()#+-.!|'
+
+ def escape_chars(s):
+ for ch in md_chars:
+ s = s.replace(ch, '\\' + ch)
+ return s
+
+ def unescape_chars(s):
+ for ch in md_chars:
+ s = s.replace('\\' + ch, ch)
+ return s
+
+ s = escape_chars(s)
+ s = re.sub(
+ "'([^']*)'",
+ lambda m: '`` ' + unescape_chars(m.group(1)) + ' ``',
+ s,
+ )
+ return s
+
+ def markdown_url(label, url):
+ return '[{}]({})'.format(label, url)
+
+ def diagnostic_name_visual(diagnostic_name):
+ visual = '**{}**'.format(markdown(diagnostic_name))
+ try:
+ first_dash_idx = diagnostic_name.index('-')
+ except ValueError:
+ return visual
+ namespace = urllib.parse.quote_plus(diagnostic_name[:first_dash_idx])
+ check_name = urllib.parse.quote_plus(
+ diagnostic_name[first_dash_idx + 1:])
+ return markdown_url(
+ visual,
+ 'https://clang.llvm.org/extra/clang-tidy/checks/{}/{}.html'.format(
+ namespace, check_name),
+ )
+
+ def generate_single_comment(file_path, start_line_num, end_line_num,
+ name, message, single_comment_marker,
+ replacement_text=None):
+ result = {
+ 'path': file_path,
+ 'line': end_line_num,
+ 'side': 'RIGHT',
+ 'body': '{} {} {}\n{}'.format(
+ single_comment_marker,
+ diagnostic_name_visual(name),
+ single_comment_marker,
+ markdown(message),
+ ),
+ }
+ if start_line_num != end_line_num:
+ result['start_line'] = start_line_num
+ result['start_side'] = 'RIGHT'
+ if replacement_text is not None:
+ if not replacement_text or replacement_text[-1] != '\n':
+ replacement_text += '\n'
+ result['body'] += '\n```suggestion\n{}```'.format(replacement_text)
+ return result
+
+ dropped = 0
+ for diag in clang_tidy_fixes['Diagnostics']:
+ # Upconvert clang-tidy 8 format to 9+
+ if 'DiagnosticMessage' not in diag:
+ diag['DiagnosticMessage'] = {
+ 'FileOffset': diag.get('FileOffset'),
+ 'FilePath': diag.get('FilePath'),
+ 'Message': diag.get('Message'),
+ 'Replacements': diag.get('Replacements', []),
+ }
+
+ diag_message = diag['DiagnosticMessage']
+ diag_message['FilePath'] = posixpath.normpath(
+ (diag_message.get('FilePath') or '').replace(repository_root, ''))
+ for replacement in diag_message.get('Replacements') or []:
+ replacement['FilePath'] = posixpath.normpath(
+ replacement['FilePath'].replace(repository_root, ''))
+
+ diag_name = diag.get('DiagnosticName', '')
+ diag_message_msg = diag_message.get('Message', '')
+
+ if is_dropped_diagnostic(diag_name, diag_message_msg):
+ dropped += 1
+ continue
+ level = diag.get('Level', 'Warning')
+ single_comment_marker = single_comment_markers.get(
+ level, single_comment_markers['fallback'])
+
+ replacements = diag_message.get('Replacements') or []
+ if not replacements:
+ file_path = diag_message['FilePath']
+ offset = diag_message.get('FileOffset')
+ if offset is None:
+ print('warning: skipping {!r}: missing FileOffset'.format(
+ diag_name), file=sys.stderr)
+ continue
+ if file_path not in diff_line_ranges_per_file:
+ print(
+ "'{}' for {} does not apply to the files changed in "
+ 'this PR'.format(diag_name, file_path))
+ continue
+ try:
+ line_num = get_line_by_offset(file_path, offset)
+ except (OSError, ValueError) as e:
+ print('warning: skipping {!r} on {}: {}'.format(
+ diag_name, file_path, e), file=sys.stderr)
+ continue
+
+ print("Processing '{}' at line {} of {}...".format(
+ diag_name, line_num, file_path))
+ if validate_warning_applicability(file_path, line_num, line_num):
+ yield generate_single_comment(
+ file_path,
+ line_num,
+ line_num,
+ diag_name,
+ diag_message_msg,
+ single_comment_marker=single_comment_marker,
+ )
+ else:
+ print('This warning does not apply to the lines changed '
+ 'in this PR')
+ else:
+ for file_path in {item['FilePath'] for item in replacements}:
+ if file_path not in diff_line_ranges_per_file:
+ print(
+ "'{}' for {} does not apply to the files changed "
+ 'in this PR'.format(diag_name, file_path))
+ continue
+
+ line_num = 1
+ start_line_num = None
+ end_line_num = None
+ replacement_text = None
+
+ try:
+ diff_iter = calculate_replacements_diff(
+ file_path,
+ [r for r in replacements if r['FilePath'] == file_path],
+ )
+ except (OSError, ValueError) as e:
+ print('warning: skipping {!r} on {}: {}'.format(
+ diag_name, file_path, e), file=sys.stderr)
+ continue
+
+ for line in diff_iter:
+ # Comment line, ignore.
+ if line.startswith('? '):
+ continue
+ # A '-' line is the start or continuation of a region
+ # to replace.
+ if line.startswith('- '):
+ if start_line_num is None:
+ start_line_num = line_num
+ end_line_num = line_num
+ else:
+ end_line_num = line_num
+ if replacement_text is None:
+ replacement_text = ''
+ line_num += 1
+ # A '+' line is part of the replacement text.
+ elif line.startswith('+ '):
+ if replacement_text is None:
+ replacement_text = line[2:]
+ else:
+ replacement_text += line[2:]
+ # A context line marks the end of a replacement region.
+ elif line.startswith(' '):
+ if replacement_text is not None:
+ if start_line_num is None:
+ # Pure addition: synthesize a one-line
+ # range and append the context line to
+ # the replacement.
+ start_line_num = line_num
+ end_line_num = line_num
+ replacement_text += line[2:]
+
+ print("Processing '{}' at lines {}-{} of {}...".format(
+ diag_name, start_line_num, end_line_num, file_path))
+
+ if validate_warning_applicability(
+ file_path, start_line_num, end_line_num):
+ yield generate_single_comment(
+ file_path,
+ start_line_num,
+ end_line_num,
+ diag_name,
+ diag_message_msg,
+ single_comment_marker=single_comment_marker,
+ replacement_text=replacement_text,
+ )
+ else:
+ print(
+ 'This warning does not apply to the '
+ 'lines changed in this PR')
+
+ start_line_num = None
+ end_line_num = None
+ replacement_text = None
+
+ line_num += 1
+ else:
+ # Unknown difflib prefix; skip rather than abort.
+ print('warning: unexpected diff prefix {!r}; '
+ 'skipping diagnostic'.format(line[:2]),
+ file=sys.stderr)
+ break
+
+ # End of file with a pending replacement region.
+ if replacement_text is not None and start_line_num is not None:
+ print("Processing '{}' at lines {}-{} of {}...".format(
+ diag_name, start_line_num, end_line_num, file_path))
+ if validate_warning_applicability(
+ file_path, start_line_num, end_line_num):
+ yield generate_single_comment(
+ file_path,
+ start_line_num,
+ end_line_num,
+ diag_name,
+ diag_message_msg,
+ single_comment_marker=single_comment_marker,
+ replacement_text=replacement_text,
+ )
+ else:
+ print('This warning does not apply to the lines '
+ 'changed in this PR')
+
+ if dropped:
+ print('Dropped {} diagnostic(s) matching the ignored-patterns list '
+ '(e.g. clang-diagnostic-error "file not found"); '
+ 'build_all_targets covers real missing includes.'.format(dropped))
+
+
+# ---------------------------------------------------------------------------
+# Entry point
+# ---------------------------------------------------------------------------
+
+def main(argv=None):
+ parser = argparse.ArgumentParser(
+ description='Convert a clang-tidy fixes.yml into a pr-review '
+ 'artifact (manifest.json + comments.json).',
+ )
+ parser.add_argument('--fixes', required=True,
+ help='Path to fixes.yml from clang-tidy')
+ parser.add_argument('--repo-root', required=True,
+ help='Path to the repository root containing the '
+ 'source files referenced by fixes.yml')
+ parser.add_argument('--repo', required=True,
+ help='owner/name of the repository')
+ parser.add_argument('--pr-number', required=True, type=int,
+ help='Pull request number')
+ parser.add_argument('--commit-sha', required=True,
+ help='40-char hex commit SHA the review will pin to')
+ parser.add_argument('--out-dir', required=True,
+ help='Directory to write manifest.json and '
+ 'comments.json')
+ parser.add_argument(
+ '--marker',
+ default='',
+ help='Marker string embedded in the review body so the poster '
+ 'can find and dismiss stale runs')
+ parser.add_argument(
+ '--event',
+ default='REQUEST_CHANGES',
+ choices=('COMMENT', 'REQUEST_CHANGES'),
+ help='GitHub review event type')
+ parser.add_argument(
+ '--summary', default='',
+ help='Optional review summary text appended to the review body')
+ args = parser.parse_args(argv)
+
+ if args.pr_number <= 0:
+ _fail('--pr-number must be > 0')
+ if not re.match(r'^[0-9a-f]{40}$', args.commit_sha):
+ _fail('--commit-sha must be a 40-char lowercase hex string')
+
+ token = os.environ.get('GITHUB_TOKEN')
+ if not token:
+ _fail('GITHUB_TOKEN is not set')
+
+ # Normalize the repo root with a trailing slash so the platisd-style
+ # str.replace() trick still strips it cleanly.
+ repo_root = args.repo_root
+ if not repo_root.endswith(os.sep):
+ repo_root = repo_root + os.sep
+
+ os.makedirs(args.out_dir, exist_ok=True)
+
+ client = _github_helpers.GitHubClient(token, user_agent='px4-clang-tidy-fixes-to-review')
+
+ print('Fetching PR file list from GitHub...')
+ pr_files = list(fetch_pull_request_files(client, args.repo, args.pr_number))
+ diff_line_ranges_per_file = get_diff_line_ranges_per_file(pr_files)
+
+ print('Loading clang-tidy fixes from {}...'.format(args.fixes))
+ if not os.path.isfile(args.fixes):
+ # No fixes file means clang-tidy ran cleanly. Emit an empty
+ # comments.json so the poster can short-circuit.
+ comments = []
+ else:
+ with open(args.fixes, encoding='utf-8') as fh:
+ clang_tidy_fixes = yaml.safe_load(fh)
+ if (not clang_tidy_fixes
+ or 'Diagnostics' not in clang_tidy_fixes
+ or not clang_tidy_fixes['Diagnostics']):
+ comments = []
+ else:
+ clang_tidy_fixes['Diagnostics'] = reorder_diagnostics(
+ clang_tidy_fixes['Diagnostics'])
+ comments = list(generate_review_comments(
+ clang_tidy_fixes,
+ repo_root,
+ diff_line_ranges_per_file,
+ single_comment_markers=SINGLE_COMMENT_MARKERS,
+ ))
+
+ print('Generated {} review comment(s)'.format(len(comments)))
+
+ manifest = {
+ 'pr_number': args.pr_number,
+ 'marker': args.marker,
+ 'event': args.event,
+ 'commit_sha': args.commit_sha,
+ }
+ if args.summary:
+ manifest['summary'] = args.summary
+
+ manifest_path = os.path.join(args.out_dir, 'manifest.json')
+ comments_path = os.path.join(args.out_dir, 'comments.json')
+ with open(manifest_path, 'w', encoding='utf-8') as fh:
+ json.dump(manifest, fh, indent=2)
+ fh.write('\n')
+ with open(comments_path, 'w', encoding='utf-8') as fh:
+ json.dump(comments, fh, indent=2)
+ fh.write('\n')
+
+ print('Wrote {} and {}'.format(manifest_path, comments_path))
+ return 0
+
+
+if __name__ == '__main__':
+ sys.exit(main())
diff --git a/Tools/ci/conventional_commits.py b/Tools/ci/conventional_commits.py
new file mode 100644
index 0000000000..31a18e0580
--- /dev/null
+++ b/Tools/ci/conventional_commits.py
@@ -0,0 +1,146 @@
+"""Shared constants and helpers for conventional commit validation.
+
+Format: type(scope): description
+Optional breaking change marker: type(scope)!: description
+"""
+
+import re
+
+CONVENTIONAL_TYPES = {
+ 'feat': 'A new feature',
+ 'fix': 'A bug fix',
+ 'docs': 'Documentation only changes',
+ 'style': 'Formatting, whitespace, no code change',
+ 'refactor': 'Code change that neither fixes a bug nor adds a feature',
+ 'perf': 'Performance improvement',
+ 'test': 'Adding or correcting tests',
+ 'build': 'Build system or external dependencies',
+ 'ci': 'CI configuration files and scripts',
+ 'chore': 'Other changes that don\'t modify src or test files',
+ 'revert': 'Reverts a previous commit',
+}
+
+# type(scope)[!]: description
+# - type: one of CONVENTIONAL_TYPES keys
+# - scope: required, alphanumeric with _/-/.
+# - !: optional breaking change marker
+# - description: at least 5 chars
+HEADER_PATTERN = re.compile(
+ r'^(' + '|'.join(CONVENTIONAL_TYPES.keys()) + r')'
+ r'\(([a-zA-Z0-9_/\-\.]+)\)'
+ r'(!)?'
+ r': (.{5,})$'
+)
+
+EXEMPT_PREFIXES = ('Merge ',)
+
+# Common PX4 subsystem scopes for suggestions
+KNOWN_SCOPES = [
+ 'ekf2', 'mavlink', 'commander', 'navigator', 'sensors',
+ 'mc_att_control', 'mc_pos_control', 'mc_rate_control',
+ 'fw_att_control', 'fw_pos_control', 'fw_rate_control',
+ 'vtol', 'actuators', 'battery', 'param', 'logger',
+ 'uorb', 'drivers', 'boards', 'simulation', 'sitl',
+ 'gps', 'rc', 'safety', 'can', 'serial',
+ 'ci', 'docs', 'build', 'cmake', 'tools',
+ 'mixer', 'land_detector', 'airspeed', 'gyroscope',
+ 'accelerometer', 'magnetometer', 'barometer',
+]
+
+# Keyword patterns to suggest scopes from description text
+KEYWORD_SCOPES = [
+ (r'\b(ekf|estimator|height|fusion|imu|baro)\b', 'ekf2'),
+ (r'\b(mavlink|MAVLink|MAVLINK|command_int|heartbeat)\b', 'mavlink'),
+ (r'\b(uorb|orb|pub|sub|topic)\b', 'uorb'),
+ (r'\b(board|fmu|nuttx|stm32)\b', 'boards'),
+ (r'\b(mixer|actuator|motor|servo|pwm|dshot)\b', 'actuators'),
+ (r'\b(battery|power)\b', 'battery'),
+ (r'\b(param|parameter)\b', 'param'),
+ (r'\b(log|logger|sdlog)\b', 'logger'),
+ (r'\b(sensor|accel|gyro)\b', 'sensors'),
+ (r'\b(land|takeoff|rtl|mission|navigator|geofence)\b', 'navigator'),
+ (r'\b(position|velocity|attitude|rate)\s*(control|ctrl)\b', 'mc_att_control'),
+ (r'\b(mc|multicopter|quad)\b', 'mc_att_control'),
+ (r'\b(fw|fixedwing|fixed.wing|plane)\b', 'fw_att_control'),
+ (r'\b(vtol|transition)\b', 'vtol'),
+ (r'\b(ci|workflow|github.action|pipeline)\b', 'ci'),
+ (r'\b(doc|docs|documentation|readme)\b', 'docs'),
+ (r'\b(cmake|make|toolchain|compiler)\b', 'build'),
+ (r'\b(sitl|simulation|gazebo|jmavsim|sih)\b', 'simulation'),
+ (r'\b(can|uavcan|cyphal|dronecan)\b', 'can'),
+ (r'\b(serial|uart|spi|i2c)\b', 'serial'),
+ (r'\b(safety|failsafe|arm|disarm|kill)\b', 'safety'),
+ (r'\b(rc|radio|sbus|crsf|elrs|dsm)\b', 'rc'),
+ (r'\b(gps|gnss|rtk|ubx)\b', 'gps'),
+ (r'\b(optical.flow|flow|rangefinder|lidar|distance)\b', 'sensors'),
+ (r'\b(orbit|follow|offboard)\b', 'commander'),
+ (r'\b(driver)\b', 'drivers'),
+]
+
+# Verb patterns to suggest conventional commit type
+VERB_TYPE_MAP = [
+ (r'^fix(e[ds])?[\s:]', 'fix'),
+ (r'^bug[\s:]', 'fix'),
+ (r'^add(s|ed|ing)?[\s:]', 'feat'),
+ (r'^implement', 'feat'),
+ (r'^introduce', 'feat'),
+ (r'^support', 'feat'),
+ (r'^enable', 'feat'),
+ (r'^update[ds]?[\s:]', 'feat'),
+ (r'^improv(e[ds]?|ing)', 'perf'),
+ (r'^optimi[zs](e[ds]?|ing)', 'perf'),
+ (r'^refactor', 'refactor'),
+ (r'^clean\s*up', 'refactor'),
+ (r'^restructure', 'refactor'),
+ (r'^simplif(y|ied)', 'refactor'),
+ (r'^remov(e[ds]?|ing)', 'refactor'),
+ (r'^delet(e[ds]?|ing)', 'refactor'),
+ (r'^deprecat', 'refactor'),
+ (r'^replac(e[ds]?|ing)', 'refactor'),
+ (r'^renam(e[ds]?|ing)', 'refactor'),
+ (r'^migrat', 'refactor'),
+ (r'^revert', 'revert'),
+ (r'^doc(s|ument)', 'docs'),
+ (r'^test', 'test'),
+ (r'^format', 'style'),
+ (r'^lint', 'style'),
+ (r'^whitespace', 'style'),
+ (r'^build', 'build'),
+ (r'^ci[\s:]', 'ci'),
+]
+
+
+def parse_header(text: str) -> dict | None:
+ """Parse a conventional commit header into components.
+
+ Returns dict with keys {type, scope, breaking, subject} or None if
+ the text doesn't match conventional commits format.
+ """
+ text = text.strip()
+ m = HEADER_PATTERN.match(text)
+ if not m:
+ return None
+ return {
+ 'type': m.group(1),
+ 'scope': m.group(2),
+ 'breaking': m.group(3) == '!',
+ 'subject': m.group(4),
+ }
+
+
+def suggest_type(text: str) -> str:
+ """Infer a conventional commit type from description text."""
+ lower = text.strip().lower()
+ for pattern, commit_type in VERB_TYPE_MAP:
+ if re.search(pattern, lower):
+ return commit_type
+ return 'feat'
+
+
+def suggest_scope(text: str) -> str | None:
+ """Infer a scope from keywords in the text."""
+ lower = text.strip().lower()
+ for pattern, scope in KEYWORD_SCOPES:
+ if re.search(pattern, lower, re.IGNORECASE):
+ return scope
+ return None
diff --git a/Tools/ci/generate_board_targets_json.py b/Tools/ci/generate_board_targets_json.py
index 214edd8186..922c051aaf 100755
--- a/Tools/ci/generate_board_targets_json.py
+++ b/Tools/ci/generate_board_targets_json.py
@@ -16,6 +16,7 @@ kconf.warn_assign_override = False
kconf.warn_assign_redun = False
source_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..')
+boards_dir = os.path.join(source_dir, '..', 'boards')
parser = argparse.ArgumentParser(description='Generate build targets')
@@ -26,6 +27,8 @@ parser.add_argument('-p', '--pretty', dest='pretty', action='store_true',
parser.add_argument('-g', '--groups', dest='group', action='store_true',
help='Groups targets')
parser.add_argument('-f', '--filter', dest='filter', help='comma separated list of build target name prefixes to include instead of all e.g. "px4_fmu-v5_"')
+parser.add_argument('-s', '--seeders', dest='seeders', action='store_true',
+ help='Output seeder matrix JSON (one entry per chip family)')
args = parser.parse_args()
verbose = args.verbose
@@ -35,8 +38,14 @@ if args.filter:
for target in args.filter.split(','):
target_filter.append(target)
-default_container = 'ghcr.io/px4/px4-dev:v1.16.0-rc1-258-g0369abd556'
-voxl2_container = 'ghcr.io/px4/px4-dev-voxl2:v1.5'
+# Load CI configuration from YAML
+import yaml
+ci_config_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'build_all_config.yml')
+with open(ci_config_path) as f:
+ ci_config = yaml.safe_load(f)
+
+default_container = ci_config['containers']['default']
+voxl2_container = ci_config['containers']['voxl2']
build_configs = []
grouped_targets = {}
excluded_boards = ['px4_ros2', 'espressif_esp32'] # TODO: fix and enable
@@ -56,6 +65,71 @@ excluded_labels = [
'uavcanv1', # TODO: fix and enable
]
+# Labels that mark isolated/special builds (poor cache reuse with normal builds)
+special_labels = ci_config.get('special_labels', ['lto', 'protected'])
+
+def detect_chip_family(manufacturer_name, board_name, label):
+ """Detect the chip family for a board by reading its NuttX defconfig.
+
+ Returns a chip family string used for cache grouping:
+ stm32h7, stm32f7, stm32f4, stm32f1, imxrt, kinetis, s32k, rp2040, native, special
+ """
+ # Special labels get their own group regardless of chip
+ if label in special_labels:
+ return 'special'
+
+ board_path = os.path.join(boards_dir, manufacturer_name, board_name)
+ nsh_defconfig = os.path.join(board_path, 'nuttx-config', 'nsh', 'defconfig')
+
+ if not os.path.exists(nsh_defconfig):
+ # Try bootloader defconfig as fallback
+ bl_defconfig = os.path.join(board_path, 'nuttx-config', 'bootloader', 'defconfig')
+ if os.path.exists(bl_defconfig):
+ nsh_defconfig = bl_defconfig
+ else:
+ return 'native'
+
+ arch_chip = None
+ specific_chip = None
+
+ with open(nsh_defconfig) as f:
+ for line in f:
+ line = line.strip()
+ if line.startswith('CONFIG_ARCH_CHIP='):
+ arch_chip = line.split('=')[1].strip('"')
+ elif line.startswith('CONFIG_ARCH_CHIP_STM32F') and line.endswith('=y'):
+ specific_chip = line.split('=')[0].replace('CONFIG_ARCH_CHIP_', '')
+
+ if arch_chip is None:
+ return 'native'
+
+ # Direct matches for chips that have unique CONFIG_ARCH_CHIP values
+ if arch_chip == 'stm32h7':
+ return 'stm32h7'
+ elif arch_chip == 'stm32f7':
+ return 'stm32f7'
+ elif arch_chip == 'imxrt':
+ return 'imxrt'
+ elif arch_chip == 'kinetis':
+ return 'kinetis'
+ elif arch_chip.startswith('s32k'):
+ return 's32k'
+ elif arch_chip == 'rp2040':
+ return 'rp2040'
+ elif arch_chip == 'stm32':
+ # Disambiguate STM32 sub-families using specific chip define
+ if specific_chip:
+ if specific_chip.startswith('STM32F1'):
+ return 'stm32f1'
+ elif specific_chip.startswith('STM32F4'):
+ return 'stm32f4'
+ else:
+ return 'stm32f4' # Default STM32 to F4
+ return 'stm32f4'
+ else:
+ return 'native'
+
+target_chip_families = {} # target_name -> chip_family mapping
github_action_config = { 'include': build_configs }
extra_args = {}
if args.pretty:
@@ -66,11 +140,21 @@ def chunks(arr, size):
for i in range(0, len(arr), size):
yield arr[i:i + size]
+MERGE_BACK_THRESHOLD = 5
+
+def chunks_merged(arr, size):
+ """Split array into chunks, merging the last chunk back if it's too small."""
+ result = list(chunks(arr, size))
+ if len(result) > 1 and len(result[-1]) < MERGE_BACK_THRESHOLD:
+ result[-2] = result[-2] + result[-1]
+ result.pop()
+ return result
+
def comma_targets(targets):
# turns array of targets into a comma split string
return ",".join(targets)
-def process_target(px4board_file, target_name):
+def process_target(px4board_file, target_name, manufacturer_name=None, board_dir_name=None, label=None):
# reads through the board file and grabs
# useful information for building
ret = None
@@ -107,6 +191,16 @@ def process_target(px4board_file, target_name):
if board_name in board_container_overrides:
container = board_container_overrides[board_name]
+ # Detect chip family for cache grouping
+ chip_family = 'native'
+ if manufacturer_name and board_dir_name:
+ if platform == 'nuttx':
+ chip_family = detect_chip_family(manufacturer_name, board_dir_name, label or '')
+ elif board_name in board_container_overrides or platform in platform_container_overrides:
+ chip_family = 'native' # voxl2/qurt targets
+ else:
+ chip_family = 'native'
+
# Boards with container overrides get their own group
if board_name in board_container_overrides or platform in platform_container_overrides:
group = 'voxl2'
@@ -124,7 +218,7 @@ def process_target(px4board_file, target_name):
else:
if verbose: print(f'unmatched platform: {platform}')
- ret = {'target': target_name, 'container': container}
+ ret = {'target': target_name, 'container': container, 'chip_family': chip_family}
if(args.group):
ret['arch'] = group
@@ -147,19 +241,21 @@ grouped_targets['base']['container'] = default_container
grouped_targets['base']['manufacturers'] = {}
grouped_targets['base']['manufacturers']['px4'] = []
grouped_targets['base']['manufacturers']['px4'] += metadata_targets
+for mt in metadata_targets:
+ target_chip_families[mt] = 'native'
-for manufacturer in os.scandir(os.path.join(source_dir, '../boards')):
+for manufacturer in sorted(os.scandir(os.path.join(source_dir, '../boards')), key=lambda e: e.name):
if not manufacturer.is_dir():
continue
if manufacturer.name in excluded_manufacturers:
if verbose: print(f'excluding manufacturer {manufacturer.name}')
continue
- for board in os.scandir(manufacturer.path):
+ for board in sorted(os.scandir(manufacturer.path), key=lambda e: e.name):
if not board.is_dir():
continue
- for files in os.scandir(board.path):
+ for files in sorted(os.scandir(board.path), key=lambda e: e.name):
if files.is_file() and files.name.endswith('.px4board'):
board_name = manufacturer.name + '_' + board.name
@@ -177,7 +273,10 @@ for manufacturer in os.scandir(os.path.join(source_dir, '../boards')):
if label in excluded_labels:
if verbose: print(f'excluding label {label} ({target_name})')
continue
- target = process_target(files.path, target_name)
+ target = process_target(files.path, target_name,
+ manufacturer_name=manufacturer.name,
+ board_dir_name=board.name,
+ label=label)
if (args.group and target is not None):
if (target['arch'] not in grouped_targets):
grouped_targets[target['arch']] = {}
@@ -186,9 +285,72 @@ for manufacturer in os.scandir(os.path.join(source_dir, '../boards')):
if(manufacturer.name not in grouped_targets[target['arch']]['manufacturers']):
grouped_targets[target['arch']]['manufacturers'][manufacturer.name] = []
grouped_targets[target['arch']]['manufacturers'][manufacturer.name].append(target_name)
+ target_chip_families[target_name] = target['chip_family']
if target is not None:
build_configs.append(target)
+# Remove companion targets from CI groups (parent target builds them via Make prerequisite)
+for manufacturer in sorted(os.scandir(os.path.join(source_dir, '../boards')), key=lambda e: e.name):
+ if not manufacturer.is_dir():
+ continue
+ for board in sorted(os.scandir(manufacturer.path), key=lambda e: e.name):
+ if not board.is_dir():
+ continue
+ companion_file = os.path.join(board.path, 'companion_targets')
+ if os.path.exists(companion_file):
+ with open(companion_file) as f:
+ companions = {l.strip() for l in f if l.strip() and not l.startswith('#')}
+ for arch in grouped_targets:
+ for man in grouped_targets[arch]['manufacturers']:
+ grouped_targets[arch]['manufacturers'][man] = [
+ t for t in grouped_targets[arch]['manufacturers'][man]
+ if t not in companions
+ ]
+
+# Append _deb targets for boards that have cmake/package.cmake
+for manufacturer in sorted(os.scandir(os.path.join(source_dir, '../boards')), key=lambda e: e.name):
+ if not manufacturer.is_dir():
+ continue
+ if manufacturer.name in excluded_manufacturers:
+ continue
+ for board in sorted(os.scandir(manufacturer.path), key=lambda e: e.name):
+ if not board.is_dir():
+ continue
+ board_name = manufacturer.name + '_' + board.name
+ if board_name in excluded_boards:
+ continue
+ package_cmake = os.path.join(board.path, 'cmake', 'package.cmake')
+ if os.path.exists(package_cmake):
+ deb_target = board_name + '_deb'
+ if target_filter and not any(deb_target.startswith(f) for f in target_filter):
+ continue
+ # Determine the container and group for this board
+ container = default_container
+ if board_name in board_container_overrides:
+ container = board_container_overrides[board_name]
+ target_entry = {'target': deb_target, 'container': container}
+ if args.group:
+ # Find the group where this board's _default target already lives
+ default_target = board_name + '_default'
+ group = None
+ for g in grouped_targets:
+ targets_in_group = grouped_targets[g].get('manufacturers', {}).get(manufacturer.name, [])
+ if default_target in targets_in_group:
+ group = g
+ break
+ if group is None:
+ group = 'base'
+ target_entry['arch'] = group
+ if group not in grouped_targets:
+ grouped_targets[group] = {'container': container, 'manufacturers': {}}
+ if manufacturer.name not in grouped_targets[group]['manufacturers']:
+ grouped_targets[group]['manufacturers'][manufacturer.name] = []
+ grouped_targets[group]['manufacturers'][manufacturer.name].append(deb_target)
+ # Inherit chip_family from the default target
+ default_chip = target_chip_families.get(default_target, 'native')
+ target_chip_families[deb_target] = default_chip
+ build_configs.append(target_entry)
+
if(verbose):
import pprint
print("============================")
@@ -202,109 +364,227 @@ if(verbose):
print("===================")
if (args.group):
- # if we are using this script for grouping builds
- # we loop trough the manufacturers list and split their targets
- # if a manufacturer has more than a LIMIT of boards then we split that
- # into sub groups such as "arch-manufacturer name-index"
- # example:
- # nuttx-px4-0
- # nuttx-px4-1
- # nuttx-px4-2
- # nuttx-ark-0
- # nuttx-ark-1
- # if the manufacturer doesn't have more targets than LIMIT then we add
- # them to a generic group with the following structure "arch-index"
- # example:
- # nuttx-0
- # nuttx-1
+ # Group targets by chip family for better ccache reuse.
+ # Targets sharing the same MCU family (e.g. stm32h7) benefit from
+ # a shared ccache seed since they compile the same NuttX kernel and HAL.
+ #
+ # Grouping strategy:
+ # 1. Collect all targets per (arch, chip_family, manufacturer)
+ # 2. Within each chip_family, large manufacturers get their own groups
+ # named "{manufacturer}-{chip_family}[-N]"
+ # 3. Small manufacturers are merged into "misc-{chip_family}[-N]"
+ # 4. Special groups: "special" (lto/protected/allyes), "io" (stm32f1),
+ # "voxl2-0" (unchanged)
+ # 5. Non-NuttX groups: "base-N", "aarch64-N", "armhf-N" (unchanged)
final_groups = []
- last_man = ''
- last_arch = ''
- SPLIT_LIMIT = 10
- LOWER_LIMIT = 5
+ # Load grouping and cache config
+ grouping_config = ci_config.get('grouping', {})
+ CHIP_SPLIT_LIMITS = grouping_config.get('chip_split_limits', {})
+ DEFAULT_SPLIT_LIMIT = grouping_config.get('default_split_limit', 12)
+ LOWER_LIMIT = grouping_config.get('lower_limit', 3)
+
+ cache_config = ci_config.get('cache', {})
+ DEFAULT_CACHE_SIZE = cache_config.get('default_size', '400M')
+ CHIP_CACHE_SIZES = cache_config.get('chip_sizes', {})
+
if(verbose):
print(f'=:Architectures: [{grouped_targets.keys()}]')
+
for arch in grouped_targets:
- runner = 'x64' if arch in ('nuttx', 'voxl2') else 'arm64'
+ runner = 'x64'
+ # armhf and aarch64 Linux boards need the arm64 container image
+ # which ships the arm-linux-gnueabihf and aarch64-linux-gnu cross compilers
+ # (the x64 container image does not include them)
+ if arch in ('armhf', 'aarch64'):
+ runner = 'arm64'
if(verbose):
print(f'=:Processing: [{arch}]')
- temp_group = []
- for man in grouped_targets[arch]['manufacturers']:
- if(verbose):
- print(f'=:Processing: [{arch}][{man}]')
- man_len = len(grouped_targets[arch]['manufacturers'][man])
- if(man_len > LOWER_LIMIT and man_len < (SPLIT_LIMIT + 1)):
- # Manufacturers can have their own group
+
+ if arch == 'nuttx':
+ # Re-bucket NuttX targets by chip_family then manufacturer
+ chip_man_buckets = {} # (chip_family, manufacturer) -> [target_names]
+ for man in grouped_targets[arch]['manufacturers']:
+ for target in grouped_targets[arch]['manufacturers'][man]:
+ chip = target_chip_families.get(target, 'native')
+ key = (chip, man)
+ if key not in chip_man_buckets:
+ chip_man_buckets[key] = []
+ chip_man_buckets[key].append(target)
+
+ # Collect all chip families present
+ chip_families_seen = sorted(set(k[0] for k in chip_man_buckets.keys()))
+
+ for chip in chip_families_seen:
+ SPLIT_LIMIT = CHIP_SPLIT_LIMITS.get(chip, DEFAULT_SPLIT_LIMIT)
+ # Special naming for certain chip families
+ if chip == 'special':
+ chip_label = 'special'
+ elif chip == 'stm32f1':
+ chip_label = 'io'
+ elif chip == 'rp2040':
+ chip_label = 'special' # rp2040 goes into special group
+ else:
+ chip_label = chip
+
+ # Gather all (manufacturer -> targets) for this chip family
+ # NXP chip families (imxrt, kinetis, s32k) pool all manufacturers
+ # under "nxp" since all boards use NXP silicon regardless of
+ # which directory they live in (e.g., px4/fmu-v6xrt is imxrt).
+ nxp_chips = tuple(ci_config.get('nxp_chip_families', ['imxrt', 'kinetis', 's32k']))
+ man_targets = {}
+ for (c, m), targets in chip_man_buckets.items():
+ if c == chip:
+ man_key = 'nxp' if chip in nxp_chips else m
+ if man_key not in man_targets:
+ man_targets[man_key] = []
+ man_targets[man_key].extend(targets)
+
+ # Merge rp2040 targets into a flat list for the special group
+ if chip in ('special', 'rp2040'):
+ all_targets = []
+ for m in sorted(man_targets.keys()):
+ all_targets.extend(man_targets[m])
+ # These get added to the special bucket below
+ # We'll handle after the chip loop
+ continue
+
if(verbose):
- print(f'=:Processing: [{arch}][{man}][{man_len}]==Manufacturers can have their own group')
- group_name = arch + "-" + man
- targets = comma_targets(grouped_targets[arch]['manufacturers'][man])
- final_groups.append({
- "container": grouped_targets[arch]['container'],
- "targets": targets,
- "arch": arch,
- "runner": runner,
- "group": group_name,
- "len": len(grouped_targets[arch]['manufacturers'][man])
- })
- elif(man_len >= (SPLIT_LIMIT + 1)):
- # Split big man groups into subgroups
- # example: Pixhawk
- if(verbose):
- print(f'=:Processing: [{arch}][{man}][{man_len}]==Manufacturers has multiple own groups')
- chunk_limit = SPLIT_LIMIT
+ print(f'=:Processing chip_family: [{chip}] ({chip_label})')
+
+ # Split into large-manufacturer groups and misc groups
+ # For NXP-exclusive chip families, always use the nxp name
+ # regardless of target count (there's no other manufacturer to pool with)
+ force_named = chip in nxp_chips
+ temp_group = [] # small manufacturers pooled here
+ for man in sorted(man_targets.keys()):
+ man_len = len(man_targets[man])
+ if (force_named or man_len > LOWER_LIMIT) and man_len <= SPLIT_LIMIT:
+ group_name = f"{man}-{chip_label}"
+ if(verbose):
+ print(f'=: [{man}][{man_len}] -> {group_name}')
+ final_groups.append({
+ "container": grouped_targets[arch]['container'],
+ "targets": comma_targets(man_targets[man]),
+ "arch": arch,
+ "chip_family": chip,
+ "runner": runner,
+ "group": group_name,
+ "len": man_len,
+ })
+ elif man_len > SPLIT_LIMIT:
+ chunk_counter = 0
+ for chunk in chunks_merged(man_targets[man], SPLIT_LIMIT):
+ group_name = f"{man}-{chip_label}-{chunk_counter}"
+ if(verbose):
+ print(f'=: [{man}][{man_len}] -> {group_name} ({len(chunk)})')
+ final_groups.append({
+ "container": grouped_targets[arch]['container'],
+ "targets": comma_targets(chunk),
+ "arch": arch,
+ "chip_family": chip,
+ "runner": runner,
+ "group": group_name,
+ "len": len(chunk),
+ })
+ chunk_counter += 1
+ else:
+ if(verbose):
+ print(f'=: [{man}][{man_len}] -> misc pool')
+ temp_group.extend(man_targets[man])
+
+ # Emit misc groups for small manufacturers
+ if temp_group:
+ misc_chunks = chunks_merged(temp_group, SPLIT_LIMIT)
+ num_misc_chunks = len(misc_chunks)
+ chunk_counter = 0
+ for chunk in misc_chunks:
+ if num_misc_chunks == 1:
+ group_name = f"misc-{chip_label}"
+ else:
+ group_name = f"misc-{chip_label}-{chunk_counter}"
+ if(verbose):
+ print(f'=: [misc][{len(chunk)}] -> {group_name}')
+ final_groups.append({
+ "container": grouped_targets[arch]['container'],
+ "targets": comma_targets(chunk),
+ "arch": arch,
+ "chip_family": chip,
+ "runner": runner,
+ "group": group_name,
+ "len": len(chunk),
+ })
+ chunk_counter += 1
+
+ # Now handle special + rp2040 targets
+ SPLIT_LIMIT = CHIP_SPLIT_LIMITS.get('special', DEFAULT_SPLIT_LIMIT)
+ special_targets = []
+ for (c, m), targets in chip_man_buckets.items():
+ if c in ('special', 'rp2040'):
+ special_targets.extend(targets)
+ if special_targets:
chunk_counter = 0
- for chunk in chunks(grouped_targets[arch]['manufacturers'][man], chunk_limit):
- group_name = arch + "-" + man + "-" + str(chunk_counter)
- targets = comma_targets(chunk)
+ for chunk in chunks_merged(special_targets, SPLIT_LIMIT):
+ if len(special_targets) <= SPLIT_LIMIT:
+ group_name = 'special'
+ else:
+ group_name = f'special-{chunk_counter}'
+ if(verbose):
+ print(f'=: [special][{len(chunk)}] -> {group_name}')
final_groups.append({
"container": grouped_targets[arch]['container'],
- "targets": targets,
+ "targets": comma_targets(chunk),
"arch": arch,
+ "chip_family": "special",
"runner": runner,
"group": group_name,
"len": len(chunk),
})
chunk_counter += 1
- else:
- if(verbose):
- print(f'=:Processing: [{arch}][{man}][{man_len}]==Manufacturers too small group with others')
- temp_group.extend(grouped_targets[arch]['manufacturers'][man])
- temp_len = len(temp_group)
- chunk_counter = 0
- if(temp_len > 0 and temp_len < (SPLIT_LIMIT + 1)):
- if(verbose):
- print(f'=:Processing: [{arch}][orphan][{temp_len}]==Leftover arch can have their own group')
- group_name = arch + "-" + str(chunk_counter)
- targets = comma_targets(temp_group)
- final_groups.append({
- "container": grouped_targets[arch]['container'],
- "targets": targets,
- "arch": arch,
- "runner": runner,
- "group": group_name,
- "len": temp_len
- })
- elif(temp_len >= (SPLIT_LIMIT + 1)):
- # Split big man groups into subgroups
- # example: Pixhawk
- if(verbose):
- print(f'=:Processing: [{arch}][orphan][{temp_len}]==Leftover arch can has multpile group')
- chunk_limit = SPLIT_LIMIT
- chunk_counter = 0
- for chunk in chunks(temp_group, chunk_limit):
- group_name = arch + "-" + str(chunk_counter)
- targets = comma_targets(chunk)
+ elif arch == 'voxl2':
+ # VOXL2 stays as its own group
+ all_targets = []
+ for man in grouped_targets[arch]['manufacturers']:
+ all_targets.extend(grouped_targets[arch]['manufacturers'][man])
+ if all_targets:
final_groups.append({
"container": grouped_targets[arch]['container'],
- "targets": targets,
+ "targets": comma_targets(all_targets),
"arch": arch,
+ "chip_family": "native",
"runner": runner,
- "group": group_name,
- "len": len(chunk),
+ "group": "voxl2-0",
+ "len": len(all_targets),
})
- chunk_counter += 1
+
+ else:
+ # Non-NuttX groups (base, aarch64, armhf) - keep simple grouping
+ SPLIT_LIMIT = CHIP_SPLIT_LIMITS.get('native', DEFAULT_SPLIT_LIMIT)
+ all_targets = []
+ for man in grouped_targets[arch]['manufacturers']:
+ all_targets.extend(grouped_targets[arch]['manufacturers'][man])
+ if all_targets:
+ chunk_counter = 0
+ for chunk in chunks_merged(all_targets, SPLIT_LIMIT):
+ if len(all_targets) <= SPLIT_LIMIT:
+ group_name = f"{arch}-0"
+ else:
+ group_name = f"{arch}-{chunk_counter}"
+ final_groups.append({
+ "container": grouped_targets[arch]['container'],
+ "targets": comma_targets(chunk),
+ "arch": arch,
+ "chip_family": "native",
+ "runner": runner,
+ "group": group_name,
+ "len": len(chunk),
+ })
+ chunk_counter += 1
+
+ # Add cache_size to each group based on chip family
+ for g in final_groups:
+ g['cache_size'] = CHIP_CACHE_SIZES.get(g['chip_family'], DEFAULT_CACHE_SIZE)
+
if(verbose):
import pprint
print("================")
@@ -316,6 +596,58 @@ if (args.group):
print("= JSON output =")
print("===============")
- print(json.dumps({ "include": final_groups }, **extra_args))
+ if args.seeders:
+ # Generate one seeder entry per chip family present in the groups.
+ # Each seeder builds a representative target to warm the ccache for
+ # all groups sharing that chip family.
+ seeder_targets = ci_config.get('seeders', {})
+ seeder_containers = {
+ 'native': default_container,
+ }
+ # Determine which chip families actually have groups
+ active_families = set()
+ for g in final_groups:
+ cf = g['chip_family']
+ active_families.add(cf)
+ # voxl2 gets its own seeder with a different container
+ if g['group'].startswith('voxl2'):
+ active_families.add('voxl2')
+
+ seeders = []
+ for cf in sorted(active_families):
+ if cf == 'special':
+ continue # special group seeds from stm32h7
+ if cf == 'voxl2':
+ seeders.append({
+ 'chip_family': 'voxl2',
+ 'target': 'modalai_voxl2_default',
+ 'container': voxl2_container,
+ 'runner': 'x64',
+ })
+ elif cf == 'native':
+ # One seeder per runner arch that has native groups (exclude voxl2
+ # which has its own seeder with a different container)
+ native_runners = set()
+ for g in final_groups:
+ if g['chip_family'] == 'native' and not g['group'].startswith('voxl2'):
+ native_runners.add(g['runner'])
+ for r in sorted(native_runners):
+ seeders.append({
+ 'chip_family': 'native',
+ 'target': seeder_targets['native'],
+ 'container': default_container,
+ 'runner': r,
+ })
+ else:
+ seeders.append({
+ 'chip_family': cf,
+ 'target': seeder_targets.get(cf, seeder_targets['stm32h7']),
+ 'container': seeder_containers.get(cf, default_container),
+ 'runner': 'x64',
+ })
+
+ print(json.dumps({ "include": seeders }, **extra_args))
+ else:
+ print(json.dumps({ "include": final_groups }, **extra_args))
else:
print(json.dumps(github_action_config, **extra_args))
diff --git a/Tools/ci/generate_sbom.py b/Tools/ci/generate_sbom.py
new file mode 100755
index 0000000000..cbca30db39
--- /dev/null
+++ b/Tools/ci/generate_sbom.py
@@ -0,0 +1,607 @@
+#!/usr/bin/env python3
+"""Generate SPDX 2.3 JSON SBOM for a PX4 firmware build.
+
+Produces one SBOM per board target containing:
+- PX4 firmware as the primary package
+- Git submodules as CONTAINS dependencies
+- Python build requirements as BUILD_DEPENDENCY_OF packages
+- Board-specific modules as CONTAINS packages
+
+Requires PyYAML (pyyaml) for loading license overrides.
+"""
+import argparse
+import configparser
+import json
+import re
+import subprocess
+import uuid
+from datetime import datetime, timezone
+from pathlib import Path
+
+import yaml
+
+# Ordered most-specific first: all keywords must appear for a match.
+LICENSE_PATTERNS = [
+ # Copyleft licenses first (more specific keywords prevent false matches)
+ ("GPL-3.0-only", ["GNU GENERAL PUBLIC LICENSE", "Version 3"]),
+ ("GPL-2.0-only", ["GNU GENERAL PUBLIC LICENSE", "Version 2"]),
+ ("LGPL-3.0-only", ["GNU LESSER GENERAL PUBLIC LICENSE", "Version 3"]),
+ ("LGPL-2.1-only", ["GNU Lesser General Public License", "Version 2.1"]),
+ ("AGPL-3.0-only", ["GNU AFFERO GENERAL PUBLIC LICENSE", "Version 3"]),
+ # Permissive licenses
+ ("Apache-2.0", ["Apache License", "Version 2.0"]),
+ ("MIT", ["Permission is hereby granted"]),
+ ("BSD-3-Clause", ["Redistribution and use", "Neither the name"]),
+ ("BSD-2-Clause", ["Redistribution and use", "THIS SOFTWARE IS PROVIDED"]),
+ ("ISC", ["Permission to use, copy, modify, and/or distribute"]),
+ ("EPL-2.0", ["Eclipse Public License", "2.0"]),
+ ("Unlicense", ["The Unlicense", "unlicense.org"]),
+]
+
+COPYLEFT_LICENSES = {
+ "GPL-2.0-only", "GPL-3.0-only",
+ "LGPL-2.1-only", "LGPL-3.0-only",
+ "AGPL-3.0-only",
+}
+
+def load_license_overrides(source_dir):
+ """Load license overrides and comments from YAML config file.
+
+ Returns (overrides, comments) dicts mapping submodule path to values.
+ Falls back to empty dicts if the file is missing.
+ """
+ yaml_path = source_dir / "Tools" / "ci" / "license-overrides.yaml"
+ if not yaml_path.exists():
+ return {}, {}
+
+ with open(yaml_path) as f:
+ data = yaml.safe_load(f)
+
+ overrides = {}
+ comments = {}
+ for path, entry in (data.get("overrides") or {}).items():
+ overrides[path] = entry["license"]
+ if "comment" in entry:
+ comments[path] = entry["comment"]
+
+ return overrides, comments
+
+LICENSE_FILENAMES = ["LICENSE", "LICENSE.md", "LICENSE.txt", "LICENCE", "LICENCE.md", "COPYING", "COPYING.md"]
+
+
+def detect_license(submodule_dir):
+ """Auto-detect SPDX license ID from LICENSE/COPYING file in a directory.
+
+ Reads the first 100 lines of the first license file found and matches
+ keywords against LICENSE_PATTERNS. Returns 'NOASSERTION' if no file
+ is found or no pattern matches.
+ """
+ for fname in LICENSE_FILENAMES:
+ license_file = submodule_dir / fname
+ if license_file.is_file():
+ try:
+ lines = license_file.read_text(errors="replace").splitlines()[:100]
+ text = "\n".join(lines)
+ except OSError:
+ continue
+
+ text_upper = text.upper()
+ for spdx_id_val, keywords in LICENSE_PATTERNS:
+ if all(kw.upper() in text_upper for kw in keywords):
+ return spdx_id_val
+
+ return "NOASSERTION"
+
+ return "NOASSERTION"
+
+
+def get_submodule_license(source_dir, sub_path, license_overrides):
+ """Return the SPDX license for a submodule: override > auto-detect."""
+ if sub_path in license_overrides:
+ return license_overrides[sub_path]
+ return detect_license(source_dir / sub_path)
+
+
+def spdx_id(name: str) -> str:
+ """Convert a name to a valid SPDX identifier (letters, digits, dots, hyphens)."""
+ return re.sub(r"[^a-zA-Z0-9.\-]", "-", name)
+
+
+def parse_gitmodules(source_dir):
+ """Parse .gitmodules and return list of {name, path, url}."""
+ gitmodules_path = source_dir / ".gitmodules"
+ if not gitmodules_path.exists():
+ return []
+
+ config = configparser.ConfigParser()
+ config.read(str(gitmodules_path))
+
+ submodules = []
+ for section in config.sections():
+ if section.startswith("submodule "):
+ name = section.split('"')[1] if '"' in section else section.split(" ", 1)[1]
+ path = config.get(section, "path", fallback="")
+ url = config.get(section, "url", fallback="")
+ submodules.append({"name": name, "path": path, "url": url})
+
+ return submodules
+
+
+def get_submodule_commits(source_dir):
+ """Get commit hashes for all submodules via git ls-tree -r (works without init)."""
+ try:
+ result = subprocess.run(
+ ["git", "ls-tree", "-r", "HEAD"],
+ cwd=str(source_dir),
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ text=True,
+ check=True,
+ )
+ except (subprocess.CalledProcessError, FileNotFoundError):
+ return {}
+
+ commits = {}
+ for line in result.stdout.splitlines():
+ parts = line.split()
+ if len(parts) >= 4 and parts[1] == "commit":
+ commits[parts[3]] = parts[2]
+
+ return commits
+
+
+def get_git_info(source_dir: Path) -> dict:
+ """Get PX4 git version and hash."""
+ info = {"version": "unknown", "hash": "unknown"}
+ try:
+ result = subprocess.run(
+ ["git", "describe", "--always", "--tags", "--dirty"],
+ cwd=str(source_dir),
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ text=True,
+ check=True,
+ )
+ info["version"] = result.stdout.strip()
+ except (subprocess.CalledProcessError, FileNotFoundError):
+ pass
+ try:
+ result = subprocess.run(
+ ["git", "rev-parse", "HEAD"],
+ cwd=str(source_dir),
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ text=True,
+ check=True,
+ )
+ info["hash"] = result.stdout.strip()
+ except (subprocess.CalledProcessError, FileNotFoundError):
+ pass
+ return info
+
+
+def parse_requirements(requirements_path):
+ """Parse pip requirements.txt into list of {name, version_spec}."""
+ if not requirements_path.exists():
+ return []
+
+ deps = []
+ for line in requirements_path.read_text().splitlines():
+ line = line.strip()
+ if not line or line.startswith("#") or line.startswith("-"):
+ continue
+ # Split on version specifiers
+ match = re.match(r"^([a-zA-Z0-9_\-]+)(.*)?$", line)
+ if match:
+ deps.append({
+ "name": match.group(1),
+ "version_spec": match.group(2).strip() if match.group(2) else "",
+ })
+ return deps
+
+
+def read_module_list(modules_file, source_dir):
+ """Read board-specific module list from file.
+
+ Paths may be absolute; they are converted to relative paths under src/.
+ Duplicates are removed while preserving order.
+ """
+ if not modules_file or not modules_file.exists():
+ return []
+
+ seen = set()
+ modules = []
+ source_str = str(source_dir.resolve()) + "/"
+
+ for line in modules_file.read_text().splitlines():
+ path = line.strip()
+ if not path or path.startswith("#"):
+ continue
+ # Convert absolute path to relative
+ if path.startswith(source_str):
+ path = path[len(source_str):]
+ if path not in seen:
+ seen.add(path)
+ modules.append(path)
+
+ return modules
+
+
+def make_purl(pkg_type: str, namespace: str, name: str, version: str = "") -> str:
+ """Construct a Package URL (purl)."""
+ purl = f"pkg:{pkg_type}/{namespace}/{name}"
+ if version:
+ purl += f"@{version}"
+ return purl
+
+
+def extract_git_host_org_repo(url):
+ """Extract host type, org, and repo from a git URL.
+
+ Returns (host, org, repo) where host is 'github', 'gitlab', or ''.
+ """
+ match = re.search(r"github\.com[:/]([^/]+)/([^/]+?)(?:\.git)?$", url)
+ if match:
+ return "github", match.group(1), match.group(2)
+ match = re.search(r"gitlab\.com[:/](.+?)/([^/]+?)(?:\.git)?$", url)
+ if match:
+ return "gitlab", match.group(1), match.group(2)
+ return "", "", ""
+
+
+def generate_sbom(source_dir, board, modules_file, compiler, platform=""):
+ """Generate a complete SPDX 2.3 JSON document."""
+ license_overrides, license_comments = load_license_overrides(source_dir)
+ git_info = get_git_info(source_dir)
+ timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
+
+ # Deterministic namespace using UUID5 from git hash + board
+ ns_seed = f"{git_info['hash']}:{board}"
+ doc_namespace = f"https://spdx.org/spdxdocs/{board}-{uuid.uuid5(uuid.NAMESPACE_URL, ns_seed)}"
+
+ doc = {
+ "spdxVersion": "SPDX-2.3",
+ "dataLicense": "CC0-1.0",
+ "SPDXID": "SPDXRef-DOCUMENT",
+ "name": f"PX4 Firmware SBOM for {board}",
+ "documentNamespace": doc_namespace,
+ "creationInfo": {
+ "created": timestamp,
+ "creators": [
+ "Tool: px4-generate-sbom",
+ "Organization: Dronecode Foundation",
+ ],
+ "licenseListVersion": "3.22",
+ },
+ "packages": [],
+ "relationships": [],
+ }
+
+ # Primary package: PX4 firmware
+ primary_spdx_id = f"SPDXRef-PX4-{spdx_id(board)}"
+ doc["packages"].append({
+ "SPDXID": primary_spdx_id,
+ "name": board,
+ "versionInfo": git_info["version"],
+ "packageFileName": f"{board}.px4",
+ "supplier": "Organization: Dronecode Foundation",
+ "downloadLocation": "https://github.com/PX4/PX4-Autopilot",
+ "filesAnalyzed": False,
+ "primaryPackagePurpose": "FIRMWARE",
+ "licenseConcluded": "BSD-3-Clause",
+ "licenseDeclared": "BSD-3-Clause",
+ "copyrightText": "Copyright (c) PX4 Development Team",
+ "externalRefs": [
+ {
+ "referenceCategory": "PACKAGE-MANAGER",
+ "referenceType": "purl",
+ "referenceLocator": make_purl(
+ "github", "PX4", "PX4-Autopilot", git_info["version"]
+ ),
+ }
+ ],
+ })
+
+ doc["relationships"].append({
+ "spdxElementId": "SPDXRef-DOCUMENT",
+ "relationshipType": "DESCRIBES",
+ "relatedSpdxElement": primary_spdx_id,
+ })
+
+ # Git submodules (filtered to those relevant to this board's modules)
+ submodules = parse_gitmodules(source_dir)
+ submodule_commits = get_submodule_commits(source_dir)
+ modules = read_module_list(modules_file, source_dir)
+
+ def submodule_is_relevant(sub_path):
+ """A submodule is relevant if any board module path overlaps with it."""
+ # NuttX platform submodules are only relevant for NuttX builds
+ if sub_path.startswith("platforms/nuttx/"):
+ return platform in ("nuttx", "")
+ if not modules:
+ return True # no module list means include all
+ # Other platform submodules are always relevant
+ if sub_path.startswith("platforms/"):
+ return True
+ for mod in modules:
+ # Module is under this submodule, or submodule is under a module
+ if mod.startswith(sub_path + "/") or sub_path.startswith(mod + "/"):
+ return True
+ return False
+
+ for sub in submodules:
+ if not submodule_is_relevant(sub["path"]):
+ continue
+ sub_path = sub["path"]
+ sub_path_id = sub_path.replace("/", "-")
+ sub_spdx_id = f"SPDXRef-Submodule-{spdx_id(sub_path_id)}"
+ commit = submodule_commits.get(sub_path, "unknown")
+ license_id = get_submodule_license(source_dir, sub_path, license_overrides)
+
+ host, org, repo = extract_git_host_org_repo(sub["url"])
+ download = sub["url"] if sub["url"] else "NOASSERTION"
+
+ # Use repo name from URL for human-readable name, fall back to last path component
+ display_name = repo if repo else sub_path.rsplit("/", 1)[-1]
+
+ pkg = {
+ "SPDXID": sub_spdx_id,
+ "name": display_name,
+ "versionInfo": commit,
+ "supplier": f"Organization: {org}" if org else "NOASSERTION",
+ "downloadLocation": download,
+ "filesAnalyzed": False,
+ "licenseConcluded": license_id,
+ "licenseDeclared": license_id,
+ "copyrightText": "NOASSERTION",
+ }
+
+ comment = license_comments.get(sub_path)
+ if comment:
+ pkg["licenseComments"] = comment
+
+ if host and org and repo:
+ pkg["externalRefs"] = [
+ {
+ "referenceCategory": "PACKAGE-MANAGER",
+ "referenceType": "purl",
+ "referenceLocator": make_purl(host, org, repo, commit),
+ }
+ ]
+
+ doc["packages"].append(pkg)
+ doc["relationships"].append({
+ "spdxElementId": primary_spdx_id,
+ "relationshipType": "CONTAINS",
+ "relatedSpdxElement": sub_spdx_id,
+ })
+
+ # Python build dependencies
+ requirements_path = source_dir / "Tools" / "setup" / "requirements.txt"
+ py_deps = parse_requirements(requirements_path)
+
+ for dep in py_deps:
+ dep_name = dep["name"]
+ dep_spdx_id = f"SPDXRef-PyDep-{spdx_id(dep_name)}"
+ version_str = dep["version_spec"] if dep["version_spec"] else "NOASSERTION"
+
+ doc["packages"].append({
+ "SPDXID": dep_spdx_id,
+ "name": dep_name,
+ "versionInfo": version_str,
+ "supplier": "NOASSERTION",
+ "downloadLocation": f"https://pypi.org/project/{dep_name}/",
+ "filesAnalyzed": False,
+ "primaryPackagePurpose": "APPLICATION",
+ "licenseConcluded": "NOASSERTION",
+ "licenseDeclared": "NOASSERTION",
+ "copyrightText": "NOASSERTION",
+ "externalRefs": [
+ {
+ "referenceCategory": "PACKAGE-MANAGER",
+ "referenceType": "purl",
+ "referenceLocator": f"pkg:pypi/{dep_name}",
+ }
+ ],
+ })
+ doc["relationships"].append({
+ "spdxElementId": dep_spdx_id,
+ "relationshipType": "BUILD_DEPENDENCY_OF",
+ "relatedSpdxElement": primary_spdx_id,
+ })
+
+ # Board-specific modules (already read above for submodule filtering)
+ for mod in modules:
+ mod_path_id = mod.replace("/", "-")
+ mod_spdx_id = f"SPDXRef-Module-{spdx_id(mod_path_id)}"
+
+ # Derive short name: strip leading src/ for readability
+ display_name = mod
+ if display_name.startswith("src/"):
+ display_name = display_name[4:]
+
+ doc["packages"].append({
+ "SPDXID": mod_spdx_id,
+ "name": display_name,
+ "versionInfo": git_info["version"],
+ "supplier": "Organization: Dronecode Foundation",
+ "downloadLocation": "https://github.com/PX4/PX4-Autopilot",
+ "filesAnalyzed": False,
+ "licenseConcluded": "BSD-3-Clause",
+ "licenseDeclared": "BSD-3-Clause",
+ "copyrightText": "NOASSERTION",
+ })
+ doc["relationships"].append({
+ "spdxElementId": primary_spdx_id,
+ "relationshipType": "CONTAINS",
+ "relatedSpdxElement": mod_spdx_id,
+ })
+
+ # Compiler as a build tool
+ if compiler:
+ compiler_spdx_id = f"SPDXRef-Compiler-{spdx_id(compiler)}"
+ doc["packages"].append({
+ "SPDXID": compiler_spdx_id,
+ "name": compiler,
+ "versionInfo": "NOASSERTION",
+ "supplier": "NOASSERTION",
+ "downloadLocation": "NOASSERTION",
+ "filesAnalyzed": False,
+ "primaryPackagePurpose": "APPLICATION",
+ "licenseConcluded": "NOASSERTION",
+ "licenseDeclared": "NOASSERTION",
+ "copyrightText": "NOASSERTION",
+ })
+ doc["relationships"].append({
+ "spdxElementId": compiler_spdx_id,
+ "relationshipType": "BUILD_TOOL_OF",
+ "relatedSpdxElement": primary_spdx_id,
+ })
+
+ return doc
+
+
+def verify_licenses(source_dir):
+ """Verify license detection for all submodules. Returns exit code."""
+ license_overrides, _ = load_license_overrides(source_dir)
+ submodules = parse_gitmodules(source_dir)
+ if not submodules:
+ print("No submodules found in .gitmodules")
+ return 1
+
+ has_noassertion = False
+ print(f"{'Submodule Path':<65} {'Detected':<16} {'Override':<16} {'Final'}")
+ print("-" * 115)
+
+ for sub in submodules:
+ sub_path = sub["path"]
+ sub_dir = source_dir / sub_path
+
+ checked_out = sub_dir.is_dir() and any(sub_dir.iterdir())
+ has_explicit_override = sub_path in license_overrides
+ if not checked_out:
+ detected = "(not checked out)"
+ override = license_overrides.get(sub_path, "")
+ final = override if override else "NOASSERTION"
+ else:
+ detected = detect_license(sub_dir)
+ override = license_overrides.get(sub_path, "")
+ final = override if override else detected
+
+ if final == "NOASSERTION" and has_explicit_override:
+ # Explicitly acknowledged in overrides file — not a failure
+ marker = " (acknowledged)"
+ elif final == "NOASSERTION" and checked_out:
+ has_noassertion = True
+ marker = " <-- UNRESOLVED"
+ elif final == "NOASSERTION" and not checked_out:
+ marker = " (skipped)"
+ else:
+ marker = ""
+
+ print(f"{sub_path:<65} {str(detected):<16} {str(override) if override else '':<16} {final}{marker}")
+
+ # Copyleft warning (informational, not a failure)
+ copyleft_found = []
+ for sub in submodules:
+ sub_path = sub["path"]
+ sub_dir = source_dir / sub_path
+ checked_out = sub_dir.is_dir() and any(sub_dir.iterdir())
+ override = license_overrides.get(sub_path, "")
+ if checked_out:
+ final_lic = override if override else detect_license(sub_dir)
+ else:
+ final_lic = override if override else "NOASSERTION"
+ for cl in COPYLEFT_LICENSES:
+ if cl in final_lic:
+ copyleft_found.append((sub_path, final_lic))
+ break
+
+ print()
+ if copyleft_found:
+ print("Copyleft licenses detected (informational):")
+ for path, lic in copyleft_found:
+ print(f" {path}: {lic}")
+ print()
+
+ if has_noassertion:
+ print("FAIL: Some submodules have unresolved licenses. "
+ "Add an entry to Tools/ci/license-overrides.yaml or check the LICENSE file.")
+ return 1
+
+ print("OK: All submodules have a resolved license.")
+ return 0
+
+
+def main():
+ parser = argparse.ArgumentParser(
+ description="Generate SPDX 2.3 JSON SBOM for PX4 firmware"
+ )
+ parser.add_argument(
+ "--source-dir",
+ type=Path,
+ default=Path.cwd(),
+ help="PX4 source directory (default: cwd)",
+ )
+ parser.add_argument(
+ "--verify-licenses",
+ action="store_true",
+ help="Verify license detection for all submodules and exit",
+ )
+ parser.add_argument(
+ "--board",
+ default=None,
+ help="Board target name (e.g. px4_fmu-v5x_default)",
+ )
+ parser.add_argument(
+ "--modules-file",
+ type=Path,
+ default=None,
+ help="Path to config_module_list.txt",
+ )
+ parser.add_argument(
+ "--compiler",
+ default="",
+ help="Compiler identifier (e.g. arm-none-eabi-gcc)",
+ )
+ parser.add_argument(
+ "--platform",
+ default="",
+ help="PX4 platform (nuttx, posix, qurt). Filters platform-specific submodules.",
+ )
+ parser.add_argument(
+ "--output",
+ type=Path,
+ default=None,
+ help="Output SBOM file path",
+ )
+
+ args = parser.parse_args()
+
+ if args.verify_licenses:
+ raise SystemExit(verify_licenses(args.source_dir))
+
+ if not args.board:
+ parser.error("--board is required when not using --verify-licenses")
+ if not args.output:
+ parser.error("--output is required when not using --verify-licenses")
+
+ sbom = generate_sbom(
+ source_dir=args.source_dir,
+ board=args.board,
+ modules_file=args.modules_file,
+ compiler=args.compiler,
+ platform=args.platform,
+ )
+
+ args.output.parent.mkdir(parents=True, exist_ok=True)
+ with open(args.output, "w") as f:
+ json.dump(sbom, f, indent=2)
+ f.write("\n")
+
+ pkg_count = len(sbom["packages"])
+ print(f"SBOM generated: {args.output} ({pkg_count} packages)")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/Tools/ci/inspect_sbom.py b/Tools/ci/inspect_sbom.py
new file mode 100755
index 0000000000..ac31660c80
--- /dev/null
+++ b/Tools/ci/inspect_sbom.py
@@ -0,0 +1,163 @@
+#!/usr/bin/env python3
+"""Inspect a PX4 SPDX SBOM file.
+
+Usage:
+ inspect_sbom.py # full summary
+ inspect_sbom.py search # search packages by name
+ inspect_sbom.py ntia # NTIA minimum elements check
+ inspect_sbom.py licenses # license summary
+ inspect_sbom.py list # list packages (Submodule|PyDep|Module|all)
+"""
+
+import json
+import sys
+from collections import Counter
+from pathlib import Path
+
+
+def load(path):
+ return json.loads(Path(path).read_text())
+
+
+def pkg_type(pkg):
+ spdx_id = pkg["SPDXID"]
+ for prefix in ("Submodule", "PyDep", "Module", "Compiler", "PX4"):
+ if f"-{prefix}-" in spdx_id or spdx_id.startswith(f"SPDXRef-{prefix}"):
+ return prefix
+ return "Other"
+
+
+def summary(doc):
+ print(f"spdxVersion: {doc['spdxVersion']}")
+ print(f"name: {doc['name']}")
+ print(f"namespace: {doc['documentNamespace']}")
+ print(f"created: {doc['creationInfo']['created']}")
+ print(f"creators: {', '.join(doc['creationInfo']['creators'])}")
+ print()
+
+ types = Counter(pkg_type(p) for p in doc["packages"])
+ print(f"Packages: {len(doc['packages'])}")
+ for t, c in types.most_common():
+ print(f" {t}: {c}")
+ print()
+
+ rc = Counter(r["relationshipType"] for r in doc["relationships"])
+ print(f"Relationships: {len(doc['relationships'])}")
+ for t, n in rc.most_common():
+ print(f" {t}: {n}")
+ print()
+
+ primary = doc["packages"][0]
+ print(f"Primary package:")
+ print(f" name: {primary['name']}")
+ print(f" version: {primary['versionInfo']}")
+ print(f" purpose: {primary.get('primaryPackagePurpose', 'N/A')}")
+ print(f" license: {primary['licenseDeclared']}")
+ print()
+
+ noassert = [
+ p["name"]
+ for p in doc["packages"]
+ if pkg_type(p) == "Submodule" and p["licenseDeclared"] == "NOASSERTION"
+ ]
+ if noassert:
+ print(f"WARNING: {len(noassert)} submodules with NOASSERTION license:")
+ for n in noassert:
+ print(f" - {n}")
+ else:
+ print("All submodule licenses mapped")
+
+ print(f"\nFile size: {Path(sys.argv[1]).stat().st_size // 1024}KB")
+
+
+def search(doc, term):
+ term = term.lower()
+ found = [p for p in doc["packages"] if term in p["name"].lower()]
+ if not found:
+ print(f"No packages matching '{term}'")
+ return
+ print(f"Found {len(found)} packages matching '{term}':\n")
+ for p in found:
+ print(json.dumps(p, indent=2))
+ print()
+
+
+def ntia_check(doc):
+ required = ["SPDXID", "name", "versionInfo", "supplier", "downloadLocation"]
+ missing = []
+ for p in doc["packages"]:
+ for f in required:
+ if f not in p or p[f] in ("", None):
+ missing.append((p["name"], f))
+
+ if missing:
+ print(f"FAIL: {len(missing)} missing fields:")
+ for name, field in missing:
+ print(f" {name}: missing {field}")
+ else:
+ print(f"PASS: All {len(doc['packages'])} packages have required fields")
+
+ print(f"\nCreators: {doc['creationInfo']['creators']}")
+ print(f"Timestamp: {doc['creationInfo']['created']}")
+
+ rels = [r for r in doc["relationships"] if r["relationshipType"] == "DESCRIBES"]
+ print(f"DESCRIBES relationships: {len(rels)}")
+
+ return len(missing) == 0
+
+
+def licenses(doc):
+ by_license = {}
+ for p in doc["packages"]:
+ lic = p.get("licenseDeclared", "NOASSERTION")
+ by_license.setdefault(lic, []).append(p["name"])
+
+ for lic in sorted(by_license.keys()):
+ names = by_license[lic]
+ print(f"\n{lic} ({len(names)}):")
+ for n in sorted(names):
+ print(f" {n}")
+
+
+def list_packages(doc, filter_type):
+ filter_type = filter_type.lower()
+ for p in sorted(doc["packages"], key=lambda x: x["name"]):
+ t = pkg_type(p)
+ if filter_type != "all" and t.lower() != filter_type:
+ continue
+ lic = p.get("licenseDeclared", "?")
+ ver = p["versionInfo"][:20] if len(p["versionInfo"]) > 20 else p["versionInfo"]
+ print(f" {t:10s} {p['name']:50s} {ver:20s} {lic}")
+
+
+def main():
+ if len(sys.argv) < 2:
+ print(__doc__)
+ sys.exit(1)
+
+ doc = load(sys.argv[1])
+ cmd = sys.argv[2] if len(sys.argv) > 2 else "summary"
+
+ if cmd == "summary":
+ summary(doc)
+ elif cmd == "search":
+ if len(sys.argv) < 4:
+ print("Usage: inspect_sbom.py search ")
+ sys.exit(1)
+ search(doc, sys.argv[3])
+ elif cmd == "ntia":
+ if not ntia_check(doc):
+ sys.exit(1)
+ elif cmd == "licenses":
+ licenses(doc)
+ elif cmd == "list":
+ filter_type = sys.argv[3] if len(sys.argv) > 3 else "all"
+ list_packages(doc, filter_type)
+ else:
+ print(f"Unknown command: {cmd}")
+ print(__doc__)
+ sys.exit(1)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/Tools/ci/license-overrides.yaml b/Tools/ci/license-overrides.yaml
new file mode 100644
index 0000000000..54457c7fad
--- /dev/null
+++ b/Tools/ci/license-overrides.yaml
@@ -0,0 +1,62 @@
+# SPDX license overrides for submodules where auto-detection fails or is wrong.
+# Each entry maps a submodule path to its SPDX license identifier and an
+# optional comment explaining why the override exists.
+#
+# Run `python3 Tools/ci/generate_sbom.py --verify-licenses` to validate.
+
+overrides:
+ src/modules/mavlink/mavlink:
+ license: "LGPL-3.0-only AND MIT"
+ comment: "Generator is LGPL-3.0; PX4 ships only MIT-licensed generated headers."
+
+ Tools/simulation/gazebo-classic/sitl_gazebo-classic:
+ license: "BSD-3-Clause"
+ comment: >-
+ PX4 org project. No LICENSE file in repo; source files carry
+ BSD-3-Clause headers consistent with the PX4 project license.
+
+ src/lib/cdrstream/cyclonedds:
+ license: "EPL-2.0 OR BSD-3-Clause"
+ comment: >-
+ Dual-licensed. PX4 elects BSD-3-Clause.
+ No board currently enables CONFIG_LIB_CDRSTREAM.
+
+ src/lib/cdrstream/rosidl:
+ license: "Apache-2.0"
+
+ src/lib/crypto/monocypher:
+ license: "BSD-2-Clause OR CC0-1.0"
+ comment: >-
+ Dual-licensed. LICENCE.md offers BSD-2-Clause with CC0-1.0 as
+ public domain fallback.
+
+ src/lib/crypto/libtomcrypt:
+ license: "Unlicense"
+ comment: "Public domain dedication. Functionally equivalent to Unlicense."
+
+ src/lib/crypto/libtommath:
+ license: "Unlicense"
+ comment: "Public domain dedication. Functionally equivalent to Unlicense."
+
+ platforms/nuttx/NuttX/nuttx:
+ license: "Apache-2.0"
+ comment: >-
+ Composite LICENSE (6652 lines) includes BSD/MIT/ISC sub-components.
+ Primary license is Apache-2.0. NOTICE file contains FAT LFN patent warnings.
+
+ platforms/nuttx/NuttX/apps:
+ license: "Apache-2.0"
+
+ boards/modalai/voxl2/libfc-sensor-api:
+ license: "NOASSERTION"
+ comment: >-
+ No LICENSE file in repo. README describes it as public interface
+ for proprietary sensor library.
+
+ boards/modalai/voxl2/src/lib/mpa/libmodal-json:
+ license: "LGPL-3.0-only"
+ comment: "LGPL-3.0 weak copyleft. Used via header includes in VOXL2 mpa library."
+
+ boards/modalai/voxl2/src/lib/mpa/libmodal-pipe:
+ license: "LGPL-3.0-only"
+ comment: "LGPL-3.0 weak copyleft. Used via header includes in VOXL2 mpa library."
diff --git a/Tools/ci/package_build_artifacts.sh b/Tools/ci/package_build_artifacts.sh
index aaf978b19f..1c33b4434d 100755
--- a/Tools/ci/package_build_artifacts.sh
+++ b/Tools/ci/package_build_artifacts.sh
@@ -1,37 +1,53 @@
-#!/bin/bash
+#!/usr/bin/env bash
mkdir artifacts
cp **/**/*.px4 artifacts/ 2>/dev/null || true
cp **/**/*.elf artifacts/ 2>/dev/null || true
+cp **/**/*.deb artifacts/ 2>/dev/null || true
for build_dir_path in build/*/ ; do
build_dir_path=${build_dir_path::${#build_dir_path}-1}
build_dir=${build_dir_path#*/}
- mkdir artifacts/$build_dir
+ mkdir -p artifacts/$build_dir
find artifacts/ -maxdepth 1 -type f -name "*$build_dir*"
- # Airframe
- cp $build_dir_path/airframes.xml artifacts/$build_dir/
+ # Airframe (NuttX: build root, SITL: docs/ subdirectory)
+ airframes_src=""
+ if [ -f "$build_dir_path/airframes.xml" ]; then
+ airframes_src="$build_dir_path/airframes.xml"
+ elif [ -f "$build_dir_path/docs/airframes.xml" ]; then
+ airframes_src="$build_dir_path/docs/airframes.xml"
+ fi
+ if [ -n "$airframes_src" ]; then
+ cp "$airframes_src" "artifacts/$build_dir/"
+ fi
# Parameters
- cp $build_dir_path/parameters.xml artifacts/$build_dir/
- cp $build_dir_path/parameters.json artifacts/$build_dir/
- cp $build_dir_path/parameters.json.xz artifacts/$build_dir/
+ cp $build_dir_path/parameters.xml artifacts/$build_dir/ 2>/dev/null || true
+ cp $build_dir_path/parameters.json artifacts/$build_dir/ 2>/dev/null || true
+ cp $build_dir_path/parameters.json.xz artifacts/$build_dir/ 2>/dev/null || true
# Actuators
- cp $build_dir_path/actuators.json artifacts/$build_dir/
- cp $build_dir_path/actuators.json.xz artifacts/$build_dir/
+ cp $build_dir_path/actuators.json artifacts/$build_dir/ 2>/dev/null || true
+ cp $build_dir_path/actuators.json.xz artifacts/$build_dir/ 2>/dev/null || true
# Events
- cp $build_dir_path/events/all_events.json.xz artifacts/$build_dir/
- # ROS 2 msgs
- cp $build_dir_path/events/all_events.json.xz artifacts/$build_dir/
- # Module Docs
+ mkdir -p artifacts/$build_dir/events/
+ cp $build_dir_path/events/all_events.json.xz artifacts/$build_dir/events/ 2>/dev/null || true
+ # Also copy to top level: firmware advertises the metadata URI without the events/ subdirectory
+ # (see src/lib/component_information/CMakeLists.txt comp_metadata_events_uri_board)
+ cp $build_dir_path/events/all_events.json.xz artifacts/$build_dir/ 2>/dev/null || true
+ # SBOM
+ cp $build_dir_path/*.sbom.spdx.json artifacts/$build_dir/ 2>/dev/null || true
ls -la artifacts/$build_dir
echo "----------"
done
if [ -d artifacts/px4_sitl_default ]; then
- # general metadata
- mkdir artifacts/_general/
- cp artifacts/px4_sitl_default/airframes.xml artifacts/_general/
+ # general metadata (used by Flight Review and other downstream consumers)
+ mkdir -p artifacts/_general/
# Airframe
- cp artifacts/px4_sitl_default/airframes.xml artifacts/_general/
+ if [ -f artifacts/px4_sitl_default/airframes.xml ]; then
+ cp artifacts/px4_sitl_default/airframes.xml artifacts/_general/
+ else
+ echo "Error: expected 'artifacts/px4_sitl_default/airframes.xml' not found." >&2
+ exit 1
+ fi
# Parameters
cp artifacts/px4_sitl_default/parameters.xml artifacts/_general/
cp artifacts/px4_sitl_default/parameters.json artifacts/_general/
@@ -40,9 +56,11 @@ if [ -d artifacts/px4_sitl_default ]; then
cp artifacts/px4_sitl_default/actuators.json artifacts/_general/
cp artifacts/px4_sitl_default/actuators.json.xz artifacts/_general/
# Events
- cp artifacts/px4_sitl_default/events/all_events.json.xz artifacts/_general/
- # ROS 2 msgs
- cp artifacts/px4_sitl_default/events/all_events.json.xz artifacts/_general/
- # Module Docs
+ if [ -f artifacts/px4_sitl_default/events/all_events.json.xz ]; then
+ cp artifacts/px4_sitl_default/events/all_events.json.xz artifacts/_general/
+ else
+ echo "Error: expected 'artifacts/px4_sitl_default/events/all_events.json.xz' not found." >&2
+ exit 1
+ fi
ls -la artifacts/_general/
fi
diff --git a/Tools/ci/pr-comment-poster.py b/Tools/ci/pr-comment-poster.py
new file mode 100755
index 0000000000..c28b127568
--- /dev/null
+++ b/Tools/ci/pr-comment-poster.py
@@ -0,0 +1,288 @@
+#!/usr/bin/env python3
+"""
+PR comment poster for analysis workflows.
+
+This script is invoked from the `PR Comment Poster` workflow which runs on
+`workflow_run` in the base repository context. It consumes a `pr-comment`
+artifact produced by an upstream analysis job (clang-tidy, flash_analysis,
+etc.) and posts or updates a sticky PR comment via the GitHub REST API.
+
+Artifact contract (directory passed on the command line):
+
+ manifest.json
+ {
+ "pr_number": 12345, (required, int > 0)
+ "marker": "", (required, printable ASCII)
+ "mode": "upsert" (optional, default "upsert")
+ }
+
+ body.md
+ Markdown comment body, posted verbatim. Must be non-empty and
+ <= 60000 bytes (GitHub's hard limit is 65535, we cap under).
+
+Security: this script is run in a write-token context from a workflow that
+MUST NOT check out PR code. Both manifest.json and body.md are treated as
+opaque data. The marker is validated to printable ASCII only before use.
+
+Subcommands:
+
+ validate Validate that contains a conforming manifest + body.
+ post Validate, then upsert a sticky comment on the target PR.
+ Requires env GITHUB_TOKEN and GITHUB_REPOSITORY.
+
+Python stdlib only. No third-party dependencies.
+"""
+
+import argparse
+import json
+import os
+import sys
+
+import _github_helpers
+from _github_helpers import fail as _fail
+
+
+# GitHub hard limit is 65535 bytes. Cap well under to leave headroom for
+# the appended marker line and any future wrapping.
+MAX_BODY_BYTES = 60000
+
+# Marker length bounds. 1..200 is plenty for an HTML comment tag such as
+# "".
+MARKER_MIN_LEN = 1
+MARKER_MAX_LEN = 200
+
+ACCEPTED_MODES = ('upsert',)
+
+USER_AGENT = 'px4-pr-comment-poster'
+
+
+# ---------------------------------------------------------------------------
+# Validation
+# ---------------------------------------------------------------------------
+
+def _is_printable_ascii(s):
+ # Space (0x20) through tilde (0x7E) inclusive.
+ return all(0x20 <= ord(ch) <= 0x7E for ch in s)
+
+
+def validate_marker(marker):
+ """Validate the marker string.
+
+ The marker is printable ASCII only and bounded in length. The original
+ shell implementation also rejected quotes, backticks, and backslashes
+ because the value flowed through jq and shell contexts. Now that Python
+ owns the handling (the value is only ever used as a substring match in
+ comment bodies and as a literal string in JSON request payloads that
+ urllib serialises for us) those characters are safe. We keep the
+ printable-ASCII and length rules as a belt-and-braces sanity check.
+ """
+ if not isinstance(marker, str):
+ _fail('marker must be a string')
+ n = len(marker)
+ if n < MARKER_MIN_LEN or n > MARKER_MAX_LEN:
+ _fail('marker length out of range ({}..{}): {}'.format(
+ MARKER_MIN_LEN, MARKER_MAX_LEN, n))
+ if not _is_printable_ascii(marker):
+ _fail('marker contains non-printable or non-ASCII character')
+
+
+def validate_manifest(directory):
+ """Validate /manifest.json and /body.md.
+
+ Returns a dict with keys: pr_number (int), marker (str), mode (str),
+ body (str, verbatim contents of body.md).
+ """
+ manifest_path = os.path.join(directory, 'manifest.json')
+ body_path = os.path.join(directory, 'body.md')
+
+ if not os.path.isfile(manifest_path):
+ _fail('manifest.json missing at {}'.format(manifest_path))
+ if not os.path.isfile(body_path):
+ _fail('body.md missing at {}'.format(body_path))
+
+ try:
+ with open(manifest_path, 'r', encoding='utf-8') as f:
+ manifest = json.load(f)
+ except (OSError, json.JSONDecodeError) as e:
+ _fail('manifest.json is not valid JSON: {}'.format(e))
+
+ if not isinstance(manifest, dict):
+ _fail('manifest.json must be a JSON object')
+
+ pr_number = manifest.get('pr_number')
+ # bool is a subclass of int in Python, so isinstance(True, int) is True.
+ # Reject bools explicitly so "true"/"false" in the manifest doesn't silently
+ # validate as 1/0 and then either fail upstream or poke the wrong PR.
+ if not isinstance(pr_number, int) or isinstance(pr_number, bool):
+ _fail('pr_number must be an integer')
+ if pr_number <= 0:
+ _fail('pr_number must be > 0 (got {})'.format(pr_number))
+
+ marker = manifest.get('marker')
+ validate_marker(marker)
+
+ mode = manifest.get('mode', 'upsert')
+ if mode not in ACCEPTED_MODES:
+ _fail('unsupported mode {!r} (accepted: {})'.format(
+ mode, ', '.join(ACCEPTED_MODES)))
+
+ # Read as bytes first so the size check is an honest byte count (matching
+ # GitHub's own 65535-byte comment limit) before we pay the cost of decoding.
+ try:
+ with open(body_path, 'rb') as f:
+ body_bytes = f.read()
+ except OSError as e:
+ _fail('could not read body.md: {}'.format(e))
+
+ if len(body_bytes) == 0:
+ _fail('body.md is empty')
+ if len(body_bytes) > MAX_BODY_BYTES:
+ _fail('body.md too large: {} bytes (max {})'.format(
+ len(body_bytes), MAX_BODY_BYTES))
+
+ # Require UTF-8 up front so a producer that wrote a garbage encoding fails
+ # here rather than later inside json.dumps with a less obvious traceback.
+ try:
+ body = body_bytes.decode('utf-8')
+ except UnicodeDecodeError as e:
+ _fail('body.md is not valid UTF-8: {}'.format(e))
+
+ return {
+ 'pr_number': pr_number,
+ 'marker': marker,
+ 'mode': mode,
+ 'body': body,
+ }
+
+
+# ---------------------------------------------------------------------------
+# Comment upsert
+# ---------------------------------------------------------------------------
+
+def find_existing_comment_id(client, repo, pr_number, marker):
+ """Return the id of the first PR comment whose body contains marker, or None.
+
+ PR comments are issue comments in GitHub's data model, so we hit
+ /issues/{n}/comments rather than /pulls/{n}/comments (the latter only
+ returns review comments tied to specific code lines, which is not what
+ we want). The match is a plain substring check against the comment body;
+ the marker is expected to be an HTML comment that will not accidentally
+ appear in user-written prose.
+ """
+ path = 'repos/{}/issues/{}/comments'.format(repo, pr_number)
+ for comment in client.paginated(path):
+ body = comment.get('body') or ''
+ if marker in body:
+ return comment.get('id')
+ return None
+
+
+def build_final_body(body, marker):
+ """Append the marker to body if not already present.
+
+ If the caller already embedded the marker (e.g. inside a hidden HTML
+ comment anywhere in their body) we leave the body alone; otherwise we
+ rstrip trailing newlines and append the marker on its own line after a
+ blank-line separator. Trailing-newline stripping keeps the output from
+ accumulating extra blank lines every time an existing comment is
+ re-rendered and re-posted.
+ """
+ if marker in body:
+ return body
+ return '{}\n\n{}\n'.format(body.rstrip('\n'), marker)
+
+
+def upsert_comment(client, repo, pr_number, marker, body):
+ final_body = build_final_body(body, marker)
+ existing_id = find_existing_comment_id(client, repo, pr_number, marker)
+
+ if existing_id is not None:
+ print('Updating comment {} on PR #{}'.format(existing_id, pr_number))
+ client.request(
+ 'PATCH',
+ 'repos/{}/issues/comments/{}'.format(repo, existing_id),
+ json_body={'body': final_body},
+ )
+ else:
+ print('Creating new comment on PR #{}'.format(pr_number))
+ client.request(
+ 'POST',
+ 'repos/{}/issues/{}/comments'.format(repo, pr_number),
+ json_body={'body': final_body},
+ )
+
+
+# ---------------------------------------------------------------------------
+# Entry points
+# ---------------------------------------------------------------------------
+
+def cmd_validate(args):
+ result = validate_manifest(args.directory)
+ print('ok: pr_number={} marker_len={} mode={} body_bytes={}'.format(
+ result['pr_number'],
+ len(result['marker']),
+ result['mode'],
+ len(result['body'].encode('utf-8')),
+ ))
+ return 0
+
+
+def cmd_post(args):
+ result = validate_manifest(args.directory)
+
+ # GITHUB_TOKEN is provided by the workflow via env; GITHUB_REPOSITORY is
+ # auto-set on every Actions runner. Both are required here because a local
+ # developer running the script directly won't have either unless they
+ # export them, and we want a clear error in that case.
+ token = os.environ.get('GITHUB_TOKEN')
+ if not token:
+ _fail('GITHUB_TOKEN is not set')
+ repo = os.environ.get('GITHUB_REPOSITORY')
+ if not repo:
+ _fail('GITHUB_REPOSITORY is not set (expected "owner/name")')
+ # Minimal shape check. If "owner/name" is malformed the subsequent API
+ # calls would 404 with an unhelpful URL. Fail fast here instead.
+ if '/' not in repo:
+ _fail('GITHUB_REPOSITORY must be "owner/name", got {!r}'.format(repo))
+
+ try:
+ client = _github_helpers.GitHubClient(token, user_agent=USER_AGENT)
+ upsert_comment(
+ client=client,
+ repo=repo,
+ pr_number=result['pr_number'],
+ marker=result['marker'],
+ body=result['body'],
+ )
+ except RuntimeError as e:
+ _fail(str(e))
+ return 0
+
+
+def main(argv=None):
+ parser = argparse.ArgumentParser(
+ description='Validate and post sticky PR comments from CI artifacts.',
+ )
+ sub = parser.add_subparsers(dest='command', required=True)
+
+ p_validate = sub.add_parser(
+ 'validate',
+ help='Validate manifest.json and body.md in the given directory.',
+ )
+ p_validate.add_argument('directory')
+ p_validate.set_defaults(func=cmd_validate)
+
+ p_post = sub.add_parser(
+ 'post',
+ help='Validate, then upsert a sticky PR comment. Requires env '
+ 'GITHUB_TOKEN and GITHUB_REPOSITORY.',
+ )
+ p_post.add_argument('directory')
+ p_post.set_defaults(func=cmd_post)
+
+ args = parser.parse_args(argv)
+ return args.func(args)
+
+
+if __name__ == '__main__':
+ sys.exit(main())
diff --git a/Tools/ci/pr-review-poster.py b/Tools/ci/pr-review-poster.py
new file mode 100644
index 0000000000..f46f8616a6
--- /dev/null
+++ b/Tools/ci/pr-review-poster.py
@@ -0,0 +1,481 @@
+#!/usr/bin/env python3
+"""
+PR review-comment poster for analysis workflows.
+
+Sibling of Tools/ci/pr-comment-poster.py. Where pr-comment-poster.py posts
+sticky issue-style PR comments, this script posts line-anchored review
+comments on the "Files changed" tab. Use it for tools like clang-tidy that
+want to flag specific lines instead of (or in addition to) a rollup
+comment.
+
+This script is invoked from the `PR Review Poster` workflow which runs on
+`workflow_run` in the base repository context. It consumes a `pr-review`
+artifact produced by an upstream analysis job and posts a fresh PR review
+via the GitHub REST API, dismissing any stale review the same producer
+left on a previous run.
+
+Artifact contract (directory passed on the command line):
+
+ manifest.json
+ {
+ "pr_number": 12345, (required, int > 0)
+ "marker": "", (required, printable ASCII)
+ "event": "COMMENT", (required, "COMMENT" only)
+ "commit_sha": "0123456789abcdef0123456789abcdef01234567",(required, 40 hex chars)
+ "summary": "Optional review body text" (optional)
+ }
+
+ comments.json
+ JSON array of line-anchored review comment objects:
+ [
+ {"path": "src/foo.cpp", "line": 42, "side": "RIGHT",
+ "body": "..."},
+ {"path": "src/bar.hpp", "start_line": 10, "line": 15,
+ "side": "RIGHT", "start_side": "RIGHT", "body": "..."}
+ ]
+
+Note: `APPROVE` and `REQUEST_CHANGES` events are intentionally NOT
+supported. Bots should never approve a pull request, and REQUEST_CHANGES
+cannot be dismissed by the GITHUB_TOKEN when branch protection restricts
+review dismissals, leading to undismissable spam on every push.
+
+Security: this script is run in a write-token context from a workflow that
+MUST NOT check out PR code. Both manifest.json and comments.json are
+treated as opaque data. The marker is validated to printable ASCII only
+before use, and only reviews authored by github-actions[bot] whose body
+contains the marker can be dismissed (a fork cannot spoof either).
+
+Subcommands:
+
+ validate Validate that contains a conforming manifest +
+ comments file.
+ post Validate, then dismiss any stale matching review and
+ post a new review on the target PR. Requires env
+ GITHUB_TOKEN and GITHUB_REPOSITORY.
+
+Python stdlib only. No third-party dependencies.
+"""
+
+import argparse
+import json
+import os
+import re
+import sys
+import time
+
+import _github_helpers
+from _github_helpers import fail as _fail
+
+
+USER_AGENT = 'px4-pr-review-poster'
+
+# Marker length bounds. 1..200 is plenty for an HTML comment tag such as
+# "".
+MARKER_MIN_LEN = 1
+MARKER_MAX_LEN = 200
+
+# Cap per-comment body size well under GitHub's hard limit so we leave
+# headroom for the wrapping JSON envelope. Empirically GitHub allows ~65535
+# bytes per review comment body; 60000 is a safe ceiling.
+MAX_COMMENT_BODY_BYTES = 60000
+
+# Cap on number of comments per single review POST. platisd uses 10. The
+# value matters because GitHub's review-creation endpoint has a payload
+# size limit and review comments occasionally trip an abuse-detection
+# threshold when posted in very large batches. Smaller chunks also let us
+# spread the work across multiple reviews so a single bad entry only
+# fails its own chunk.
+COMMENTS_PER_REVIEW = 10
+
+# Sleep between successive review POSTs to stay clear of GitHub's
+# secondary rate limits. platisd uses 10s; 5s is enough for our volume
+# and cuts user-visible latency.
+SLEEP_BETWEEN_CHUNKS_SECONDS = 5
+
+ACCEPTED_EVENTS = ('COMMENT',)
+ACCEPTED_SIDES = ('LEFT', 'RIGHT')
+COMMIT_SHA_RE = re.compile(r'^[0-9a-f]{40}$')
+
+# The login GitHub assigns to the built-in actions token. Used to filter
+# the list of existing reviews so we never touch a human reviewer's review.
+BOT_LOGIN = 'github-actions[bot]'
+
+
+# ---------------------------------------------------------------------------
+# Validation
+# ---------------------------------------------------------------------------
+
+def _is_printable_ascii(s):
+ return all(0x20 <= ord(ch) <= 0x7E for ch in s)
+
+
+def validate_marker(marker):
+ """Validate the marker string. See pr-comment-poster.py for rationale."""
+ if not isinstance(marker, str):
+ _fail('marker must be a string')
+ n = len(marker)
+ if n < MARKER_MIN_LEN or n > MARKER_MAX_LEN:
+ _fail('marker length out of range ({}..{}): {}'.format(
+ MARKER_MIN_LEN, MARKER_MAX_LEN, n))
+ if not _is_printable_ascii(marker):
+ _fail('marker contains non-printable or non-ASCII character')
+
+
+def _validate_comment_entry(idx, entry):
+ """Validate a single review-comment entry. Raises via _fail on error."""
+ if not isinstance(entry, dict):
+ _fail('comments[{}]: must be an object'.format(idx))
+
+ path = entry.get('path')
+ if not isinstance(path, str) or not path:
+ _fail('comments[{}].path: required non-empty string'.format(idx))
+
+ line = entry.get('line')
+ if not isinstance(line, int) or isinstance(line, bool) or line <= 0:
+ _fail('comments[{}].line: required positive integer'.format(idx))
+
+ side = entry.get('side', 'RIGHT')
+ if side not in ACCEPTED_SIDES:
+ _fail('comments[{}].side: must be one of {} (got {!r})'.format(
+ idx, ', '.join(ACCEPTED_SIDES), side))
+
+ if 'start_line' in entry:
+ start_line = entry['start_line']
+ if (not isinstance(start_line, int)
+ or isinstance(start_line, bool)
+ or start_line <= 0):
+ _fail('comments[{}].start_line: must be positive integer'.format(idx))
+ if start_line >= line:
+ _fail('comments[{}].start_line ({}) must be < line ({})'.format(
+ idx, start_line, line))
+ start_side = entry.get('start_side', side)
+ if start_side not in ACCEPTED_SIDES:
+ _fail('comments[{}].start_side: must be one of {}'.format(
+ idx, ', '.join(ACCEPTED_SIDES)))
+
+ body = entry.get('body')
+ if not isinstance(body, str) or not body:
+ _fail('comments[{}].body: required non-empty string'.format(idx))
+ body_bytes = len(body.encode('utf-8'))
+ if body_bytes > MAX_COMMENT_BODY_BYTES:
+ _fail('comments[{}].body too large: {} bytes (max {})'.format(
+ idx, body_bytes, MAX_COMMENT_BODY_BYTES))
+
+
+def validate_manifest(directory):
+ """Validate /manifest.json and /comments.json.
+
+ Returns a dict with keys: pr_number, marker, event, commit_sha,
+ summary, comments (list of validated comment dicts).
+ """
+ manifest_path = os.path.join(directory, 'manifest.json')
+ comments_path = os.path.join(directory, 'comments.json')
+
+ if not os.path.isfile(manifest_path):
+ _fail('manifest.json missing at {}'.format(manifest_path))
+ if not os.path.isfile(comments_path):
+ _fail('comments.json missing at {}'.format(comments_path))
+
+ try:
+ with open(manifest_path, 'r', encoding='utf-8') as f:
+ manifest = json.load(f)
+ except (OSError, json.JSONDecodeError) as e:
+ _fail('manifest.json is not valid JSON: {}'.format(e))
+
+ if not isinstance(manifest, dict):
+ _fail('manifest.json must be a JSON object')
+
+ pr_number = manifest.get('pr_number')
+ if not isinstance(pr_number, int) or isinstance(pr_number, bool):
+ _fail('pr_number must be an integer')
+ if pr_number <= 0:
+ _fail('pr_number must be > 0 (got {})'.format(pr_number))
+
+ marker = manifest.get('marker')
+ validate_marker(marker)
+
+ event = manifest.get('event')
+ if event not in ACCEPTED_EVENTS:
+ _fail('event must be one of {} (got {!r}). APPROVE and '
+ 'REQUEST_CHANGES are intentionally forbidden.'.format(
+ ', '.join(ACCEPTED_EVENTS), event))
+
+ commit_sha = manifest.get('commit_sha')
+ if not isinstance(commit_sha, str) or not COMMIT_SHA_RE.match(commit_sha):
+ _fail('commit_sha must be a 40-character lowercase hex string')
+
+ summary = manifest.get('summary', '')
+ if summary is None:
+ summary = ''
+ if not isinstance(summary, str):
+ _fail('summary must be a string if present')
+
+ try:
+ with open(comments_path, 'r', encoding='utf-8') as f:
+ comments = json.load(f)
+ except (OSError, json.JSONDecodeError) as e:
+ _fail('comments.json is not valid JSON: {}'.format(e))
+
+ if not isinstance(comments, list):
+ _fail('comments.json must be a JSON array')
+
+ for idx, entry in enumerate(comments):
+ _validate_comment_entry(idx, entry)
+
+ return {
+ 'pr_number': pr_number,
+ 'marker': marker,
+ 'event': event,
+ 'commit_sha': commit_sha,
+ 'summary': summary,
+ 'comments': comments,
+ }
+
+
+# ---------------------------------------------------------------------------
+# Stale-review dismissal
+# ---------------------------------------------------------------------------
+
+def find_stale_reviews(client, repo, pr_number, marker):
+ """Yield (id, state) for each existing review owned by the bot AND
+ whose body contains the marker.
+
+ Filtering on BOTH author == github-actions[bot] AND marker-in-body is
+ the security invariant: a fork PR cannot impersonate the bot login,
+ and a fork PR also cannot inject the marker into a human reviewer's
+ body without API access.
+ """
+ path = 'repos/{}/pulls/{}/reviews'.format(repo, pr_number)
+ for review in client.paginated(path):
+ user = review.get('user') or {}
+ if user.get('login') != BOT_LOGIN:
+ continue
+ body = review.get('body') or ''
+ if marker not in body:
+ continue
+ yield review.get('id'), review.get('state')
+
+
+def dismiss_stale_reviews(client, repo, pr_number, marker):
+ """Dismiss (or, for PENDING reviews, delete) every stale matching review.
+
+ Returns the number of reviews that could NOT be dismissed (still active).
+ """
+ dismissal_message = 'Superseded by a newer run'
+ failed_dismissals = 0
+ for review_id, state in find_stale_reviews(client, repo, pr_number, marker):
+ if review_id is None:
+ continue
+ if state in ('DISMISSED', 'COMMENTED'):
+ # Already inert or non-blocking; nothing to do.
+ continue
+ if state == 'PENDING':
+ # PENDING reviews cannot be dismissed; they must be deleted.
+ print('Deleting pending stale review {}'.format(review_id))
+ try:
+ client.request(
+ 'DELETE',
+ 'repos/{}/pulls/{}/reviews/{}'.format(
+ repo, pr_number, review_id))
+ except RuntimeError as e:
+ failed_dismissals += 1
+ print('warning: failed to delete pending review {}: {}'.format(
+ review_id, e), file=sys.stderr)
+ continue
+ print('Dismissing stale review {} (state={})'.format(review_id, state))
+ try:
+ client.request(
+ 'PUT',
+ 'repos/{}/pulls/{}/reviews/{}/dismissals'.format(
+ repo, pr_number, review_id),
+ json_body={
+ 'message': dismissal_message,
+ 'event': 'DISMISS',
+ },
+ )
+ except RuntimeError as e:
+ failed_dismissals += 1
+ print('warning: failed to dismiss review {}: {}'.format(
+ review_id, e), file=sys.stderr)
+ return failed_dismissals
+
+
+# ---------------------------------------------------------------------------
+# Review posting
+# ---------------------------------------------------------------------------
+
+def _chunk(lst, n):
+ """Yield successive n-sized slices of lst."""
+ for i in range(0, len(lst), n):
+ yield lst[i:i + n]
+
+
+def _build_review_body(marker, summary, chunk_index, chunk_total):
+ """Construct the review body text.
+
+ Always begins with the marker (so future runs can find and dismiss
+ this review). Appends a chunk index when the comment set is split
+ across multiple reviews, and the producer-supplied summary if any.
+ """
+ parts = [marker]
+ if chunk_total > 1:
+ parts.append('({}/{})'.format(chunk_index + 1, chunk_total))
+ if summary:
+ parts.append('')
+ parts.append(summary)
+ return '\n'.join(parts)
+
+
+def _comment_to_api(entry):
+ """Project a validated comment dict to the GitHub API shape."""
+ api = {
+ 'path': entry['path'],
+ 'line': entry['line'],
+ 'side': entry.get('side', 'RIGHT'),
+ 'body': entry['body'],
+ }
+ if 'start_line' in entry:
+ api['start_line'] = entry['start_line']
+ api['start_side'] = entry.get('start_side', api['side'])
+ return api
+
+
+def post_review(client, repo, pr_number, marker, event, commit_sha, summary,
+ comments):
+ """Post one or more reviews containing the validated comments.
+
+ Comments are split into COMMENTS_PER_REVIEW-sized chunks. Each chunk
+ becomes its own review POST. A failed chunk logs a warning and the
+ loop continues to the next chunk.
+ """
+ chunks = list(_chunk(comments, COMMENTS_PER_REVIEW))
+ total = len(chunks)
+ if total == 0:
+ print('No comments to post; skipping review creation.')
+ return
+
+ posted_any = False
+ for idx, chunk in enumerate(chunks):
+ if idx > 0:
+ time.sleep(SLEEP_BETWEEN_CHUNKS_SECONDS)
+ body = _build_review_body(marker, summary, idx, total)
+ payload = {
+ 'commit_id': commit_sha,
+ 'body': body,
+ 'event': event,
+ 'comments': [_comment_to_api(c) for c in chunk],
+ }
+ print('Posting review chunk {}/{} with {} comment(s)'.format(
+ idx + 1, total, len(chunk)))
+ try:
+ client.request(
+ 'POST',
+ 'repos/{}/pulls/{}/reviews'.format(repo, pr_number),
+ json_body=payload,
+ )
+ posted_any = True
+ except RuntimeError as e:
+ # Most common cause is HTTP 422: a comment refers to a line
+ # GitHub does not consider part of the diff. Skip the bad
+ # chunk and keep going so other findings still get posted.
+ print('warning: review chunk {}/{} failed: {}'.format(
+ idx + 1, total, e), file=sys.stderr)
+
+ if not posted_any:
+ # If every single chunk failed, surface that as a hard error so
+ # the workflow turns red and a human looks at it.
+ _fail('all review chunks failed to post; see warnings above')
+
+
+# ---------------------------------------------------------------------------
+# Entry points
+# ---------------------------------------------------------------------------
+
+def cmd_validate(args):
+ result = validate_manifest(args.directory)
+ print(('ok: pr_number={} marker_len={} event={} commit_sha={} '
+ 'comments={} summary_len={}').format(
+ result['pr_number'],
+ len(result['marker']),
+ result['event'],
+ result['commit_sha'],
+ len(result['comments']),
+ len(result['summary']),
+ ))
+ return 0
+
+
+def cmd_post(args):
+ result = validate_manifest(args.directory)
+
+ # Empty comment lists short-circuit silently. A producer that ran but
+ # found nothing to flag should not generate noise on the PR.
+ if len(result['comments']) == 0:
+ print('No comments in artifact; nothing to post.')
+ return 0
+
+ token = os.environ.get('GITHUB_TOKEN')
+ if not token:
+ _fail('GITHUB_TOKEN is not set')
+ repo = os.environ.get('GITHUB_REPOSITORY')
+ if not repo:
+ _fail('GITHUB_REPOSITORY is not set (expected "owner/name")')
+ if '/' not in repo:
+ _fail('GITHUB_REPOSITORY must be "owner/name", got {!r}'.format(repo))
+
+ try:
+ client = _github_helpers.GitHubClient(token, user_agent=USER_AGENT)
+ undismissed = dismiss_stale_reviews(
+ client=client,
+ repo=repo,
+ pr_number=result['pr_number'],
+ marker=result['marker'],
+ )
+
+ if undismissed > 0:
+ print('{} prior review(s) could not be dismissed (likely '
+ 'branch protection).'.format(undismissed))
+
+ post_review(
+ client=client,
+ repo=repo,
+ pr_number=result['pr_number'],
+ marker=result['marker'],
+ event=result['event'],
+ commit_sha=result['commit_sha'],
+ summary=result['summary'],
+ comments=result['comments'],
+ )
+ except RuntimeError as e:
+ _fail(str(e))
+ return 0
+
+
+def main(argv=None):
+ parser = argparse.ArgumentParser(
+ description='Validate and post line-anchored PR review comments '
+ 'from CI artifacts.',
+ )
+ sub = parser.add_subparsers(dest='command', required=True)
+
+ p_validate = sub.add_parser(
+ 'validate',
+ help='Validate manifest.json and comments.json in the given directory.',
+ )
+ p_validate.add_argument('directory')
+ p_validate.set_defaults(func=cmd_validate)
+
+ p_post = sub.add_parser(
+ 'post',
+ help='Validate, then dismiss any stale review and post a new one. '
+ 'Requires env GITHUB_TOKEN and GITHUB_REPOSITORY.',
+ )
+ p_post.add_argument('directory')
+ p_post.set_defaults(func=cmd_post)
+
+ args = parser.parse_args(argv)
+ return args.func(args)
+
+
+if __name__ == '__main__':
+ sys.exit(main())
diff --git a/Tools/ci/pr_comment.py b/Tools/ci/pr_comment.py
new file mode 100755
index 0000000000..6306b13ef4
--- /dev/null
+++ b/Tools/ci/pr_comment.py
@@ -0,0 +1,95 @@
+#!/usr/bin/env python3
+"""Post, update, or delete a PR comment with deduplication.
+
+Uses hidden HTML markers to find existing comments and avoid duplicates.
+Reads comment body from stdin when posting or updating.
+
+Usage:
+ echo "comment body" | python3 pr_comment.py --marker pr-title --pr 123 --result fail
+ python3 pr_comment.py --marker pr-title --pr 123 --result pass
+
+Results:
+ fail - post/update comment with body from stdin
+ warn - post/update comment with body from stdin
+ pass - delete existing comment if any
+
+Requires GH_TOKEN and GITHUB_REPOSITORY environment variables.
+"""
+
+import argparse
+import json
+import os
+import subprocess
+import sys
+
+
+def gh_api(endpoint: str, method: str = 'GET', body: dict | None = None) -> str:
+ """Call the GitHub API via gh cli."""
+ cmd = ['gh', 'api', endpoint, '-X', method]
+ if body:
+ for key, value in body.items():
+ cmd.extend(['-f', f'{key}={value}'])
+ result = subprocess.run(cmd, capture_output=True, text=True)
+ if result.returncode != 0 and method != 'DELETE':
+ print(f"gh api error: {result.stderr}", file=sys.stderr)
+ return result.stdout
+
+
+def find_comment(repo: str, pr: int, marker: str) -> str | None:
+ """Find an existing comment by its hidden marker. Returns comment ID or None."""
+ response = gh_api(f"repos/{repo}/issues/{pr}/comments?per_page=100")
+ try:
+ comments = json.loads(response)
+ except json.JSONDecodeError:
+ return None
+
+ if not isinstance(comments, list):
+ return None
+
+ for comment in comments:
+ if isinstance(comment, dict) and comment.get('body', '').startswith(marker):
+ return str(comment['id'])
+ return None
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description='Manage PR quality comments')
+ parser.add_argument('--marker', required=True,
+ help='Marker name (e.g. pr-title, commit-msgs, pr-body)')
+ parser.add_argument('--pr', required=True, type=int,
+ help='Pull request number')
+ parser.add_argument('--result', required=True, choices=['pass', 'fail', 'warn'],
+ help='Check result: pass deletes comment, fail/warn posts it')
+ args = parser.parse_args()
+
+ repo = os.environ.get('GITHUB_REPOSITORY', '')
+ if not repo:
+ print("GITHUB_REPOSITORY not set", file=sys.stderr)
+ sys.exit(2)
+
+ marker = f""
+ existing_id = find_comment(repo, args.pr, marker)
+
+ if args.result == 'pass':
+ if existing_id:
+ gh_api(f"repos/{repo}/issues/comments/{existing_id}", method='DELETE')
+ return
+
+ # Read comment body from stdin
+ body_content = sys.stdin.read().strip()
+ if not body_content:
+ print("No comment body provided on stdin", file=sys.stderr)
+ sys.exit(2)
+
+ full_body = f"{marker}\n{body_content}"
+
+ if existing_id:
+ gh_api(f"repos/{repo}/issues/comments/{existing_id}", method='PATCH',
+ body={'body': full_body})
+ else:
+ gh_api(f"repos/{repo}/issues/{args.pr}/comments", method='POST',
+ body={'body': full_body})
+
+
+if __name__ == '__main__':
+ main()
diff --git a/Tools/ci/run-clang-tidy-pr.py b/Tools/ci/run-clang-tidy-pr.py
new file mode 100755
index 0000000000..226bdae2e3
--- /dev/null
+++ b/Tools/ci/run-clang-tidy-pr.py
@@ -0,0 +1,147 @@
+#!/usr/bin/env python3
+"""
+Run clang-tidy incrementally on files changed in a PR.
+
+Usage: run-clang-tidy-pr.py
+ base-ref: e.g. origin/main
+
+Computes the set of translation units (TUs) affected by the PR diff,
+then invokes Tools/run-clang-tidy.py on that subset only.
+Exits 0 silently when no C++ files were changed.
+"""
+
+import argparse
+import json
+import os
+import subprocess
+import sys
+
+EXTENSIONS_CPP = {'.cpp', '.c'}
+EXTENSIONS_HDR = {'.hpp', '.h'}
+# Manual exclusions from Makefile:508
+EXCLUDE_EXTRA = '|'.join([
+ 'src/systemcmds/tests',
+ 'src/examples',
+ 'src/modules/gyro_fft/CMSIS_5',
+ 'src/lib/drivers/smbus',
+ 'src/drivers/gpio',
+ r'src/modules/commander/failsafe/emscripten',
+ r'failsafe_test\.dir',
+])
+
+
+def repo_root():
+ try:
+ return subprocess.check_output(
+ ['git', 'rev-parse', '--show-toplevel'], text=True).strip()
+ except subprocess.CalledProcessError:
+ print('error: not inside a git repository', file=sys.stderr)
+ sys.exit(1)
+
+
+def changed_files(base_ref, root):
+ try:
+ out = subprocess.check_output(
+ ['git', 'diff', '--name-only', f'{base_ref}...HEAD',
+ '--', '*.cpp', '*.hpp', '*.h', '*.c'],
+ text=True, cwd=root).strip()
+ return out.splitlines() if out else []
+ except subprocess.CalledProcessError:
+ print(f'error: could not diff against "{base_ref}" — '
+ 'is the ref valid and fetched?', file=sys.stderr)
+ sys.exit(1)
+
+
+def submodule_paths(root):
+ # Returns [] if .gitmodules is absent or has no paths — both valid
+ try:
+ out = subprocess.check_output(
+ ['git', 'config', '--file', '.gitmodules',
+ '--get-regexp', 'path'],
+ text=True, cwd=root).strip()
+ return [line.split()[1] for line in out.splitlines()]
+ except subprocess.CalledProcessError:
+ return []
+
+
+def build_exclude(root):
+ submodules = '|'.join(submodule_paths(root))
+ return f'{submodules}|{EXCLUDE_EXTRA}' if submodules else EXCLUDE_EXTRA
+
+
+def load_db(build_dir):
+ db_path = os.path.join(build_dir, 'compile_commands.json')
+ if not os.path.isfile(db_path):
+ print(f'error: {db_path} not found', file=sys.stderr)
+ print('Run "make px4_sitl_default-clang" first to generate '
+ 'the compilation database', file=sys.stderr)
+ sys.exit(1)
+ try:
+ with open(db_path) as f:
+ return json.load(f)
+ except json.JSONDecodeError as e:
+ print(f'error: compile_commands.json is malformed: {e}', file=sys.stderr)
+ sys.exit(1)
+
+
+def find_tus(changed, db, root):
+ db_files = {e['file'] for e in db}
+ result = set()
+ for f in changed:
+ abs_path = os.path.join(root, f)
+ ext = os.path.splitext(f)[1]
+ if ext in EXTENSIONS_CPP:
+ if abs_path in db_files:
+ result.add(abs_path)
+ elif ext in EXTENSIONS_HDR:
+ hdr = os.path.basename(f)
+ for e in db:
+ try:
+ if hdr in open(e['file']).read():
+ result.add(e['file'])
+ except OSError:
+ pass # file deleted in PR — skip
+ return sorted(result)
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__,
+ formatter_class=argparse.RawDescriptionHelpFormatter)
+ parser.add_argument('base_ref',
+ help='Git ref to diff against, e.g. origin/main')
+ args = parser.parse_args()
+
+ root = repo_root()
+ build_dir = os.path.join(root, 'build', 'px4_sitl_default-clang')
+
+ run_tidy = os.path.join(root, 'Tools', 'run-clang-tidy.py')
+ if not os.path.isfile(run_tidy):
+ print(f'error: {run_tidy} not found', file=sys.stderr)
+ sys.exit(1)
+
+ changed = changed_files(args.base_ref, root)
+ if not changed:
+ print('No C++ files changed — skipping clang-tidy')
+ sys.exit(0)
+
+ db = load_db(build_dir)
+ tus = find_tus(changed, db, root)
+
+ if not tus:
+ print('No matching TUs in compile_commands.json — skipping clang-tidy')
+ sys.exit(0)
+
+ print(f'Running clang-tidy on {len(tus)} translation unit(s)')
+
+ result = subprocess.run(
+ [sys.executable, run_tidy,
+ '-header-filter=.*\\.hpp',
+ '-j0',
+ f'-exclude={build_exclude(root)}',
+ '-p', build_dir] + tus
+ )
+ sys.exit(result.returncode)
+
+
+if __name__ == '__main__':
+ main()
diff --git a/Tools/ci/run_fuzz_tests.sh b/Tools/ci/run_fuzz_tests.sh
index 4aa4a9d3b6..3007dc744e 100755
--- a/Tools/ci/run_fuzz_tests.sh
+++ b/Tools/ci/run_fuzz_tests.sh
@@ -1,4 +1,4 @@
-#!/bin/bash
+#!/usr/bin/env bash
# This script runs the fuzz tests from a given binary for a certain amount of time
set -e
diff --git a/Tools/ci/use_aws_apt_mirror.sh b/Tools/ci/use_aws_apt_mirror.sh
new file mode 100755
index 0000000000..ea21e9aaaf
--- /dev/null
+++ b/Tools/ci/use_aws_apt_mirror.sh
@@ -0,0 +1,42 @@
+#!/bin/sh
+# Rewrite the container's apt sources to point at the AWS regional Ubuntu
+# mirror that is local to the runs-on instance.
+#
+# The default archive.ubuntu.com round-robin sometimes serves out-of-sync
+# index files mid-sync, breaking apt-get update with errors like:
+# File has unexpected size (25378 != 25381). Mirror sync in progress?
+# The Canonical-operated EC2 mirrors are region-local and sync aggressively,
+# eliminating that failure mode.
+#
+# This script is a no-op outside runs-on, so it is safe to call from any CI
+# job (forks, self-hosted runners, local docker runs, etc.) without changing
+# behavior there.
+#
+# Usage (from a workflow step running inside the container):
+# ./Tools/ci/use_aws_apt_mirror.sh
+
+set -e
+
+if [ -z "$RUNS_ON_AWS_REGION" ]; then
+ echo "use_aws_apt_mirror: not running on runs-on (RUNS_ON_AWS_REGION unset), skipping"
+ exit 0
+fi
+
+MIRROR="http://${RUNS_ON_AWS_REGION}.ec2.archive.ubuntu.com/ubuntu"
+echo "use_aws_apt_mirror: rewriting apt sources to ${MIRROR}"
+
+# Noble (24.04+) uses the deb822 format at /etc/apt/sources.list.d/ubuntu.sources
+if [ -f /etc/apt/sources.list.d/ubuntu.sources ]; then
+ sed -i \
+ -e "s|http://archive.ubuntu.com/ubuntu|${MIRROR}|g" \
+ -e "s|http://security.ubuntu.com/ubuntu|${MIRROR}|g" \
+ /etc/apt/sources.list.d/ubuntu.sources
+fi
+
+# Jammy (22.04) and earlier use the legacy /etc/apt/sources.list
+if [ -f /etc/apt/sources.list ]; then
+ sed -i \
+ -e "s|http://archive.ubuntu.com/ubuntu|${MIRROR}|g" \
+ -e "s|http://security.ubuntu.com/ubuntu|${MIRROR}|g" \
+ /etc/apt/sources.list
+fi
diff --git a/Tools/copy_to_ros_ws.sh b/Tools/copy_to_ros_ws.sh
index 48fcaa0d1f..b27fb07e34 100755
--- a/Tools/copy_to_ros_ws.sh
+++ b/Tools/copy_to_ros_ws.sh
@@ -1,4 +1,4 @@
-#! /bin/bash
+#!/usr/bin/env bash
# Copy msgs and the message translation node into a ROS workspace directory
DIR=$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )
diff --git a/Tools/dist/vehicle_configs.xml b/Tools/dist/vehicle_configs.xml
deleted file mode 100644
index 76d7ba11eb..0000000000
--- a/Tools/dist/vehicle_configs.xml
+++ /dev/null
@@ -1,23 +0,0 @@
-
-
-
- Standard 8" Prop Quadrotor (x)
- Standard quadrotor configuration in x configuration for 8-" propellers
- /etc/mixers/quad_x.main.mix
-
-
- Standard 8" Prop Quadrotor (+)
- Standard quadrotor configuration in + configuration for 8-" propellers
- /etc/mixers/quad_+.main.mix
-
-
- Standard 8" Prop Quadrotor (x)
- Standard quadrotor configuration in x configuration for 8-" propellers
- /etc/mixers/quad_x.main.mix
-
-
- Zeta Science Wing Wing Z-84
- Configuration for a small flying wing.
- /etc/mixers/wingwing.main.mix
-
-
diff --git a/Tools/docker_run.sh b/Tools/docker_run.sh
index 773c8992eb..fda1ee65e0 100755
--- a/Tools/docker_run.sh
+++ b/Tools/docker_run.sh
@@ -1,4 +1,4 @@
-#! /bin/bash
+#!/usr/bin/env bash
if [ -z ${PX4_DOCKER_REPO+x} ]; then
PX4_DOCKER_REPO="px4io/px4-dev:v1.17.0-beta1"
diff --git a/Tools/kconfig/allyesconfig.py b/Tools/kconfig/allyesconfig.py
index e432c52ec0..a7398a5427 100644
--- a/Tools/kconfig/allyesconfig.py
+++ b/Tools/kconfig/allyesconfig.py
@@ -57,14 +57,47 @@ exception_list = [
]
exception_list_sitl = [
- 'DRIVERS_BAROMETER', # Fails I2C dependencies
+ 'DRIVERS_ADC', # Fails I2C dependencies
'COMMON_BAROMETERS', # Fails I2C dependencies
+ 'DRIVERS_BAROMETER', # Fails I2C dependencies
+ 'DRIVERS_BATT_SMBUS', # Fails I2C dependencies in SMBus library
+ 'COMMON_DIFFERENTIAL_PRESSURE', # Fails I2C dependencies
+ 'DRIVERS_DIFFERENTIAL_PRESSURE', # Fails I2C dependencies
+ 'COMMON_DISTANCE_SENSOR', # Fails I2C dependencies
+ 'DRIVERS_DISTANCE_SENSOR', # Fails I2C dependencies
+ 'COMMON_HYGROMETERS', # Fails I2C dependencies
+ 'DRIVERS_HYGROMETER', # Fails I2C dependencies
+ 'COMMON_IMU', # Fails I2C dependencies
+ 'DRIVERS_IMU', # Fails I2C dependencies
+ 'DRIVERS_IRLOCK', # Fails I2C dependencies
+ 'COMMON_LIGHT', # Fails I2C dependencies
+ 'DRIVERS_LIGHTS', # Fails I2C dependencies
+ 'DRIVERS_MAGNETOMETER', # Fails I2C dependencies
+ 'COMMON_MAGNETOMETER', # Fails I2C dependencies
+ 'DRIVERS_OPTICAL_FLOW', # Fails I2C dependencies
+ 'COMMON_OPTICAL_FLOW', # Fails I2C dependencies
+ 'COMMON_OSD', # Fails I2C dependencies
+ 'DRIVERS_OSD', # Fails I2C dependencies
+ 'DRIVERS_PCA9685', # Fails I2C dependencies
+ 'DRIVERS_POWER_MONITOR', # Fails I2C dependencies
+ 'DRIVERS_RPM', # Fails I2C dependencies
+ 'COMMON_RPM', # Fails I2C dependencies
+ 'DRIVERS_SMART_BATTERY', # Fails I2C dependencies in SMBus library
+ 'DRIVERS_TELEMETRY', # Fails I2C dependencies
+ 'COMMON_TELEMETRY', # Fails I2C dependencies
+ 'DRIVERS_TEMPERATURE', # Fails I2C dependencies
+ 'COMMON_UWB', # Fail in UWB_PORT_CFG
+ 'DRIVERS_UWB', # Fail in UWB_PORT_CFG
+ 'DRIVERS_HEATER', # GPIO config failure
+ 'DRIVERS_AUTERION_AUTOSTARTER', # Sitl doesn't provide a builtin lib
+ 'DRIVERS_CYPHAL', # Sitl doesn't provide a memalign function
+ 'DRIVERS_GPIO', # Sitl doesn't provide a mcp-common lib
'DRIVERS_ADC_BOARD_ADC', # Fails HW dependencies, I think this only works on NuttX
'DRIVERS_CAMERA_CAPTURE', # GPIO config failure
+ 'DRIVERS_SAFETY_BUTTON', # GPIO config failure
+ 'DRIVERS_TAP_ESC', # No nuttx/arch.h
'DRIVERS_DSHOT', # No Posix driver, I think this only works on NuttX
'DRIVERS_PWM_OUT', # No Posix driver, I think this only works on NuttX
- 'COMMON', # Fails I2C dependencies
- 'DRIVERS', # Fails I2C dependencies
'SYSTEMCMDS_REBOOT', # Sitl can't reboot
'MODULES_BATTERY_STATUS', # Sitl doesn't provide a power brick
'SYSTEMCMDS_SERIAL_PASSTHRU', # Not supported in SITL
diff --git a/Tools/migrate_c_params.py b/Tools/migrate_c_params.py
index 042bdfb543..5d4e7a8bb6 100755
--- a/Tools/migrate_c_params.py
+++ b/Tools/migrate_c_params.py
@@ -128,6 +128,9 @@ class SourceParser:
# start waiting for the next part - long comment.
if state == "wait-short-end":
state = "wait-long"
+ elif state == "wait-long-end":
+ # Preserve paragraph breaks in long description
+ long_desc += "\n"
else:
m = self.re_comment_tag.match(comment_content)
if m:
@@ -208,8 +211,7 @@ class SourceParser:
raise Exception('short description too long (150 max, is {:}, parameter: {:})'.format(len(short_desc), name))
param.fields["short_desc"] = self.re_remove_dots.sub('', short_desc)
if long_desc is not None:
- long_desc = self.re_remove_carriage_return.sub(' ', long_desc)
- param.fields["long_desc"] = long_desc
+ param.fields["long_desc"] = long_desc.rstrip('\n')
for tag in tags:
if tag == "group":
group = tags[tag]
@@ -407,7 +409,15 @@ def generate_yaml(filename: str, groups: list[ParameterGroup]) -> str:
g["definitions"][parameter.name] = p
data["parameters"].append(g)
- return yaml.dump(data, sort_keys=False)
+ # Use block scalar style for multi-line strings
+ class BlockStyleDumper(yaml.SafeDumper):
+ pass
+ def str_representer(dumper, data):
+ if '\n' in data:
+ return dumper.represent_scalar('tag:yaml.org,2002:str', data, style='|')
+ return dumper.represent_scalar('tag:yaml.org,2002:str', data)
+ BlockStyleDumper.add_representer(str, str_representer)
+ return yaml.dump(data, Dumper=BlockStyleDumper, sort_keys=False)
def main():
diff --git a/Tools/models/sdp3x_pitot_model.py b/Tools/models/sdp3x_pitot_model.py
deleted file mode 100644
index 864823d663..0000000000
--- a/Tools/models/sdp3x_pitot_model.py
+++ /dev/null
@@ -1,110 +0,0 @@
-"""
-Copyright (c) 2017, Sensirion AG
-All rights reserved.
-
-Redistribution and use in source and binary forms, with or without
-modification, are permitted provided that the following conditions are met:
-
-* Redistributions of source code must retain the above copyright notice, this
- list of conditions and the following disclaimer.
-
-* Redistributions in binary form must reproduce the above copyright notice,
- this list of conditions and the following disclaimer in the documentation
- and/or other materials provided with the distribution.
-
-* Neither the name of Sensirion AG nor the names of its
- contributors may be used to endorse or promote products derived from
- this software without specific prior written permission.
-
-THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
-AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
-DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
-FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
-DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
-SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
-CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
-OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
-OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
-"""
-
-
-
-# formula for metal pitot tube with round tip as here: https://drotek.com/shop/2986-large_default/sdp3x-airspeed-sensor-kit-sdp31.jpg
-# and tubing as provided by px4/drotek (1.5 mm diameter)
-
-
-
-import numpy as np
-import matplotlib.pyplot as plt
-
-P_cal=96600. #Pa
-P_amb=96600. #dummy-value, use absolute pressure sensor!!
-
-
-## differential pressure, sensor values in Pascal
-dp_SDP33_raw=np.linspace(0,80,100)
-
-
-
-
-dp_SDP33=dp_SDP33_raw*P_cal/P_amb
-
-
-## total length tube in mm = length dynamic port+ length static port; compensation only valid for inner diameter of 1.5mm
-l_tube=450
-
-## densitiy air in kg/m3
-rho_air=1.29
-
-
-## flow through sensor
-flow_SDP33=(300.805 - 300.878/(0.00344205*dp_SDP33**0.68698 + 1))*1.29/rho_air
-
-
-
-## additional dp through pitot tube
-dp_Pitot=(0.0032*flow_SDP33**2 + 0.0123*flow_SDP33+1.)*1.29/rho_air
-
-## pressure drop through tube
-dp_Tube=(flow_SDP33*0.674)/450*l_tube*rho_air/1.29
-
-## speed at pitot-tube tip due to flow through sensor
-dv=0.125*flow_SDP33
-
-## sum of all pressure drops
-dp_tot=dp_SDP33+dp_Tube+dp_Pitot
-
-
-## computed airspeed without correction for inflow-speed at tip of pitot-tube
-airspeed_uncorrected=np.sqrt(2*dp_tot/rho_air)
-
-## corrected airspeed
-airspeed_corrected=airspeed_uncorrected+dv
-
-
-## just to compare to value without compensation
-airspeed_raw=np.sqrt(2*dp_SDP33/rho_air)
-
-
-plt.figure()
-plt.plot(dp_SDP33,airspeed_corrected)
-plt.xlabel('differential pressure raw value [Pa]')
-plt.ylabel('airspeed_corrected [m/s]')
-plt.show()
-
-
-
-##plt.figure()
-##plt.plot(dp_SDP33,airspeed_corrected/airspeed_raw)
-##plt.xlabel('differential pressure raw value [Pa]')
-##plt.ylabel('correction factor [-]')
-##plt.show()
-##
-##
-##
-##plt.figure()
-##plt.plot(airspeed_corrected,(airspeed_corrected-airspeed_raw)/airspeed_corrected)
-##plt.xlabel('airspeed [m/s]')
-##plt.ylabel('relative error [-]')
-##plt.show()
diff --git a/Tools/module_config/generate_actuators_metadata.py b/Tools/module_config/generate_actuators_metadata.py
index 5d38ecf499..38471b6821 100755
--- a/Tools/module_config/generate_actuators_metadata.py
+++ b/Tools/module_config/generate_actuators_metadata.py
@@ -234,14 +234,14 @@ def get_actuator_output(yaml_config, output_functions, timer_config_file, verbos
param_prefix = process_param_prefix(group['param_prefix'])
standard_params = group.get('standard_params', {})
standard_params_array = [
- ( 'function', 'Function', 'FUNC', False ),
- ( 'disarmed', 'Disarmed', 'DIS', False ),
- ( 'min', 'Minimum', 'MIN', False ),
- ( 'max', 'Maximum', 'MAX', False ),
- ( 'center', 'Center\n(for Servos)', 'CENT', False ),
- ( 'failsafe', 'Failsafe', 'FAIL', True ),
+ ( 'function', 'Function', 'FUNC', False, True ),
+ ( 'disarmed', 'Disarmed', 'DIS', False, True ),
+ ( 'min', 'Minimum', 'MIN', False, True ),
+ ( 'center', 'Center\n(for Servos)', 'CENT', False, False ),
+ ( 'max', 'Maximum', 'MAX', False, True ),
+ ( 'failsafe', 'Failsafe', 'FAIL', True, True ),
]
- for key, label, param_suffix, advanced in standard_params_array:
+ for key, label, param_suffix, advanced, has_function in standard_params_array:
show_if = None
if key in standard_params and 'show_if' in standard_params[key]:
show_if = standard_params[key]['show_if']
@@ -250,13 +250,12 @@ def get_actuator_output(yaml_config, output_functions, timer_config_file, verbos
param = {
'label': label,
'name': param_prefix+'_'+param_suffix+'${i}',
- 'function': key,
}
+ if has_function: param['function'] = key
if advanced: param['advanced'] = True
if show_if: param['show-if'] = show_if
per_channel_params.append(param)
-
param = {
'label': 'Rev Range\n(for Servos)',
'name': param_prefix+'_REV',
diff --git a/Tools/module_config/generate_params.py b/Tools/module_config/generate_params.py
index ecc29a4519..ea9e7d1ffc 100755
--- a/Tools/module_config/generate_params.py
+++ b/Tools/module_config/generate_params.py
@@ -108,7 +108,7 @@ def parse_yaml_parameters_config(yaml_config, ethernet_supported):
tags = '@group {:}'.format(param_group)
if param['type'] == 'enum':
param_type = 'INT32'
- for key in param['values']:
+ for key in sorted(param['values'], key=float):
tags += '\n * @value {:} {:}'.format(key, param['values'][key])
elif param['type'] == 'bitmask':
param_type = 'INT32'
@@ -124,6 +124,9 @@ def parse_yaml_parameters_config(yaml_config, ethernet_supported):
param_type = 'INT32'
elif param['type'] == 'float':
param_type = 'FLOAT'
+ if 'values' in param:
+ for key in sorted(param['values'], key=float):
+ tags += '\n * @value {:} {:}'.format(key, param['values'][key])
else:
raise Exception("unknown param type {:}".format(param['type']))
@@ -312,11 +315,7 @@ When set to -1 (default), the value depends on the function (see {:}).
if standard_params[key]['max'] >= 1<<16:
raise Exception('maximum value for {:} expected <= {:} (got {:})'.format(key, 1<<16, standard_params[key]['max']))
- if key == 'failsafe':
- standard_params[key]['default'] = -1
- standard_params[key]['min'] = -1
-
- if key == 'center':
+ if key == 'failsafe' or key == 'center':
standard_params[key]['default'] = -1
standard_params[key]['min'] = -1
diff --git a/Tools/msg/generate_msg_docs.py b/Tools/msg/generate_msg_docs.py
index 0b30cba0c5..95ab3f9af6 100755
--- a/Tools/msg/generate_msg_docs.py
+++ b/Tools/msg/generate_msg_docs.py
@@ -17,10 +17,10 @@ VALID_FIELDS = { #Note, also have to add the message types as those can be field
'uint32'
}
-ALLOWED_UNITS = set(["m", "m/s", "m/s^2", "(m/s)^2", "deg", "deg/s", "rad", "rad/s", "rad^2", "rpm" ,"V", "A", "mA", "mAh", "W", "dBm", "h", "s", "ms", "us", "Ohm", "MB", "Kb/s", "degC","Pa","%","-"])
+ALLOWED_UNITS = set(["m", "m/s", "m/s^2", "(m/s)^2", "deg", "deg/s", "rad", "rad/s", "rad^2", "rpm" ,"V", "A", "mA", "mAh", "W", "Wh", "dBm", "h", "minutes", "s", "ms", "us", "Ohm", "MB", "Kb/s", "degC","Pa", "%", "norm", "-"])
invalid_units = set()
-ALLOWED_FRAMES = set(["NED","Body"])
-ALLOWED_INVALID_VALUES = set(["NaN", "0"])
+ALLOWED_FRAMES = set(["NED", "Body", "FRD", "ENU"])
+ALLOWED_INVALID_VALUES = set(["NaN", "0", "-1"])
ALLOWED_CONSTANTS_NOT_IN_ENUM = set(["ORB_QUEUE_LENGTH","MESSAGE_VERSION"])
class Error:
@@ -36,14 +36,14 @@ class Error:
if 'trailing_whitespace' == self.type:
- if self.issueString.strip():
+ if self.issueString.strip():
print(f"NOTE: Line has trailing whitespace ({self.message}: {self.linenumber}): {self.issueString}")
else:
print(f"NOTE: Line has trailing whitespace ({self.message}: {self.linenumber})")
elif 'leading_whitespace_field_or_constant' == self.type:
- print(f"NOTE: Whitespace before field or constant ({self.message}: {self.linenumber}): {self.issueString}")
+ print(f"NOTE: Whitespace before field or constant ({self.message}: {self.linenumber}): {self.issueString}")
elif 'field_or_constant_has_multiple_whitepsace' == self.type:
- print(f"NOTE: Field/constant has more than one sequential whitespace character ({self.message}: {self.linenumber}): {self.issueString}")
+ print(f"NOTE: Field/constant has more than one sequential whitespace character ({self.message}: {self.linenumber}): {self.issueString}")
elif 'empty_start_line' == self.type:
print(f"NOTE: Empty line at start of file ({self.message}: {self.linenumber})")
elif 'internal_comment' == self.type:
@@ -191,7 +191,7 @@ class CommandParam:
if not "unknown_frame" in self.parent.errors:
self.parent.errors["unknown_frame"] = []
self.parent.errors["unknown_frame"].append(error)
- """
+ """
else:
print(f"WARNING: Unhandled metadata in message comment: {item}")
# TODO - report errors for different kinds of metadata
@@ -202,9 +202,9 @@ class CommandParam:
if item == "-":
unit = ""
-
+
if unit and unit not in self.units:
- self.units.append(unit)
+ self.units.append(unit)
if unit not in ALLOWED_UNITS:
invalid_units.add(unit)
@@ -221,7 +221,7 @@ class CommandParam:
print(f" paramText: {self.paramText}\n unit: {self.units}\n enums: {self.enums}\n lineNumber: {self.lineNumber}\n range: {self.range}\n minValue: {self.minValue}\n maxValue: {self.maxValue}\n invalidValue: {self.invalidValue}\n frameValue: {self.frameValue}\n parent: {self.parent}\n ")
-
+
class CommandConstant:
"""
Represents a constant that is a command definition.
@@ -252,9 +252,9 @@ class CommandConstant:
if not self.comment: # This is an bug for a command
#print(f"Debug WARNING: NO COMMENT in CommandConstant: {self.name}") ## TODO make into ERROR
return
-
+
# Parse command comment to get the description and parameters.
- # print(f"Debug CommandConstant: {self.comment}")
+ # print(f"Debug CommandConstant: {self.comment}")
if not "|" in self.comment:
# This is an error for a command constant
error = Error("command_no_params_pipes", self.parent.filename, self.line_number, self.comment, self.name)
@@ -263,7 +263,7 @@ class CommandConstant:
self.parent.errors["command_no_params_pipes"] = []
self.parent.errors["command_no_params_pipes"].append(error)
return
-
+
# Split on pipes
commandSplit = self.comment.split("|")
if len(commandSplit) < 9:
@@ -316,9 +316,11 @@ Param | Units | Range/Enum | Description
if val.minValue or val.maxValue:
rangeVal = f"[{val.minValue if val.minValue else '-'} : {val.maxValue if val.maxValue else '-' }]"
- output+=f"{i} | {", ".join(val.units)}|{', '.join(f"[{e}](#{e})" for e in val.enums)}{rangeVal} | {val.description}\n"
+ units_str = ", ".join(val.units)
+ enums_str = ', '.join("[{}](#{})".format(e, e) for e in val.enums)
+ output+=f"{i} | {units_str}|{enums_str}{rangeVal} | {val.description}\n"
else:
- output+=f"{i} | | | ?\n"
+ output+=f"{i} | | | ?\n"
output+=f"\n"
return output
@@ -419,7 +421,7 @@ class MessageField:
class UORBMessage:
"""
Represents a whole message, including fields, enums, commands, constants.
- The parser function delegates the parsing of each part of the message to
+ The parser function delegates the parsing of each part of the message to
more appropriate classes, once the specific type of line has been identified.
"""
@@ -511,11 +513,11 @@ pageClass: is-wide-page
markdown += "--- | --- | --- |---\n"
for name, command in self.commandConstants.items():
description = f" {command.comment} " if enum.comment else " "
- markdown += f' {name} | `{command.type}` | {command.value} |{description}\n'
+ markdown += f' {name} | `{command.type}` | {command.value} |{description}\n'
"""
for commandConstant in self.commandConstants.values():
#print(commandConstant)
- markdown += commandConstant.markdown_out()
+ markdown += commandConstant.markdown_out()
# Generate enum docs
if len(self.enums) > 0:
@@ -529,7 +531,7 @@ pageClass: is-wide-page
for enumValueName, enumValue in enum.enumValues.items():
description = f" {enumValue.comment} " if enumValue.comment else " "
- markdown += f' {enumValueName} | `{enumValue.type}` | {enumValue.value} |{description}\n'
+ markdown += f' {enumValueName} | `{enumValue.type}` | {enumValue.value} |{description}\n'
# Generate table for constants docs
if len(self.constantFields) > 0:
@@ -538,7 +540,7 @@ pageClass: is-wide-page
markdown += "--- | --- | --- |---\n"
for name, enum in self.constantFields.items():
description = f" {enum.comment} " if enum.comment else " "
- markdown += f' {name} | `{enum.type}` | {enum.value} |{description}\n'
+ markdown += f' {name} | `{enum.type}` | {enum.value} |{description}\n'
@@ -635,8 +637,8 @@ pageClass: is-wide-page
temp = fieldOrConstant.split("=")
value = temp[-1]
typeAndName = temp[0].split(" ")
- type = typeAndName[0]
- name = typeAndName[1]
+ type = typeAndName[0].strip()
+ name = typeAndName[1].strip()
if name.startswith("VEHICLE_CMD_") and parentMessage.name == 'VehicleCommand': #it's a command.
#print(f"DEBUG: startswith VEHICLE_CMD_ {name}")
commandConstant = CommandConstant(name, type, value, comment, line_number, parentMessage)
@@ -708,7 +710,7 @@ pageClass: is-wide-page
if stripped_line.startswith("#"):
# Its an internal comment
stripped_line=stripped_line[1:].strip()
-
+
if stripped_line:
#print(f"{self.filename}: Internal comment: [{line_number}]\n {line}")
error = Error("internal_comment", self.filename, line_number, line)
@@ -723,7 +725,7 @@ pageClass: is-wide-page
self.errors["internal_comment_empty"].append(error)
#pass # Empty comment
continue
-
+
# Must be a field or a comment.
self.handleField(line, line_number, parentMessage=self)
@@ -833,11 +835,9 @@ def generate_dds_yaml_doc(allMessageFiles, output_file = 'dds_topics.md'):
for message in data["subscriptions"]:
all_message_types.add(message['type'].split("::")[-1])
all_topics.add(message['topic'].split('/')[-1])
- if data["subscriptions_multi"]: # There is none now
- dds_markdown += "None\n"
- for message in data["subscriptions_multi"]:
- all_message_types.add(message['type'].split("::")[-1])
- all_topics.add(message['topic'].split('/')[-1])
+ for message in (data.get("subscriptions_multi") or []):
+ all_message_types.add(message['type'].split("::")[-1])
+ all_topics.add(message['topic'].split('/')[-1])
for message in allMessageFiles:
all_messages_in_source.add(message.split('/')[-1].split('.')[0])
messagesNotExported = all_messages_in_source - all_message_types
@@ -874,13 +874,17 @@ Topic | Type| Rate Limit
dds_markdown += "\n## Subscriptions Multi\n\n"
- if not data["subscriptions_multi"]: # There is none now
+ subscriptions_multi = data.get("subscriptions_multi") or []
+ if not subscriptions_multi:
dds_markdown += "None\n"
else:
- print("Warning - we now have subscription_multi data - check format")
- dds_markdown += "Topic | Type\n--- | ---\n"
- for message in data["subscriptions_multi"]:
- dds_markdown += f"{message['topic']} | {message['type']}\n"
+ dds_markdown += "Topic | Type | Route Field | Max Instances\n--- | --- | --- | ---\n"
+ for message in subscriptions_multi:
+ type = message['type']
+ px4Type = type.split("::")[-1]
+ route_field = f"`{message['route_field']}`" if 'route_field' in message else "-"
+ max_instances = message.get('max_instances', '-')
+ dds_markdown += f"{message['topic']} | [{type}](../msg_docs/{px4Type}.md) | {route_field} | {max_instances}\n"
if messagesNotExported:
# Print the topics that are not exported to DDS
@@ -944,9 +948,6 @@ if __name__ == "__main__":
for msg_file in msg_files:
# Add messages to set of allowed types (compound types)
- #msg_type = msg_file.rsplit('/')[-1]
- #msg_type = msg_type.rsplit('\\')[-1]
- #msg_type = msg_type.rsplit('.')[0]
msg_name = os.path.splitext(os.path.basename(msg_file))[0]
msgTypes.add(msg_name)
diff --git a/Tools/packaging/Dockerfile.gazebo b/Tools/packaging/Dockerfile.gazebo
new file mode 100644
index 0000000000..4b07098b04
--- /dev/null
+++ b/Tools/packaging/Dockerfile.gazebo
@@ -0,0 +1,93 @@
+# syntax=docker/dockerfile:1
+# PX4 SITL Gazebo Harmonic runtime image
+# Runs PX4 SITL with Gazebo Harmonic. Supports X11 forwarding for GUI.
+#
+# Build:
+# make px4_sitl_default && cd build/px4_sitl_default && cpack -G DEB && cd ../..
+# docker build -f Tools/packaging/Dockerfile.gazebo -t px4io/px4-sitl-gazebo:v1.17.0 build/px4_sitl_default/
+#
+# Run (headless):
+# docker run --rm -it --network host px4io/px4-sitl-gazebo:v1.17.0
+#
+# Run (X11 GUI):
+# xhost +local:docker
+# docker run --rm -it --network host \
+# -e DISPLAY=$DISPLAY -v /tmp/.X11-unix:/tmp/.X11-unix \
+# --gpus all px4io/px4-sitl-gazebo:v1.17.0
+#
+# Persist flight logs on the host (ulog files):
+# mkdir -p ./px4-logs
+# docker run --rm -it --network host \
+# -v $(pwd)/px4-logs:/root/.local/share/px4/rootfs/log \
+# px4io/px4-sitl-gazebo:v1.17.0
+
+FROM ubuntu:24.04
+LABEL maintainer="PX4 Development Team"
+LABEL description="PX4 SITL with Gazebo Harmonic simulation"
+
+ENV DEBIAN_FRONTEND=noninteractive
+ENV RUNS_IN_DOCKER=true
+
+# Configure the OSRF Gazebo apt repo so Gazebo Harmonic (a Depends: of the
+# px4-gazebo .deb) resolves from it in the install step below.
+RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
+ --mount=type=cache,target=/var/lib/apt,sharing=locked \
+ apt-get update \
+ && apt-get install -y --no-install-recommends \
+ bc \
+ ca-certificates \
+ gnupg \
+ lsb-release \
+ wget \
+ && wget -q https://packages.osrfoundation.org/gazebo.gpg \
+ -O /usr/share/keyrings/pkgs-osrf-archive-keyring.gpg \
+ && echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/pkgs-osrf-archive-keyring.gpg] http://packages.osrfoundation.org/gazebo/ubuntu-stable $(lsb_release -cs) main" \
+ > /etc/apt/sources.list.d/gazebo-stable.list \
+ && apt-get update
+
+# Install px4-gazebo via apt so its Depends (Gazebo Harmonic, OpenCV, GStreamer
+# core libs, gstreamer plugin packages) are resolved automatically. The binary
+# is stripped after install to trim image size (~a few MB).
+COPY px4-gazebo_*.deb /tmp/
+RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
+ --mount=type=cache,target=/var/lib/apt,sharing=locked \
+ apt-get update \
+ && apt-get install -y --no-install-recommends \
+ /tmp/px4-gazebo_*.deb \
+ binutils \
+ && strip /opt/px4-gazebo/bin/px4 \
+ && apt-get purge -y binutils \
+ && apt-get autoremove -y \
+ && rm -f /tmp/px4-gazebo_*.deb \
+ && ln -sf /opt/px4-gazebo/bin/px4-gazebo /usr/bin/px4-gazebo
+
+# Create the DART physics engine symlink (avoids needing the -dev package)
+RUN GZ_PHYSICS_DIR=$(find /usr/lib -maxdepth 3 -type d -name "engine-plugins" -path "*/gz-physics-7/*" 2>/dev/null | head -1) \
+ && if [ -n "$GZ_PHYSICS_DIR" ] && [ -d "$GZ_PHYSICS_DIR" ]; then \
+ VERSIONED=$(ls "$GZ_PHYSICS_DIR"/libgz-physics*-dartsim-plugin.so.* 2>/dev/null | head -1) \
+ && [ -n "$VERSIONED" ] \
+ && ln -sf "$(basename "$VERSIONED")" "$GZ_PHYSICS_DIR/libgz-physics-dartsim-plugin.so"; \
+ fi
+
+# Gazebo resource paths
+ENV GZ_SIM_RESOURCE_PATH=/opt/px4-gazebo/share/gz/models:/opt/px4-gazebo/share/gz/worlds
+ENV GZ_SIM_SYSTEM_PLUGIN_PATH=/opt/px4-gazebo/lib/gz/plugins
+ENV GZ_SIM_SERVER_CONFIG_PATH=/opt/px4-gazebo/share/gz/server.config
+ENV PX4_GZ_MODELS=/opt/px4-gazebo/share/gz/models
+ENV PX4_GZ_WORLDS=/opt/px4-gazebo/share/gz/worlds
+
+ENV PX4_SIM_MODEL=gz_x500
+ENV HOME=/root
+
+# MAVLink, MAVSDK, DDS
+EXPOSE 14550/udp 14540/udp 8888/udp
+
+# Platform-adaptive entrypoint: detects Docker Desktop (macOS/Windows) via
+# host.docker.internal and configures MAVLink + DDS to target the host.
+COPY px4-entrypoint.sh /opt/px4-gazebo/bin/px4-entrypoint.sh
+RUN chmod +x /opt/px4-gazebo/bin/px4-entrypoint.sh
+
+WORKDIR /root
+
+ENTRYPOINT ["/opt/px4-gazebo/bin/px4-entrypoint.sh"]
+CMD []
diff --git a/Tools/packaging/Dockerfile.sih b/Tools/packaging/Dockerfile.sih
new file mode 100644
index 0000000000..6067158fb0
--- /dev/null
+++ b/Tools/packaging/Dockerfile.sih
@@ -0,0 +1,57 @@
+# syntax=docker/dockerfile:1
+# PX4 SITL SIH runtime image
+# Minimal container that runs PX4 with the SIH physics engine (no Gazebo).
+#
+# Build:
+# make px4_sitl_sih && cd build/px4_sitl_sih && cpack -G DEB && cd ../..
+# docker build -f Tools/packaging/Dockerfile.sih -t px4io/px4-sitl:v1.17.0 build/px4_sitl_sih/
+#
+# Run (Linux):
+# docker run --rm -it --network host px4io/px4-sitl:v1.17.0
+#
+# Run (macOS / Windows):
+# docker run --rm -it -p 14550:14550/udp -p 14540:14540/udp -p 19410:19410/udp -p 8888:8888/udp px4io/px4-sitl:v1.17.0
+#
+# Persist flight logs on the host (ulog files):
+# mkdir -p ./px4-logs
+# docker run --rm -it --network host \
+# -v $(pwd)/px4-logs:/root/.local/share/px4/rootfs/log \
+# px4io/px4-sitl:v1.17.0
+
+FROM ubuntu:24.04
+LABEL maintainer="PX4 Development Team"
+LABEL description="PX4 SITL with SIH physics (no simulator dependencies)"
+
+ENV DEBIAN_FRONTEND=noninteractive
+
+# Install px4 via apt so Depends: are resolved automatically. The binary is
+# stripped after install to trim image size (~a few MB).
+COPY px4_*.deb /tmp/
+RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
+ --mount=type=cache,target=/var/lib/apt,sharing=locked \
+ apt-get update \
+ && apt-get install -y --no-install-recommends \
+ /tmp/px4_*.deb \
+ bc \
+ binutils \
+ && strip /opt/px4/bin/px4 \
+ && apt-get purge -y binutils \
+ && apt-get autoremove -y \
+ && rm -f /tmp/px4_*.deb \
+ && ln -sf /opt/px4/bin/px4 /usr/bin/px4
+
+# Platform-adaptive entrypoint: detects Docker Desktop (macOS/Windows) via
+# host.docker.internal and configures MAVLink + DDS to target the host.
+COPY px4-entrypoint.sh /opt/px4/bin/px4-entrypoint.sh
+RUN chmod +x /opt/px4/bin/px4-entrypoint.sh
+
+ENV PX4_SIM_MODEL=sihsim_quadx
+ENV HOME=/root
+
+# MAVLink (QGC, MAVSDK), DDS (ROS 2), jMAVSim/viewer display
+EXPOSE 14550/udp 14540/udp 19410/udp 8888/udp
+
+WORKDIR /root
+
+ENTRYPOINT ["/opt/px4/bin/px4-entrypoint.sh"]
+CMD []
diff --git a/Tools/packaging/postinst b/Tools/packaging/postinst
new file mode 100755
index 0000000000..2de9347bcf
--- /dev/null
+++ b/Tools/packaging/postinst
@@ -0,0 +1,4 @@
+#!/bin/sh
+set -e
+ln -sf /opt/px4-gazebo/bin/px4-gazebo /usr/bin/px4-gazebo
+exit 0
diff --git a/Tools/packaging/postrm b/Tools/packaging/postrm
new file mode 100755
index 0000000000..1c8227e1c9
--- /dev/null
+++ b/Tools/packaging/postrm
@@ -0,0 +1,6 @@
+#!/bin/sh
+set -e
+if [ "$1" = "remove" ] || [ "$1" = "purge" ]; then
+ rm -f /usr/bin/px4-gazebo
+fi
+exit 0
diff --git a/Tools/packaging/px4-entrypoint.sh b/Tools/packaging/px4-entrypoint.sh
new file mode 100644
index 0000000000..a1d9b6c1f8
--- /dev/null
+++ b/Tools/packaging/px4-entrypoint.sh
@@ -0,0 +1,35 @@
+#!/bin/sh
+# Docker entrypoint for PX4 SITL containers.
+#
+# On Docker Desktop (macOS/Windows), host.docker.internal resolves to the
+# host machine. We detect this and configure MAVLink + DDS to send to the
+# host IP instead of localhost (which stays inside the container VM).
+#
+# On Linux with --network host, host.docker.internal does not resolve and
+# PX4 defaults work without modification.
+
+set -e
+
+# Detect install prefix (SIH uses /opt/px4, Gazebo uses /opt/px4-gazebo)
+if [ -d /opt/px4-gazebo ]; then
+ PX4_PREFIX=/opt/px4-gazebo
+else
+ PX4_PREFIX=/opt/px4
+fi
+
+# Resolve host.docker.internal to an IPv4 address. mavlink and uxrce_dds_client
+# only parse IPv4, and on Docker Desktop for Windows the default `getent hosts`
+# lookup can return an IPv6 ULA first, which both modules then reject.
+DOCKER_HOST_IP=$(getent ahostsv4 host.docker.internal 2>/dev/null | awk '/STREAM/ {print $1; exit}')
+
+if [ -n "$DOCKER_HOST_IP" ]; then
+ # MAVLink: replace default target (127.0.0.1) with the Docker host IP
+ sed -i "s/mavlink start -x -u/mavlink start -x -t $DOCKER_HOST_IP -u/g" \
+ "$PX4_PREFIX/etc/init.d-posix/px4-rc.mavlink"
+
+ # DDS: point uXRCE-DDS client at the host
+ sed -i "s|uxrce_dds_client start -t udp|uxrce_dds_client start -t udp -h $DOCKER_HOST_IP|" \
+ "$PX4_PREFIX/etc/init.d-posix/rcS"
+fi
+
+exec "$PX4_PREFIX/bin/px4" "$@"
diff --git a/Tools/packaging/px4-gazebo.sh b/Tools/packaging/px4-gazebo.sh
new file mode 100644
index 0000000000..7f26aaafed
--- /dev/null
+++ b/Tools/packaging/px4-gazebo.sh
@@ -0,0 +1,32 @@
+#!/usr/bin/env bash
+# px4-gazebo: Launch PX4 SITL with Gazebo from the installed .deb package
+set -e
+
+PX4_GAZEBO_DIR="$(cd "$(dirname "$(readlink -f "$0")")/.." && pwd)"
+PX4_BINARY="${PX4_GAZEBO_DIR}/bin/px4"
+
+# Set Gazebo resource paths so gz-sim finds PX4 models, worlds, and plugins.
+export PX4_GZ_MODELS="${PX4_GAZEBO_DIR}/share/gz/models"
+export PX4_GZ_WORLDS="${PX4_GAZEBO_DIR}/share/gz/worlds"
+export PX4_GZ_PLUGINS="${PX4_GAZEBO_DIR}/lib/gz/plugins"
+export PX4_GZ_SERVER_CONFIG="${PX4_GAZEBO_DIR}/share/gz/server.config"
+export GZ_SIM_RESOURCE_PATH="${GZ_SIM_RESOURCE_PATH}:${PX4_GZ_MODELS}:${PX4_GZ_WORLDS}"
+export GZ_SIM_SYSTEM_PLUGIN_PATH="${GZ_SIM_SYSTEM_PLUGIN_PATH}:${PX4_GZ_PLUGINS}"
+export GZ_SIM_SERVER_CONFIG_PATH="${PX4_GZ_SERVER_CONFIG}"
+
+# Gazebo's Physics system searches for "gz-physics-dartsim-plugin" which maps
+# to the unversioned libgz-physics-dartsim-plugin.so. The runtime package only
+# ships versioned .so files; the unversioned symlink lives in the -dev package.
+# Create it if missing so Gazebo finds the DART engine without installing -dev.
+GZ_PHYSICS_ENGINE_DIR=$(find /usr/lib -maxdepth 3 -type d -name "engine-plugins" -path "*/gz-physics-7/*" 2>/dev/null | head -1)
+if [ -n "$GZ_PHYSICS_ENGINE_DIR" ] && [ -d "$GZ_PHYSICS_ENGINE_DIR" ]; then
+ UNVERSIONED="$GZ_PHYSICS_ENGINE_DIR/libgz-physics-dartsim-plugin.so"
+ if [ ! -e "$UNVERSIONED" ]; then
+ VERSIONED=$(ls "$GZ_PHYSICS_ENGINE_DIR"/libgz-physics*-dartsim-plugin.so.* 2>/dev/null | head -1)
+ if [ -n "$VERSIONED" ]; then
+ ln -sf "$(basename "$VERSIONED")" "$UNVERSIONED" 2>/dev/null || true
+ fi
+ fi
+fi
+
+exec "${PX4_BINARY}" "$@"
diff --git a/Tools/packaging/sih/postinst b/Tools/packaging/sih/postinst
new file mode 100755
index 0000000000..23ed21fbdc
--- /dev/null
+++ b/Tools/packaging/sih/postinst
@@ -0,0 +1,4 @@
+#!/bin/sh
+set -e
+ln -sf /opt/px4/bin/px4 /usr/bin/px4
+exit 0
diff --git a/Tools/packaging/sih/postrm b/Tools/packaging/sih/postrm
new file mode 100755
index 0000000000..ecde8ccce7
--- /dev/null
+++ b/Tools/packaging/sih/postrm
@@ -0,0 +1,6 @@
+#!/bin/sh
+set -e
+if [ "$1" = "remove" ] || [ "$1" = "purge" ]; then
+ rm -f /usr/bin/px4
+fi
+exit 0
diff --git a/Tools/packaging/test_sih_mission.py b/Tools/packaging/test_sih_mission.py
new file mode 100644
index 0000000000..deeee6df84
--- /dev/null
+++ b/Tools/packaging/test_sih_mission.py
@@ -0,0 +1,144 @@
+#!/usr/bin/env python3
+"""
+MAVSDK mission test for PX4 SIH SITL in Docker.
+
+Takes off to 100m, flies a short 4-waypoint box mission, then lands.
+Validates that the SIH Docker container works end-to-end with MAVSDK.
+
+Prerequisites:
+ - Docker container running:
+ docker run --rm --network host px4io/px4-sitl:v1.17.0-alpha1
+ - pip install mavsdk
+ - mavsim-viewer running (optional):
+ /path/to/mavsim-viewer -n 1
+
+Usage:
+ python3 Tools/packaging/test_sih_mission.py
+ python3 Tools/packaging/test_sih_mission.py --speed 10 # faster-than-realtime
+"""
+
+import asyncio
+import argparse
+import sys
+import time
+
+from mavsdk import System
+from mavsdk.mission import MissionItem, MissionPlan
+
+
+async def run_mission(speed_factor: int = 1):
+ drone = System()
+ print(f"Connecting to drone on udp://:14540 ...")
+ await drone.connect(system_address="udp://:14540")
+
+ print("Waiting for drone to connect...")
+ async for state in drone.core.connection_state():
+ if state.is_connected:
+ print(f"Connected (UUID: {state.uuid if hasattr(state, 'uuid') else 'N/A'})")
+ break
+
+ print("Waiting for global position estimate...")
+ async for health in drone.telemetry.health():
+ if health.is_global_position_ok and health.is_home_position_ok:
+ print("Global position OK")
+ break
+
+ # Get home position for reference
+ async for pos in drone.telemetry.position():
+ home_lat = pos.latitude_deg
+ home_lon = pos.longitude_deg
+ print(f"Home position: {home_lat:.6f}, {home_lon:.6f}")
+ break
+
+ # Build a small box mission at 100m AGL
+ # ~100m offset in each direction
+ offset = 0.001 # roughly 111m at equator
+ mission_items = [
+ MissionItem(
+ home_lat + offset, home_lon,
+ 100, 10, True, float('nan'), float('nan'),
+ MissionItem.CameraAction.NONE,
+ float('nan'), float('nan'), float('nan'),
+ float('nan'), float('nan'),
+ MissionItem.VehicleAction.NONE,
+ ),
+ MissionItem(
+ home_lat + offset, home_lon + offset,
+ 100, 10, True, float('nan'), float('nan'),
+ MissionItem.CameraAction.NONE,
+ float('nan'), float('nan'), float('nan'),
+ float('nan'), float('nan'),
+ MissionItem.VehicleAction.NONE,
+ ),
+ MissionItem(
+ home_lat, home_lon + offset,
+ 100, 10, True, float('nan'), float('nan'),
+ MissionItem.CameraAction.NONE,
+ float('nan'), float('nan'), float('nan'),
+ float('nan'), float('nan'),
+ MissionItem.VehicleAction.NONE,
+ ),
+ MissionItem(
+ home_lat, home_lon,
+ 100, 10, True, float('nan'), float('nan'),
+ MissionItem.CameraAction.NONE,
+ float('nan'), float('nan'), float('nan'),
+ float('nan'), float('nan'),
+ MissionItem.VehicleAction.NONE,
+ ),
+ ]
+
+ mission_plan = MissionPlan(mission_items)
+
+ print(f"Uploading mission ({len(mission_items)} waypoints, 100m AGL)...")
+ await drone.mission.upload_mission(mission_plan)
+ print("Mission uploaded")
+
+ print("Arming...")
+ await drone.action.arm()
+ print("Armed")
+
+ t0 = time.time()
+ print("Starting mission...")
+ await drone.mission.start_mission()
+
+ # Monitor mission progress
+ async for progress in drone.mission.mission_progress():
+ elapsed = time.time() - t0
+ print(f" [{elapsed:6.1f}s] Waypoint {progress.current}/{progress.total}")
+ if progress.current == progress.total:
+ print(f"Mission complete in {elapsed:.1f}s (speed factor: {speed_factor}x)")
+ break
+
+ print("Returning to launch...")
+ await drone.action.return_to_launch()
+
+ # Wait for landing
+ async for in_air in drone.telemetry.in_air():
+ if not in_air:
+ print("Landed")
+ break
+
+ print("Disarming...")
+ await drone.action.disarm()
+ print("Test PASSED")
+
+
+def main():
+ parser = argparse.ArgumentParser(description="PX4 SIH Docker mission test")
+ parser.add_argument("--speed", type=int, default=1,
+ help="PX4_SIM_SPEED_FACTOR (must match container)")
+ args = parser.parse_args()
+
+ try:
+ asyncio.run(run_mission(args.speed))
+ except KeyboardInterrupt:
+ print("\nInterrupted")
+ sys.exit(1)
+ except Exception as e:
+ print(f"Test FAILED: {e}")
+ sys.exit(1)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/Tools/px4_uploader.py b/Tools/px4_uploader.py
index 3b54950747..10814b2c26 100755
--- a/Tools/px4_uploader.py
+++ b/Tools/px4_uploader.py
@@ -198,7 +198,6 @@ class BootloaderCommand(IntEnum):
GET_OTP = 0x2A # rev4+, get a word from OTP area
GET_SN = 0x2B # rev4+, get a word from SN area
GET_CHIP = 0x2C # rev5+, get chip version
- SET_BOOT_DELAY = 0x2D # rev5+, set boot delay
GET_CHIP_DES = 0x2E # rev5+, get chip description in ASCII
GET_VERSION = 0x2F # rev5+, get bootloader version in ASCII
REBOOT = 0x30
@@ -1148,20 +1147,6 @@ class BootloaderProtocol:
else:
self.verify_read(firmware, progress_callback)
- def set_boot_delay(self, delay_ms: int) -> None:
- """Set boot delay in flash (v5+).
-
- Args:
- delay_ms: Boot delay in milliseconds
- """
- if self.bl_rev < 5:
- logger.warning("Boot delay requires bootloader v5+")
- return
-
- self._send_command(BootloaderCommand.SET_BOOT_DELAY, struct.pack("b", delay_ms))
- self._get_sync()
- logger.info(f"Boot delay set to {delay_ms}ms")
-
def reboot(self) -> None:
"""Reboot into the application.
@@ -1569,7 +1554,6 @@ class UploaderConfig:
baud_flightstack: list[int] = field(default_factory=lambda: [57600])
force: bool = False
force_erase: bool = False
- boot_delay: Optional[int] = None
use_protocol_splitter: bool = False
retry_count: int = 3
windowed: bool = False
@@ -1924,10 +1908,6 @@ class Uploader:
# Verify
protocol.verify(firmware, progress_callback=progress.update_verify)
- # Set boot delay if requested
- if self.config.boot_delay is not None:
- protocol.set_boot_delay(self.config.boot_delay)
-
# Reboot and show summary
protocol.reboot()
progress.finish()
@@ -2009,9 +1989,6 @@ Examples:
action="store_true",
help="Force full chip erase (v6+ bootloader)",
)
- parser.add_argument(
- "--boot-delay", type=int, help="Boot delay in milliseconds to store in flash"
- )
parser.add_argument(
"--use-protocol-splitter-format",
action="store_true",
@@ -2068,7 +2045,6 @@ Examples:
baud_flightstack=baud_flightstack,
force=args.force,
force_erase=args.force_erase,
- boot_delay=args.boot_delay,
use_protocol_splitter=args.use_protocol_splitter_format,
windowed=args.windowed,
noninteractive=args.noninteractive or args.noninteractive_json,
diff --git a/Tools/px_mkfw.py b/Tools/px_mkfw.py
index f31d2a8972..8ed8d4aa19 100755
--- a/Tools/px_mkfw.py
+++ b/Tools/px_mkfw.py
@@ -42,6 +42,7 @@
import argparse
import json
import base64
+import os
import zlib
import time
import subprocess
@@ -99,14 +100,13 @@ if args.summary != None:
if args.description != None:
desc['description'] = str(args.description)
if args.git_identity != None:
- cmd = "git --git-dir '{:}/.git' describe --exclude ext/* --always --tags".format(args.git_identity)
- p = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE).stdout
- desc['git_identity'] = p.read().strip().decode('utf-8')
- p.close()
- cmd = "git --git-dir '{:}/.git' rev-parse --verify HEAD".format(args.git_identity)
- p = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE).stdout
- desc['git_hash'] = p.read().strip().decode('utf-8')
- p.close()
+ git_dir = os.path.join(args.git_identity, '.git')
+ p = subprocess.run(["git", "--git-dir", git_dir, "describe", "--exclude", "ext/*", "--always", "--tags"],
+ stdout=subprocess.PIPE, text=True)
+ desc['git_identity'] = p.stdout.strip()
+ p = subprocess.run(["git", "--git-dir", git_dir, "rev-parse", "--verify", "HEAD"],
+ stdout=subprocess.PIPE, text=True)
+ desc['git_hash'] = p.stdout.strip()
if args.parameter_xml != None:
f = open(args.parameter_xml, "rb")
bytes = f.read()
diff --git a/Tools/run-shellcheck.sh b/Tools/run-shellcheck.sh
index 5c745bad7a..bdd78f28d0 100755
--- a/Tools/run-shellcheck.sh
+++ b/Tools/run-shellcheck.sh
@@ -1,4 +1,4 @@
-#!/bin/bash
+#!/usr/bin/env bash
# Script to run ShellCheck (a static analysis tool for shell scripts) over a
# script directory
diff --git a/Tools/setup/docker-entrypoint.sh b/Tools/setup/docker-entrypoint.sh
index 69101c5833..5ffe214569 100755
--- a/Tools/setup/docker-entrypoint.sh
+++ b/Tools/setup/docker-entrypoint.sh
@@ -1,4 +1,4 @@
-#!/bin/bash
+#!/usr/bin/env bash
GREEN='\033[0;32m'
NO_COLOR='\033[0m' # No Color
diff --git a/Tools/setup/macos.sh b/Tools/setup/macos.sh
index 8218b4e9ab..2d2326d4ea 100755
--- a/Tools/setup/macos.sh
+++ b/Tools/setup/macos.sh
@@ -6,11 +6,11 @@
## Installs:
## - Common dependencies and tools for building PX4
## - Cross compilers for building hardware targets using NuttX
-## - Can also install the default simulation provided by the px4-sim homebrew
-## Formula
+## - With --sim-tools: Gazebo Harmonic and jMAVSim simulation stack
##
-## For more information regarding the Homebrew Formulas see:
-## https://github.com/PX4/homebrew-px4/
+## Homebrew 4.5+ no longer auto-resolves cross-tap dependencies, so
+## every tap and package is listed explicitly here rather than hidden
+## behind meta-formulae. See PX4/homebrew-px4#104 for background.
##
# script directory
@@ -40,45 +40,110 @@ then
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install.sh)"
fi
-# Install px4-dev formula
+# Required taps. Homebrew 4.5+ no longer auto-resolves cross-tap
+# dependencies, so every tap that a package lives in must be added
+# explicitly here before `brew install`.
+#
+# - osx-cross/arm: arm-gcc-bin@13 (ARM cross-compiler)
+# - PX4/px4: fastdds, genromfs, kconfig-frontends (PX4-specific)
+brew tap osx-cross/arm
+brew tap PX4/px4
+
+# Package list. This replaces the px4-dev meta-formula, which is kept
+# as a deprecated no-op upstream. See PX4/homebrew-px4 for history.
+PX4_BREW_PACKAGES=(
+ ant
+ astyle
+ bash-completion
+ ccache
+ cmake
+ fastdds
+ genromfs
+ kconfig-frontends
+ ncurses
+ ninja
+ osx-cross/arm/arm-gcc-bin@13
+ python
+ python-tk
+)
+
if [[ $REINSTALL_FORMULAS == "--reinstall" ]]; then
- echo "[macos.sh] Re-installing dependencies (homebrew px4-dev)"
-
- # confirm Homebrew installed correctly
+ echo "[macos.sh] Re-installing PX4 toolchain dependencies"
brew doctor
-
- brew tap osx-cross/arm
- brew tap PX4/px4
-
- brew reinstall px4-dev
- brew link --overwrite --force arm-gcc-bin@13
+ brew reinstall "${PX4_BREW_PACKAGES[@]}"
else
- if brew ls --versions px4-dev > /dev/null; then
- echo "[macos.sh] px4-dev already installed"
- else
- echo "[macos.sh] Installing general dependencies (homebrew px4-dev)"
-
- brew tap osx-cross/arm
- brew tap PX4/px4
-
- brew install px4-dev
- brew link --overwrite --force arm-gcc-bin@13
- fi
+ echo "[macos.sh] Installing PX4 toolchain dependencies"
+ brew install "${PX4_BREW_PACKAGES[@]}"
fi
+brew link --overwrite --force arm-gcc-bin@13
+
# Python dependencies
echo "[macos.sh] Installing Python3 dependencies"
+
+# Resolve to git repo root based on script location (handles submodules and subdirectory invocation)
+ROOT_DIR="$(git -C "$DIR" rev-parse --show-toplevel 2>/dev/null || echo "$DIR")"
+VENV_DIR="$ROOT_DIR/.venv"
+
+# Create virtual environment if it doesn't exist
+if [ ! -d "$VENV_DIR" ]; then
+ echo "[macos.sh] Creating Python virtual environment at $VENV_DIR"
+ python3 -m venv "$VENV_DIR"
+fi
+
# We need to have future to install pymavlink later.
-python3 -m pip install future
-python3 -m pip install --user -r ${DIR}/requirements.txt
+"$VENV_DIR/bin/pip" install future
+"$VENV_DIR/bin/pip" install -r "${DIR}/requirements.txt"
# Optional, but recommended additional simulation tools:
if [[ $INSTALL_SIM == "--sim-tools" ]]; then
- if ! brew ls --versions px4-sim > /dev/null; then
- brew install px4-sim
- elif [[ $REINSTALL_FORMULAS == "--reinstall" ]]; then
- brew reinstall px4-sim
+ # Simulation packages. This replaces the px4-sim / px4-sim-gazebo
+ # meta-formulae, which declared cross-tap dependencies that
+ # Homebrew 4.5+ no longer auto-resolves. Same migration pattern as
+ # the toolchain block above. See PX4/homebrew-px4#104 for the
+ # px4-dev precedent.
+ #
+ # osrf/simulation: gz-harmonic (Gazebo Harmonic meta-formula)
+ brew tap osrf/simulation
+
+ PX4_SIM_BREW_PACKAGES=(
+ exiftool
+ glog
+ graphviz
+ gstreamer
+ opencv
+ osrf/simulation/gz-harmonic
+ protobuf
+ )
+
+ if [[ $REINSTALL_FORMULAS == "--reinstall" ]]; then
+ echo "[macos.sh] Re-installing PX4 simulation dependencies"
+ brew reinstall "${PX4_SIM_BREW_PACKAGES[@]}"
+ else
+ echo "[macos.sh] Installing PX4 simulation dependencies"
+ brew install "${PX4_SIM_BREW_PACKAGES[@]}"
+ fi
+
+ # XQuartz is required for Gazebo GUI display on macOS.
+ if ! brew list --cask xquartz &> /dev/null; then
+ echo "[macos.sh] Installing XQuartz (required for Gazebo display)"
+ brew install --cask xquartz
+ fi
+
+ # jMAVSim requires a JDK (Java 17 LTS recommended)
+ if ! brew ls --versions openjdk@17 > /dev/null; then
+ echo "[macos.sh] Installing OpenJDK 17 (required for jMAVSim)"
+ brew install openjdk@17
+ sudo ln -sfn $(brew --prefix openjdk@17)/libexec/openjdk.jdk /Library/Java/JavaVirtualMachines/openjdk-17.jdk
fi
fi
+echo ""
echo "[macos.sh] All set! The PX4 Autopilot toolchain was installed."
+echo ""
+echo "Python dependencies were installed into a virtual environment at:"
+echo " $VENV_DIR"
+echo ""
+echo "Activate it before building (run in each new terminal session):"
+echo " source $VENV_DIR/bin/activate"
+echo ""
diff --git a/Tools/setup/requirements.txt b/Tools/setup/requirements.txt
index b9d83da852..1725da87ab 100644
--- a/Tools/setup/requirements.txt
+++ b/Tools/setup/requirements.txt
@@ -23,7 +23,7 @@ pyserial
pyulog>=0.5.0
pyyaml
requests
-setuptools>=39.2.0
+setuptools>=39.2.0,<=81.0.0
six>=1.12.0
sympy>=1.10.1
toml>=0.9
diff --git a/Tools/setup/ubuntu.sh b/Tools/setup/ubuntu.sh
index f84db9a603..842eb57124 100755
--- a/Tools/setup/ubuntu.sh
+++ b/Tools/setup/ubuntu.sh
@@ -196,6 +196,11 @@ if [[ $INSTALL_NUTTX == "true" ]]; then
fi
fi
+if [[ "${UBUNTU_RELEASE}" == "25.10" ]]; then
+ echo "[ubuntu.sh] Gazebo binaries are not available for 25.10, skipping installation"
+ INSTALL_SIM="false"
+fi
+
# Simulation tools
if [[ $INSTALL_SIM == "true" ]]; then
diff --git a/Tools/simulation/gazebo-classic/setup_gazebo.bash b/Tools/simulation/gazebo-classic/setup_gazebo.bash
index 7829c929c6..2be4e42fbf 100755
--- a/Tools/simulation/gazebo-classic/setup_gazebo.bash
+++ b/Tools/simulation/gazebo-classic/setup_gazebo.bash
@@ -1,4 +1,4 @@
-#!/bin/bash
+#!/usr/bin/env bash
#
# Setup environment to make PX4 visible to Gazebo.
#
diff --git a/Tools/simulation/gazebo-classic/sitl_gazebo-classic b/Tools/simulation/gazebo-classic/sitl_gazebo-classic
index 5b6966ed57..f835e077d0 160000
--- a/Tools/simulation/gazebo-classic/sitl_gazebo-classic
+++ b/Tools/simulation/gazebo-classic/sitl_gazebo-classic
@@ -1 +1 @@
-Subproject commit 5b6966ed572a02e8273f446acb504a45a841ca53
+Subproject commit f835e077d06eaf09a57d5152fcfb85244b53b77a
diff --git a/Tools/simulation/gazebo-classic/sitl_multiple_run.sh b/Tools/simulation/gazebo-classic/sitl_multiple_run.sh
index 114003163a..3f3ff6f5d8 100755
--- a/Tools/simulation/gazebo-classic/sitl_multiple_run.sh
+++ b/Tools/simulation/gazebo-classic/sitl_multiple_run.sh
@@ -1,4 +1,4 @@
-#!/bin/bash
+#!/usr/bin/env bash
# run multiple instances of the 'px4' binary, with the gazebo SITL simulation
# It assumes px4 is already built, with 'make px4_sitl_default sitl_gazebo-classic'
diff --git a/Tools/simulation/sitl_multiple_run.sh b/Tools/simulation/sitl_multiple_run.sh
index 6b95de44cf..39554a622d 100755
--- a/Tools/simulation/sitl_multiple_run.sh
+++ b/Tools/simulation/sitl_multiple_run.sh
@@ -1,25 +1,28 @@
-#!/bin/bash
-# run multiple instances of the 'px4' binary, but w/o starting the simulator.
-# It assumes px4 is already built, with 'make px4_sitl_default'
+#!/usr/bin/env bash
+# Run multiple instances of the 'px4' binary, without starting an external simulator.
+# It assumes px4 is already built with the specified build target.
+#
+# Usage: ./Tools/simulation/sitl_multiple_run.sh [num_instances] [model] [build_target]
+# Examples:
+# ./Tools/simulation/sitl_multiple_run.sh 3 sihsim_quadx px4_sitl_sih
+# ./Tools/simulation/sitl_multiple_run.sh 2 gazebo-classic_iris px4_sitl_default
+# ./Tools/simulation/sitl_multiple_run.sh # defaults: 2 instances, gazebo-classic_iris, px4_sitl_default
-# The simulator is expected to send to TCP port 4560+i for i in [0, N-1]
-# For example jmavsim can be run like this:
-#./Tools/simulation/jmavsim/jmavsim_run.sh -p 4561 -l
-
-sitl_num=2
-[ -n "$1" ] && sitl_num="$1"
+sitl_num=${1:-2}
+sim_model=${2:-gazebo-classic_iris}
+build_target=${3:-px4_sitl_default}
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
src_path="$SCRIPT_DIR/../../"
-build_path=${src_path}/build/px4_sitl_default
+build_path=${src_path}/build/${build_target}
echo "killing running instances"
pkill -x px4 || true
sleep 1
-export PX4_SIM_MODEL=gazebo-classic_iris
+export PX4_SIM_MODEL=${sim_model}
n=0
while [ $n -lt $sitl_num ]; do
diff --git a/Tools/stack_usage/avstack.pl b/Tools/stack_usage/avstack.pl
deleted file mode 100755
index 5af499b145..0000000000
--- a/Tools/stack_usage/avstack.pl
+++ /dev/null
@@ -1,251 +0,0 @@
-#!/usr/bin/perl -w
-# avstack.pl: AVR stack checker
-# Copyright (C) 2013 Daniel Beer
-#
-# Permission to use, copy, modify, and/or distribute this software for
-# any purpose with or without fee is hereby granted, provided that the
-# above copyright notice and this permission notice appear in all
-# copies.
-#
-# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL
-# WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED
-# WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE
-# AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL
-# DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR
-# PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER
-# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
-# PERFORMANCE OF THIS SOFTWARE.
-#
-# Usage
-# -----
-#
-# This script requires that you compile your code with -fstack-usage.
-# This results in GCC generating a .su file for each .o file. Once you
-# have these, do:
-#
-# ./avstack.pl