Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
e2e0e86
feat: add auto-fix option to automatically format and commit clang-fo…
shenxianpeng Jun 8, 2026
9ecd4de
refactor: rename fix-commit-msg to auto-fix-commit-msg for consistency
shenxianpeng Jun 8, 2026
b5aa8fc
fix: address review feedback for auto-fix implementation
shenxianpeng Jul 22, 2026
90ef577
fix: change args from let to mut for reassignment compatibility
shenxianpeng Jul 22, 2026
355cbfa
refactor: use git -c for commit author instead of global git config
shenxianpeng Jul 22, 2026
f84c0ed
docs: document CI re-triggering limitation and fork PR incompatibility
shenxianpeng Jul 22, 2026
883fdfc
feat: add auto-fix-git-user and auto-fix-git-email inputs
shenxianpeng Jul 22, 2026
43b4905
chore: Apply suggestions from code review
shenxianpeng Jul 22, 2026
88d5c80
chore: let auto-fix-git-user/email defaults reference env vars directly
shenxianpeng Jul 24, 2026
fbfbf55
fix(auto-fix): gate to same-repo PRs, avoid stray commits
shenxianpeng Jul 24, 2026
cd071f6
chore: update auto-fix-commit-msg default value
shenxianpeng Jul 24, 2026
148523a
docs: correct what auto-fix does to CI runs and to unchanged lines
shenxianpeng Aug 19, 2026
7aae5aa
docs: describe the GitHub App token setup for auto-fix
shenxianpeng Sep 13, 2026
b064aec
Apply batched suggestions from code review
shenxianpeng Sep 13, 2026
1678b6a
docs: surface the GitHub App token chapter in the README
shenxianpeng Sep 13, 2026
3dcf220
Apply batched suggestions from code review
shenxianpeng Sep 13, 2026
da9a8d4
Apply batched suggestions from code review
shenxianpeng Sep 13, 2026
e1bbeb8
fix(auto-fix): address review: no forced checkout, source-only commits
shenxianpeng Sep 13, 2026
d6664a0
fix(auto-fix): stop checking out the PR head; require it from the wor…
shenxianpeng Sep 14, 2026
ae9f3d0
refactor(auto-fix): fold the fork warning into the commit step, trim …
shenxianpeng Sep 16, 2026
8feb22c
test: run auto-fix in the self-test
shenxianpeng Aug 24, 2026
c9db607
fix(auto-fix): check out the PR head in the action, count changes aft…
shenxianpeng Sep 18, 2026
b4e1034
test: run the auto-fix self-test on the default checkout
shenxianpeng Sep 18, 2026
25f45eb
fix(auto-fix): skip fork pull requests on every event, not only pull_…
shenxianpeng Sep 20, 2026
adbb9f0
fix(auto-fix): run the git steps in repo-root
shenxianpeng Sep 20, 2026
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
30 changes: 30 additions & 0 deletions .github/workflows/examples/auto-fix.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
name: cpp-linter (auto-fix)
on:
pull_request:
branches: [main, master, develop]
paths: ['**.c', '**.cpp', '**.h', '**.hpp', '**.cxx', '**.hxx', '**.cc', '**.hh', '**CMakeLists.txt', 'meson.build', '**.cmake']

jobs:
cpp-linter:
runs-on: ubuntu-latest
permissions: # explicit permissions granted to the `secrets.GITHUB_TOKEN`
contents: write # needed for auto-fix commits
pull-requests: read # needed to list changed files on pull_request events
steps:
- uses: actions/checkout@v7
# Pushes made with the default GITHUB_TOKEN do not start new workflow
# runs. To have the auto-fix commit re-checked by CI, check out and run
# the action with a GitHub App token; see the permissions docs.

- uses: cpp-linter/cpp-linter-action@v2
id: linter
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
style: 'file' # Use .clang-format config file
tidy-checks: '-*' # disable clang-tidy
auto-fix: 'true' # auto-apply clang-format fixes

- name: Fail fast?!
if: steps.linter.outputs.clang-format-checks-failed > 0
run: exit 1
101 changes: 101 additions & 0 deletions .github/workflows/self-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,104 @@ jobs:
echo "clang-format checks-failed: ${{ steps.linter.outputs.clang-format-checks-failed }}"
# for actual deployment
# run: exit 1

