SonarQube в GitLab CI: dotnet-sonarscanner и дельта покрытия

Published: 2026-05-28

У статического анализа SonarQube для .NET в GitLab CI есть несколько шероховатостей: сканеру нужен GIT_DEPTH: "0" для blame-аннотаций, версия SDK в build-образе должна совпадать с версией для инструмента сканера, а запуск только при push в dev (не на каждой MR-ветке) не даёт очереди анализа разрастаться. Этот пост документирует настройку, которую мы используем для 20+ .NET-микросервисов, отправляющих отчёты в один экземпляр SonarQube.


CI-образ

Мы собираем свой образ: в нём и .NET SDK, и глобально установленный dotnet-sonarscanner. Предустановка избавляет от dotnet tool install на каждый job (это обращение к NuGet по сети и +90 секунд):

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"

Собираем варианты: sonarqube:net6, sonarqube:net8, sonarqube:net10. CI job выбирает тег по $CI_PROJECT_PATH_SLUG в rules.


Job анализа

yamlvariables:
  DOCKER_NET_IMAGE: registry.example.com/ci-images/sonarqube:${DOCKER_NET_IMAGE_TAG}
  DOCKER_NET_IMAGE_TAG: net6   # по умолчанию

analysis:
  stage: sonarqube
  image: ${DOCKER_NET_IMAGE}
  variables:
    SONAR_USER_HOME: "${CI_PROJECT_DIR}/.sonar"
    GIT_STRATEGY: clone     # обязательно clone, не fetch
    GIT_DEPTH: "0"          # полная история для blame
  cache:
    key: "${CI_JOB_NAME}"
    paths:
      - .sonar/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   # проверить NuGet feed'ы
    - dotnet build --no-incremental src/
    # Раскомментировать для сбора покрытия (нужен 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 с доступом к сети SonarQube

GIT_DEPTH: "0"

Это не обсуждается. Функция blame в SonarQube помечает каждую проблему коммитом, который её внёс, а для этого нужен полный git log. При GIT_DEPTH: "4" (значение по умолчанию в GitLab) git blame падает на строках, тронутых более 4 коммитов назад, и SonarQube сообщает «SCM blame information is missing» для большей части кодовой базы.

GIT_STRATEGY: clone (не fetch) обеспечивает чистый checkout. С fetch на shallow-клонах углубление не всегда работает надёжно.

sonar.token vs sonar.login

SonarQube 9.x объявил sonar.login устаревшим в пользу sonar.token. Оба ещё работают в SonarQube 10.x, но новый параметр предпочтительнее. Храните токен в групповой CI-переменной GitLab (SONAR_TOKEN) с включённой маскировкой.


Выбор SDK для конкретного проекта

Не все сервисы на одной версии .NET. Вместо отдельных job единственный analysis job выбирает образ, переопределяя variables в rules:

yamlrules:
  # Сервисы на net8
  - if: $CI_PROJECT_PATH_SLUG =~ /^(my-api|other-service|third-service)/
    variables:
      DOCKER_NET_IMAGE_TAG: net8
  # net10 для одного bleeding-edge проекта
  - if: $CI_PROJECT_PATH_SLUG == "my-new-api"
    variables:
      DOCKER_NET_IMAGE_TAG: net10
  # Запускать только на ветке dev
  - if: $CI_COMMIT_BRANCH == "dev"
  # Никогда на всём остальном
  - when: never

Это значит, что SonarQube запускается только при push в dev. MR-пайплайны получают job test, но не статический анализ — быстрая обратная связь без дублирования дорогостоящего прогона сканера.


Именование SONAR_PROJECT

Переменная SONAR_PROJECT задаётся в CI-переменных каждого репозитория. Мы используем slug пути проекта GitLab: company-backend-my-service. Он совпадает с ключом проекта SonarQube и упрощает переход из GitLab в SonarQube: URL проекта предсказуем.

Для отображаемого имени проекта (флаг /n:) используем $CI_PROJECT_PATH_SLUG — никакого специального именования не нужно.


Сбор покрытия

Мы собираем покрытие через dotnet-coverage, а не coverlet: он работает с dotnet test и не требует package reference в проекте:

bashdotnet-coverage collect \
  "dotnet test src/ -c Release --no-build --filter Category!~IntegrationExternal" \
  -f xml -o coverage.xml

Путь вывода coverage.xml совпадает с /d:sonar.cs.vscoveragexml.reportsPaths=coverage.xml, передаваемым в sonarscanner begin.

Примечание: в примере выше сбор покрытия закомментирован. Так сделано намеренно — в этих репозиториях интеграционным тестам нужны внешние сервисы. Для репозиториев только с unit-тестами — раскомментируйте.


Уведомление о дельте покрытия

Отдельно от job анализа на MR запускается скрытый job cover_compare — он сравнивает покрытие с последним успешным пайплайном на базовой ветке:

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

ACCESS_TOKEN_READ — групповой токен GitLab со скоупом read_api. Он даёт доступ только к метаданным пайплайнов, не к коду.


Quality gate в GitLab MR

Чтобы показывать результат quality gate SonarQube прямо в MR, настройте интеграцию SonarQube с GitLab:

  1. В SonarQube → Administration → DevOps Platform Integrations → GitLab → добавьте URL вашего экземпляра и GitLab-токен со скоупом api.
  2. В каждом проекте SonarQube → Project Settings → General Settings → pull request decoration → выберите GitLab-проект.

После этого SonarQube постит комментарий в MR со статусом gate (pass/fail) и ссылками на найденные проблемы. Сам job analysis может быть allow_failure: true — комментарий в MR даёт нужный сигнал.



Quality Gate и требования к раннерам

Quality Gate — это вердикт pass/fail. По умолчанию используется «Sonar Way»:

  • Нет новых багов
  • Нет новых уязвимостей
  • Покрытие нового кода ≥ 80%
  • Дублирование кода ≤ 3%

Настройка: Administration → Quality Gates → [ваш gate] → Conditions.

Требования к раннерам:

  • Docker-in-Docker или доступ к docker socket
  • Минимум 4 ГБ RAM для JVM сканера (анализ SonarQube очень ресурсоёмкий)

Типичные проблемы

Проблема Причина Решение
Нет GIT_DEPTH: "0" Shallow clone ломает blame Добавить GIT_DEPTH: "0" в задание анализа
OOM сканера JVM heap по умолчанию мал Добавить SONAR_SCANNER_OPTS: "-Xmx2g"
Исключения не работают Неверный паттерн пути Использовать **/*.generated.cs, не *.generated.cs
Покрытие не загружается Неверный путь к *.xml Glob: --coverage-paths "**/coverage.opencover.xml"
Медленный анализ Слишком много файлов Исключить bin/, obj/, node_modules/

Запуск только на MR и основной ветке

yamlanalysis:sonarqube:
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

Не нужно запускать SonarQube на каждой feature-ветке — это тратит время раннеров и засоряет историю анализов в UI.