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
177 changes: 177 additions & 0 deletions .github/workflows/uncrustify-wheels.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
---
# Builds the `ros-uncrustify-bin` wheels from `uncrustify_wheel/` and publishes them
# to a GitHub release tagged `uncrustify-bin-v<version>`.
#
# Each wheel is `py3-none-<platform>` and bundles the prebuilt uncrustify binaries that
# `ros-uncrustify` runs, so consumers never build uncrustify and never depend on the
# version their distribution happens to package.
# The hook consumes the release assets: this repo's own `pyproject.toml` depends on these
# wheel URLs directly, with platform markers picking the right one per machine.
#
# Every cibuildwheel setting other than the architecture lives in
# `uncrustify_wheel/pyproject.toml` under `[tool.cibuildwheel]`.
#
# A release happens only on `main`, and only when the tag does not exist yet.
# Bumping `version` in `uncrustify_wheel/pyproject.toml` is how a new release is produced;
# a push that does not bump it rebuilds the wheels and leaves the existing release alone.
name: Build uncrustify wheels

on:
push:
branches: [main]
paths:
- uncrustify_wheel/**
- .github/workflows/uncrustify-wheels.yml
pull_request:
paths:
- uncrustify_wheel/**
- .github/workflows/uncrustify-wheels.yml
workflow_dispatch:

# One run per ref at a time, so two pushes to main cannot race on the same release.
# Superseded pull request runs are cancelled; main runs queue instead.
concurrency:
group: uncrustify-wheels-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

jobs:
build:
name: ${{ matrix.name }}
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- name: manylinux x86_64
runner: ubuntu-latest
arch: x86_64
artifact: wheels-manylinux-x86_64
# Native arm64 runner, so the aarch64 wheels build without QEMU.
- name: manylinux aarch64
runner: ubuntu-24.04-arm
arch: aarch64
artifact: wheels-manylinux-aarch64
# macos-13 was retired in December 2025. macos-15-intel is the x86_64
# replacement and the last Intel macOS image Actions will offer, through
# August 2027.
- name: macOS x86_64
runner: macos-15-intel
arch: x86_64
artifact: wheels-macos-x86_64
- name: macOS arm64
runner: macos-15
arch: arm64
artifact: wheels-macos-arm64
steps:
- uses: actions/checkout@v6
# The cibuildwheel action brings its own Python, so no setup-python step is needed.
- uses: pypa/cibuildwheel@v4.2.1
with:
package-dir: uncrustify_wheel
env:
CIBW_ARCHS: ${{ matrix.arch }}
- uses: actions/upload-artifact@v7
with:
name: ${{ matrix.artifact }}
path: wheelhouse/*.whl
if-no-files-found: error

release:
name: Publish release
runs-on: ubuntu-latest
needs: build
# Pull requests build only. A manual dispatch releases from main and nowhere else.
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
permissions:
contents: write
steps:
- uses: actions/checkout@v6
- uses: actions/download-artifact@v8
with:
pattern: wheels-*
path: dist
merge-multiple: true

- name: Read version and check the tag
id: version
run: |
PYPROJECT=uncrustify_wheel/pyproject.toml
# -m1 takes the [project] version, not a 'version' key in a later table.
VERSION=$(grep -m1 '^version' "$PYPROJECT" | sed 's/version = "\(.*\)"/\1/')
case "$VERSION" in
[0-9]*.[0-9]*) ;;
*) echo "No version read from $PYPROJECT, got '$VERSION'"; exit 1 ;;
esac
TAG="uncrustify-bin-v$VERSION"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
if git ls-remote --exit-code --tags origin "refs/tags/$TAG" > /dev/null; then
echo "Tag $TAG already exists, leaving its release untouched."
echo "Bump 'version' in uncrustify_wheel/pyproject.toml to publish new wheels."
echo "exists=true" >> "$GITHUB_OUTPUT"
else
echo "exists=false" >> "$GITHUB_OUTPUT"
fi

- name: Generate SHA256SUMS
if: steps.version.outputs.exists == 'false'
run: |
cd dist
sha256sum -- *.whl > SHA256SUMS
cat SHA256SUMS

# Notes list the uncrustify versions actually found inside the wheels, so they
# cannot drift from what was built.
- name: Publish the release
if: steps.version.outputs.exists == 'false'
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ steps.version.outputs.tag }}
run: |
BUNDLED=$(python3 - <<'PY'
import glob
import posixpath
import zipfile

versions = set()
for wheel in glob.glob('dist/*.whl'):
with zipfile.ZipFile(wheel) as archive:
for name in archive.namelist():
head, tail = posixpath.split(name)
if posixpath.basename(head) == 'bin' and tail.startswith('uncrustify-'):
versions.add(tail[len('uncrustify-'):])
print(', '.join(sorted(versions, reverse=True)))
PY
)
test -n "$BUNDLED" || { echo "No bundled uncrustify binaries found in the wheels"; exit 1; }
{
echo "Prebuilt uncrustify binaries for \`ros-uncrustify\`, bundled versions: $BUNDLED."
echo
echo "Wheels:"
echo
for wheel in dist/*.whl; do
echo "- \`$(basename "$wheel")\`"
done
echo
echo "\`SHA256SUMS\` covers every wheel in this release."
} > notes.md
cat notes.md
gh release create "$TAG" --title "$TAG" --notes-file notes.md dist/*.whl dist/SHA256SUMS

# Task 4.4 pastes these into pyproject.toml as direct-URL dependencies with
# platform markers.
- name: Print the asset download URLs
if: steps.version.outputs.exists == 'false'
env:
TAG: ${{ steps.version.outputs.tag }}
run: |-
BASE="${{ github.server_url }}/${{ github.repository }}/releases/download/$TAG"
{
echo "### Wheel download URLs"
echo
echo '```'
for wheel in dist/*.whl; do
echo "$BASE/$(basename "$wheel")"
done
echo "$BASE/SHA256SUMS"
echo '```'
} | tee -a "$GITHUB_STEP_SUMMARY"
16 changes: 14 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,16 @@
/*.egg-info/
# Python
__pycache__/
/.pytest_tmp*/
*.egg-info/
/.venv/
/.pytest_tmp*/
.pytest_cache/
.mypy_cache/

