diff --git a/.github/workflows/fork-coverage.yml b/.github/workflows/fork-coverage.yml new file mode 100644 index 00000000..f396d968 --- /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: Publish fork PR test execution results to Codecov + 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: Publish fork PR test coverage to Codecov + if: env.CODECOV_TOKEN != '' && github.event.workflow_run.conclusion == 'success' + 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/fork-sonar.yml b/.github/workflows/fork-sonar.yml new file mode 100644 index 00000000..f757db2b --- /dev/null +++ b/.github/workflows/fork-sonar.yml @@ -0,0 +1,111 @@ +# SonarCloud analysis for pull requests from forks (manual maintainer gate). +# +# 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. +# +# 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: + workflow_run: + workflows: ["Java CI with Maven"] + types: [completed] + +permissions: + contents: read + actions: read + +concurrency: + group: fork-sonar-${{ github.event.workflow_run.id }} + cancel-in-progress: true + +jobs: + sonar: + 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 + runs-on: ubuntu-latest + timeout-minutes: 20 + environment: fork-ci + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + steps: + - 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: + # 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 + 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: Restore compiled classes and coverage reports + run: | + src="$RUNNER_TEMP/sonar-analysis-data" + mkdir -p core/target/classes processor/target/classes + 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 "$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: ${{ 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 \ + -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 5fa54732..a700570a 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@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: @@ -74,11 +81,39 @@ jobs: echo "✅ No uncommitted generated source changes found" fi - - name: SonarCloud Analysis - # Skip for Dependabot PRs (no access to secrets) - if: github.actor != 'dependabot[bot]' + - 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: - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + # 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 + 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 != '' run: mvn -B org.sonarsource.scanner.maven:sonar-maven-plugin:sonar --file pom.xml - name: Upload JaCoCo HTML report (processor) @@ -97,9 +132,41 @@ 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 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 +177,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 diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 8c675af9..4ddddad4 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -346,6 +346,26 @@ 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 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. 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 + 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? If you have questions or need help: