So, as promised, here is how we managed to get a Sonar analysis on a PR based on a fork.
We now have 3 CI workflows:
- “CI”: the usual one that works only on PR from inside our repository and therefore can access the secrets
- “CI on forks - build and tests”: the workflow that run only on PR from forks. It does all the parts that do not require access to the secrets (building and testing)
- “CI on forks - Sonar analysis”: this workflow is triggered only when the “CI on forks - build and tests” successfully completed. Since it’s run from inside the repository, it has access to the secrets and therefore can run the Sonar analysis. However, since it’s not triggered by the PR, the difficulty was to properly link the Sonar analysis to the PR.
First workflow triggered by usual PRs:
name: CI
on:
push:
branches:
- 'main'
- 'release-v**'
- 'full-sonar-analysis-**'
tags:
- 'v[0-9]+.[0-9]+.[0-9]+*'
pull_request:
permissions: {}
jobs:
build:
name: Build OS ${{ matrix.os }}
runs-on: ${{ matrix.os }}
if: github.event.pull_request.head.repo.fork == false
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
steps:
- name: Checkout sources
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- name: Set up JDK 17
uses: actions/setup-java@387ac29b308b003ca37ba93a6cab5eb57c8f5f93 # v4.0.0
with:
distribution: 'temurin'
java-version: '17'
- name: Build with Maven (Ubuntu)
if: matrix.os == 'ubuntu-latest'
run: ./mvnw --batch-mode -Dpowsybl.docker-unit-tests.skip=false -Pjacoco install
- name: Build with Maven (Windows)
if: matrix.os == 'windows-latest'
run: mvnw.cmd --batch-mode install
shell: cmd
- name: Build with Maven (MacOS)
if: matrix.os == 'macos-latest'
run: ./mvnw --batch-mode install
- name: Run SonarCloud analysis
if: matrix.os == 'ubuntu-latest'
run: >
./mvnw --batch-mode -DskipTests sonar:sonar
-Dsonar.host.url=https://sonarcloud.io
-Dsonar.organization=<your-sonar-organisation>
-Dsonar.projectKey=<your-sonar-project-key>
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
Note the condition if: github.event.pull_request.head.repo.fork == false
to not run this workflow if the PR is from a fork.
Second workflow triggered by PRs from forks:
name: CI on forks - build and tests
on:
pull_request:
permissions: {}
jobs:
build:
name: Build OS ${{ matrix.os }}
runs-on: ${{ matrix.os }}
if: github.event.pull_request.head.repo.fork == true
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
steps:
- name: Checkout sources
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- name: Set up JDK 17
uses: actions/setup-java@387ac29b308b003ca37ba93a6cab5eb57c8f5f93 # v4.0.0
with:
distribution: 'temurin'
java-version: '17'
- name: Build with Maven (Ubuntu)
if: matrix.os == 'ubuntu-latest'
run: ./mvnw --batch-mode -Dpowsybl.docker-unit-tests.skip=false -Pjacoco install
- name: Build with Maven (Windows)
if: matrix.os == 'windows-latest'
run: mvnw.cmd --batch-mode install
shell: cmd
- name: Build with Maven (MacOS)
if: matrix.os == 'macos-latest'
run: ./mvnw --batch-mode install
- name: Regroup dependencies in target folders
if: matrix.os == 'ubuntu-latest'
run: ./mvnw dependency:copy-dependencies
# adapt the paths to your project
- name: Save classes and Jacoco report
if: matrix.os == 'ubuntu-latest'
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: data-for-sonar-analysis-${{ github.event.pull_request.number }}
retention-days: 1
path: |
*/target/classes
*/*/target/classes
*/*/*/target/classes
*/target/generated-sources
*/*/target/generated-sources
*/*/*/target/generated-sources
distribution-core/target/dependency
distribution-core/target/site/jacoco-aggregate/jacoco.xml
- name: Save PR Information
if: matrix.os == 'ubuntu-latest'
run: |
mkdir -p pr-info
echo "${{ github.event.pull_request.head.repo.full_name }}" > pr-info/repo-name
echo "${{ github.event.pull_request.head.ref }}" > pr-info/head-ref
echo "${{ github.event.pull_request.head.sha }}" > pr-info/head-sha
echo "${{ github.event.pull_request.number }}" > pr-info/pr-number
echo "${{ github.event.pull_request.base.ref }}" > pr-info/base-ref
- name: Upload PR Information
if: matrix.os == 'ubuntu-latest'
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: pr-info-${{ github.event.pull_request.number }}
path: pr-info/
retention-days: 1
Here we build the project, do the tests and then upload an artifact with all the data required for the Sonar Analysis. Note the same line if: github.event.pull_request.head.repo.fork == true
with the inverted condition to run this workflow only for PRs from forks.
Third workflow triggered once the second one is completed:
name: CI on forks - Sonar analysis
on:
workflow_run:
workflows: [CI on forks - build and tests]
types:
- completed
permissions:
actions: write
checks: write
contents: read
issues: read
pull-requests: write
statuses: write
jobs:
sonar:
name: Run Sonar Analysis for forks
runs-on: ubuntu-latest
if: >
github.event.workflow_run.event == 'pull_request' &&
github.event.workflow_run.conclusion == 'success'
steps:
- name: Download PR information
uses: actions/github-script@v7
with:
script: |
let allArtifacts = await github.rest.actions.listWorkflowRunArtifacts({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: context.payload.workflow_run.id,
});
let prInfoArtifact = allArtifacts.data.artifacts.filter((artifact) => {
return artifact.name.startsWith("pr-info-")
})[0];
if (!prInfoArtifact) {
core.setFailed("❌ No PR info artifact found");
return;
}
let download = await github.rest.actions.downloadArtifact({
owner: context.repo.owner,
repo: context.repo.repo,
artifact_id: prInfoArtifact.id,
archive_format: 'zip',
});
const fs = require('fs');
const path = require('path');
const temp = '${{ runner.temp }}/pr-info';
if (!fs.existsSync(temp)){
fs.mkdirSync(temp, { recursive: true });
}
fs.writeFileSync(path.join(temp, 'pr-info.zip'), Buffer.from(download.data));
console.log("PR information downloaded");
- name: Extract PR Information
run: |
mkdir -p ${{ runner.temp }}/pr-info-extracted
unzip -q ${{ runner.temp }}/pr-info/pr-info.zip -d ${{ runner.temp }}/pr-info-extracted
REPO_NAME=$(cat ${{ runner.temp }}/pr-info-extracted/repo-name)
HEAD_REF=$(cat ${{ runner.temp }}/pr-info-extracted/head-ref)
HEAD_SHA=$(cat ${{ runner.temp }}/pr-info-extracted/head-sha)
PR_NUMBER=$(cat ${{ runner.temp }}/pr-info-extracted/pr-number)
BASE_REF=$(cat ${{ runner.temp }}/pr-info-extracted/base-ref)
echo "REPO_NAME=$REPO_NAME" >> $GITHUB_ENV
echo "HEAD_REF=$HEAD_REF" >> $GITHUB_ENV
echo "HEAD_SHA=$HEAD_SHA" >> $GITHUB_ENV
echo "PR_NUMBER=$PR_NUMBER" >> $GITHUB_ENV
echo "BASE_REF=$BASE_REF" >> $GITHUB_ENV
echo "PR information extracted: $REPO_NAME $HEAD_REF $HEAD_SHA $PR_NUMBER $BASE_REF"
- name: Checkout sources
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
with:
ref: ${{ env.HEAD_REF }}
repository: ${{ env.REPO_NAME }}
fetch-depth: 0
- name: Set up JDK 17
uses: actions/setup-java@387ac29b308b003ca37ba93a6cab5eb57c8f5f93 # v4.0.0
with:
distribution: 'temurin'
java-version: '17'
- name: Download artifact
uses: actions/github-script@v7
with:
script: |
let allArtifacts = await github.rest.actions.listWorkflowRunArtifacts({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: context.payload.workflow_run.id,
});
let matchArtifact = allArtifacts.data.artifacts.filter((artifact) => {
return artifact.name.startsWith("data-for-sonar-analysis-")
})[0];
if (!matchArtifact) {
core.setFailed("❌ No matching artifact found");
return;
}
const prNumber = matchArtifact.name.replace("data-for-sonar-analysis-", "");
console.log(`PR number: ${prNumber}`);
core.exportVariable('PR_NUMBER', prNumber);
let download = await github.rest.actions.downloadArtifact({
owner: context.repo.owner,
repo: context.repo.repo,
artifact_id: matchArtifact.id,
archive_format: 'zip',
});
const fs = require('fs');
const path = require('path');
const temp = '${{ runner.temp }}/artifacts';
if (!fs.existsSync(temp)){
fs.mkdirSync(temp);
}
fs.writeFileSync(path.join(temp, 'sonar-data.zip'), Buffer.from(download.data));
# adapt the paths to your project
- name: Extract Sonar Analysis Data
run: |
mkdir -p ${{ runner.temp }}/extracted
unzip -q ${{ runner.temp }}/artifacts/sonar-data.zip -d ${{ runner.temp }}/extracted
cp -r ${{ runner.temp }}/extracted/* .
ls -la distribution-core/target/site/jacoco-aggregate/ || echo "Jacoco report directory not found"
# adapt the paths to your project
- name: Prepare Sonar Analysis
run: |
echo "Checking required directories..."
if [ -f "distribution-core/target/site/jacoco-aggregate/jacoco.xml" ]; then
echo "Jacoco report found"
else
echo "Warning: Jacoco report not found at expected location"
fi
echo "Finding sources and binaries..."
SOURCES=$(find . -type d -path "*/main/java" | sort -u | paste -sd "," -)
if [ -z "$SOURCES" ]; then
echo "Warning: No source directories found!"
else
echo "SOURCES : $SOURCES"
echo "SOURCES=$SOURCES" >> $GITHUB_ENV
fi
GENERATED=$(find . -type d -path "*/target/generated-sources" | sort -u | paste -sd "," -)
if [ -z "$GENERATED" ]; then
echo "Warning: No generated source directories found!"
else
echo "GENERATED : $GENERATED"
echo "GENERATED=$GENERATED" >> $GITHUB_ENV
fi
TESTS=$(find . -type d -path "*/test/java" | sort -u | paste -sd "," -)
if [ -z "$TESTS" ]; then
echo "Warning: No test directories found!"
else
echo "TESTS : $TESTS"
echo "TESTS=$TESTS" >> $GITHUB_ENV
fi
BINARIES=$(find . -type d -path "*/target/classes" | sort -u | paste -sd "," -)
if [ -z "$BINARIES" ]; then
echo "Warning: No binaries directories found!"
else
echo "BINARIES : $BINARIES"
echo "BINARIES=$BINARIES" >> $GITHUB_ENV
fi
LIBRARIES="distribution-core/target/dependency"
if [ -z "$LIBRARIES" ]; then
echo "Warning: No libraries directory found!"
else
echo "LIBRARIES : $LIBRARIES"
echo "LIBRARIES=$LIBRARIES" >> $GITHUB_ENV
fi
# This sonar action should NOT be replaced by a direct use of the mvn verify command since we don't want external
# code to run in a workflow_run workflow (it may lead to security issues).
# adapt the paths to your project
- name: Run Sonar Analysis
uses: SonarSource/sonarqube-scan-action@2500896589ef8f7247069a56136f8dc177c27ccf # v5.2.0
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
args: >
-Dsonar.projectKey=<your-sonar-project-key>
-Dsonar.organization=<your-sonar-organisation>
-Dsonar.sources="${{ env.SOURCES }}"
-Dsonar.generatedSources="${{ env.GENERATED }}"
-Dsonar.tests="${{ env.TESTS }}"
-Dsonar.java.binaries="${{ env.BINARIES }}"
-Dsonar.java.libraries="${{ env.LIBRARIES }}"
-Dsonar.java.test.libraries="${{ env.LIBRARIES }}"
-Dsonar.coverage.jacoco.xmlReportPaths="distribution-core/target/site/jacoco-aggregate/jacoco.xml"
-Dsonar.pullrequest.key="${{ env.PR_NUMBER }}"
-Dsonar.pullrequest.branch="${{ env.HEAD_REF }}"
-Dsonar.pullrequest.base="${{ env.BASE_REF }}"
-Dsonar.pullrequest.provider=github
-Dsonar.pullrequest.github.repository=${{ github.repository }}
-Dsonar.host.url=https://sonarcloud.io
-Dsonar.scm.provider=git
-Dsonar.qualitygate.wait=true
-Dsonar.scm.revision=${{ env.HEAD_SHA }}
- name: Delete artifacts used in analysis
if: always()
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
let artifacts = await github.rest.actions.listWorkflowRunArtifacts({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: context.payload.workflow_run.id,
});
for (const artifact of artifacts.data.artifacts) {
if (
artifact.name.startsWith("data-for-sonar-analysis-") ||
artifact.name.startsWith("pr-info-")
) {
console.log(`Deleting artifact: ${artifact.name} (ID: ${artifact.id})`);
await github.rest.actions.deleteArtifact({
owner: context.repo.owner,
repo: context.repo.repo,
artifact_id: artifact.id
});
}
}
In this last one we checkout the PR branch (but we do not run it in any way), download the data previously gathered in the previous workflow, put it back where it should be as if we had build the project in this workflow, and then run the Sonar analysis using the SonarSource/sonarqube-scan-action
GitHub action. Since it does not use Maven, it is safe to run. Last step is here to delete the artifacts in order to avoid storing them (they can take a lot of space, depending on your project and its dependencies).
Enjoy!