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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .github/workflows/pre-commit.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
name: pre-commit

on:
pull_request:
push:
branches: [main]

jobs:
pre-commit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-python@v3
- uses: pre-commit/action@v3.0.1
50 changes: 50 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
---
name: Test

on:
push:
branches: [main]
pull_request:

jobs:
presets:
name: ${{ matrix.preset }} on ${{ matrix.runner }}
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
runner: [ubuntu-22.04, ubuntu-24.04, ubuntu-26.04]
preset: [asan-ubsan]
steps:
- uses: actions/checkout@v6

- run: test/run_preset.sh ${{ matrix.preset }}

action:
name: action end to end
runs-on: ubuntu-26.04
container: ros:rolling-ros-base
steps:
- uses: actions/checkout@v6

- name: Build a workspace from the fixtures
run: |
mkdir -p ws/src/fixture_pkg
cp -r test/fixtures ws/src/fixture_pkg/
cp test/pkg_CMakeLists.txt ws/src/fixture_pkg/CMakeLists.txt

# The workspace's only test is a heap overflow, so the action must fail. That failure
# is the assertion: it can only happen if the CMake injection, the instrumented
# build, the test environment and result reporting all worked.
- uses: ./asan
id: sanitized
continue-on-error: true
with:
workspace: ws

- name: Assert the defect was caught
run: |-
if [[ 'failure' != '${{ steps.sanitized.outcome }}' ]]; then
echo "::error::asan action reported ${{ steps.sanitized.outcome }} on a workspace with a known heap overflow"
exit 1
fi
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
__pycache__/
*.pyc
.ruff.toml
17 changes: 17 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
repos:
- repo: https://github.com/polymathrobotics/polymath_code_standard
rev: v2.2.0
hooks:
# Basic checks and fixes that apply to any text file and the git repository itself
- id: polymath-general
- id: polymath-copyright
args: [--license, Apache-2.0, --copyright-org, 'Polymath Robotics, Inc.', --reuse-style]
# Specific languages
- id: polymath-python
- id: polymath-cpp
- id: polymath-shell
- id: polymath-cmake
- id: polymath-markdown
- id: polymath-yaml
- id: polymath-json
2 changes: 1 addition & 1 deletion LICENSE
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@
same "printed page" as the copyright notice for easier
identification within third-party archives.

Copyright [yyyy] [name of copyright owner]
Copyright 2026 Polymath Robotics, Inc.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
Expand Down
91 changes: 90 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,91 @@
# ros2_github_actions
Reusable github actions and cmake hooks

Reusable github actions and cmake hooks. Currently just includes sanitizer jobs.

