From 0fb80d88867a68965023c54769cdc07cfe40e2a0 Mon Sep 17 00:00:00 2001 From: Xianpeng Shen Date: Sat, 31 Oct 2026 15:24:58 +0200 Subject: [PATCH 1/6] feat: add a one-step format check sub-action cpp-linter/cpp-linter-action/format checks the C/C++ lines a pull request or push changes with clang-format and fails the job when they are not formatted. It downloads clang-format from our static binaries (checked against the SHA-512 in index.json), needs no token, and reports error annotations plus the suggested diff in the step summary. The main action is unchanged. --- docs/format-check.md | 107 ++++++++++++++++++ format/action.yml | 70 ++++++++++++ format/check.sh | 255 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 432 insertions(+) create mode 100644 docs/format-check.md create mode 100644 format/action.yml create mode 100644 format/check.sh diff --git a/docs/format-check.md b/docs/format-check.md new file mode 100644 index 00000000..a3536f63 --- /dev/null +++ b/docs/format-check.md @@ -0,0 +1,107 @@ +--- +title: Format check +description: A one-step GitHub Action that fails a pull request or push when the C/C++ lines it changes are not clang-formatted. +--- + +# Format check + +`cpp-linter/cpp-linter-action/format` checks that the C/C++ lines a pull request or push +changes follow clang-format, and fails the job when they do not. It is a separate, smaller +action in this repository: one step, no token, no Python. + +```yaml +name: cpp-linter +on: + pull_request: + push: + branches: [main] + +jobs: + format: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: cpp-linter/cpp-linter-action/format@v2 +``` + +## What it does + +1. Downloads the newest patch release of the clang-format major you ask for (21 by default) + from [cpp-linter/clang-tools-static-binaries], and checks its SHA-512. +2. Works out what changed: + - `pull_request`: the merge commit that `actions/checkout` checks out, compared with its + first parent (the base branch). + - `push`: the pushed commits, compared with the commit before the push (for a new branch, + with the parent of the last commit). + - `merge_group`: the merge queue's base commit. + - Any other event, such as `workflow_dispatch` or `schedule`: every file. + + The base commit is fetched on its own, so the default `fetch-depth: 1` of + `actions/checkout` is enough. +3. Runs `clang-format --dry-run --Werror` on the changed lines of each changed file. +4. For every line that is not formatted, adds an error annotation, which shows in the pull + request's diff view. The step summary lists the files and shows the change clang-format + would make; the job log has the full diff. +5. Fails the step when any file needs formatting. + +Lines the change does not touch are not checked, so an old code base can turn the check on +without reformatting everything first. + +## Inputs + +| Input | Default | Description | +|---|---|---| +| `version` | `21` | clang-format major version, from 12 to 23. | +| `style` | `file` | Passed to `clang-format --style`. `file` uses the closest `.clang-format`, and LLVM style when there is none. Any built-in style (`google`, `webkit`, ...) works too. | +| `extensions` | `c,h,C,H,cpp,hpp,cc,hh,c++,h++,cxx,hxx` | File extensions to check, comma-separated. | +| `ignore` | (none) | Paths to skip, separated by `\|` or new lines, as git pathspecs relative to the repository root. `build` skips the `build` directory, `*.pb.cc` skips matching files anywhere. | +| `files-changed-only` | `true` | Check only the files the pull request or push changes. `false` checks every tracked file with a matching extension. | +| `lines-changed-only` | `true` | Check only the added or changed lines. `false` checks the whole of each changed file. | + +Files listed in a [`.clang-format-ignore`][clang-format-ignore] file are skipped too, with +clang-format 18 or later. + +## Outputs + +| Output | Description | +|---|---| +| `checks-failed` | The number of files with style issues. | +| `clang-format-version` | The full clang-format version used, for example `21.1.8`. | + +The step fails when `checks-failed` is not 0. To report without failing, set +`continue-on-error: true` on the step. + +## Moving from another clang-format action + +Those actions check the whole repository by default. To get the same, set +`files-changed-only: false`; to check only what each pull request changes, keep the default. + +| [DoozyX/clang-format-lint-action] | This action | +|---|---| +| `clangFormatVersion: 18` | `version: 18` (12 to 23) | +| `style: file` | `style: file` (the default) | +| `extensions: 'h,cpp'` | `extensions: 'h,cpp'` | +| `exclude` (paths to skip) | `ignore`, with paths separated by `\|` or new lines | +| `source: './src'` | not needed; use `ignore` for paths to skip | +| `inplace: True` | use the main action's [`auto-fix`](inputs-outputs.md#auto-fix) | + +| [jidicula/clang-format-action] | This action | +|---|---| +| `clang-format-version: '22'` | `version: '22'` (12 to 23) | +| `check-path: 'src'` | not needed; use `ignore` for paths to skip | +| `exclude-regex: '^./third_party/.*$'` | `ignore: 'third_party'` (a pathspec, not a regex) | +| `include-regex` | `extensions` (a list of extensions, not a regex) | + +## Runners and limits + +- GitHub-hosted Linux (x64, arm64), macOS and Windows runners work. Self-hosted runners and + `container:` jobs need `bash`, `git`, `curl` and `jq`. +- It only reports. For review suggestions, a pull request comment, auto-fix or clang-tidy, + use the [main action](index.md). +- `--style=file` reads `.clang-format` files from the checked-out pull request, as + clang-format does locally. + +[cpp-linter/clang-tools-static-binaries]: https://github.com/cpp-linter/clang-tools-static-binaries +[clang-format-ignore]: https://clang.llvm.org/docs/ClangFormat.html#clang-format-ignore +[DoozyX/clang-format-lint-action]: https://github.com/DoozyX/clang-format-lint-action +[jidicula/clang-format-action]: https://github.com/jidicula/clang-format-action diff --git a/format/action.yml b/format/action.yml new file mode 100644 index 00000000..6fb0e724 --- /dev/null +++ b/format/action.yml @@ -0,0 +1,70 @@ +name: C/C++ format check +description: >- + Check that the C/C++ lines a pull request or push changes follow clang-format. + One line to set up, fails the job on style issues, no token needed. +author: cpp-linter +branding: + icon: "check-circle" + color: "green" +inputs: + version: + description: | + The clang-format major version to use, from 12 to 23. + The newest patch release of that major from + [cpp-linter/clang-tools-static-binaries](https://github.com/cpp-linter/clang-tools-static-binaries) + is downloaded and its SHA-512 checked. + required: false + default: '21' + style: + description: | + The style passed to `clang-format --style`. + The default `file` uses the closest `.clang-format` file, and LLVM style when there is none. + required: false + default: file + extensions: + description: The file extensions to check, comma-separated. + required: false + default: "c,h,C,H,cpp,hpp,cc,hh,c++,h++,cxx,hxx" + ignore: + description: | + Paths to skip, separated by `|` or new lines, as git pathspecs relative to the + repository root: `build` skips the build directory, `*.pb.cc` skips matching files + anywhere. Files listed in `.clang-format-ignore` are skipped too (clang-format 18+). + required: false + default: '' + files-changed-only: + description: | + Check only the files the pull request or push changes. + Set to `false` to check every tracked file with a matching extension. + Other events (for example `workflow_dispatch`) always check every file. + required: false + default: 'true' + lines-changed-only: + description: | + Check only the lines the pull request or push adds or changes. + Set to `false` to check the whole of each changed file. + required: false + default: 'true' +outputs: + checks-failed: + description: The number of files with style issues. + value: ${{ steps.check.outputs.checks-failed }} + clang-format-version: + description: The full clang-format version that was used, for example `21.1.8`. + value: ${{ steps.check.outputs.clang-format-version }} +runs: + using: composite + steps: + - name: Check C/C++ format + id: check + shell: bash + env: + INPUT_VERSION: ${{ inputs.version }} + INPUT_STYLE: ${{ inputs.style }} + INPUT_EXTENSIONS: ${{ inputs.extensions }} + INPUT_IGNORE: ${{ inputs.ignore }} + INPUT_FILES_CHANGED_ONLY: ${{ inputs.files-changed-only }} + INPUT_LINES_CHANGED_ONLY: ${{ inputs.lines-changed-only }} + # Only used to fetch the base commit when actions/checkout did not keep its credentials. + INPUT_TOKEN: ${{ github.token }} + run: bash "$GITHUB_ACTION_PATH/check.sh" diff --git a/format/check.sh b/format/check.sh new file mode 100644 index 00000000..4c648081 --- /dev/null +++ b/format/check.sh @@ -0,0 +1,255 @@ +#!/usr/bin/env bash +# The script behind format/action.yml: download clang-format, work out which files +# and lines changed, check them, report annotations and a step summary, and exit 1 +# when anything is not formatted. Inputs come from the INPUT_* variables set in +# action.yml; the GITHUB_* and RUNNER_* variables come from the runner. +set -euo pipefail + +INDEX_URL="${CLANG_TOOLS_INDEX_URL:-https://cpp-linter.github.io/clang-tools-static-binaries/index.json}" +version="${INPUT_VERSION:-21}" +style="${INPUT_STYLE:-file}" +extensions="${INPUT_EXTENSIONS:-c,h,C,H,cpp,hpp,cc,hh,c++,h++,cxx,hxx}" +files_changed_only="${INPUT_FILES_CHANGED_ONLY:-true}" +lines_changed_only="${INPUT_LINES_CHANGED_ONLY:-true}" +summary_file="${GITHUB_STEP_SUMMARY:-/dev/null}" +output_file="${GITHUB_OUTPUT:-/dev/null}" + +fail() { + echo "::error title=cpp-linter format::$*" + exit 1 +} + +is_true() { + case "$1" in true | True | TRUE | 1) return 0 ;; *) return 1 ;; esac +} + +# Escape a value for a workflow command property such as file=. +escape_property() { + local value="${1//%/%25}" + value="${value//$'\r'/%0D}" + value="${value//$'\n'/%0A}" + value="${value//:/%3A}" + printf '%s' "${value//,/%2C}" +} + +sha512() { + if command -v sha512sum >/dev/null; then + sha512sum "$1" | cut -d' ' -f1 + else + shasum -a 512 "$1" | cut -d' ' -f1 + fi +} + +# --- 1. Get clang-format ----------------------------------------------------- + +case "${RUNNER_OS:-$(uname -s)}" in + Linux) os=linux ;; + macOS | Darwin) os=macos ;; + Windows | MINGW* | MSYS* | CYGWIN*) os=windows ;; + *) fail "unsupported runner OS: ${RUNNER_OS:-$(uname -s)}" ;; +esac +case "${RUNNER_ARCH:-$(uname -m)}" in + X64 | x86_64 | AMD64) arch=amd64 ;; + ARM64 | arm64 | aarch64) arch=arm64 ;; + *) fail "unsupported runner architecture: ${RUNNER_ARCH:-$(uname -m)}" ;; +esac +command -v jq >/dev/null || fail "jq is required (it is preinstalled on GitHub-hosted runners)" + +work_dir="${RUNNER_TEMP:-/tmp}/cpp-linter-format" +mkdir -p "$work_dir" +curl -fsSL --retry 3 -o "$work_dir/index.json" "$INDEX_URL" \ + || fail "could not download $INDEX_URL" + +asset="$(jq -c --arg v "$version" --arg p "$os-$arch" \ + '.versions[$v].tools["clang-format"][$p] // empty' "$work_dir/index.json")" +[ -n "$asset" ] || fail "clang-format $version is not available for $os-$arch; use a major version from $(jq -r '.versions | keys | join(", ")' "$work_dir/index.json")" +full_version="$(jq -r --arg v "$version" '.versions[$v].version' "$work_dir/index.json")" +bin="$work_dir/$(jq -r .name <<<"$asset")" +expected_sha="$(jq -r .sha512 <<<"$asset")" + +if [ ! -f "$bin" ] || [ "$(sha512 "$bin")" != "$expected_sha" ]; then + curl -fsSL --retry 3 -o "$bin" "$(jq -r .url <<<"$asset")" \ + || fail "could not download clang-format $full_version" + [ "$(sha512 "$bin")" = "$expected_sha" ] || fail "SHA-512 mismatch for $(basename "$bin")" + chmod +x "$bin" +fi +echo "Using $("$bin" --version)" +echo "clang-format-version=$full_version" >>"$output_file" + +# --- 2. Work out what to check ----------------------------------------------- + +cd "$(git rev-parse --show-toplevel)" + +pathspecs=() +IFS=',' read -r -a exts <<<"$extensions" +for ext in "${exts[@]}"; do + ext="${ext//[[:space:]]/}" + [ -n "$ext" ] && pathspecs+=("*.${ext#.}") +done +[ "${#pathspecs[@]}" -gt 0 ] || fail "the extensions input is empty" +while IFS= read -r pattern; do + pattern="$(sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' <<<"$pattern")" + [ -n "$pattern" ] && pathspecs+=(":(exclude)$pattern") +done < <(tr '|' '\n' <<<"${INPUT_IGNORE:-}") + +parents_of_head() { + git cat-file -p HEAD | sed -n 's/^parent //p' +} + +base="" +if is_true "$files_changed_only"; then + event_file="${GITHUB_EVENT_PATH:-/dev/null}" + case "${GITHUB_EVENT_NAME:-}" in + pull_request | pull_request_target) + # actions/checkout checks out the merge commit; its first parent is the base. + read -r -a parents <<<"$(parents_of_head | tr '\n' ' ')" + if [ "${#parents[@]}" -eq 2 ]; then + base="${parents[0]}" + else + base="$(jq -r '.pull_request.base.sha // empty' "$event_file")" + fi + ;; + merge_group) + base="$(jq -r '.merge_group.base_sha // empty' "$event_file")" + ;; + push) + base="$(jq -r '.before // empty' "$event_file")" + if [ -z "$base" ] || [ "$base" = "0000000000000000000000000000000000000000" ]; then + base="$(parents_of_head | head -n 1)" # a new branch: check its last commit + fi + ;; + esac + [ -n "$base" ] || echo "No base commit for the '${GITHUB_EVENT_NAME:-}' event: checking every file." +fi + +if [ -n "$base" ] && ! git cat-file -e "$base^{commit}" 2>/dev/null; then + echo "Fetching base commit $base" + if ! git fetch --quiet --no-tags --no-recurse-submodules --depth=1 origin "$base" 2>/dev/null; then + auth="$(printf 'x-access-token:%s' "${INPUT_TOKEN:-}" | base64 | tr -d '\n')" + git -c http.extraHeader="AUTHORIZATION: basic $auth" \ + fetch --quiet --no-tags --no-recurse-submodules --depth=1 origin "$base" \ + || fail "could not fetch the base commit $base; set files-changed-only: false to check every file" + fi +fi + +# One line per file: "\t ..." (no ranges = whole file). +targets="$work_dir/targets.tsv" +if [ -z "$base" ]; then + git ls-files -z -- "${pathspecs[@]}" | tr '\0' '\n' >"$targets" + scope="every file" +elif ! is_true "$lines_changed_only"; then + git diff -z --name-only --diff-filter=d "$base" -- "${pathspecs[@]}" | tr '\0' '\n' >"$targets" + scope="changed files" +else + git -c core.quotePath=false diff -U0 --no-color --no-ext-diff --diff-filter=d \ + --src-prefix=a/ --dst-prefix=b/ "$base" -- "${pathspecs[@]}" \ + | awk ' + /^diff --git / { header = 1; file = ""; next } + header && /^\+\+\+ / { file = substr($0, 7); next } + /^@@ / { + header = 0 + if (file == "") next + split($3, range, ",") + start = substr(range[1], 2) + 0 + count = (range[2] == "") ? 1 : range[2] + 0 + if (count == 0) next + if (!(file in lines)) order[++n] = file + lines[file] = lines[file] (lines[file] == "" ? "" : " ") start ":" (start + count - 1) + } + END { for (i = 1; i <= n; i++) print order[i] "\t" lines[order[i]] } + ' >"$targets" + scope="changed lines" +fi + +# --- 3. Check ------------------------------------------------------------------ + +checked=0 +failed=0 +failed_files=() +diffs="$work_dir/diffs.patch" +: >"$diffs" + +while IFS=$'\t' read -r file ranges; do + [ -n "$file" ] && [ -f "$file" ] || continue + checked=$((checked + 1)) + args=("--style=$style") + for range in $ranges; do + args+=("--lines=$range") + done + + if output="$("$bin" "${args[@]}" --dry-run --Werror -- "$file" 2>&1)"; then + continue + fi + failed=$((failed + 1)) + failed_files+=("$file") + + violations="$(grep -F -e '[-Wclang-format-violations]' <<<"$output" || true)" + if [ -z "$violations" ]; then + # Not a style issue: a bad .clang-format, an unreadable file, ... + echo "::error file=$(escape_property "$file"),title=clang-format failed::$(head -n 1 <<<"$output")" + echo "$output" + continue + fi + # One annotation per line, however many places on it clang-format would change. + last_line="" + while IFS= read -r line; do + if [[ "$line" =~ :([0-9]+):([0-9]+):\ error:\ (.*)$ ]] && [ "${BASH_REMATCH[1]}" != "$last_line" ]; then + last_line="${BASH_REMATCH[1]}" + message="${BASH_REMATCH[3]% \[-Wclang-format-violations\]}" + echo "::error file=$(escape_property "$file"),line=$last_line,col=${BASH_REMATCH[2]},title=clang-format::$message" + fi + done <<<"$violations" + + formatted="$work_dir/formatted" + "$bin" "${args[@]}" -- "$file" >"$formatted" 2>/dev/null || true + { + diff -u --label "a/$file" --label "b/$file" "$file" "$formatted" || true + } >>"$diffs" +done <"$targets" + +echo "checks-failed=$failed" >>"$output_file" + +# --- 4. Report ----------------------------------------------------------------- + +if [ "$failed" -gt 0 ]; then + echo "::group::Changes clang-format would make" + cat "$diffs" + echo "::endgroup::" +fi + +{ + if [ "$failed" -eq 0 ]; then + echo "### clang-format: all $checked checked file(s) are formatted" + else + echo "### clang-format: $failed of $checked checked file(s) need formatting" + fi + echo + echo "clang-format $full_version, \`--style=$style\`, checked $scope." + if [ "$failed" -gt 0 ]; then + echo + for file in "${failed_files[@]}"; do + echo "- \`$file\`" + done + echo + echo "
Changes clang-format would make" + echo + echo '```diff' + head -n 2000 "$diffs" + [ "$(wc -l <"$diffs")" -le 2000 ] || echo "... (cut; the full diff is in the job log)" + echo '```' + echo + echo "
" + echo + if [ -n "$base" ] && is_true "$lines_changed_only"; then + echo "To fix only the changed lines locally, run \`git clang-format \`." + else + echo "To fix them locally, run \`clang-format -i\` on the files above." + fi + fi +} >>"$summary_file" + +if [ "$failed" -gt 0 ]; then + echo "$failed of $checked file(s) need formatting." + exit 1 +fi +echo "All $checked checked file(s) are formatted." From 1d7d22a79623c8699b0d50c17e5d21b5e54baeb4 Mon Sep 17 00:00:00 2001 From: Xianpeng Shen Date: Sat, 31 Oct 2026 15:25:59 +0200 Subject: [PATCH 2/6] docs: show the format check first and test it in CI Quick start now starts with the one-step format check, then shows the main action for clang-tidy and reviews. Adds a docs page with inputs and a move-over table for DoozyX and jidicula users, and a self-test job on Linux (x64, arm64), macOS and Windows. --- .github/workflows/self-test.yml | 66 +++++++++++++++++++++++++++++++++ docs/index.md | 1 + mkdocs.yml | 1 + 3 files changed, 68 insertions(+) diff --git a/.github/workflows/self-test.yml b/.github/workflows/self-test.yml index 6ffeee1e..1ca02251 100644 --- a/.github/workflows/self-test.yml +++ b/.github/workflows/self-test.yml @@ -5,6 +5,7 @@ on: branches: [main] paths: - 'action.yml' + - 'format/**' - '.python-version' - 'uv.lock' - 'docs/examples/demo/**' @@ -13,6 +14,7 @@ on: branches: [main] paths: - 'action.yml' + - 'format/**' - '.python-version' - 'uv.lock' - 'docs/examples/demo/**' @@ -21,6 +23,7 @@ on: # branches: [main] # paths: # - 'action.yml' + # - 'format/**' # - '.python-version' # - 'uv.lock' # - 'docs/examples/demo/**' @@ -182,3 +185,66 @@ jobs: exit 1 fi echo "PR branch is untouched, as expected" + + test-format: + # Runs the format/ sub-action. A pull request or push to this repo changes no C/C++ + # files, so the default run checks nothing and passes; the full run checks the + # mis-formatted demo sources and must fail. + permissions: + contents: read + strategy: + matrix: + os: [ubuntu-latest, ubuntu-24.04-arm, macos-latest, windows-latest] + clang-version: ['21'] + include: + - os: ubuntu-latest + clang-version: '12' + - os: ubuntu-latest + clang-version: '23' + fail-fast: false + runs-on: ${{ matrix.os }} + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Check the changed lines (expected to pass) + uses: ./format + id: changed + with: + version: ${{ matrix.clang-version }} + + - name: Check every file (expected to fail) + uses: ./format + id: all + continue-on-error: true + with: + version: ${{ matrix.clang-version }} + files-changed-only: false + + - name: Check every file except the demo (expected to pass) + uses: ./format + id: ignored + with: + version: ${{ matrix.clang-version }} + files-changed-only: false + ignore: docs/examples/demo + + - name: Assert the results + shell: bash + env: + CHANGED_FAILED: ${{ steps.changed.outputs.checks-failed }} + ALL_OUTCOME: ${{ steps.all.outcome }} + ALL_FAILED: ${{ steps.all.outputs.checks-failed }} + IGNORED_FAILED: ${{ steps.ignored.outputs.checks-failed }} + USED_VERSION: ${{ steps.all.outputs.clang-format-version }} + WANTED_VERSION: ${{ matrix.clang-version }} + run: | + set -euo pipefail + [ "$CHANGED_FAILED" = "0" ] || { echo "::error::changed-lines run reported $CHANGED_FAILED file(s)"; exit 1; } + [ "$ALL_OUTCOME" = "failure" ] || { echo "::error::full run should fail, got $ALL_OUTCOME"; exit 1; } + [ "$ALL_FAILED" = "2" ] || { echo "::error::full run should report 2 files, got $ALL_FAILED"; exit 1; } + [ "$IGNORED_FAILED" = "0" ] || { echo "::error::ignore did not skip the demo"; exit 1; } + case "$USED_VERSION" in "$WANTED_VERSION".*) ;; *) echo "::error::used clang-format $USED_VERSION, wanted $WANTED_VERSION"; exit 1 ;; esac + echo "format sub-action OK with clang-format $USED_VERSION" diff --git a/docs/index.md b/docs/index.md index 97947522..7730f26d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -14,6 +14,7 @@ description: A GitHub Action that runs clang-format and clang-tidy on the C/C++ [permissions-doc]: permissions.md [app-token-doc]: permissions.md#github-app-token [tools-doc]: required-tools.md +[format-check-doc]: format-check.md [format-annotations-preview]: images/annotations-clang-format.png [tidy-annotations-preview]: images/annotations-clang-tidy.png diff --git a/mkdocs.yml b/mkdocs.yml index b00af67f..7d5147c8 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -9,6 +9,7 @@ exclude_docs: | overrides/ nav: - index.md + - format-check.md - inputs-outputs.md - pr-review-caveats.md - permissions.md From 8863766e5393cc4d2f03f24f1b70cbdadac47aba Mon Sep 17 00:00:00 2001 From: Xianpeng Shen Date: Sat, 31 Oct 2026 15:26:46 +0200 Subject: [PATCH 3/6] docs: lead the README Quick start with the format check --- README.md | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/README.md b/README.md index 3b0fe29e..a67f975f 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ [permissions-doc]: https://cpp-linter.github.io/cpp-linter-action/permissions [app-token-doc]: https://cpp-linter.github.io/cpp-linter-action/permissions/#github-app-token [tools-doc]: https://cpp-linter.github.io/cpp-linter-action/required-tools +[format-check-doc]: https://cpp-linter.github.io/cpp-linter-action/format-check [format-annotations-preview]: https://raw.githubusercontent.com/cpp-linter/cpp-linter-action/main/docs/images/annotations-clang-format.png [tidy-annotations-preview]: https://raw.githubusercontent.com/cpp-linter/cpp-linter-action/main/docs/images/annotations-clang-tidy.png @@ -41,8 +42,39 @@ reviews (with [`tidy-review`][tidy-review] or [`format-review`][format-review]). ## Quick start +### Check formatting + Save this as `.github/workflows/cpp-linter.yml`: +```yaml +name: cpp-linter +on: + pull_request: + push: + branches: [main] + +jobs: + format: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: cpp-linter/cpp-linter-action/format@v2 +``` + +- Checks the lines the pull request or push changes against your `.clang-format`, with + clang-format 21. Set `version` for another major from 12 to 23. +- Fails the job when a line is not formatted, marks the line in the diff view, and puts the + change clang-format would make in the step summary. +- Needs no token or extra permissions, so pull requests from forks get the same result. + Linux, macOS and Windows runners work. +- [Format check][format-check-doc] lists the inputs and how to move over from other + clang-format actions. + +### Add clang-tidy and pull request reviews + +Use the main action to also run clang-tidy and to post review suggestions or a comment. +Save this as `.github/workflows/cpp-linter.yml` instead: + ```yaml name: cpp-linter on: pull_request From 20580262d13f26a01fb6ba8a5eb8f8062bee4723 Mon Sep 17 00:00:00 2001 From: Xianpeng Shen Date: Sat, 31 Oct 2026 15:29:51 +0200 Subject: [PATCH 4/6] fix(format): hash the binary from stdin so Windows paths match On Windows RUNNER_TEMP has backslashes, and sha512sum then prefixes its output line with "\", so the SHA-512 never matched index.json. --- format/check.sh | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/format/check.sh b/format/check.sh index 4c648081..4ab70bee 100644 --- a/format/check.sh +++ b/format/check.sh @@ -32,11 +32,12 @@ escape_property() { printf '%s' "${value//,/%2C}" } +# Read from stdin: with a Windows path (backslashes) sha512sum puts a "\" before the hash. sha512() { if command -v sha512sum >/dev/null; then - sha512sum "$1" | cut -d' ' -f1 + sha512sum <"$1" | cut -d' ' -f1 else - shasum -a 512 "$1" | cut -d' ' -f1 + shasum -a 512 <"$1" | cut -d' ' -f1 fi } From 589c7c418b8521859eff3c9a7fcd5b4bcb8f84c8 Mon Sep 17 00:00:00 2001 From: Xianpeng Shen <3353385+shenxianpeng@users.noreply.github.com> Date: Sat, 31 Oct 2026 13:35:44 +0000 Subject: [PATCH 5/6] fix(format): skip symlinks and refuse a base-branch pull_request_target checkout - Skip tracked symlinks so clang-format never reads a file outside the checkout. - On pull_request(_target), take the merge commit's first parent as the base only when its second parent is the pull request head. A default pull_request_target checkout (the base branch) now fails with a hint instead of passing silently. - Self test: keep the mis-formatted demo out of the expected-pass run. --- .github/workflows/self-test.yml | 7 ++++--- docs/format-check.md | 5 +++++ format/check.sh | 12 +++++++++--- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/.github/workflows/self-test.yml b/.github/workflows/self-test.yml index 1ca02251..758bb735 100644 --- a/.github/workflows/self-test.yml +++ b/.github/workflows/self-test.yml @@ -187,9 +187,8 @@ jobs: echo "PR branch is untouched, as expected" test-format: - # Runs the format/ sub-action. A pull request or push to this repo changes no C/C++ - # files, so the default run checks nothing and passes; the full run checks the - # mis-formatted demo sources and must fail. + # Runs the format/ sub-action. The changed-lines run skips the mis-formatted demo + # sources and passes; the full run checks them and must fail. permissions: contents: read strategy: @@ -214,6 +213,8 @@ jobs: id: changed with: version: ${{ matrix.clang-version }} + # The demo is mis-formatted on purpose; the next step covers it. + ignore: docs/examples/demo - name: Check every file (expected to fail) uses: ./format diff --git a/docs/format-check.md b/docs/format-check.md index a3536f63..2dede594 100644 --- a/docs/format-check.md +++ b/docs/format-check.md @@ -33,6 +33,9 @@ jobs: first parent (the base branch). - `push`: the pushed commits, compared with the commit before the push (for a new branch, with the parent of the last commit). + - `pull_request_target`: the same, but only when the job checks out the pull request + (`ref: refs/pull//merge`); with the default checkout of the base branch the step + fails and asks for `pull_request` instead. - `merge_group`: the merge queue's base commit. - Any other event, such as `workflow_dispatch` or `schedule`: every file. @@ -100,6 +103,8 @@ Those actions check the whole repository by default. To get the same, set use the [main action](index.md). - `--style=file` reads `.clang-format` files from the checked-out pull request, as clang-format does locally. +- Symbolic links are skipped, so a pull request cannot point clang-format at a file outside + the checkout. [cpp-linter/clang-tools-static-binaries]: https://github.com/cpp-linter/clang-tools-static-binaries [clang-format-ignore]: https://clang.llvm.org/docs/ClangFormat.html#clang-format-ignore diff --git a/format/check.sh b/format/check.sh index 4ab70bee..688011f4 100644 --- a/format/check.sh +++ b/format/check.sh @@ -102,10 +102,15 @@ if is_true "$files_changed_only"; then event_file="${GITHUB_EVENT_PATH:-/dev/null}" case "${GITHUB_EVENT_NAME:-}" in pull_request | pull_request_target) - # actions/checkout checks out the merge commit; its first parent is the base. + # actions/checkout checks out the merge commit: its first parent is the base + # branch, its second the pull request head. + head_sha="$(jq -r '.pull_request.head.sha // empty' "$event_file")" read -r -a parents <<<"$(parents_of_head | tr '\n' ' ')" - if [ "${#parents[@]}" -eq 2 ]; then + if [ "${#parents[@]}" -eq 2 ] && [ "${parents[1]}" = "$head_sha" ]; then base="${parents[0]}" + elif [ "$GITHUB_EVENT_NAME" = pull_request_target ] && [ "$(git rev-parse HEAD)" != "$head_sha" ]; then + # The default pull_request_target checkout is the base branch: nothing to check. + fail "on pull_request_target the checkout is the base branch, not the pull request; use the pull_request event, or check out refs/pull//merge" else base="$(jq -r '.pull_request.base.sha // empty' "$event_file")" fi @@ -171,7 +176,8 @@ diffs="$work_dir/diffs.patch" : >"$diffs" while IFS=$'\t' read -r file ranges; do - [ -n "$file" ] && [ -f "$file" ] || continue + # Skip symlinks: a tracked link could point clang-format at a file outside the checkout. + [ -n "$file" ] && [ -f "$file" ] && [ ! -L "$file" ] || continue checked=$((checked + 1)) args=("--style=$style") for range in $ranges; do From 6d1c3a77c25d148235d2024c4fef3f2306120493 Mon Sep 17 00:00:00 2001 From: Xianpeng Shen <3353385+shenxianpeng@users.noreply.github.com> Date: Sat, 31 Oct 2026 13:40:44 +0000 Subject: [PATCH 6/6] fix(format): count only style issues in checks-failed clang-format errors such as a broken .clang-format still fail the step, but are reported on their own and no longer counted as files that need formatting. --- docs/format-check.md | 5 +++-- format/check.sh | 20 ++++++++++++-------- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/docs/format-check.md b/docs/format-check.md index 2dede594..4e184400 100644 --- a/docs/format-check.md +++ b/docs/format-check.md @@ -71,8 +71,9 @@ clang-format 18 or later. | `checks-failed` | The number of files with style issues. | | `clang-format-version` | The full clang-format version used, for example `21.1.8`. | -The step fails when `checks-failed` is not 0. To report without failing, set -`continue-on-error: true` on the step. +The step fails when `checks-failed` is not 0, and also when clang-format itself fails, for +example on a broken `.clang-format` (those files are not counted in `checks-failed`). To +report without failing, set `continue-on-error: true` on the step. ## Moving from another clang-format action diff --git a/format/check.sh b/format/check.sh index 688011f4..b920c972 100644 --- a/format/check.sh +++ b/format/check.sh @@ -171,6 +171,7 @@ fi checked=0 failed=0 +errors=0 failed_files=() diffs="$work_dir/diffs.patch" : >"$diffs" @@ -187,16 +188,16 @@ while IFS=$'\t' read -r file ranges; do if output="$("$bin" "${args[@]}" --dry-run --Werror -- "$file" 2>&1)"; then continue fi - failed=$((failed + 1)) - failed_files+=("$file") - violations="$(grep -F -e '[-Wclang-format-violations]' <<<"$output" || true)" if [ -z "$violations" ]; then # Not a style issue: a bad .clang-format, an unreadable file, ... + errors=$((errors + 1)) echo "::error file=$(escape_property "$file"),title=clang-format failed::$(head -n 1 <<<"$output")" echo "$output" continue fi + failed=$((failed + 1)) + failed_files+=("$file") # One annotation per line, however many places on it clang-format would change. last_line="" while IFS= read -r line; do @@ -225,8 +226,12 @@ if [ "$failed" -gt 0 ]; then fi { + if [ "$errors" -gt 0 ]; then + echo "### clang-format failed on $errors file(s); see the job log" + echo + fi if [ "$failed" -eq 0 ]; then - echo "### clang-format: all $checked checked file(s) are formatted" + [ "$errors" -gt 0 ] || echo "### clang-format: all $checked checked file(s) are formatted" else echo "### clang-format: $failed of $checked checked file(s) need formatting" fi @@ -255,8 +260,7 @@ fi fi } >>"$summary_file" -if [ "$failed" -gt 0 ]; then - echo "$failed of $checked file(s) need formatting." - exit 1 -fi +[ "$errors" -eq 0 ] || echo "clang-format failed on $errors file(s)." +[ "$failed" -eq 0 ] || echo "$failed of $checked file(s) need formatting." +[ "$errors" -eq 0 ] && [ "$failed" -eq 0 ] || exit 1 echo "All $checked checked file(s) are formatted."