From d13ce64e6b6be7983d66cdbf5027a140ff2a327a Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 14 Jul 2026 15:48:54 +0000 Subject: [PATCH 1/6] Gate Sonar/Codecov steps on secret availability, not actor Fork and Dependabot PRs don't receive repository secrets, so the actor-only guard let the SonarCloud and Codecov steps run without their tokens and hard-fail (fail_ci_if_error: true). Expose SONAR_TOKEN and CODECOV_TOKEN as job-level env and gate each dependent step on env. != '' so they run for same-repo PRs/pushes and are cleanly skipped when secrets are absent. Refs java-helpers/simple-builders#164 Co-Authored-By: Andreas Igel --- .github/workflows/maven.yml | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml index 2f7e1ae4..d021f59d 100644 --- a/.github/workflows/maven.yml +++ b/.github/workflows/maven.yml @@ -24,6 +24,13 @@ jobs: pull-requests: write checks: write + # Expose secret-backed tokens as env so steps can be gated on their + # availability. On fork/Dependabot PRs secrets are withheld and resolve to + # an empty string, so the dependent steps are skipped rather than failing. + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + steps: - uses: actions/checkout@v4 with: @@ -75,10 +82,8 @@ jobs: fi - name: SonarCloud Analysis - # Skip for Dependabot PRs (no access to secrets) - if: github.actor != 'dependabot[bot]' - env: - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + # Skip when SONAR_TOKEN is unavailable (fork/Dependabot PRs). + if: env.SONAR_TOKEN != '' run: mvn -B org.sonarsource.scanner.maven:sonar-maven-plugin:sonar --file pom.xml - name: Upload JaCoCo HTML report (processor) @@ -98,8 +103,8 @@ jobs: if-no-files-found: warn - name: Upload test results to Codecov (processor) - # Skip for Dependabot PRs (no access to secrets) - if: always() && github.actor != 'dependabot[bot]' + # Skip when CODECOV_TOKEN is unavailable (fork/Dependabot PRs). + if: always() && env.CODECOV_TOKEN != '' uses: codecov/test-results-action@47f89e9acb64b76debcd5ea40642d25a4adced9f # v1.1.1 with: token: ${{ secrets.CODECOV_TOKEN }} @@ -110,8 +115,8 @@ jobs: verbose: false - name: Upload coverage to Codecov (processor) - # Skip for Dependabot PRs (no access to secrets) - if: always() && github.actor != 'dependabot[bot]' + # Skip when CODECOV_TOKEN is unavailable (fork/Dependabot PRs). + if: always() && env.CODECOV_TOKEN != '' uses: codecov/codecov-action@015f24e6818733317a2da2edd6290ab26238649a # v5.0.7 with: fail_ci_if_error: true From 39dc5daa59f53349a39d04798a0c769fa934b9a0 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 19 Jul 2026 10:11:54 +0000 Subject: [PATCH 2/6] Upload fork PR coverage to Codecov from a trusted workflow_run context Co-Authored-By: Andreas Igel --- .github/workflows/fork-coverage.yml | 82 +++++++++++++++++++++++++++++ .github/workflows/maven.yml | 32 +++++++++++ docs/CONTRIBUTING.md | 10 ++++ 3 files changed, 124 insertions(+) create mode 100644 .github/workflows/fork-coverage.yml diff --git a/.github/workflows/fork-coverage.yml b/.github/workflows/fork-coverage.yml new file mode 100644 index 00000000..2d8d911a --- /dev/null +++ b/.github/workflows/fork-coverage.yml @@ -0,0 +1,82 @@ +# Uploads coverage/test results for PRs from forks to Codecov. +# +# PRs from forks do not have access to repository secrets, so the direct +# Codecov steps in the main CI workflow are skipped for them. This workflow +# runs on `workflow_run` (i.e. in the base-repo context, WITH secrets) after +# the main CI completes, downloads ONLY the coverage data artifact that the CI +# build produced, and uploads it to Codecov. It never checks out or executes +# fork code, so there is no risk of leaking secrets to untrusted contributions. +name: Fork coverage (Codecov) + +on: + workflow_run: + workflows: ["Java CI with Maven"] + types: [completed] + +permissions: + contents: read + actions: read + +jobs: + codecov: + # Only for PRs that originate from a fork (same-repo PRs upload directly in + # the main CI workflow, which has secrets). + if: > + github.event.workflow_run.event == 'pull_request' && + github.event.workflow_run.head_repository.full_name != github.event.workflow_run.repository.full_name + runs-on: ubuntu-latest + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + steps: + - name: Download coverage payload from CI run + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: codecov-payload + path: codecov-payload + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ github.token }} + + - name: Read PR metadata + id: meta + # The payload file is produced by the untrusted fork build, so extract + # only the expected keys and validate their format before exposing them + # as step outputs (prevents $GITHUB_OUTPUT injection). + run: | + env_file=codecov-payload/pr-event.env + pr_number=$(grep -m1 '^pr_number=' "$env_file" | cut -d= -f2-) + pr_head_sha=$(grep -m1 '^pr_head_sha=' "$env_file" | cut -d= -f2-) + pr_head_ref=$(grep -m1 '^pr_head_ref=' "$env_file" | cut -d= -f2-) + [[ "$pr_number" =~ ^[0-9]+$ ]] || { echo "invalid pr_number"; exit 1; } + [[ "$pr_head_sha" =~ ^[0-9a-f]{40}$ ]] || { echo "invalid pr_head_sha"; exit 1; } + [[ "$pr_head_ref" =~ ^[A-Za-z0-9._/-]+$ ]] || { echo "invalid pr_head_ref"; exit 1; } + { + echo "pr_number=$pr_number" + echo "pr_head_sha=$pr_head_sha" + echo "pr_head_ref=$pr_head_ref" + } >> "$GITHUB_OUTPUT" + + - name: Upload test results to Codecov (processor) + if: env.CODECOV_TOKEN != '' + uses: codecov/test-results-action@47f89e9acb64b76debcd5ea40642d25a4adced9f # v1.1.1 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: codecov-payload/surefire/*.xml,codecov-payload/failsafe/*.xml + override_commit: ${{ steps.meta.outputs.pr_head_sha }} + override_pr: ${{ steps.meta.outputs.pr_number }} + override_branch: ${{ steps.meta.outputs.pr_head_ref }} + fail_ci_if_error: true + verbose: false + + - name: Upload coverage to Codecov (processor) + if: env.CODECOV_TOKEN != '' + uses: codecov/codecov-action@015f24e6818733317a2da2edd6290ab26238649a # v5.0.7 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: codecov-payload/jacoco.xml + flags: processor + name: codecov-upload-fork + override_commit: ${{ steps.meta.outputs.pr_head_sha }} + override_pr: ${{ steps.meta.outputs.pr_number }} + override_branch: ${{ steps.meta.outputs.pr_head_ref }} + fail_ci_if_error: true + verbose: false diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml index d021f59d..ae4ad221 100644 --- a/.github/workflows/maven.yml +++ b/.github/workflows/maven.yml @@ -102,6 +102,38 @@ jobs: path: processor/target/site/jacoco/jacoco.xml if-no-files-found: warn + # For PRs from forks, secrets are withheld so the direct Codecov steps + # below are skipped. Instead publish the coverage data (plus the PR + # identity) as an artifact; the privileged fork-coverage.yml workflow + # (triggered on workflow_run, in the base-repo context) consumes it and + # uploads to Codecov without ever executing fork code. + - name: Assemble Codecov payload (fork PRs) + if: always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository + env: + # Pass PR metadata through the environment (never interpolate the + # attacker-controlled head ref/sha directly into the shell script). + PR_NUMBER: ${{ github.event.number }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + PR_HEAD_REF: ${{ github.event.pull_request.head.ref }} + run: | + mkdir -p codecov-payload/surefire codecov-payload/failsafe + cp -f processor/target/site/jacoco/jacoco.xml codecov-payload/ 2>/dev/null || true + cp -f processor/target/surefire-reports/*.xml codecov-payload/surefire/ 2>/dev/null || true + cp -f processor/target/failsafe-reports/*.xml codecov-payload/failsafe/ 2>/dev/null || true + { + printf 'pr_number=%s\n' "$PR_NUMBER" + printf 'pr_head_sha=%s\n' "$PR_HEAD_SHA" + printf 'pr_head_ref=%s\n' "$PR_HEAD_REF" + } > codecov-payload/pr-event.env + + - name: Upload Codecov payload artifact (fork PRs) + if: always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: codecov-payload + path: codecov-payload/ + if-no-files-found: warn + - name: Upload test results to Codecov (processor) # Skip when CODECOV_TOKEN is unavailable (fork/Dependabot PRs). if: always() && env.CODECOV_TOKEN != '' diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 8c675af9..6bae2866 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -346,6 +346,16 @@ mvn fmt:check - **Include tests**: Add tests for new functionality - **Update docs**: Update README.md or other docs if needed +### CI for pull requests from forks + +GitHub does not expose repository secrets to workflows triggered by pull +requests from forks, so the secret-backed quality checks are handled specially: + +- **Codecov**: the normal CI build publishes the coverage/test data as an + artifact; a follow-up workflow (`.github/workflows/fork-coverage.yml`) then + uploads it to Codecov from the base-repo context. This is automatic and + requires no action from contributors. + ## Questions? If you have questions or need help: From 5c76c31efee1a3df172d54709c788d636699df35 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 19 Jul 2026 11:02:36 +0000 Subject: [PATCH 3/6] Add env-gated SonarCloud analysis for fork PRs (manual maintainer approval) Co-Authored-By: Andreas Igel --- .github/workflows/fork-sonar.yml | 78 ++++++++++++++++++++++++++++++++ docs/CONTRIBUTING.md | 6 +++ 2 files changed, 84 insertions(+) create mode 100644 .github/workflows/fork-sonar.yml diff --git a/.github/workflows/fork-sonar.yml b/.github/workflows/fork-sonar.yml new file mode 100644 index 00000000..1c76b480 --- /dev/null +++ b/.github/workflows/fork-sonar.yml @@ -0,0 +1,78 @@ +# SonarCloud analysis for pull requests from forks (manual maintainer gate). +# +# PRs from forks do not have access to repository secrets, so the SonarCloud +# step in the main CI workflow is skipped for them (SONAR_TOKEN is empty). +# This workflow runs on `pull_request_target` (i.e. in the base-repo context, +# WITH secrets) but is protected by the `fork-ci` GitHub Environment, which is +# configured with Required reviewers. A maintainer must click "Approve" before +# the job runs, so it is the maintainer who starts the analysis on behalf of +# the contributor and whose approval unlocks the token. +# +# SECURITY: this workflow checks out and builds the untrusted fork code with +# secrets in scope. That is only acceptable because the `fork-ci` environment +# approval gate blocks execution until a maintainer has reviewed the PR diff. +# Do NOT remove the `environment:` gate. +name: Fork SonarCloud (manual approval) + +on: + pull_request_target: + branches: [ "main" ] + types: [opened, synchronize, reopened] + +permissions: + contents: read + +concurrency: + group: fork-sonar-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + sonar: + # Only for PRs that originate from a fork. Same-repo PRs already run Sonar + # in the main CI workflow (which has secrets), so skip them here. + if: github.event.pull_request.head.repo.full_name != github.repository + runs-on: ubuntu-latest + timeout-minutes: 20 + # Manual maintainer approval gate: the run pauses here until a required + # reviewer of the `fork-ci` environment approves it. + environment: fork-ci + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + steps: + - name: Check out PR head (untrusted fork code) + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + # Explicitly analyse the fork PR's head commit, not the base branch. + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 0 + + - name: Set up JDK 17 + uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4.8.0 + with: + java-version: '17' + distribution: 'zulu' + cache: maven + + - name: Cache SonarCloud packages + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: ~/.sonar/cache + key: ${{ runner.os }}-sonar-${{ hashFiles('**/pom.xml') }} + restore-keys: ${{ runner.os }}-sonar + + - name: Build with Maven + run: mvn -B install -Pmetrics --file pom.xml + + - name: SonarCloud Analysis + if: env.SONAR_TOKEN != '' + env: + # Pass the (attacker-controlled) PR metadata via env, never inline. + PR_KEY: ${{ github.event.pull_request.number }} + PR_BRANCH: ${{ github.event.pull_request.head.ref }} + PR_BASE: ${{ github.event.pull_request.base.ref }} + run: | + mvn -B org.sonarsource.scanner.maven:sonar-maven-plugin:sonar \ + --file pom.xml \ + -Dsonar.pullrequest.key="$PR_KEY" \ + -Dsonar.pullrequest.branch="$PR_BRANCH" \ + -Dsonar.pullrequest.base="$PR_BASE" diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 6bae2866..09145503 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -355,6 +355,12 @@ requests from forks, so the secret-backed quality checks are handled specially: artifact; a follow-up workflow (`.github/workflows/fork-coverage.yml`) then uploads it to Codecov from the base-repo context. This is automatic and requires no action from contributors. +- **SonarCloud**: a separate workflow (`.github/workflows/fork-sonar.yml`) + runs the analysis with the secret token, but it is gated by the `fork-ci` + GitHub Environment. A **maintainer must approve the run** (in the PR's + "Checks"/Environments prompt) before it executes — the analysis then runs on + the maintainer's behalf. Contributors cannot trigger it themselves, which + keeps the token from being exposed to untrusted code without review. ## Questions? From 4f401c2d47c403e3d186d4f5c379a184e7a77103 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 19 Jul 2026 12:35:23 +0000 Subject: [PATCH 4/6] Separate fork CI from privileged quality publishing Co-Authored-By: Andreas Igel --- .github/workflows/fork-coverage.yml | 4 +- .github/workflows/fork-sonar.yml | 70 ++++++++++++++++------------- .github/workflows/maven.yml | 17 +++++++ docs/CONTRIBUTING.md | 23 +++++----- 4 files changed, 72 insertions(+), 42 deletions(-) diff --git a/.github/workflows/fork-coverage.yml b/.github/workflows/fork-coverage.yml index 2d8d911a..b90888d8 100644 --- a/.github/workflows/fork-coverage.yml +++ b/.github/workflows/fork-coverage.yml @@ -55,7 +55,7 @@ jobs: echo "pr_head_ref=$pr_head_ref" } >> "$GITHUB_OUTPUT" - - name: Upload test results to Codecov (processor) + - name: Publish fork PR test execution results to Codecov if: env.CODECOV_TOKEN != '' uses: codecov/test-results-action@47f89e9acb64b76debcd5ea40642d25a4adced9f # v1.1.1 with: @@ -67,7 +67,7 @@ jobs: fail_ci_if_error: true verbose: false - - name: Upload coverage to Codecov (processor) + - name: Publish fork PR test coverage to Codecov if: env.CODECOV_TOKEN != '' uses: codecov/codecov-action@015f24e6818733317a2da2edd6290ab26238649a # v5.0.7 with: diff --git a/.github/workflows/fork-sonar.yml b/.github/workflows/fork-sonar.yml index 1c76b480..0091162b 100644 --- a/.github/workflows/fork-sonar.yml +++ b/.github/workflows/fork-sonar.yml @@ -1,49 +1,45 @@ # SonarCloud analysis for pull requests from forks (manual maintainer gate). # -# PRs from forks do not have access to repository secrets, so the SonarCloud -# step in the main CI workflow is skipped for them (SONAR_TOKEN is empty). -# This workflow runs on `pull_request_target` (i.e. in the base-repo context, -# WITH secrets) but is protected by the `fork-ci` GitHub Environment, which is -# configured with Required reviewers. A maintainer must click "Approve" before -# the job runs, so it is the maintainer who starts the analysis on behalf of -# the contributor and whose approval unlocks the token. +# The unprivileged Java CI workflow performs the build, generated-source check, +# and tests before this workflow can run. This workflow restores the resulting +# analysis inputs and publishes them to SonarCloud; it does not rebuild or +# retest fork code with the Sonar token. # -# SECURITY: this workflow checks out and builds the untrusted fork code with -# secrets in scope. That is only acceptable because the `fork-ci` environment -# approval gate blocks execution until a maintainer has reviewed the PR diff. -# Do NOT remove the `environment:` gate. +# It runs on `workflow_run` in the base-repo context (with secrets), and is +# protected by the `fork-ci` GitHub Environment with Required reviewers. +# Do NOT remove that environment gate. name: Fork SonarCloud (manual approval) on: - pull_request_target: - branches: [ "main" ] - types: [opened, synchronize, reopened] + workflow_run: + workflows: ["Java CI with Maven"] + types: [completed] permissions: contents: read + actions: read concurrency: - group: fork-sonar-${{ github.event.pull_request.number }} + group: fork-sonar-${{ github.event.workflow_run.id }} cancel-in-progress: true jobs: sonar: - # Only for PRs that originate from a fork. Same-repo PRs already run Sonar - # in the main CI workflow (which has secrets), so skip them here. - if: github.event.pull_request.head.repo.full_name != github.repository + if: > + github.event.workflow_run.event == 'pull_request' && + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.head_repository.full_name != github.event.workflow_run.repository.full_name && + github.event.workflow_run.pull_requests[0].number != '' runs-on: ubuntu-latest timeout-minutes: 20 - # Manual maintainer approval gate: the run pauses here until a required - # reviewer of the `fork-ci` environment approves it. environment: fork-ci env: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} steps: - - name: Check out PR head (untrusted fork code) + - name: Check out successful fork PR source uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: - # Explicitly analyse the fork PR's head commit, not the base branch. - ref: ${{ github.event.pull_request.head.sha }} + ref: ${{ github.event.workflow_run.head_sha }} fetch-depth: 0 - name: Set up JDK 17 @@ -60,19 +56,33 @@ jobs: key: ${{ runner.os }}-sonar-${{ hashFiles('**/pom.xml') }} restore-keys: ${{ runner.os }}-sonar - - name: Build with Maven - run: mvn -B install -Pmetrics --file pom.xml + - name: Download successful fork PR analysis inputs + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: sonar-analysis-inputs + path: sonar-analysis-data + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ github.token }} + + - name: Restore compiled classes and coverage reports + run: | + mkdir -p core/target/classes processor/target/classes + cp -a sonar-analysis-data/core-classes/. core/target/classes/ 2>/dev/null || true + cp -a sonar-analysis-data/processor-classes/. processor/target/classes/ 2>/dev/null || true + mkdir -p core/target/site/jacoco processor/target/site/jacoco + cp -a sonar-analysis-data/core-jacoco.xml core/target/site/jacoco/jacoco.xml 2>/dev/null || true + cp -a sonar-analysis-data/processor-jacoco.xml processor/target/site/jacoco/jacoco.xml 2>/dev/null || true - - name: SonarCloud Analysis + - name: Publish fork PR analysis to SonarCloud if: env.SONAR_TOKEN != '' env: - # Pass the (attacker-controlled) PR metadata via env, never inline. - PR_KEY: ${{ github.event.pull_request.number }} - PR_BRANCH: ${{ github.event.pull_request.head.ref }} - PR_BASE: ${{ github.event.pull_request.base.ref }} + PR_KEY: ${{ github.event.workflow_run.pull_requests[0].number }} + PR_BRANCH: ${{ github.event.workflow_run.head_branch }} + PR_BASE: main run: | mvn -B org.sonarsource.scanner.maven:sonar-maven-plugin:sonar \ --file pom.xml \ + -DskipTests \ -Dsonar.pullrequest.key="$PR_KEY" \ -Dsonar.pullrequest.branch="$PR_BRANCH" \ -Dsonar.pullrequest.base="$PR_BASE" diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml index ae4ad221..54e49b2e 100644 --- a/.github/workflows/maven.yml +++ b/.github/workflows/maven.yml @@ -81,6 +81,23 @@ jobs: echo "✅ No uncommitted generated source changes found" fi + - name: Package Sonar analysis inputs (fork PRs) + if: always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository + run: | + mkdir -p sonar-analysis-data + cp -a core/target/classes sonar-analysis-data/core-classes 2>/dev/null || true + cp -a processor/target/classes sonar-analysis-data/processor-classes 2>/dev/null || true + cp -a core/target/site/jacoco/jacoco.xml sonar-analysis-data/core-jacoco.xml 2>/dev/null || true + cp -a processor/target/site/jacoco/jacoco.xml sonar-analysis-data/processor-jacoco.xml 2>/dev/null || true + + - name: Upload Sonar analysis inputs (fork PRs) + if: always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: sonar-analysis-inputs + path: sonar-analysis-data/ + if-no-files-found: warn + - name: SonarCloud Analysis # Skip when SONAR_TOKEN is unavailable (fork/Dependabot PRs). if: env.SONAR_TOKEN != '' diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 09145503..2ac7b6db 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -351,16 +351,19 @@ mvn fmt:check GitHub does not expose repository secrets to workflows triggered by pull requests from forks, so the secret-backed quality checks are handled specially: -- **Codecov**: the normal CI build publishes the coverage/test data as an - artifact; a follow-up workflow (`.github/workflows/fork-coverage.yml`) then - uploads it to Codecov from the base-repo context. This is automatic and - requires no action from contributors. -- **SonarCloud**: a separate workflow (`.github/workflows/fork-sonar.yml`) - runs the analysis with the secret token, but it is gated by the `fork-ci` - GitHub Environment. A **maintainer must approve the run** (in the PR's - "Checks"/Environments prompt) before it executes — the analysis then runs on - the maintainer's behalf. Contributors cannot trigger it themselves, which - keeps the token from being exposed to untrusted code without review. +- **Codecov**: the normal CI build performs the build, generated-source check, + and tests without secrets, then publishes coverage/test data as an artifact. + A follow-up workflow (`.github/workflows/fork-coverage.yml`) publishes the + test execution results and coverage to Codecov from the base-repo context. + This is automatic and requires no action from contributors. The upload can + still happen when tests fail so Codecov receives diagnostics; the CI result + remains failing and cannot satisfy a passing coverage gate. +- **SonarCloud**: after the unprivileged CI build and tests succeed, a separate + workflow (`.github/workflows/fork-sonar.yml`) restores the compiled classes + and coverage reports and publishes the analysis with the secret token. It is + gated by the `fork-ci` GitHub Environment. A **maintainer must approve the + run** (in the Actions/Environments prompt) before it executes. It does not + rebuild or retest fork code with the token. ## Questions? From b4fb23b462a65deb91b052a4d05f3fbc29858b5c Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 19 Jul 2026 12:35:45 +0000 Subject: [PATCH 5/6] Publish fork coverage only after successful CI Co-Authored-By: Andreas Igel --- .github/workflows/fork-coverage.yml | 2 +- docs/CONTRIBUTING.md | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/workflows/fork-coverage.yml b/.github/workflows/fork-coverage.yml index b90888d8..f396d968 100644 --- a/.github/workflows/fork-coverage.yml +++ b/.github/workflows/fork-coverage.yml @@ -68,7 +68,7 @@ jobs: verbose: false - name: Publish fork PR test coverage to Codecov - if: env.CODECOV_TOKEN != '' + if: env.CODECOV_TOKEN != '' && github.event.workflow_run.conclusion == 'success' uses: codecov/codecov-action@015f24e6818733317a2da2edd6290ab26238649a # v5.0.7 with: token: ${{ secrets.CODECOV_TOKEN }} diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 2ac7b6db..4ddddad4 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -355,9 +355,10 @@ requests from forks, so the secret-backed quality checks are handled specially: and tests without secrets, then publishes coverage/test data as an artifact. A follow-up workflow (`.github/workflows/fork-coverage.yml`) publishes the test execution results and coverage to Codecov from the base-repo context. - This is automatic and requires no action from contributors. The upload can - still happen when tests fail so Codecov receives diagnostics; the CI result - remains failing and cannot satisfy a passing coverage gate. + This is automatic and requires no action from contributors. Test execution + results are uploaded even when tests fail so Codecov receives diagnostics; + coverage is published only after a successful CI run. The CI result remains + authoritative and cannot be made passing by an upload. - **SonarCloud**: after the unprivileged CI build and tests succeed, a separate workflow (`.github/workflows/fork-sonar.yml`) restores the compiled classes and coverage reports and publishes the analysis with the secret token. It is From 4340ead36d763d217d25b8adfb15d78d24c26bc1 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 19 Jul 2026 12:47:25 +0000 Subject: [PATCH 6/6] Make fork Sonar workflow trigger and resolve PR from artifact metadata Co-Authored-By: Andreas Igel --- .github/workflows/fork-sonar.yml | 61 ++++++++++++++++++++++---------- .github/workflows/maven.yml | 13 +++++++ 2 files changed, 55 insertions(+), 19 deletions(-) diff --git a/.github/workflows/fork-sonar.yml b/.github/workflows/fork-sonar.yml index 0091162b..f757db2b 100644 --- a/.github/workflows/fork-sonar.yml +++ b/.github/workflows/fork-sonar.yml @@ -28,18 +28,48 @@ jobs: if: > github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success' && - github.event.workflow_run.head_repository.full_name != github.event.workflow_run.repository.full_name && - github.event.workflow_run.pull_requests[0].number != '' + github.event.workflow_run.head_repository.full_name != github.event.workflow_run.repository.full_name runs-on: ubuntu-latest timeout-minutes: 20 environment: fork-ci env: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} steps: - - name: Check out successful fork PR source + - name: Download successful fork PR analysis inputs + # Store outside the workspace so the later source checkout (which cleans + # the workspace) does not remove these files. + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: sonar-analysis-inputs + path: ${{ runner.temp }}/sonar-analysis-data + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ github.token }} + + - name: Read PR metadata + id: meta + # The payload file is produced by the untrusted fork build, so extract + # only the expected keys and validate their format before exposing them + # as step outputs (prevents $GITHUB_OUTPUT injection). + run: | + env_file="$RUNNER_TEMP/sonar-analysis-data/pr-event.env" + pr_number=$(grep -m1 '^pr_number=' "$env_file" | cut -d= -f2-) + pr_head_ref=$(grep -m1 '^pr_head_ref=' "$env_file" | cut -d= -f2-) + pr_base_ref=$(grep -m1 '^pr_base_ref=' "$env_file" | cut -d= -f2-) + [[ "$pr_number" =~ ^[0-9]+$ ]] || { echo "invalid pr_number"; exit 1; } + [[ "$pr_head_ref" =~ ^[A-Za-z0-9._/-]+$ ]] || { echo "invalid pr_head_ref"; exit 1; } + [[ "$pr_base_ref" =~ ^[A-Za-z0-9._/-]+$ ]] || { echo "invalid pr_base_ref"; exit 1; } + { + echo "pr_number=$pr_number" + echo "pr_head_ref=$pr_head_ref" + echo "pr_base_ref=$pr_base_ref" + } >> "$GITHUB_OUTPUT" + + - name: Check out fork PR source (from the base repo's PR ref) uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: - ref: ${{ github.event.workflow_run.head_sha }} + # The fork head SHA is not present in the base repo, but the PR head + # ref is. Check that out so Sonar can read the analysed source. + ref: refs/pull/${{ steps.meta.outputs.pr_number }}/head fetch-depth: 0 - name: Set up JDK 17 @@ -56,29 +86,22 @@ jobs: key: ${{ runner.os }}-sonar-${{ hashFiles('**/pom.xml') }} restore-keys: ${{ runner.os }}-sonar - - name: Download successful fork PR analysis inputs - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 - with: - name: sonar-analysis-inputs - path: sonar-analysis-data - run-id: ${{ github.event.workflow_run.id }} - github-token: ${{ github.token }} - - name: Restore compiled classes and coverage reports run: | + src="$RUNNER_TEMP/sonar-analysis-data" mkdir -p core/target/classes processor/target/classes - cp -a sonar-analysis-data/core-classes/. core/target/classes/ 2>/dev/null || true - cp -a sonar-analysis-data/processor-classes/. processor/target/classes/ 2>/dev/null || true + cp -a "$src/core-classes/." core/target/classes/ 2>/dev/null || true + cp -a "$src/processor-classes/." processor/target/classes/ 2>/dev/null || true mkdir -p core/target/site/jacoco processor/target/site/jacoco - cp -a sonar-analysis-data/core-jacoco.xml core/target/site/jacoco/jacoco.xml 2>/dev/null || true - cp -a sonar-analysis-data/processor-jacoco.xml processor/target/site/jacoco/jacoco.xml 2>/dev/null || true + cp -a "$src/core-jacoco.xml" core/target/site/jacoco/jacoco.xml 2>/dev/null || true + cp -a "$src/processor-jacoco.xml" processor/target/site/jacoco/jacoco.xml 2>/dev/null || true - name: Publish fork PR analysis to SonarCloud if: env.SONAR_TOKEN != '' env: - PR_KEY: ${{ github.event.workflow_run.pull_requests[0].number }} - PR_BRANCH: ${{ github.event.workflow_run.head_branch }} - PR_BASE: main + PR_KEY: ${{ steps.meta.outputs.pr_number }} + PR_BRANCH: ${{ steps.meta.outputs.pr_head_ref }} + PR_BASE: ${{ steps.meta.outputs.pr_base_ref }} run: | mvn -B org.sonarsource.scanner.maven:sonar-maven-plugin:sonar \ --file pom.xml \ diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml index 5c585a0c..a700570a 100644 --- a/.github/workflows/maven.yml +++ b/.github/workflows/maven.yml @@ -83,12 +83,25 @@ jobs: - name: Package Sonar analysis inputs (fork PRs) if: always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository + env: + # Pass PR metadata through the environment (never interpolate the + # attacker-controlled head ref/sha directly into the shell script). + PR_NUMBER: ${{ github.event.number }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + PR_HEAD_REF: ${{ github.event.pull_request.head.ref }} + PR_BASE_REF: ${{ github.event.pull_request.base.ref }} run: | mkdir -p sonar-analysis-data cp -a core/target/classes sonar-analysis-data/core-classes 2>/dev/null || true cp -a processor/target/classes sonar-analysis-data/processor-classes 2>/dev/null || true cp -a core/target/site/jacoco/jacoco.xml sonar-analysis-data/core-jacoco.xml 2>/dev/null || true cp -a processor/target/site/jacoco/jacoco.xml sonar-analysis-data/processor-jacoco.xml 2>/dev/null || true + { + printf 'pr_number=%s\n' "$PR_NUMBER" + printf 'pr_head_sha=%s\n' "$PR_HEAD_SHA" + printf 'pr_head_ref=%s\n' "$PR_HEAD_REF" + printf 'pr_base_ref=%s\n' "$PR_BASE_REF" + } > sonar-analysis-data/pr-event.env - name: Upload Sonar analysis inputs (fork PRs) if: always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository