diff --git a/.github/workflows/examples/auto-fix.yml b/.github/workflows/examples/auto-fix.yml new file mode 100644 index 00000000..aeda08d3 --- /dev/null +++ b/.github/workflows/examples/auto-fix.yml @@ -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 diff --git a/.github/workflows/self-test.yml b/.github/workflows/self-test.yml index 1f37930e..6ffeee1e 100644 --- a/.github/workflows/self-test.yml +++ b/.github/workflows/self-test.yml @@ -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 ' + 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" diff --git a/README.md b/README.md index a3a4a318..2ffe562f 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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

diff --git a/action.yml b/action.yml index 1076a995..926f4009 100644 --- a/action.yml +++ b/action.yml @@ -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. @@ -425,6 +460,35 @@ 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. + 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} @@ -433,7 +497,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 }}' @@ -457,6 +521,9 @@ runs: '--jobs=${{ inputs.jobs }}' '--summary-output-file=${{ inputs.summary-output-file }}' ] + if '${{ inputs.auto-fix }}' == 'true' { + $args = ($args | append ['--fix']) + } mut uv_args = [run --no-sync --project $action_path --directory (pwd)] let gh_action_debug = $env | get --optional 'ACTIONS_STEP_DEBUG' @@ -482,3 +549,87 @@ 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. + if '${{ github.event_name }}' == 'pull_request' and '${{ github.event.pull_request.head.repo.full_name }}' != '${{ 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/. + 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). + let head_sha = '${{ github.event.pull_request.head.sha }}' + 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)" + } diff --git a/docs/action.yml b/docs/action.yml index 84b047d8..a9146157 100644 --- a/docs/action.yml +++ b/docs/action.yml @@ -47,6 +47,15 @@ inputs: passive-reviews: minimum-version: '2.12.0' required-permission: 'pull-requests: write #pull-request-reviews' + auto-fix: + minimum-version: '2.23.0' + required-permission: 'contents: write #auto-fix' + auto-fix-commit-msg: + minimum-version: '2.23.0' + auto-fix-git-user: + minimum-version: '2.23.0' + auto-fix-git-email: + minimum-version: '2.23.0' jobs: minimum-version: '2.11.0' cache-enable: diff --git a/docs/examples/index.md b/docs/examples/index.md index a36646e0..e0679327 100644 --- a/docs/examples/index.md +++ b/docs/examples/index.md @@ -2,11 +2,22 @@ [style]: ../inputs-outputs.md#style [tidy-checks]: ../inputs-outputs.md#tidy-checks [thread-comments]: ../inputs-outputs.md#thread-comments +[auto-fix]: ../inputs-outputs.md#auto-fix # Recipes Here are some example workflows to get started quickly. +=== "auto-fix clang-format" + + ``` yaml + --8<-- ".github/workflows/examples/auto-fix.yml" + ``` + + 1. See also [`auto-fix`][auto-fix] + 2. See also [`style`][style] + 3. See also [`tidy-checks`][tidy-checks] + === "only clang-tidy" ``` yaml diff --git a/docs/index.md b/docs/index.md index 9a55fa3f..d5ecfe83 100644 --- a/docs/index.md +++ b/docs/index.md @@ -6,6 +6,8 @@ [io-doc]: inputs-outputs.md [recipes-doc]: examples/index.md +[permissions-doc]: permissions.md +[app-token-doc]: permissions.md#github-app-token [format-annotations-preview]: images/annotations-clang-format.png [tidy-annotations-preview]: images/annotations-clang-tidy.png diff --git a/docs/permissions.md b/docs/permissions.md index 3495c392..23285c43 100644 --- a/docs/permissions.md +++ b/docs/permissions.md @@ -78,3 +78,77 @@ The [`tidy-review`](inputs-outputs.md#tidy-review), [`format-review`](inputs-out permissions: pull-requests: write ``` + +## Auto-fix + +The [`auto-fix`](inputs-outputs.md#auto-fix) feature requires `contents: write` permission +in addition to any other permissions needed for other features: + +```yaml + permissions: + contents: write # (1)! +``` + +1. Needed by the token used in `actions/checkout` to commit and push the + formatted changes back to the branch. + +!!! info "The action checks out the pull request head" + + On `pull_request` events `actions/checkout` provides the merge commit + (`refs/pull/N/merge`), not the branch. A commit made on it would carry that + merge into the pull request, so with `auto-fix` the action checks out the + pull request's head commit before it lints. Steps that run after the action + see that commit plus the auto-fix commit. If git refuses the checkout + because of local changes, auto-fix is skipped with a warning. + +!!! warning "Limits" + + 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, push + with a [GitHub App token](#github-app-token) or a personal access token + that has `contents: write`; add `[skip ci]` to + [`auto-fix-commit-msg`](./inputs-outputs.md#auto-fix-commit-msg) if a + particular auto-fix commit should not start a run. + + Pull requests from forks are skipped with a warning: `GITHUB_TOKEN` cannot + push to the fork's branch, and fork pull requests receive no secrets, so an + App token or PAT is not available there either. + +## GitHub App token + +A token minted from a GitHub App you own replaces the default `GITHUB_TOKEN` +for every feature on this page. Pushes made with it start workflow runs, and +comments and reviews are posted under the App's name instead of +`github-actions[bot]`. + +1. [Register a GitHub App](https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/registering-a-github-app) + with the repository permissions **Contents: Read and write** and + **Pull requests: Read and write**, then install it on the repository. +2. Store the App ID as a repository variable and the private key as a secret. +3. Mint the token at the start of the job and pass it to both `actions/checkout` + and cpp-linter: + +```yaml + steps: + - uses: actions/create-github-app-token@v3 + id: app-token + with: + app-id: ${{ vars.CPP_LINTER_APP_ID }} + private-key: ${{ secrets.CPP_LINTER_APP_PRIVATE_KEY }} + - uses: actions/checkout@v7 + with: + token: ${{ steps.app-token.outputs.token }} # (1)! + - uses: cpp-linter/cpp-linter-action@v2 + env: + GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} # (2)! + with: + style: 'file' + auto-fix: 'true' +``` + +1. The auto-fix commit is pushed with this token, so the push triggers your + other workflows. +2. Thread comments and pull request reviews are posted with this token. + +The job's `permissions` block only applies to `GITHUB_TOKEN`; the App token's +permissions come from the App's settings.