Skip to content
82 changes: 82 additions & 0 deletions .github/workflows/fork-coverage.yml
Original file line number Diff line number Diff line change
@@ -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
111 changes: 111 additions & 0 deletions .github/workflows/fork-sonar.yml
Original file line number Diff line number Diff line change
@@ -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"
83 changes: 75 additions & 8 deletions .github/workflows/maven.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand All @@ -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 }}
Expand All @@ -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
Expand Down
20 changes: 20 additions & 0 deletions docs/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading