From 2d79066ddb7aeb990adb632b8446782556016ec5 Mon Sep 17 00:00:00 2001 From: Joao Luis Sombrio Date: Wed, 27 May 2026 00:45:42 -0300 Subject: [PATCH 1/2] ci: split clang-tidy-bazel into pull_request + workflow_run stages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fork PRs on the public repo (where external contributors land) get a read-only GITHUB_TOKEN, so the single-stage pull_request workflow couldn't post reviewdog review comments — POST /pulls/N/reviews returned 403 and reviewdog exited 1. Stage A (clang-tidy-bazel) keeps the bazel --config=lint build but now only uploads clang-tidy.txt and pr-meta.txt as an artifact. It needs no write permissions, so it stays safe on fork code. Stage B (clang-tidy-bazel-post) runs on workflow_run in the base repo's context with a writable token. It downloads the artifact, validates pr-meta fields against a strict allowlist, synthesizes a pull_request event payload (workflow_run.pull_requests[] is empty for fork PRs), and runs reviewdog with -reporter=github-pr-review. It never checks out the fork's code or runs any fork scripts. Signed-off-by: Joao Luis Sombrio --- .../github-actions-clang-tidy-bazel-post.yml | 126 ++++++++++++++++++ .../github-actions-clang-tidy-bazel.yml | 46 ++++--- 2 files changed, 154 insertions(+), 18 deletions(-) create mode 100644 .github/workflows/github-actions-clang-tidy-bazel-post.yml diff --git a/.github/workflows/github-actions-clang-tidy-bazel-post.yml b/.github/workflows/github-actions-clang-tidy-bazel-post.yml new file mode 100644 index 00000000000..e99a550b8b6 --- /dev/null +++ b/.github/workflows/github-actions-clang-tidy-bazel-post.yml @@ -0,0 +1,126 @@ +name: clang-tidy-bazel-post + +# Runs in the base repository's context with a writable GITHUB_TOKEN, so it +# can post reviewdog comments on PRs opened from forks (which the upstream +# `clang-tidy-bazel` workflow cannot, since fork pull_request runs get a +# read-only token by GitHub's design). +# +# Security: this workflow MUST NOT execute untrusted PR code. It only reads +# the text artifact (clang-tidy.txt) produced by the upstream workflow and +# the metadata file we wrote there ourselves. No checkout of the PR head, +# no bazel build, no scripts from the fork. + +on: + workflow_run: + workflows: ["clang-tidy-bazel"] + types: + - completed + +permissions: + contents: read + pull-requests: write + # Needed for actions/download-artifact@v4 to fetch from another workflow. + actions: read + +jobs: + Post-Reviewdog: + # Skip if the upstream build failed before producing an artifact. + if: ${{ github.event.workflow_run.conclusion == 'success' }} + runs-on: ${{ vars.USE_SELF_HOSTED == 'true' && 'self-hosted' || 'ubuntu-latest' }} + steps: + - name: Download clang-tidy artifact + uses: actions/download-artifact@v4 + with: + name: clang-tidy-bazel + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Load PR metadata + id: meta + run: | + # pr-meta.txt is produced by the upstream workflow from the + # pull_request event payload. It is text we wrote ourselves — + # not arbitrary fork content — and is parsed with a strict + # allowlist below before being exported. + if [ ! -f pr-meta.txt ]; then + echo "::error::pr-meta.txt missing from artifact" + exit 1 + fi + while IFS='=' read -r key value; do + case "$key" in + pr_number|head_sha|base_sha|head_repo|base_repo) ;; + *) continue ;; + esac + # Validate values: numbers, hex SHAs, or owner/repo slugs only. + case "$key" in + pr_number) + [[ "$value" =~ ^[0-9]+$ ]] || { echo "::error::bad pr_number"; exit 1; } ;; + head_sha|base_sha) + [[ "$value" =~ ^[0-9a-f]{40}$ ]] || { echo "::error::bad $key"; exit 1; } ;; + head_repo|base_repo) + [[ "$value" =~ ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ ]] || { echo "::error::bad $key"; exit 1; } ;; + esac + echo "$key=$value" >> "$GITHUB_OUTPUT" + done < pr-meta.txt + + - name: Synthesize pull_request event payload + id: event + env: + PR_NUMBER: ${{ steps.meta.outputs.pr_number }} + HEAD_SHA: ${{ steps.meta.outputs.head_sha }} + BASE_SHA: ${{ steps.meta.outputs.base_sha }} + HEAD_REPO: ${{ steps.meta.outputs.head_repo }} + BASE_REPO: ${{ steps.meta.outputs.base_repo }} + run: | + # Reviewdog's `github-pr-review` reporter reads GITHUB_EVENT_PATH + # expecting a pull_request payload. The real event here is + # workflow_run, so we synthesize the minimum payload reviewdog + # needs and point GITHUB_EVENT_PATH at it for the next step. + EVENT_PATH="${RUNNER_TEMP}/pr-event.json" + python3 - <<'PY' > "$EVENT_PATH" + import json, os + payload = { + "action": "synchronize", + "number": int(os.environ["PR_NUMBER"]), + "pull_request": { + "number": int(os.environ["PR_NUMBER"]), + "head": { + "sha": os.environ["HEAD_SHA"], + "repo": {"full_name": os.environ["HEAD_REPO"]}, + }, + "base": { + "sha": os.environ["BASE_SHA"], + "repo": {"full_name": os.environ["BASE_REPO"]}, + }, + }, + "repository": { + "full_name": os.environ["BASE_REPO"], + "owner": {"login": os.environ["BASE_REPO"].split("/")[0]}, + "name": os.environ["BASE_REPO"].split("/")[1], + }, + } + print(json.dumps(payload)) + PY + echo "event_path=${EVENT_PATH}" >> "$GITHUB_OUTPUT" + + - name: Set up reviewdog + uses: reviewdog/action-setup@v1 + with: + reviewdog_version: latest + + - name: Run reviewdog + env: + REVIEWDOG_GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_EVENT_NAME: pull_request + GITHUB_EVENT_PATH: ${{ steps.event.outputs.event_path }} + GITHUB_SHA: ${{ steps.meta.outputs.head_sha }} + GITHUB_REPOSITORY: ${{ steps.meta.outputs.base_repo }} + run: | + reviewdog \ + -efm="%E%f:%l:%c: error: %m" \ + -efm="%W%f:%l:%c: warning: %m" \ + -name="clang-tidy" \ + -reporter=github-pr-review \ + -filter-mode=added \ + -fail-level=any \ + < clang-tidy.txt diff --git a/.github/workflows/github-actions-clang-tidy-bazel.yml b/.github/workflows/github-actions-clang-tidy-bazel.yml index f71d49682ff..522120654e3 100644 --- a/.github/workflows/github-actions-clang-tidy-bazel.yml +++ b/.github/workflows/github-actions-clang-tidy-bazel.yml @@ -5,9 +5,13 @@ on: branches: - master +# Read-only by design: fork PRs get a read-only GITHUB_TOKEN regardless of +# what this block requests, so this workflow only builds clang-tidy and +# uploads the findings as an artifact. The companion workflow +# `clang-tidy-bazel-post` runs on `workflow_run` in the base repo context +# with a writable token and posts the reviewdog comments. permissions: contents: read - pull-requests: write jobs: Clang-Tidy-Bazel: @@ -17,7 +21,8 @@ jobs: uses: actions/checkout@v6 with: submodules: 'recursive' - # Need full history so reviewdog can diff against the PR base. + # Need full history so the post workflow's reviewdog can diff + # against the PR base via the API. fetch-depth: 0 - name: Set up bazel @@ -30,11 +35,6 @@ jobs: bazelisk-version: 1.x bazelisk-cache: true - - name: Set up reviewdog - uses: reviewdog/action-setup@v1 - with: - reviewdog_version: latest - - name: Run bazel clang-tidy env: BAZEL_CACHE_PASSWORD: ${{ secrets.BAZEL_CACHE_PASSWORD }} @@ -88,15 +88,25 @@ jobs: echo "::endgroup::" echo "Findings: $(wc -l < clang-tidy.txt)" - - name: Run reviewdog - env: - REVIEWDOG_GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Save PR metadata for post workflow run: | - reviewdog \ - -efm="%E%f:%l:%c: error: %m" \ - -efm="%W%f:%l:%c: warning: %m" \ - -name="clang-tidy" \ - -reporter=github-pr-review \ - -filter-mode=added \ - -fail-level=any \ - < clang-tidy.txt + # workflow_run.event.pull_requests[] is empty for fork PRs, so the + # post workflow needs the PR number and head SHA delivered via the + # artifact itself. + { + echo "pr_number=${{ github.event.pull_request.number }}" + echo "head_sha=${{ github.event.pull_request.head.sha }}" + echo "base_sha=${{ github.event.pull_request.base.sha }}" + echo "head_repo=${{ github.event.pull_request.head.repo.full_name }}" + echo "base_repo=${{ github.event.pull_request.base.repo.full_name }}" + } > pr-meta.txt + + - name: Upload clang-tidy artifact + uses: actions/upload-artifact@v4 + with: + name: clang-tidy-bazel + path: | + clang-tidy.txt + pr-meta.txt + retention-days: 7 + if-no-files-found: error From 24d544ace08c462a927dbe441f5ae091391f848d Mon Sep 17 00:00:00 2001 From: Joao Luis Sombrio Date: Wed, 27 May 2026 03:53:10 -0300 Subject: [PATCH 2/2] ci: check out base repo in clang-tidy-bazel-post so reviewdog finds .git MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewdog's github-pr-review reporter resolves the local git root before flushing comments. On a fresh runner containing only the downloaded artifact, reviewdog logs "reviewdog: .git not found" and silently no-ops (exit 0, no review posted). Confirmed locally by running reviewdog from a scratch dir against the same synthesized event + artifact: zero comments posted; running the same command from inside a checkout works. Add a shallow actions/checkout step before the artifact download. The default ref in workflow_run context is the base repo's default branch, which is safe (no fork code) and sufficient — reviewdog fetches the actual PR diff via the GitHub API, so the local SHA need not match. Signed-off-by: Joao Luis Sombrio --- .../github-actions-clang-tidy-bazel-post.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.github/workflows/github-actions-clang-tidy-bazel-post.yml b/.github/workflows/github-actions-clang-tidy-bazel-post.yml index e99a550b8b6..881ea716040 100644 --- a/.github/workflows/github-actions-clang-tidy-bazel-post.yml +++ b/.github/workflows/github-actions-clang-tidy-bazel-post.yml @@ -28,6 +28,20 @@ jobs: if: ${{ github.event.workflow_run.conclusion == 'success' }} runs-on: ${{ vars.USE_SELF_HOSTED == 'true' && 'self-hosted' || 'ubuntu-latest' }} steps: + # Reviewdog's github-pr-review reporter resolves the local git root + # before flushing comments and silently no-ops if .git is missing. + # Check out the base repo (default branch, shallow) so reviewdog has + # a .git directory to operate against. No fork code involved — this + # is the base repo at HEAD of its default branch. + - name: Check out base repo for reviewdog .git requirement + uses: actions/checkout@v6 + with: + fetch-depth: 1 + # Default ref in workflow_run context is the base repo's default + # branch, which is the safe choice here. Reviewdog uses GitHub + # API to fetch the actual PR diff, so the local SHA need not + # match the PR head. + - name: Download clang-tidy artifact uses: actions/download-artifact@v4 with: