SonarQube in GitLab CI: dotnet-sonarscanner and coverage delta
Published: 2026-05-28
SonarQube static analysis for .NET in GitLab CI has a few rough edges: the scanner
needs GIT_DEPTH: "0" for blame annotations, the SDK version must match between the
build image and the scanner tool, and running it only on dev pushes (not every MR
branch) keeps the queue short. This post documents the setup we use for 20+ .NET
microservices feeding a single SonarQube instance.
CI image
We build a custom image that includes both the .NET SDK and dotnet-sonarscanner
installed globally. Having it pre-installed avoids dotnet tool install on every job
(which hits NuGet over the network and adds ~90 seconds):
dockerfileFROM mcr.microsoft.com/dotnet/sdk:8.0
RUN dotnet tool install --global dotnet-sonarscanner \
&& dotnet tool install --global dotnet-coverage
ENV PATH="$PATH:/root/.dotnet/tools"
We build variants: sonarqube:net6, sonarqube:net8, sonarqube:net10. The CI job
selects the tag based on $CI_PROJECT_PATH_SLUG in rules.
The analysis job
yamlvariables:
DOCKER_NET_IMAGE: registry.example.com/ci-images/sonarqube:${DOCKER_NET_IMAGE_TAG}
DOCKER_NET_IMAGE_TAG: net6 # default
analysis:
stage: sonarqube
image: ${DOCKER_NET_IMAGE}
variables:
SONAR_USER_HOME: "${CI_PROJECT_DIR}/.sonar"
GIT_STRATEGY: clone # must be clone, not fetch
GIT_DEPTH: "0" # full history for blame
cache:
key: "${CI_JOB_NAME}"
paths:
- .sonar/cache # scanner analysis cache
script:
- dotnet sonarscanner begin
/k:"$SONAR_PROJECT"
/d:sonar.token="$SONAR_TOKEN"
/d:sonar.host.url="$SONAR_HOST_URL"
/d:sonar.cs.vscoveragexml.reportsPaths=coverage.xml
/d:sonar.scm.provider=git
/n:$CI_PROJECT_PATH_SLUG
- dotnet nuget list source # verify NuGet feeds
- dotnet build --no-incremental src/
# Uncomment to collect coverage (requires dotnet-coverage):
# - dotnet-coverage collect "dotnet test src/" -f xml -o coverage.xml
- dotnet sonarscanner end /d:sonar.token="$SONAR_TOKEN"
allow_failure: true
tags:
- sonarqube # runner with access to SonarQube network
GIT_DEPTH: "0"
This is non-negotiable. SonarQube's blame feature annotates each issue with the commit
that introduced it. The blame query walks the git log. With GIT_DEPTH: "4" (GitLab's
default), git blame fails on lines touched more than 4 commits ago and SonarQube
reports "SCM blame information is missing" for most of the codebase.
GIT_STRATEGY: clone (not fetch) ensures a clean checkout. With fetch on shallow
clones, deepening doesn't always work reliably.
sonar.token vs sonar.login
SonarQube 9.x deprecated sonar.login in favor of sonar.token. Both still work in
SonarQube 10.x but the token parameter is cleaner. Store it in a GitLab CI group
variable (SONAR_TOKEN) with "masked" enabled.
Per-project SDK selection
Not all services are on the same .NET version. Rather than separate jobs, a single
analysis job selects the image via variables override in rules:
yamlrules:
# net8 services
- if: $CI_PROJECT_PATH_SLUG =~ /^(my-api|other-service|third-service)/
variables:
DOCKER_NET_IMAGE_TAG: net8
# net10 for one bleeding-edge project
- if: $CI_PROJECT_PATH_SLUG == "my-new-api"
variables:
DOCKER_NET_IMAGE_TAG: net10
# Only run on dev branch
- if: $CI_COMMIT_BRANCH == "dev"
# Never on anything else
- when: never
This means SonarQube runs only on dev pushes. MR pipelines get the test job but
not static analysis — fast feedback without duplicating the expensive scanner run.
SONAR_PROJECT naming
The SONAR_PROJECT variable is set per-repo in GitLab CI variables. We use the GitLab
project path slug: company-backend-my-service. This matches the SonarQube project key
and makes cross-linking from GitLab to SonarQube straightforward (the SonarQube project
URL is predictable).
For the project display name (/n: flag) we use $CI_PROJECT_PATH_SLUG — no special
naming needed.
Coverage collection
We collect coverage with dotnet-coverage rather than coverlet because it works with
dotnet test without project-level package references:
bashdotnet-coverage collect \
"dotnet test src/ -c Release --no-build --filter Category!~IntegrationExternal" \
-f xml -o coverage.xml
The output path coverage.xml matches /d:sonar.cs.vscoveragexml.reportsPaths=coverage.xml
passed to sonarscanner begin.
Note: the analysis job in the example above has coverage collection commented out.
That's intentional for repos where integration tests need external services. For unit-
test-only repos, uncomment it.
Coverage delta notification
Separately from the SonarQube job, a cover_compare hidden job runs on MRs and
compares coverage against the last successful pipeline on the base branch:
yaml.cover_compare:
stage: cover_compare
image: alpine:latest
script:
- apk add --no-cache jq curl
- |
latest=$(curl -sH "PRIVATE-TOKEN: $ACCESS_TOKEN_READ" \
"$CI_API_V4_URL/projects/$CI_PROJECT_ID/pipelines/$CI_PIPELINE_ID" \
| jq -r '.coverage // "0"' | awk -F. '{print $1}')
- |
prev_id=$(curl -sH "PRIVATE-TOKEN: $ACCESS_TOKEN_READ" \
"$CI_API_V4_URL/projects/$CI_PROJECT_ID/pipelines?ref=dev&status=success" \
| jq -r '.[0].id')
prev=$(curl -sH "PRIVATE-TOKEN: $ACCESS_TOKEN_READ" \
"$CI_API_V4_URL/projects/$CI_PROJECT_ID/pipelines/$prev_id" \
| jq -r '.coverage // "0"' | awk -F. '{print $1}')
- |
if [ "$latest" -ge "$prev" ]; then
status="✓ coverage ${latest}% >= ${prev}%"
else
status="⚠ coverage dropped: ${latest}% < ${prev}%"
fi
curl -s -X POST "https://api.telegram.org/bot${TG_BOT_TOKEN}/sendMessage" \
-d chat_id="${TG_CHAT_ID}" \
-d text="${CI_PROJECT_PATH_SLUG}: ${status}"
rules:
- if: $CI_MERGE_REQUEST_ID
allow_failure: true
tags:
- dind
The ACCESS_TOKEN_READ is a group-level GitLab token with read_api scope. It only
reads pipeline metadata — not code.
Quality gate in GitLab MR
To show the SonarQube quality gate result directly in the MR, configure the SonarQube GitLab integration:
- In SonarQube → Administration → DevOps Platform Integrations → GitLab → add your
instance URL and a GitLab token with
apiscope. - In each SonarQube project → Project Settings → General Settings → pull request decoration → select the GitLab project.
After this, SonarQube posts a comment on the MR with the gate status (pass/fail) and
links to the issues found. The analysis job itself can be allow_failure: true —
the MR comment provides the signal.
Runner requirements
The sonarqube tag routes to a runner with:
- Network access to
sonarqube.test.antonnovikov.com(not all runners have this) - Docker-in-Docker or Docker socket access (for the image pull)
- At least 4 GB RAM for the scanner JVM (SonarQube analysis is memory-hungry)
Separate the SonarQube runner from the build runners so a slow analysis doesn't block
a docker build waiting for a runner slot.
SonarQube Quality Gate and PR decoration
Quality Gate is the pass/fail verdict. By default it uses the "Sonar Way" gate:
- No new bugs
- No new vulnerabilities
- Coverage ≥ 80% on new code
- Duplication ≤ 3%
Configure in SonarQube: Administration → Quality Gates → [your gate] → Conditions.
For PR decoration (comments on GitLab MR):
- SonarQube → Administration → Configuration → GitLab → set URL + personal access token
- Each project → Settings → General Settings → Pull Request Decoration → choose GitLab project
The analysis job can be allow_failure: true — the MR comment still signals the result.
Common pitfalls
| Problem | Cause | Fix |
|---|---|---|
GIT_DEPTH: "0" missing |
Shallow clone breaks blame | Set GIT_DEPTH: "0" in analysis job |
| Scanner OOM | JVM default heap too small | Set SONAR_SCANNER_OPTS: "-Xmx2g" |
| Exclusions not working | Wrong path pattern | Use **/*.generated.cs not *.generated.cs |
| Coverage not uploaded | *.xml path wrong |
Use glob: --coverage-paths "**/coverage.opencover.xml" |
| New code baseline wrong | First run or branch change | Set baseline in SonarQube project settings |
| Analysis slow | Too many files included | Exclude bin/, obj/, node_modules/ |
Full pipeline integration
yamlinclude:
- local: '.gitlab/jobs/build.yml'
- local: '.gitlab/jobs/test.yml'
- local: '.gitlab/jobs/sonarqube.yml'
stages:
- build
- test
- analysis
- deploy
Keep SonarQube analysis after tests but before deploy. Analysis only needs to run on default branch and MRs — skip it on feature branches to save runner time:
yamlanalysis:sonarqube:
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
Summary
- Custom CI image with
dotnet-sonarscannerpre-installed avoids slowdotnet tool installper job GIT_DEPTH: "0"+GIT_STRATEGY: cloneare mandatory — without them blame annotations break- Analysis runs only on
devbranch pushes; MR pipelines run tests only, keeping the queue fast - Coverage delta is posted to Telegram on every MR — no SonarQube UI access required for quick feedback
- Per-project SDK version is selected via image tag in rules, all from one template job