From 0724e89d5530800ff52893792f8561002691f618 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Wed, 1 Jul 2026 01:36:45 -0700 Subject: [PATCH] ci(coverage): upload fork-PR coverage to Codecov via workflow_run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fork PRs run without access to secrets, so `secrets.CODECOV_TOKEN` is empty and the inline codecov upload fails ("Token required because branch is protected") with `fail_ci_if_error: true`, turning every backend-touching fork PR red through no fault of the contributor — and the auto-close gate then closes it. Keep the inline upload for trusted contexts (push + same-repo PRs) but skip it on forks; instead ci.yml stashes the coverage report + PR metadata as the `fork-coverage` artifact, and a new `Codecov fork upload` workflow runs on workflow_run in the base-repo context (which CAN read the token) to perform the upload. codecov/patch stays enforced on fork PRs. Security: the privileged workflow never checks out or runs fork code — it only downloads the artifact and passes metadata to codecov-action. It runs only for successful fork-PR CI runs, fork-controlled values reach the shell via env (never `${{ }}` interpolation), and the PR number / sha / branch are re-validated against a strict charset before use. Validated with actionlint. --- .github/workflows/ci.yml | 33 ++++++++- .github/workflows/codecov-fork-upload.yml | 84 +++++++++++++++++++++++ 2 files changed, 115 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/codecov-fork-upload.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6685ecd5f2..fe6060badb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -136,8 +136,10 @@ jobs: echo "::error title=Coverage::coverage/lcov.info is missing or empty" exit 1 fi + # Direct upload for trusted contexts only (push + same-repo PRs), where secrets.CODECOV_TOKEN is + # available. Fork PRs skip this — they cannot read the token — and take the artifact path below. - name: Upload coverage to Codecov - if: ${{ success() && (github.event_name == 'push' || needs.changes.outputs.backend == 'true') }} + if: ${{ success() && (github.event_name == 'push' || needs.changes.outputs.backend == 'true') && github.event.pull_request.head.repo.fork != true }} uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 with: token: ${{ secrets.CODECOV_TOKEN }} @@ -153,7 +155,7 @@ jobs: # their upload non-blocking so a JUnit ingestion hiccup does not fail CI # after the tests and hard coverage upload have already passed. - name: Upload Vitest results to Codecov - if: ${{ !cancelled() && (github.event_name == 'push' || needs.changes.outputs.backend == 'true') }} + if: ${{ !cancelled() && (github.event_name == 'push' || needs.changes.outputs.backend == 'true') && github.event.pull_request.head.repo.fork != true }} uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 with: token: ${{ secrets.CODECOV_TOKEN }} @@ -164,6 +166,33 @@ jobs: override_commit: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} override_pr: ${{ github.event_name == 'pull_request' && github.event.pull_request.number || '' }} fail_ci_if_error: false + # Fork PRs run without secrets, so they cannot upload to Codecov here. Stash the coverage report and the + # PR metadata as an artifact; the privileged `Codecov fork upload` workflow (workflow_run, base-repo + # context, has the token) uploads it — keeping codecov/patch enforced on forks. Fork-controlled values + # (branch name) go through env, never shell interpolation, and are re-validated before use downstream. + - name: Stash fork coverage for out-of-band upload + if: ${{ success() && needs.changes.outputs.backend == 'true' && github.event.pull_request.head.repo.fork == true }} + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + HEAD_BRANCH: ${{ github.event.pull_request.head.ref }} + run: | + mkdir -p fork-coverage + cp coverage/lcov.info fork-coverage/lcov.info + cp reports/junit/vitest.xml fork-coverage/vitest.xml 2>/dev/null || true + { + printf 'PR_NUMBER=%s\n' "$PR_NUMBER" + printf 'HEAD_SHA=%s\n' "$HEAD_SHA" + printf 'HEAD_BRANCH=%s\n' "$HEAD_BRANCH" + } > fork-coverage/codecov-meta.env + - name: Upload fork coverage artifact + if: ${{ success() && needs.changes.outputs.backend == 'true' && github.event.pull_request.head.repo.fork == true }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: fork-coverage + path: fork-coverage/ + retention-days: 1 + if-no-files-found: error - name: Worker runtime tests if: ${{ github.event_name == 'push' || needs.changes.outputs.backend == 'true' }} run: npm run test:workers diff --git a/.github/workflows/codecov-fork-upload.yml b/.github/workflows/codecov-fork-upload.yml new file mode 100644 index 0000000000..bd0da3d4ac --- /dev/null +++ b/.github/workflows/codecov-fork-upload.yml @@ -0,0 +1,84 @@ +name: Codecov fork upload + +# Fork PR CI runs without access to secrets, so ci.yml cannot upload their coverage to Codecov directly — +# it stashes the coverage report + PR metadata as the `fork-coverage` artifact. This workflow runs on +# `workflow_run` in the BASE-repo context (so it CAN read secrets.CODECOV_TOKEN) and performs the upload, +# keeping the codecov/patch gate enforced on fork PRs. +# +# Security: this is a privileged workflow processing a fork-produced artifact. It never checks out or runs +# fork code — it only downloads the artifact and passes validated metadata to codecov-action. The job runs +# ONLY for successful fork-PR CI runs, and every fork-controlled value is re-validated against a strict +# charset before use. +on: + workflow_run: + workflows: ["CI"] + types: [completed] + +permissions: + contents: read + actions: read + +jobs: + upload: + if: >- + ${{ github.event.workflow_run.conclusion == 'success' + && github.event.workflow_run.event == 'pull_request' + && github.event.workflow_run.head_repository.full_name != github.repository }} + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Download fork coverage artifact + id: download + continue-on-error: true + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: fork-coverage + path: fork-coverage + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ github.token }} + - name: Read and validate coverage metadata + id: meta + run: | + f=fork-coverage/codecov-meta.env + if [ ! -f "$f" ]; then + echo "no fork-coverage artifact on this run — nothing to upload" + echo "skip=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + pr="$(sed -n 's/^PR_NUMBER=//p' "$f" | head -1)" + sha="$(sed -n 's/^HEAD_SHA=//p' "$f" | head -1)" + branch="$(sed -n 's/^HEAD_BRANCH=//p' "$f" | head -1)" + # Reject anything that is not a plain PR number / hex sha / safe ref name — a fork controls these. + case "$pr" in '' | *[!0-9]*) echo "invalid PR number"; echo "skip=true" >> "$GITHUB_OUTPUT"; exit 0 ;; esac + case "$sha" in '' | *[!0-9a-fA-F]*) echo "invalid head sha"; echo "skip=true" >> "$GITHUB_OUTPUT"; exit 0 ;; esac + case "$branch" in '' | *[!A-Za-z0-9._/-]*) echo "invalid head branch"; echo "skip=true" >> "$GITHUB_OUTPUT"; exit 0 ;; esac + { + echo "skip=false" + echo "pr=$pr" + echo "sha=$sha" + echo "branch=$branch" + } >> "$GITHUB_OUTPUT" + - name: Upload coverage to Codecov + if: ${{ steps.meta.outputs.skip == 'false' }} + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: fork-coverage/lcov.info + disable_search: true + override_branch: ${{ steps.meta.outputs.branch }} + override_commit: ${{ steps.meta.outputs.sha }} + override_pr: ${{ steps.meta.outputs.pr }} + # Same hard-gate semantics as the inline upload: a failed upload must not silently drop patch data. + fail_ci_if_error: true + - name: Upload Vitest results to Codecov + if: ${{ steps.meta.outputs.skip == 'false' && hashFiles('fork-coverage/vitest.xml') != '' }} + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: fork-coverage/vitest.xml + report_type: test_results + disable_search: true + override_branch: ${{ steps.meta.outputs.branch }} + override_commit: ${{ steps.meta.outputs.sha }} + override_pr: ${{ steps.meta.outputs.pr }} + fail_ci_if_error: false