test-auto-fix:
# Runs the real action with auto-fix and checks the commit it makes. The
# job never gets push credentials (persist-credentials: false, contents:
# read), so the push is rejected on purpose and the demo sources stay
# mis-formatted for the other jobs; the last step proves nothing left the
# runner. pull_request only: on a push the target branch would be main.
# Not on forks: auto-fix skips them, so there would be no commit to check.
if: >-
github.event_name == 'pull_request'
&& github.event.pull_request.head.repo.full_name == github.repository
permissions:
contents: read
pull-requests: read
runs-on: ubuntu-latest
env:
EXPECTED_MSG: 'style: apply clang-format fixes [self-test]'
EXPECTED_AUTHOR: 'cpp-linter-self-test <self-test@users.noreply.github.com>'
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- name: Self test auto-fix
uses: ./
id: linter
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
style: file
auto-fix: 'true'
auto-fix-commit-msg: ${{ env.EXPECTED_MSG }}
auto-fix-git-user: cpp-linter-self-test
auto-fix-git-email: self-test@users.noreply.github.com
tidy-checks: '-*' # no clang-tidy, no compilation database needed
files-changed-only: false
lines-changed-only: false
ignore: build|venv
version: '16'
verbosity: debug
thread-comments: false
file-annotations: false
step-summary: false

- name: Assert auto-fix committed the formatting changes
run: |
set -euo pipefail

echo "::group::Tip commit after auto-fix"
git --no-pager log -1 --pretty=fuller --stat
echo "::endgroup::"

subject="$(git log -1 --pretty=%s)"
if [ "$subject" != "$EXPECTED_MSG" ]; then
echo "::error title=auto-fix::tip commit is '$subject', expected the auto-fix commit '$EXPECTED_MSG'"
exit 1
fi

author="$(git log -1 --pretty='%an <%ae>')"
if [ "$author" != "$EXPECTED_AUTHOR" ]; then
echo "::error title=auto-fix::commit author is '$author', expected '$EXPECTED_AUTHOR'"
exit 1
fi

changed="$(git show --pretty=format: --name-only HEAD | sed '/^$/d')"
if [ -z "$changed" ]; then
echo "::error title=auto-fix::the auto-fix commit is empty; clang-format changed nothing"
exit 1
fi

if stray="$(printf '%s\n' "$changed" | grep -v '^docs/examples/demo/')"; then
echo "::error title=auto-fix::commit touched files outside docs/examples/demo:"
printf '%s\n' "$stray"
exit 1
fi

if [ -n "$(git status --porcelain --untracked-files=no)" ]; then
echo "::error title=auto-fix::tracked files are still modified after the commit:"
git status --porcelain --untracked-files=no
exit 1
fi

failed='${{ steps.linter.outputs.clang-format-checks-failed }}'
if [ "${failed:-0}" != "0" ]; then
echo "::error title=auto-fix::clang-format still reports $failed issue(s) after auto-fix"
exit 1
fi

echo "auto-fix committed $(printf '%s\n' "$changed" | wc -l) file(s) as expected"

- name: Assert the commit never reached the PR branch
run: |
set -euo pipefail

git fetch --no-tags --depth=1 origin "$GITHUB_HEAD_REF"
if [ "$(git rev-parse HEAD)" = "$(git rev-parse FETCH_HEAD)" ]; then
echo "::error title=auto-fix::the auto-fix commit reached the PR branch; this job must never push"
exit 1
fi
echo "PR branch is untouched, as expected"
46 changes: 46 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@

