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:
- В SonarQube → Administration → DevOps Platform Integrations → GitLab → добавьте URL вашего экземпляра и GitLab-токен со скоупом
api. - В каждом проекте 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.