> For the complete documentation index, see [llms.txt](https://wong-coupon.gitbook.io/flutter/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://wong-coupon.gitbook.io/flutter/my-flutter/quality-delivery/sonar-quality-gate-flutter.md).

# Sonar Quality Gate for Flutter Monorepo

Turn Sonar analysis into a required quality gate for a Flutter monorepo using commit-specific LCOV, gate waiting, and GitLab merge policy

## Result

A Sonar job does not become a quality gate simply because it sits in a stage named `sonar-check`. To block a change that violates the quality policy, the pipeline must connect the entire chain from testing to the merge decision:

```
             Merge request pipeline
                       │
                       ▼
┌───────────────────────────────────────────────┐
│ Test                                          │
│ root + packages → deterministic merged LCOV  │
└──────────────────────┬────────────────────────┘
                       │ artifact
          LCOV + commit marker + reports
                       │
                       ▼
┌───────────────────────────────────────────────┐
│ Sonar Quality Gate                            │
│ verify artifact → scan → wait gate result     │
└──────────────────────┬────────────────────────┘
                       │
                 OK ───┴─── ERROR/timeout
                  │              │
                  ▼              ▼
           pipeline green   pipeline failed
                  │              │
                  ▼              ▼
            merge allowed    merge blocked
```

After tracing `.gitlab-ci.yml`, `sonar-project.properties`, the Makefile, and the way root and package tests produce coverage in the reference source, I chose three contracts for the target state:

* The test job owns the LCOV for the exact commit and passes the report as an artifact.
* The Sonar job verifies the artifact, runs a scanner with a known version, and waits for the quality gate result.
* GitLab receives the Sonar exit code in a pipeline that must succeed before merge.

For a Flutter monorepo with legacy debt, I enforce the quality gate on new code first. Overall coverage and existing issues remain visible on the dashboard and enter a remediation backlog, but they do not use an unachievable overall threshold that pushes the team to bypass the gate repeatedly.

The observable results should be:

* Root app and package tests all belong to the correct Sonar test scope.
* The merged LCOV is rebuilt from scratch, uses correct source paths, and contains no data from a previous run.
* The Sonar job receives the report for the exact `$CI_COMMIT_SHA` instead of taking coverage from a cache.
* After uploading the analysis, the scanner still waits for Sonar processing and the quality gate result.
* A gate `ERROR` or timeout fails the job and the pipeline.
* A merge request cannot be merged while the required pipeline is red.

This is a target architecture derived from the source, not a claim that the current production Sonar setup already blocks merges. I verified the configuration and call graph with read-only operations; I did not run a Sonar analysis, a GitLab pipeline, or a merge-policy check against the live systems.

## Problem

The current Sonar job has the following generalized flow:

```
test produces coverage artifact
        │
        ▼
manual Sonar job
        │
        ├── restore scanner cache + coverage cache
        ├── run sonar-scanner
        ├── do not wait for quality gate
        └── allow_failure: true
                     │
                     ▼
             pipeline can still pass
```

This job remains useful as an optional report. However, it is not a required quality gate: a user can skip the manual job, the scanner exits after submitting the analysis, and failures are allowed.

Monorepo coverage also has two risks. The root app and each package produce `lcov.info` in different working directories, so package source paths must be normalized relative to the project root. The current target appends package reports with `>>` without deleting the aggregate file first; a workspace containing old coverage can add records from a previous pipeline to the new analysis.

The current Sonar scope declares root `lib` and `packages` as sources, but `sonar.tests` points only to the root `test` directory. The actual source contains tests in multiple packages. Excluding every `**/test/**` path from source does not automatically add those files to the test scope.

Some exclusions also rely on broad directory names such as model or mock. If those directories contain handwritten code, the scanner may ignore real code solely because of its path name. Exclusions should reflect file ownership, not an unverified convention.

Finally, coverage is both an artifact from the test job and part of the Sonar job cache. A cache is appropriate for a reproducible dependency; it is not evidence for a specific commit. Even if the current artifact overwrites the cached file, retaining both sources makes provenance difficult to understand and creates a path for stale data when the pipeline changes.

## Solution

### Start with the test contract

This article assumes that analysis and tests already return correct exit codes while coverage is retained as output from the test job. GitLab owns triggers, `rules`, `needs`, and artifacts; Make or a script owns test order and LCOV generation; Sonar only imports the report and evaluates policy.

If the pipeline still mixes tests, reporting, and release in one job, establish that underlying contract first. The article below owns GitLab CI, Make, Melos, exit codes, and the raw artifact that Sonar uses as a prerequisite.

{% content-ref url="/pages/iXaQgqQ1eqF71OtJywau" %}
[GitLab CI for a Flutter Monorepo](/flutter/my-flutter/quality-delivery/gitlab-ci-flutter-monorepo.md)
{% endcontent-ref %}

I do not make the Sonar job rerun the entire Flutter test suite. One test run produces one report; the scanner must analyze that exact report so that, when the pipeline fails, I know which job and commit produced the binary evidence.

### Distinguish the five gate states

```
1. Tests passed
       │
2. Scanner uploaded analysis
       │
3. Sonar processed analysis
       │
4. Quality Gate = OK / ERROR
       │
5. GitLab merge allowed / blocked
```

A passing test only proves that the test command succeeded and a report was produced. A successful scanner upload does not mean the quality gate is green because Sonar still has to process the analysis.

A red gate only turns the pipeline red when the scanner waits for the result and returns a non-zero exit code. A red pipeline only blocks merge when the GitLab project has a merge check that requires a successful pipeline. Merge request decoration or a red badge is feedback, not enforcement.

### Gate new code first

I do not start by hard-coding an arbitrary coverage percentage in YAML. Before changing the pipeline, confirm the assigned quality gate, the new code definition, and the conditions applied to merge requests.

Sonar Way currently focuses on four groups of signals on new code:

* New reliability, security, and maintainability issues.
* New security hotspots that have been reviewed.
* Coverage of new code.
* Duplication on new code.

Thresholds and quality profiles belong to Sonar project policy and may change with the version or the organization's needs. The pipeline is responsible only for retrieving the correct result and enforcing it; this public article does not copy an internal gate or describe administrative permission changes.

For a legacy project, I use this rollout direction:

```
Overall debt → dashboard + remediation backlog
New code     → required quality gate
```

This does not legitimize legacy debt. It prevents each merge from making the situation worse while an overall condition becomes required only after the team has a baseline and a feasible remediation plan.

### Partition source and test scope for the monorepo

I keep project and organization identifiers out of the example and configure only the technical scope in `sonar-project.properties`:

```properties
sonar.sourceEncoding=UTF-8

sonar.sources=lib,packages
sonar.inclusions=lib/**/*.dart,packages/*/lib/**/*.dart

sonar.tests=test,packages
sonar.test.inclusions=test/**/*_test.dart,packages/*/test/**/*_test.dart

sonar.exclusions=**/generated/**,**/*.g.dart,**/*.freezed.dart,**/build/**,**/.dart_tool/**
sonar.dart.lcov.reportPaths=coverage/merged_coverage.lcov.info
```

`sonar.sources` and `sonar.tests` accept directory paths for their initial scopes; wildcards do not expand these two values. Patterns belong in `sonar.inclusions`, `sonar.exclusions`, and `sonar.test.inclusions`.

`packages` appears in both the initial source and test scopes, but the two inclusions partition files into `packages/*/lib` and `packages/*/test`. A Dart file cannot be both source and test.

If the workspace has source under `bin` or `tool`, packages nested more than one level deep, or tests that do not use the `*_test.dart` suffix, extend the scope from the actual inventory. Do not copy the glob and assume every module is scanned.

Generated code can be excluded when the team only wants to review human-authored source. I do not exclude entire `models` or `mock` directories without proving that every file there is generated or test-only.

### Merge LCOV deterministically

Root `flutter test --coverage` produces `coverage/lcov.info`. Package tests produce a similar report inside each package, but a package record such as `SF:lib/...` must be prefixed as `SF:packages/<package>/lib/...` before merging.

The target script removes old output, sorts its inputs, normalizes source paths, and fails when a required report is missing:

```bash
#!/usr/bin/env bash
set -euo pipefail

output_dir="coverage"
normalized_dir="$output_dir/normalized"
merged_report="$output_dir/merged_coverage.lcov.info"

rm -rf "$normalized_dir"
rm -f "$merged_report"
mkdir -p "$normalized_dir"

root_report="$output_dir/lcov.info"
test -s "$root_report"

reports=("$root_report")

while IFS= read -r report; do
  package_dir="${report%/coverage/lcov.info}"
  normalized="$normalized_dir/${package_dir//\//_}.info"

  awk -v prefix="$package_dir/" '
    /^SF:/ { sub(/^SF:/, "SF:" prefix) }
    { print }
  ' "$report" > "$normalized"

  test -s "$normalized"
  reports+=("$normalized")
done < <(find packages -path '*/coverage/lcov.info' -type f | sort)

lcov_args=()
for report in "${reports[@]}"; do
  lcov_args+=(--add-tracefile "$report")
done

lcov "${lcov_args[@]}" --output-file "$merged_report"
test -s "$merged_report"
lcov --summary "$merged_report"

printf '%s\n' "${CI_COMMIT_SHA:-local}" > "$output_dir/commit-sha.txt"
```

The script uses Bash arrays and process substitution, so CI must invoke it with `bash`, not `sh`. `find ... | sort` keeps the input order stable, while `rm -f` prevents old data from being appended.

The commit marker is not a cryptographic signature. It is a simple preflight that makes the Sonar job reject an artifact whose metadata does not match the current commit.

A project does not need to fail merely because a package without tests produces no LCOV. However, a package that has tests but no report should be caught by a separate inventory or validation step; otherwise, coverage for a module can disappear while the pipeline remains green.

### Pass coverage as an artifact, not a cache

The test job owns the report and retains it together with the raw logs:

```yaml
flutter_test:
  stage: test
  script:
    - make ci-test
    - bash scripts/merge_lcov.sh
  artifacts:
    when: always
    expire_in: 1 week
    paths:
      - coverage/merged_coverage.lcov.info
      - coverage/commit-sha.txt
      - reports/
```

`when: always` retains evidence when a test or the report generator fails. By default, the Sonar job runs only after the test job succeeds; once the pipeline is red, there is no need to scan an incomplete report.

GitLab caches and artifacts have different purposes:

| Mechanism | Use it for                                        | Do not use it for                     |
| --------- | ------------------------------------------------- | ------------------------------------- |
| Cache     | Scanner downloads/cache that can be recreated     | Coverage for the current commit       |
| Artifact  | LCOV, commit marker, and logs from a specific job | Dependencies shared by many pipelines |

The Sonar job caches only scanner data:

```yaml
cache:
  key: "sonar-${CI_PROJECT_ID}"
  paths:
    - .sonar/cache
```

If the scanner cache is lost, the tool can download it again. If coverage is incorrect or stale, the quality result loses provenance; I do not put these two kinds of data under the same cache policy.

### Pin the scanner and scope variables to the job

A Sonar scan does not build Android or iOS, so it does not need a macOS runner simply because the repository contains Flutter. On infrastructure with a container executor, I use a scanner image pinned to a verified version or digest:

```yaml
image:
  name: "sonarsource/sonar-scanner-cli:<pinned-version>"
  entrypoint: [""]
```

`<pinned-version>` is a placeholder, not a tag to copy literally. If the infrastructure uses a shell runner, provisioning must pin the scanner and the job should print `sonar-scanner --version`. Do not use `latest` as a promise of reproducibility.

`GIT_DEPTH: "0"` and `SONAR_USER_HOME` belong only to the Sonar job. Full history supports branch, new-code, and blame analysis, but other build and test jobs should not fetch the full history merely because the scanner needs it.

The Sonar Dart documentation recommends resolving dependencies before analysis. If the scanner runs in a separate container and the analyzer needs `.dart_tool` or dependency metadata, pass a preparation artifact with an explicit contract or resolve dependencies with the correct toolchain. LCOV alone may not be sufficient for accurate Dart analysis; inspect the scanner log on the real runner.

### Turn the Sonar report into a required job

The target GitLab job receives the artifact, verifies the commit marker, waits for the quality gate, and does not allow failure:

```yaml
sonar_quality_gate:
  stage: sonar-check
  image:
    name: "sonarsource/sonar-scanner-cli:<pinned-version>"
    entrypoint: [""]
  needs:
    - job: flutter_test
      artifacts: true
  variables:
    SONAR_USER_HOME: "$CI_PROJECT_DIR/.sonar"
    GIT_DEPTH: "0"
  cache:
    key: "sonar-${CI_PROJECT_ID}"
    paths:
      - .sonar/cache
  script:
    - test -n "${SONAR_TOKEN:-}"
    - test -s coverage/merged_coverage.lcov.info
    - test "$(cat coverage/commit-sha.txt)" = "$CI_COMMIT_SHA"
    - sonar-scanner --version
    - >
      sonar-scanner
      -Dsonar.projectKey="$SONAR_PROJECT_KEY"
      -Dsonar.organization="$SONAR_ORGANIZATION"
      -Dsonar.qualitygate.wait=true
      -Dsonar.qualitygate.timeout=600
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
    - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
  allow_failure: false
```

The job has no `when: manual`. `needs:artifacts` makes it explicit that the report comes from `flutter_test`, while the first three preflight commands fail the job before the scanner if the token, report, or commit marker is invalid.

`sonar.qualitygate.wait=true` makes the scanner wait for Sonar processing and return a non-zero exit code when the gate is red. A timeout is also a failure. The value `600` is only a placeholder illustrating the contract; choose it from measured duration instead of treating it as a universal SLA.

`SONAR_TOKEN` is a secret. Project key, organization, and host are not necessarily secrets, but they can still be internal metadata, so the example uses only neutral variable names. Do not print `env`, enable debug tracing around the scanner, or put the token directly in the command.

A protected or masked variable does not automatically make a merge request trustworthy. Fork or untrusted merge requests that cannot receive the secret need a separate rule or pipeline. Do not weaken token protection merely to run the job for every source.

### Enable merge enforcement in GitLab

A required job may still fail to block merge if the project permits merging with a red pipeline. The final acceptance check must traverse the whole chain:

```
Sonar Gate = ERROR
→ sonar_quality_gate exit non-zero
→ pipeline failed
→ merge request cannot merge
```

The GitLab project must enable “Pipelines must succeed,” a merge train, or an equivalent policy approved by the organization. This article does not grant Maintainer or Owner permissions or operate on a production project; I verify the result only with an authorized merge request or test project.

Merge request decoration remains useful because it brings issues and gate status to reviewers. However, if the job has `allow_failure` or the merge policy does not require a green pipeline, decoration does not replace enforcement.

### Roll out from report to gate

I do not enable a required gate while the scope and LCOV are still unreliable. The rollout has four stages:

| Stage            | Job policy                                                     | Goal                                                 |
| ---------------- | -------------------------------------------------------------- | ---------------------------------------------------- |
| Observe          | May be manual or allow failure, but named as a report          | Measure scope, path warnings, duration, and baseline |
| Stabilize        | Runs automatically without blocking merge for a defined period | Fix LCOV, scanner flakiness, and token boundaries    |
| Enforce new code | Automatic, waits for gate, `allow_failure: false`              | Block new regressions according to the approved gate |
| Tighten overall  | Only after baseline and remediation                            | Reduce debt without creating a permanent bypass      |

Every phase needs an owner and an end date. “Temporarily allow failure” must not continue indefinitely while the job is still described as a required gate.

### Verify before enforcement

Static checks do not require a Sonar token:

```bash
ruby -e 'require "yaml"; YAML.safe_load(File.read(".gitlab-ci.yml"), aliases: true)'
make -n ci-coverage
bash -n scripts/merge_lcov.sh
git grep -n 'sonar.qualitygate.wait\|allow_failure\|coverage'
```

A YAML parser does not replace GitLab CI Lint. `bash -n` validates syntax only; it does not prove that source paths in LCOV are correct.

I use a small LCOV fixture to test the merge script independently of the full application:

1. The root report contains one `SF:lib/...` record.
2. The package report contains one `SF:lib/...` record inside a package.
3. Run the merge script twice with the same input.
4. Both outputs must be identical.
5. The package path must become `packages/<package>/lib/...`.
6. `lcov --summary` must be able to read the merged report.

Minimum pipeline acceptance:

| Case                                | Sonar job          | Pipeline | Merge                                |
| ----------------------------------- | ------------------ | -------- | ------------------------------------ |
| Tests pass, gate OK                 | Green              | Green    | Allowed under the remaining policies |
| Tests fail                          | Does not run       | Red      | Blocked                              |
| LCOV missing or empty               | Red before scanner | Red      | Blocked                              |
| Artifact belongs to another commit  | Red before scanner | Red      | Blocked                              |
| Scanner upload succeeds, gate ERROR | Red after waiting  | Red      | Blocked                              |
| Sonar timeout                       | Red                | Red      | Blocked, or exception is audited     |

When checking the scope, select at least one root test, one package test, one root source file, and one package source file. The scanner must classify each correctly, report no unresolved LCOV paths, and still exclude generated files according to policy.

### Failure, retry, and rollback

| Failure                        | Action                                                                      |
| ------------------------------ | --------------------------------------------------------------------------- |
| Test failure                   | Do not scan an incomplete report; retain logs and artifacts to fix the test |
| Merged LCOV missing or empty   | Fail preflight and fix the merge script                                     |
| Commit marker mismatch         | Reject the scan and inspect the artifact or cache from the wrong pipeline   |
| LCOV source path does not map  | Normalize the path; do not remove coverage to make the gate green           |
| Missing `SONAR_TOKEN`          | Check variable scope; do not dump the environment                           |
| Scanner network timeout        | Retry within a bounded outage policy                                        |
| Quality Gate ERROR             | Fix code or tests, or review the issue; do not retry to hunt for green      |
| Scanner fails after an upgrade | Roll back to the pinned version; do not remove the gate condition           |

A safe rollback returns to the last verified scanner version or scope configuration. If a Sonar outage lasts long enough that the organization needs an exception, that exception needs its own owner, expiry, and audit trail. This public article does not describe a bypass control or how to broaden production permissions.

### Related articles

The Coverage article below owns the steps for producing `lcov.info` in a simple Flutter project and uploading it to Codecov. This article extends only the monorepo merge, artifact provenance, and Sonar gate.

{% content-ref url="/pages/GYRjxkG3zMjUYpCTRcv4" %}
[Coverage](/flutter/my-flutter/foundations/coverage-codecov.md)
{% endcontent-ref %}

After the required quality gate passes, the iOS pipeline can proceed to signing and TestFlight. The article below owns the build-once IPA flow and separated upload permission; this article does not repeat the release credential model.

{% content-ref url="/pages/AfcP6VFNqQRoYmvOMT1Z" %}
[Fastlane iOS, Code Signing, and TestFlight](/flutter/my-flutter/quality-delivery/fastlane-ios-code-signing-testflight.md)
{% endcontent-ref %}

### Verified versions and scope

The reference source was inspected on `2026-09-02` with:

* Flutter `3.41.2` from `.fvmrc`.
* Dart SDK from `3.11.0` up to, but not including, `4.0.0`.
* SonarScanner not pinned in the source.
* LCOV and genhtml not pinned in the source.
* GitLab, GitLab Runner, executor, and quality gate assignment not confirmed.

At the time of inspection, the SonarQube Cloud documentation listed Dart 3 through 3.11 as fully supported. Recheck compatibility and CI keywords when upgrading Dart, the scanner, or GitLab.

The read-only checks that passed include parsing the GitLab YAML, reading the Sonar/test/coverage call graph, running `make -n` for the coverage target, inventorying root and package tests, and scanning tracked source for Sonar credentials. The research machine did not have `sonar-scanner`, `lcov`, `genhtml`, or FVM in `PATH`; I did not install them or run a live analysis or pipeline.

### References

* [SonarQube Cloud — GitLab CI](https://docs.sonarsource.com/sonarqube-cloud/advanced-setup/ci-based-analysis/gitlab-ci)
* [SonarQube Cloud — Dart test coverage](https://docs.sonarsource.com/sonarqube-cloud/enriching/test-coverage/dart-test-coverage)
* [SonarQube Cloud — Test coverage parameters](https://docs.sonarsource.com/sonarqube-cloud/enriching/test-coverage/test-coverage-parameters)
* [SonarQube Cloud — Dart language](https://docs.sonarsource.com/sonarqube-cloud/advanced-setup/languages/dart)
* [SonarQube — Analysis scope](https://docs.sonarsource.com/sonarqube-server/project-administration/setting-analysis-scope)
* [SonarQube — Understanding quality gates](https://docs.sonarsource.com/sonarqube/latest/user-guide/quality-gates)
* [GitLab — Job control](https://docs.gitlab.com/ci/jobs/job_control/)
* [GitLab — YAML `needs:artifacts`](https://docs.gitlab.com/ci/yaml/)
* [GitLab — Job artifacts](https://docs.gitlab.com/ci/jobs/job_artifacts/)
* [GitLab — CI caching](https://docs.gitlab.com/ci/caching/)
* [GitLab — CI/CD variables](https://docs.gitlab.com/ci/variables/)
* [GitLab — Pipelines must succeed](https://docs.gitlab.com/user/project/merge_requests/auto_merge/)

## Conclusion

A Sonar dashboard does not become a quality gate by itself. A complete gate requires LCOV from the exact commit, a scanner that waits for the quality gate, and GitLab passing that failure into its merge policy.

For a legacy monorepo, enforcing new code first prevents each merge from adding more debt while overall debt remains measured and addressed through a plan. Only after the gate passes should build, signing, and release continue; this article does not connect the scanner to production permissions or go-live automatically.

[Buy Me a Coffee](https://buymeacoffee.com/ducmng12g) | [Support Me on Ko-fi](https://ko-fi.com/I2I81AEJG8)