# Wheel builds under uncrustify_wheel/ (local `uv build`, cibuildwheel, scikit-build-core)
uncrustify_wheel/dist/
uncrustify_wheel/build/
uncrustify_wheel/_skbuild/
uncrustify_wheel/wheelhouse/
uncrustify_wheel/.venv/
*.whl
*.tar.gz
2 changes: 2 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ repos:
- id: ros-lint-cmake
- id: ros-pep257
- id: ros-uncrustify
# Until the ros-uncrustify-bin wheel is wired into pyproject.toml (plan task 4.4).
args: [--system-uncrustify]
- id: ros-xmllint
- id: ros-clang-format
- id: ros-mypy
Expand Down
5 changes: 3 additions & 2 deletions .pre-commit-hooks.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,9 @@
language: python
description: >
uncrustify with the ament_code_style configuration. Reformats in place and fails
so you re-stage. Requires uncrustify on PATH. Accepts args: [--linelength, "100"]
and [--language, C++].
so you re-stage. The binary ships with the hook for Linux x86_64/aarch64 and
macOS x86_64/arm64. Accepts args: [--uncrustify-version, "0.72.0"] for humble,
[--system-uncrustify], [--linelength, "100"], and [--language, C++].
entry: ros_code_standard uncrustify
types_or: [c, c++]

Expand Down
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,29 @@
All notable changes to this project are documented in this file.
Releases follow semantic versioning as described in [DEVELOPING.md](./DEVELOPING.md).

## Unreleased

### Changed

- `ros-uncrustify` runs a bundled uncrustify binary instead of whatever is on `PATH`.
The `ros-uncrustify-bin` wheel, built from `uncrustify_wheel/` by the `Build uncrustify wheels` workflow and published to this repo's releases, carries both 0.78.1 (rolling, jazzy) and 0.72.0 (humble) for Linux x86_64 and aarch64 and macOS x86_64 and arm64.
`--uncrustify-version` selects one, default 0.78.1.
`--system-uncrustify` restores the `PATH` lookup for other platforms.
Motivation: Ubuntu 22.04's apt uncrustify is 0.72.0, and with the 0_72 config it reformats files that rolling's pinned 0.78.1 accepts, so the distro binary cannot give parity.

### Pending

- The root `pyproject.toml` does not yet depend on the wheel. That wiring waits for the first `uncrustify-bin-v0.1.0` release to exist (plan task 4.4).

## 0.1.1

### Fixed

- `ros-cpplint` and `ros-lint-cmake` run `ament_cpplint` and `ament_lint_cmake` themselves instead of PyPI cpplint 2.0 and cmakelint 1.4.3 with ament's flags.
Both ament packages vendor modified forks, and cpplint 2.0 reported `build/c++17`, `whitespace/newline`, and wider include-what-you-use findings on `ros2/rcpputils`, which is green under ament_lint on rolling.
Findings now match `colcon test` exactly.
- README migration section maps `ament_<tool>(EXCLUDE ...)`, `LANGUAGE`, `FILTERS`, and `MAX_LINE_LENGTH` onto hook fields, with rcpputils as the worked example.

## 0.1.0

Initial release: the `ament_lint` linters as pre-commit hooks, one hook per `ament_cmake_<tool>` package.
Expand Down
13 changes: 13 additions & 0 deletions DEVELOPING.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,16 @@ Then add a `## <version>` section to `CHANGELOG.md` describing the release.

CI fails PRs where the README pin or the changelog section is missing for the version in `pyproject.toml`.
On merge to main, CI pushes the tag and publishes a GitHub release whose notes are that changelog section.

### uncrustify binary wheels

`uncrustify_wheel/` builds the `ros-uncrustify-bin` wheels that carry the uncrustify binaries `ros-uncrustify` runs.
The `Build uncrustify wheels` workflow builds all four platforms on every push to `main` that touches `uncrustify_wheel/`, and publishes them to a release tagged `uncrustify-bin-v<version>` only when that tag does not exist yet.
Bumping `version` in `uncrustify_wheel/pyproject.toml` is the only way to cut a new one, and the direct-URL dependencies in the root `pyproject.toml` have to be repointed at the new tag in the same change.
Take the exact wheel filenames from the workflow's "Wheel download URLs" step summary, since auditwheel and delocate decide the final platform tags.

To lint the workflow files locally with the shell and Python rules enabled:

```shell
uvx --from actionlint-py --with shellcheck-py --with pyflakes actionlint .github/workflows/*.yml
```
29 changes: 17 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@ Set up pre-commit hooks in the repository:
pre-commit install
```

`ros-uncrustify` also needs `uncrustify` on `PATH`.
See its [hook reference](#ros-uncrustify) entry.
Nothing else is needed on Linux x86_64 and aarch64 or macOS x86_64 and arm64.
The uncrustify binary ships with the hook.

## Configuration

Expand Down Expand Up @@ -144,8 +144,6 @@ jobs:
- uses: actions/setup-python@v5
with:
python-version: '3.10'
# Only needed for ros-uncrustify.
- run: sudo apt-get install -y --no-install-recommends uncrustify
- uses: pre-commit/action@v3.0.1
```

Expand Down Expand Up @@ -310,8 +308,14 @@ Unlike bare `pydocstyle`, files whose names begin with `test_` are checked.
### `ros-uncrustify`

Runs `uncrustify` with the `ament_code_style` configuration, the same one `ament_uncrustify` ships.
`uncrustify --version` selects the config: 0.78.1 and newer get `ament_code_style_0_78.cfg`, anything older gets `ament_code_style_0_72.cfg`.
Ubuntu 24.04 ships 0.78.1 and Ubuntu 22.04 ships 0.72.0, so both are supported.

The uncrustify version decides the output, not just the configuration, so the hook ships its own binary rather than using whatever is installed.
`--uncrustify-version` selects it, and the matching ament config follows: 0.78.1 uses `ament_code_style_0_78.cfg`, 0.72.0 uses `ament_code_style_0_72.cfg`.

| ROS 2 distribution | `--uncrustify-version` |
|---|---|
| rolling, jazzy | `0.78.1` (the default) |
| humble | `0.72.0` |

Files are split into a `-l C` group (`.c`, `.cc`, `.h`, `.hh`) and a `-l CPP` group (`.cpp`, `.cxx`, `.hpp`, `.hxx`), matching `ament_uncrustify`, and each group is checked separately.
A C++ header named `.h` is therefore parsed as C, as in ament.
Expand All @@ -322,20 +326,21 @@ This differs from `ament_uncrustify`, which prints the diff and leaves the files
Nothing else is written to your repository.

> [!NOTE]
> Requires `uncrustify` on `PATH`.
> There is no PyPI package and no upstream Linux binary, so it cannot be installed into the hook virtualenv.
> Ubuntu and Debian: `sudo apt install uncrustify`.
> macOS: `brew install uncrustify`.
> ROS 2 builds it through [`uncrustify_vendor`](https://github.com/ros2/uncrustify_vendor), so sourcing a ROS 2 installation also puts a suitable version on `PATH`.
> The uncrustify binary ships with the hook, as the `ros-uncrustify-bin` wheel, for Linux x86_64, Linux aarch64, macOS x86_64, and macOS arm64.
> Nothing needs to be installed on those platforms.
> Anywhere else, install uncrustify yourself and add `--system-uncrustify`, which uses the binary on `PATH` and picks the ament config from the version it reports.
> A distribution package may not match your ROS 2 distribution's pin: Ubuntu 22.04 packages 0.72.0, and it reformats files that rolling's 0.78.1 accepts.

**Optional:**

- `--uncrustify-version {0.78.1,0.72.0}` -- Which bundled uncrustify to run (default `0.78.1`). Ignored with `--system-uncrustify`
- `--system-uncrustify` -- Use the `uncrustify` on `PATH` instead of the bundled one, with the ament config chosen from the version it reports
- `--linelength N` -- Override the config's `code_width` of 100
- `--language {C,C++,CPP}` -- Force uncrustify's `-l` instead of choosing it per file extension

```yaml
- id: ros-uncrustify
args: [--language, C++]
args: [--uncrustify-version, '0.72.0', --language, C++]
```

---
Expand Down
38 changes: 20 additions & 18 deletions docs/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,8 +134,7 @@ pre-commit's exit code is the test result.
### Shared virtualenv

As in polymath_code_standard, every hook declares the same `language: python` and no `additional_dependencies`, so pre-commit builds one virtualenv for the whole repo regardless of which hooks a consumer picks.
All tools are installed up front.
The one exception is uncrustify, discussed below.
All tools are installed up front, including the prebuilt uncrustify wheel for the current platform.

## Challenges

Expand All @@ -146,26 +145,29 @@ These are the linters that need more than "install from PyPI and pass a config".
uncrustify is the primary ROS 2 C++ formatter, and it is the hardest to ship.
There is no PyPI package.
The upstream GitHub release only publishes source archives and Windows zips.
ROS builds it from source through `uncrustify_vendor`.
ROS builds it from source through `uncrustify_vendor`, and the version it pins is part of the standard: rolling and jazzy pin 0.78.1, humble pins 0.72.0, and the two produce different output on real code.
Ubuntu 22.04's apt package is 0.72.0, so on a 22.04 machine the distro binary fails rcpputils files that rolling's CI accepts.

Phase 1 (this iteration) mirrors how `polymath-go` handles the Go toolchain:
`ros-uncrustify` looks for `uncrustify` on `PATH`, fails with an install hint if absent, and picks `ament_code_style_0_78.cfg` or `ament_code_style_0_72.cfg` from `uncrustify --version` exactly as `ament_uncrustify` does.
Ubuntu 24.04 ships 0.78.1 and Ubuntu 22.04 ships 0.72.0, so both configs matter.
The first implementation took `uncrustify` from `PATH` and picked the ament config from `uncrustify --version`, exactly as `ament_uncrustify` does.
That reproduces ament's behavior, and also ament's failure: it matches whatever the machine has, not what the distribution pins.

Phase 2 options, for follow-up:
Options considered:

1. **Host prebuilt binaries** in this repo's GitHub releases and download them on first use with pinned checksums, the `golangci-lint` pattern.
We control the build, users get a consistent 0.78.1 everywhere.
Cost: a release job that cross-compiles for linux/amd64, linux/arm64, and macOS.
1. **Host prebuilt binaries** in this repo's GitHub releases and download them on first use with pinned checksums, the `golangci-lint` pattern from polymath_code_standard.
2. **Build from source into the hook virtualenv** on first use.
Needs cmake, a C++ compiler, and a Python 3 interpreter, and no third-party libraries.
Measured on 0.78.1 during implementation: 43 CPU-seconds, about 12 s on four cores, for a 1.4 MiB stripped binary that links only libstdc++ and libc.
Cheap enough to be a real fallback where no prebuilt binary exists.
3. **Publish a wheel** the way `clang-format`, `clang-tidy`, and `cppcheck` wheels are built with scikit-build-core.
Cleanest for consumers.
Most upfront work and a PyPI project to maintain.

Recommendation: option 1, because the download-and-verify code already exists in polymath_code_standard.
Measured on 0.78.1: 43 CPU-seconds, about 12 s on four cores, for a 1.4 MiB stripped binary that links only libstdc++ and libc, needing cmake, a C++ compiler, and Python.
Rejected: consumers should not need a C++ toolchain to run a linter, and a first-commit build step is a poor experience.
3. **Publish wheels** containing the binaries, the way the `clang-format`, `clang-tidy`, and `cppcheck` wheels are built with scikit-build-core.

Decision: option 3, hosted on this repo rather than PyPI.
A wheel project under `uncrustify_wheel/` bundles both pinned versions in one `py3-none-<platform>` wheel per platform (manylinux x86_64 and aarch64, macOS x86_64 and arm64).
A GitHub Action builds it with cibuildwheel whenever that directory changes and publishes the wheels to a release tagged `uncrustify-bin-v<version>`.
The hook depends on those wheels by direct URL with `sys_platform` and `platform_machine` markers, so pip installs the right one into the shared virtualenv and pre-commit needs no extra configuration.
`--uncrustify-version` selects 0.78.1 (default, rolling and jazzy) or 0.72.0 (humble), and `--system-uncrustify` opts out to the `PATH` binary for unsupported platforms.

Consequences: a Windows consumer has no bundled binary and must use `--system-uncrustify`.
Bumping uncrustify means editing two pins and the project version under `uncrustify_wheel/`, pushing, and then updating the dependency URLs in the hook's `pyproject.toml` once the release exists.
Until the first release is published the hook's dependency lines cannot be resolved, so wiring them is the last step of the rollout.

### clang-tidy: needs a compilation database

Expand Down
Loading
Loading