From 09867e5cc18678cb9ae9579c912dc54a597bf651 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Tue, 1 Sep 2026 07:32:28 -0400 Subject: [PATCH] ci: comment coverage on pull requests from forks (#6065) * ci: comment coverage on pull requests from forks A pull_request run from a fork gets a read-only GITHUB_TOKEN, so octocov could not post its comment: it logged a 403 and exited 0, leaving the job green and the PR silent. The 'permissions:' block cannot grant what the token does not have. The comment now comes from a workflow_run workflow, which runs on the base repository and does get a write token. The pipeline job keeps the job summary and the default-branch baseline, and hands the merged profile and the PR number to it as an artifact. A workflow_run job otherwise looks like a push to the default branch, so octocov is pointed back at the pull request and at the run that produced the profile via its OCTOCOV_ environment overrides. Without the run id override the test execution time would be read from the wrong run; without the ref override a fork's coverage would be stored as the master baseline. The job holds a write token, so it reads .octocov.yml from the base branch rather than from the fork. * ci: stop checking out the fork in the coverage comment workflow CodeQL flagged the pull request checkout as untrusted code in a privileged context (actions/untrusted-checkout/high): the job holds a write token. The checkout existed only so the code-to-test ratio would reflect the pull request, which does not justify the alert. The workflow now checks out just .octocov.yml from the base branch, and the ratio is skipped when reporting from there. Coverage and its delta against master, the metrics that motivated the report, are unaffected: they come from the profile the pipeline uploads. * ci: treat the coverage artifact as untrusted input A pull_request run executes the fork's own copy of pipeline.yml, so every file in the octocov-pr artifact is attacker-controlled. The artifact was extracted into the workspace root, on top of the base-branch checkout, and download-artifact truncates existing files. A fork could therefore replace .octocov.yml before octocov loaded it. That is not only a config swap. config.Load expands ${VAR} from the job environment and the action sets OCTOCOV_GITHUB_TOKEN, so a crafted comment.message posts the privileged job's token into a public comment; a body: section rewrites a pull request description, which pull-requests: write allows. The artifact now lands in a subdirectory and only coverage.out is copied out, after pr_number is checked to be digits and the named pull request's head is confirmed to be the sha that triggered this run. Without that check the artifact could aim the comment at any open pull request, and unvalidated content reached GITHUB_OUTPUT. --- .github/workflows/coverage-on-pr.yml | 60 ++++++++++++++++++++++++++++ .github/workflows/pipeline.yml | 17 +++++++- .octocov.yml | 7 +++- 3 files changed, 82 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/coverage-on-pr.yml diff --git a/.github/workflows/coverage-on-pr.yml b/.github/workflows/coverage-on-pr.yml new file mode 100644 index 000000000..03260cacc --- /dev/null +++ b/.github/workflows/coverage-on-pr.yml @@ -0,0 +1,60 @@ +name: Report coverage on PR +on: + workflow_run: + workflows: ['Pipeline: Test, Lint, Build'] + types: [completed] +jobs: + comment: + name: Comment coverage report + if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success' + runs-on: ubuntu-latest + permissions: + contents: read + actions: read + pull-requests: write + env: + COVERAGE_COMMENT: 'true' + steps: + # Only the config, from the base branch: this job holds a write token, so + # it must never check out the fork. + - name: Check out the octocov config + uses: actions/checkout@v7 + with: + sparse-checkout: .octocov.yml + sparse-checkout-cone-mode: false + persist-credentials: false + + # Into a subdirectory. A pull_request run executes the fork's own copy of + # pipeline.yml, so every file in here is attacker-controlled. + - uses: actions/download-artifact@v8 + with: + name: octocov-pr + path: untrusted + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ github.token }} + + - name: Verify the artifact and take the coverage profile + id: pr + env: + GH_TOKEN: ${{ github.token }} + HEAD_SHA: ${{ github.event.workflow_run.head_sha }} + run: | + number=$(head -c 20 untrusted/pr_number | tr -d '[:space:]') + case "$number" in ''|*[!0-9]*) + echo "::error::artifact pr_number is not a number"; exit 1;; + esac + sha=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$number" --jq .head.sha) + if [ "$sha" != "$HEAD_SHA" ]; then + echo "::error::artifact claims PR #$number, but its head $sha is not $HEAD_SHA"; exit 1 + fi + cp untrusted/coverage.out coverage.out + echo "number=$number" >> "$GITHUB_OUTPUT" + + - uses: k1LoW/octocov-action@v1 + env: + # A workflow_run job looks like a push to the default branch. Point + # octocov back at the pull request and at the run that produced it. + GITHUB_PULL_REQUEST_NUMBER: ${{ steps.pr.outputs.number }} + OCTOCOV_GITHUB_REF: refs/pull/${{ steps.pr.outputs.number }}/merge + OCTOCOV_GITHUB_SHA: ${{ github.event.workflow_run.head_sha }} + OCTOCOV_GITHUB_RUN_ID: ${{ github.event.workflow_run.id }} diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index 7625cf410..aa8e29e49 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -193,8 +193,9 @@ jobs: needs: [go, go-plugins] permissions: contents: read - pull-requests: write actions: write + env: + COVERAGE_COMMENT: 'false' steps: - uses: actions/checkout@v7 @@ -212,6 +213,20 @@ jobs: - uses: k1LoW/octocov-action@v1 + - name: Save the PR number for the comment workflow + if: github.event_name == 'pull_request' + run: echo "${{ github.event.pull_request.number }}" > pr_number + + - name: Upload the merged profile for the comment workflow + if: github.event_name == 'pull_request' + uses: actions/upload-artifact@v7 + with: + name: octocov-pr + path: | + coverage.out + pr_number + if-no-files-found: error + go-windows: name: Test Go code (Windows) runs-on: windows-2022 diff --git a/.octocov.yml b/.octocov.yml index 397b5b364..52d4ff9bf 100644 --- a/.octocov.yml +++ b/.octocov.yml @@ -8,6 +8,9 @@ coverage: paths: - coverage.out codeToTestRatio: + # Needs the pull request's own source, which the comment workflow must not + # check out: it holds a write token. + if: env.COVERAGE_COMMENT != 'true' code: - '**/*.go' - '!**/*_test.go' @@ -23,7 +26,9 @@ diff: datastores: - artifact://${GITHUB_REPOSITORY} comment: - if: is_pull_request + # Only the 'Report coverage on PR' workflow sets this: a pull_request run from + # a fork gets a read-only token, so commenting from here 403s. + if: env.COVERAGE_COMMENT == 'true' updatePrevious: true summary: if: true