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:

  1. In SonarQube → Administration → DevOps Platform Integrations → GitLab → add your instance URL and a GitLab token with api scope.
  2. 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):

  1. SonarQube → Administration → Configuration → GitLab → set URL + personal access token
  2. 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-sonarscanner pre-installed avoids slow dotnet tool install per job
  • GIT_DEPTH: "0" + GIT_STRATEGY: clone are mandatory — without them blame annotations break
  • Analysis runs only on dev branch 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