| Action | Purpose |
| --- | --- |
| [`asan`](#sanitizers) | Build and test a workspace under AddressSanitizer and UndefinedBehaviorSanitizer. |

# Sanitizers

```yaml
- uses: polymathrobotics/ros2_github_actions/asan@v1
```

Builds a ROS workspace with the sanitizer enabled, runs `colcon test` under the right
runtime environment, and reports results, without having to change the `CMakeLists.txt`
in the workspace under test.

| Action | Sanitizers | Compilers | Run Overhead |
| --- | --- | --- | --- |
| `asan` | address, undefined | GCC, clang | ~2x |

## Usage

```yaml
jobs:
sanitize:
runs-on: ubuntu-24.04
container: ros:jazzy-ros-base
steps:
- uses: actions/checkout@v6
with: {path: src/my_package}
- uses: polymathrobotics/ros2_github_actions/asan@v1
```

Inputs:

| Input | Default | Purpose |
| --- | --- | --- |
| `workspace` | `.` | Colcon workspace root, containing `src/`. |
| `ros-setup` | `/opt/ros/${ROS_DISTRO}/setup.bash` | Sourced when present. |
| `exclude-packages` | `_msgs$` | Regex on CMake `PROJECT_NAME`; matches build uninstrumented. |
| `packages` | | Selection args for both build and test. |
| `build-args` / `test-args` | | Extra colcon args. |
| `compiler` | `g++` | Used to locate the sanitizer runtime library. |
| `skip-build` | `false` | Test an already-instrumented workspace. |

## How the flags are applied

`cmake/sanitizers.cmake` is passed as `-DCMAKE_PROJECT_INCLUDE`. That reaches every package
in the workspace without editing any of them, and applies flags as directory properties
so they land *after* the per-config flags.

Message packages are deliberately excluded so as to keep them usable and not break usage
with rclpy.

C and C++ are both instrumented, including a pure `project(foo C)` library with no C++ at
all. Compiles on x86 only.

Exclusion works per CMake project. A package that pulls in a nested project via
`add_subdirectory` passes its own compile options down, and the nested one inherits them
whatever its name.

## Outside GitHub Actions

The pieces are ordinary files. `scripts/sanitize.sh` takes `PRESET` and `WORKSPACE` from
the environment, and the tool is stdlib-only Python 3.10+:

```bash
python3 sanitizer_tool list
mapfile -t cmake_args < <(python3 sanitizer_tool cmake-args asan-ubsan)
colcon build --cmake-args "${cmake_args[@]}"

eval "$(python3 sanitizer_tool test-env asan-ubsan)"
colcon test --executor sequential
```

`cmake-args` prints one argument per line.

## Known rough edges

- **Leak detection is off.** ASan's leak checker is disabled, as CPython and the DDS stack
leak enough at exit to bury codebase-specific findings.
- **Uninstrumented ROS.** Only your workspace is rebuilt with the flags; the apt ROS
underneath is not.

## Tests

`test/run_preset.sh asan-ubsan` builds deliberately-buggy fixtures through the real
`CMAKE_PROJECT_INCLUDE` path and asserts each is caught, plus a package the exclude regex
matches that must stay uninstrumented. CI runs it on ubuntu 22.04, 24.04 and 26.04.
48 changes: 48 additions & 0 deletions asan/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
---
name: ROS 2 AddressSanitizer
description: Build and test a ROS 2 workspace under AddressSanitizer and UndefinedBehaviorSanitizer.

inputs:
workspace:
description: Colcon workspace root, containing src/.
default: .
ros-setup:
description: setup.bash to source before colcon. Skipped when the file does not exist.
default: /opt/ros/${ROS_DISTRO}/setup.bash
exclude-packages:
description: >
Regex matched against each package's CMake PROJECT_NAME. Matches build uninstrumented.
Defaults to message packages, whose generated typesupport rclpy dlopens into an
uninstrumented python3, which aborts an instrumented process.
default: _msgs$
packages:
description: Package selection arguments passed to both colcon build and colcon test.
default: ''
build-args:
description: Extra arguments for colcon build.
default: ''
test-args:
description: Extra arguments for colcon test.
default: ''
compiler:
description: Compiler driver used to locate the sanitizer runtime library.
default: g++
skip-build:
description: Test an already-instrumented workspace instead of building it.
default: 'false'

runs:
using: composite
steps:
- shell: bash
run: ${{ github.action_path }}/../scripts/sanitize.sh
env:
PRESET: asan-ubsan
WORKSPACE: ${{ inputs.workspace }}
ROS_SETUP: ${{ inputs.ros-setup }}
EXCLUDE_PACKAGES: ${{ inputs.exclude-packages }}
PACKAGES: ${{ inputs.packages }}
BUILD_ARGS: ${{ inputs.build-args }}
TEST_ARGS: ${{ inputs.test-args }}
COMPILER: ${{ inputs.compiler }}
SKIP_BUILD: ${{ inputs.skip-build }}
79 changes: 79 additions & 0 deletions cmake/sanitizers.cmake
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# SPDX-FileCopyrightText: 2026 Polymath Robotics, Inc.
# SPDX-License-Identifier: Apache-2.0
#
# Injected into every package via -DCMAKE_PROJECT_INCLUDE, which CMake evaluates
# immediately after each project() call. Instruments a whole workspace without
# editing any package's CMakeLists.txt.
#
# Flags are applied as directory properties rather than CMAKE_CXX_FLAGS so they
# land after the per-config flags. A -DCMAKE_BUILD_TYPE=RelWithDebInfo would
# otherwise append -O2 and undo -O1 -fno-omit-frame-pointer.

if(NOT ROS2_SANITIZER)
return()
endif()

if(NOT PROJECT_SOURCE_DIR STREQUAL CMAKE_SOURCE_DIR)
return()
endif()

# Excluded packages still build, just uninstrumented. Defaults to message packages
if(ROS2_SANITIZER_EXCLUDE AND PROJECT_NAME MATCHES "${ROS2_SANITIZER_EXCLUDE}")
message(STATUS "ros2-sanitizers: skipping ${PROJECT_NAME}")
return()
endif()

# Nothing here runs on the host, and no sanitizer runtime exists for most embedded
# toolchains. A repo mixing firmware with host code would otherwise fail to configure.
if(CMAKE_CROSSCOMPILING)
message(STATUS "ros2-sanitizers: skipping ${PROJECT_NAME}, cross-compiling")
return()
endif()

# Checked per enabled language rather than against CMAKE_CXX_COMPILER_ID alone, which is
# empty in a `project(foo C)` package and would reject every pure C library.
get_property(_ros2_sanitizer_languages GLOBAL PROPERTY ENABLED_LANGUAGES)
set(_ros2_sanitizer_instrumentable OFF)

foreach(_ros2_sanitizer_lang IN ITEMS C CXX)
if(_ros2_sanitizer_lang IN_LIST _ros2_sanitizer_languages)
if(NOT CMAKE_${_ros2_sanitizer_lang}_COMPILER_ID MATCHES "GNU|Clang")
message(FATAL_ERROR
"ros2-sanitizers: unsupported ${_ros2_sanitizer_lang} compiler "
"${CMAKE_${_ros2_sanitizer_lang}_COMPILER_ID}")
endif()
set(_ros2_sanitizer_instrumentable ON)
endif()
endforeach()

# project(foo NONE), or a language-less interface package. Nothing to instrument.
if(NOT _ros2_sanitizer_instrumentable)
return()
endif()

set(_ros2_sanitizer_common -O1 -g -fno-omit-frame-pointer)

if(ROS2_SANITIZER STREQUAL "asan-ubsan")
# vptr is clang-only and false-positives on classes deriving from an uninstrumented
# base, which is every rclcpp::Node subclass in an apt-installed ROS. Without
# -fno-sanitize-recover UBSan reports a finding and the process still exits 0.
set(_ros2_sanitizer_flags
-fsanitize=address,undefined
-fno-sanitize=vptr
-fsanitize-address-use-after-scope
-fno-sanitize-recover=all)
else()
message(FATAL_ERROR "ros2-sanitizers: unknown preset '${ROS2_SANITIZER}'")
endif()

# GCC's uninitialized analysis does not survive the ASan instrumentation pass and
# false-positives inside libstdc++ <regex>. Downgraded rather than silenced, and only for
# GNU, since the option name does not exist in Clang.
list(APPEND _ros2_sanitizer_common
"$<$<COMPILE_LANG_AND_ID:C,GNU>:-Wno-error=maybe-uninitialized>"
"$<$<COMPILE_LANG_AND_ID:CXX,GNU>:-Wno-error=maybe-uninitialized>")

message(STATUS "ros2-sanitizers: instrumenting ${PROJECT_NAME} with ${ROS2_SANITIZER}")

add_compile_options(${_ros2_sanitizer_common} ${_ros2_sanitizer_flags})
add_link_options(${_ros2_sanitizer_flags})
Loading
Loading