[io-doc]: https://cpp-linter.github.io/cpp-linter-action/inputs-outputs
[recipes-doc]: https://cpp-linter.github.io/cpp-linter-action/examples
[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

[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
Expand Down Expand Up @@ -67,6 +69,50 @@ For all explanations of our available input parameters and output variables, see
See also our [example recipes][recipes-doc].
### Auto-fix clang-format issues
Set `auto-fix: 'true'` and the action applies `clang-format -i` to the files with style
issues and commits the result to the branch:

```yaml
steps:
- uses: actions/checkout@v7
- uses: cpp-linter/cpp-linter-action@v2
id: linter
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
style: 'file'
auto-fix: 'true' # automatically fix format issues
```

On `pull_request` events `actions/checkout` checks out the merge commit, so the action switches
the workspace to the pull request's head commit before it lints and commits.

> [!TIP]
> Commits pushed with the default `GITHUB_TOKEN` do not start new workflow runs,
> so CI does not re-check the auto-fix commit. To change that, check out and run
> the action with a [GitHub App token][app-token-doc]. To keep a particular
> auto-fix commit from re-running CI, add `[skip ci]` to its message:
>
> ```yaml
> with:
> auto-fix: 'true'
> auto-fix-commit-msg: 'style: apply clang-format fixes [skip ci]'
> ```
>
> See [our documented permissions][permissions-doc] for the required scopes.

### Use your own GitHub App

Every feature above can run with a token minted from a GitHub App that you own
instead of the default `GITHUB_TOKEN`. Comments and reviews are then posted
under your App's name rather than `github-actions[bot]`, and commits pushed by
`auto-fix` do start new workflow runs. The token is minted inside the job, so
there is no server or webhook handling to host.

See [GitHub App token][app-token-doc] for the setup steps.

## Used By

<p align="center">
Expand Down
159 changes: 158 additions & 1 deletion action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,41 @@ inputs:
Set this option to `true` to prevent Pull Request reviews from approving or requesting changes.
default: 'false'
required: false
auto-fix:
description: |
Set this option to `true` to apply clang-format fixes (`clang-format -i`)
and commit them to the branch. Fixes respect
[`lines-changed-only`](#lines-changed-only): Only the changed lines are
reformatted accordingly.

On `pull_request` events the action checks out the pull request's head
commit before linting: the default checkout is the merge commit, and a fix
committed on it would carry that merge into the branch. Pull requests from
forks are skipped.

This option has no effect on clang-tidy issues.
default: 'false'
required: false
auto-fix-commit-msg:
description: |
Custom commit message for the auto-fix commit.
Only used when ``auto-fix`` is ``true``.
default: 'style: apply clang-format fixes'
required: false
auto-fix-git-user:
description: |-
Git username for the auto-fix commit.
Defaults to the value of ``$GITHUB_ACTOR``.
Only used when ``auto-fix`` is ``true``.
default: ''
required: false
auto-fix-git-email:
description: |-
Git email for the auto-fix commit.
Defaults to the value of ``$GITHUB_ACTOR_ID+$GITHUB_ACTOR@users.noreply.github.com``.
Only used when ``auto-fix`` is ``true``.
default: ''
required: false
jobs:
description: |
The number of jobs to run in parallel.
Expand Down Expand Up @@ -425,6 +460,36 @@ runs:
^$'($env.UV_INSTALL_DIR)/uv' ...$uv_args ...$cmd
}

- name: Check out the pull request head for auto-fix
if: >-
(inputs.auto-fix == 'true' || inputs.auto-fix == true)
&& github.event_name == 'pull_request'
&& github.event.pull_request.head.repo.full_name == github.repository
shell: nu {0}
run: |
# On pull_request events actions/checkout provides the merge commit
# (refs/pull/N/merge), detached. A fix committed on top of it would carry
# that merge into the PR branch, so format the head commit instead.
# Nothing is forced: git refuses to switch over conflicting local changes.
# Any other checkout (e.g. the workflow set `ref`) is left alone.
cd '${{ inputs.repo-root }}' # where the repository is checked out
if (^git rev-parse HEAD | str trim) != $env.GITHUB_SHA {
exit 0
}
let head_sha = '${{ github.event.pull_request.head.sha }}'
print $"(ansi purple)Checking out pull request head ($head_sha) for auto-fix(ansi reset)"
# The default shallow clone only holds the merge commit. Fetch the head
# commit only when it is missing: --depth would truncate a full clone.
if (^git cat-file -e $head_sha | complete).exit_code != 0 {
let fetched = (^git fetch origin --depth=1 $head_sha | complete)
if $fetched.exit_code != 0 { print $fetched.stderr }
}
let checked_out = (^git checkout --detach $head_sha | complete)
if $checked_out.exit_code != 0 {
let reason = ($checked_out.stderr | str replace --all "\n" " " | str trim)
print $"::warning title=Auto-fix checkout failed::Could not check out the pull request head ($head_sha): ($reason)"
}

- name: Run cpp-linter
id: cpp-linter
shell: nu {0}
Expand All @@ -433,7 +498,7 @@ runs:
$env.UV_INSTALL_DIR = $action_path | path join 'bin'
$env.UV_CACHE_DIR = $env.RUNNER_TEMP | path join 'cpp-linter-action-cache'

let args = [
mut args = [
'--style=${{ inputs.style }}'
'--extensions=${{ inputs.extensions }}'
'--tidy-checks=${{ inputs.tidy-checks }}'
Expand All @@ -457,6 +522,9 @@ runs:
'--jobs=${{ inputs.jobs }}'
'--summary-output-file=${{ inputs.summary-output-file }}'
]
if '${{ inputs.auto-fix }}' == 'true' {
$args = ($args | append ['--fix'])
Comment thread
shenxianpeng marked this conversation as resolved.
}
mut uv_args = [run --no-sync --project $action_path --directory (pwd)]

let gh_action_debug = $env | get --optional 'ACTIONS_STEP_DEBUG'
Expand All @@ -482,3 +550,92 @@ runs:

print $"\n(ansi purple)Running cpp-linter(ansi reset)"
^$'($env.UV_INSTALL_DIR)/uv' ...$uv_args cpp-linter ...$args

- name: Auto-commit clang-format fixes
if: inputs.auto-fix == 'true' || inputs.auto-fix == true
shell: nu {0}
run: |
# The token cannot push to a third-party fork's branch. Check every event
# that carries a pull request: under pull_request_target the push below
# would otherwise send the fork's commits to this repository, to a branch
# named after the fork's branch.
let head_sha = '${{ github.event.pull_request.head.sha }}'
let head_repo = '${{ github.event.pull_request.head.repo.full_name }}'
if ($head_sha | is-not-empty) and $head_repo != '${{ github.repository }}' {
print "::warning title=Auto-fix skipped::auto-fix cannot push to a third-party fork's branch, so no formatting commit was made. Apply clang-format fixes from within the fork or run cpp-linter locally."
exit 0
}

# Destination branch: the PR head ref, or the pushed branch. Tags and
# other refs are skipped so HEAD never lands on refs/heads/<tag>.
let head_ref = $env.GITHUB_HEAD_REF
let branch = if ($head_ref | is-not-empty) {
$head_ref
} else if ($env.GITHUB_REF | str starts-with "refs/heads/") {
$env.GITHUB_REF_NAME
} else {
""
}
if ($branch | is-empty) {
print $"::notice title=Auto-fix skipped::($env.GITHUB_REF) is not a branch or pull request ref; skipping auto-fix commit."
exit 0
}

# Only commit on the pull request head (see the checkout step above).
cd '${{ inputs.repo-root }}' # where the repository is checked out
if ($head_sha | is-not-empty) {
let current = (^git rev-parse HEAD | str trim)
if $current != $head_sha {
print $"::warning title=Auto-fix skipped::HEAD is ($current), not the pull request head ($head_sha), so no formatting commit was made. auto-fix runs on `push` and `pull_request` events; see https://cpp-linter.github.io/cpp-linter-action/permissions/#auto-fix"
exit 0
}
}

# Stage only source files (the configured extensions), so anything else an
# earlier step modified stays out of the commit. `git add` aborts on a
# pathspec that matches nothing, which most of the default extensions do,
# so resolve them to modified tracked files first. Untracked sources
# (generated code, CMake's compiler-id files) stay out as well.
let path_specs = ('${{ inputs.extensions }}' | split row ',' | each { |ext| $"*.($ext | str trim)" })
let modified = (^git ls-files --modified -- ...$path_specs | lines)
if ($modified | is-not-empty) {
^git add -- ...$modified
}
# Count what got staged: .gitattributes rules (LF/CRLF) can normalize a
# modified file back to its committed content.
let changed = (
^git status --short --untracked-files=no -- ...$path_specs
| lines
| each { |line| $line | str substring 3.. }
)
if ($changed | is-not-empty) {
print $"(ansi purple)Committing ($changed | length) formatted file\(s\)(ansi reset)"
for file in $changed { print $" ($file)" }
let git_user_name = if ('${{ inputs.auto-fix-git-user }}' | is-empty) {
$env.GITHUB_ACTOR
} else {
'${{ inputs.auto-fix-git-user }}'
}
let git_user_email = if ('${{ inputs.auto-fix-git-email }}' | is-empty) {
$"($env.GITHUB_ACTOR_ID)+($env.GITHUB_ACTOR)@users.noreply.github.com"
} else {
'${{ inputs.auto-fix-git-email }}'
}
let git_user = $"user.name=($git_user_name)"
let git_email = $"user.email=($git_user_email)"
let commit_msg = if ('${{ inputs.auto-fix-commit-msg }}' | is-empty) {
'style: apply clang-format fixes'
} else {
'${{ inputs.auto-fix-commit-msg }}'
}
^git -c $git_user -c $git_email commit -m $commit_msg
let push_result = (^git push origin $"HEAD:refs/heads/($branch)") | complete
if $push_result.exit_code != 0 {
let reason = ($push_result.stderr | str replace --all "\n" " " | str trim)
print $"::warning title=Auto-fix push failed::($reason) The token used by actions/checkout needs `contents: write` and branch protection must allow the push; see https://cpp-linter.github.io/cpp-linter-action/permissions/#auto-fix"
} else {
print $"(ansi green)Auto-fix commit pushed successfully(ansi reset)"
}
} else {
print $"(ansi green)No formatting changes to commit(ansi reset)"
}
Loading
Loading