Mert Özen Further With Every Line
Testing & Quality Static Analysis — #05

Quality Gates with SonarQube: Community Build or Server, Quality Profiles, New-Code Gates and CI Integration

Mert Özen Nov 14, 2026 13 min 12 views
Quality Gates with SonarQube: Community Build or Server, Quality Profiles, New-Code Gates and CI Integration

SonarQube's default gate only looks at new code: zero new issues and at least 80% coverage on new code. In Bookstore a 'similar books' endpoint with a clean build and green tests failed the gate with 10.3% new-code coverage and passed after two tests. The post covers running the Community Build with PostgreSQL in Docker, SonarScanner for .NET, the Sonar way core profile, double reporting with Roslyn, the stale coverage report trap and the Community Build's lack of PR analysis, with real output.

Version note: The code in this post was built and run with .NET 10 (SDK 10.0.402); the analyses ran on SonarQube Community Build 26.11 and PostgreSQL 17 in Docker, with SonarScanner for .NET 12.1 and Microsoft.Testing.Extensions.CodeCoverage 18.12, and the tests ran against SQL Server 2025. SonarQube Server, SonarQube Cloud, pull request analysis and SonarQube for IDE's connected mode are described from the documentation and were not run; the GitHub Actions workflow was validated with actionlint 1.7.12.

Guess First

Bookstore, the online bookstore used as the shared example across this site's series, is getting a "similar books" endpoint for the book page: up to four in-stock books by the same author or in the same category. Ayşe's code:

// Up to four in-stock books by the same author or in the same category.
private static async Task<Results<Ok<List<BookSummary>>, NotFound>> GetSimilar(
    string isbn, BookstoreContext db, IInventoryService inventory, CancellationToken ct)
{
    var book = await db.Books.AsNoTracking()
        .Where(b => b.Isbn == isbn)
        .Select(b => new { b.Id, b.AuthorId, b.CategoryId })
        .FirstOrDefaultAsync(ct);
    if (book is null)
    {
        return TypedResults.NotFound();
    }

    List<Book> candidates = await db.Books.AsNoTracking()
        .Include(b => b.Author)
        .Include(b => b.Category)
        .Where(b => b.Id != book.Id && (b.AuthorId == book.AuthorId || b.CategoryId == book.CategoryId))
        .OrderBy(b => b.AuthorId == book.AuthorId ? 0 : 1)
        .ThenBy(b => b.Title)
        .ToListAsync(ct);

    List<BookSummary> similar = candidates
        .Where(b => inventory.GetAvailableAsync(b.Id, ct).Result > 0)
        .Take(4)
        .Select(b => new BookSummary(b.Isbn, b.Title, b.Author.Name, b.Category.Name, b.Price, b.PublishedYear))
        .ToList();
    return TypedResults.Ok(similar);
}

With the analyzers from the second and third posts the build is clean: zero warnings, zero errors. The existing ten integration tests pass. Does this code pass SonarQube's default quality gate? If not, which condition fails?

Which SonarQube?

In the first post we talked about the dashboard layer: a server that looks not at a single pull request but at time, and gathers the results of the .NET and Angular sides in one place. SonarQube comes as three products today, and the difference between them is not just price:

ProductRelease modelBranch and PR analysisIn this post
SonarQube Community BuildMonthly, numbered year.month (26.11, November 2026)None, single branchRun in Docker
SonarQube Server (Developer, Enterprise, Data Center)Yearly version scheme; latest LTA 2026.5, September 2026From Developer Edition upFrom the documentation
SonarQube CloudContinuously updated SaaSYesNo account created, from the documentation

We tested the Community Build's limit: when we passed a branch name in the analysis parameters, the scanner rejected the analysis:

ERROR: Validation of project failed:
  o To use the property "sonar.branch.name" and analyze branches, Developer Edition or above is required.

The practical meaning is big. The Community Build has no analysis of a pull request with the result written back to it (PR decoration); analysis runs only on the main branch, after the merge. If you want to see the gate turn red in the pull request, you need Developer Edition or above, or SonarQube Cloud. If you install Server from the ZIP, it needs Java 21 or 25; the Docker image brings Java with it.

Local Setup: SonarQube and PostgreSQL with Docker Compose

SonarQube's embedded H2 database is for evaluation only; a lasting installation needs a separate database. The compose file we added to Bookstore's repo:

# Local SonarQube Community Build for Bookstore.
# Start:  docker compose -f tools/sonarqube/compose.yaml up -d   (then open http://localhost:9000)
# Needs:  SONAR_DB_PASSWORD in the environment or in tools/sonarqube/.env (not committed).
services:
  sonarqube:
    image: sonarqube:26.11.0.134175-community
    depends_on:
      db:
        condition: service_healthy
    environment:
      SONAR_JDBC_URL: jdbc:postgresql://db:5432/sonar
      SONAR_JDBC_USERNAME: sonar
      SONAR_JDBC_PASSWORD: ${SONAR_DB_PASSWORD:?set SONAR_DB_PASSWORD}
    ports:
      - "127.0.0.1:9000:9000"
    volumes:
      - sonarqube_data:/opt/sonarqube/data
      - sonarqube_extensions:/opt/sonarqube/extensions
      - sonarqube_logs:/opt/sonarqube/logs

  db:
    image: postgres:17
    environment:
      POSTGRES_USER: sonar
      POSTGRES_PASSWORD: ${SONAR_DB_PASSWORD:?set SONAR_DB_PASSWORD}
      POSTGRES_DB: sonar
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U sonar -d sonar"]
      interval: 5s
      timeout: 5s
      retries: 10
    volumes:
      - postgresql:/var/lib/postgresql/data

volumes:
  sonarqube_data:
  sonarqube_extensions:
  sonarqube_logs:
  postgresql:

For the experiment we mapped the host port to 19000 to avoid a clash, which is why some outputs below show that address. The image tag is written with the exact version; the community tag moves forward every month and one morning you'd face a database migration. The port is open on 127.0.0.1 only. The Elasticsearch inside SonarQube wants vm.max_map_count on the host to be at least 524288; the documentation says this has to be set on the host, not in the container. On Windows, Docker Desktop runs inside a Linux VM, so the setting belongs to that VM. When the server is ready:

$ curl http://127.0.0.1:19000/api/system/status
{"id":"243B8A4D-AaHZ5i6CAftXmZhh9lVq","version":"26.11.0.134175","status":"UP"}

The first job is to change the default admin/admin password and create a token for analysis. We did both through the API (api/users/change_password, api/user_tokens/generate with type=GLOBAL_ANALYSIS_TOKEN); the token can only run analyses and has no administration rights. The password and the token go to the CI's secret store, not the repo.

SonarScanner for .NET: begin, build, test, end

.NET projects are analyzed not with the generic scanner that reads source code from the outside, but with SonarScanner for .NET, which hooks into MSBuild. We added the scanner to Bookstore's local tool manifest (dotnet tool install dotnet-sonarscanner --version 12.1.0); no global tool was installed. The flow has four steps:

dotnet sonarscanner begin /k:bookstore /n:Bookstore /v:1.0 /d:sonar.host.url=http://localhost:9000 /d:sonar.token=<token> "/d:sonar.cs.cobertura.reportsPaths=TestResults/*.cobertura.xml"
dotnet build Bookstore.slnx --no-incremental
dotnet test --project tests/Bookstore.Api.Tests --coverage --coverage-output-format cobertura --coverage-output Bookstore.Api.Tests.cobertura.xml
dotnet test --project tests/Bookstore.Analyzers.Tests --coverage --coverage-output-format cobertura --coverage-output Bookstore.Analyzers.Tests.cobertura.xml
dotnet sonarscanner end /d:sonar.token=<token>

begin downloads the quality profile from the server and installs MSBuild targets that add SonarAnalyzer's Roslyn analyzers to the build. So the build itself is the analysis step; Sonar's rules show up as warnings in the build log too:

src/Bookstore.Api/Catalog/Isbn.cs(7,51): warning S6444: Pass a timeout to limit the execution time.
src/Bookstore.Api/Program.cs(48,1): warning S6966: Await RunAsync instead.

The analysis ran even though there was no Java on the machine: the scanner downloaded a JRE from SonarQube and wrote to the log that, if Java is already installed, you can pass /d:sonar.scanner.skipJreProvisioning=true or /d:sonar.scanner.javaExePath=<PATH>. For coverage we added the Microsoft.Testing.Extensions.CodeCoverage package, which we used in "What Code Coverage Tells You and What It Doesn't" in the Test Strategy series, to both test projects. Sonar's .NET documentation supports the Cobertura format through sonar.cs.cobertura.reportsPaths. We gave each test project its own file name: when we had both write to the same name with a single dotnet test --solution command, the second report overwrote the first.

One surprise was a good one: the end step analyzed not only the C# files but also web/bookstore-web in the repo. The log says "7 languages detected", and the language distribution is cs=1354;css=74;js=45;ts=243;web=120 lines. For the TypeScript analysis the scanner used its own Node.js runtime ("Using embedded Node.js runtime."); there was no need to set up a separate frontend project.

The First Analysis: What Did We See?

We analyzed Bookstore as it was before similar books, as version 1.0. From the server's API (a summary of the api/measures/component and api/issues/search responses):

coverage 79.3
duplicated_lines_density 1.6
cognitive_complexity 72
ncloc 1836
violations 6
alert_status OK
csharpsquid:S6444 src/Bookstore.Api/Catalog/Isbn.cs 7 Pass a timeout to limit the execution time.
external_roslyn:SYSLIB1045 src/Bookstore.Api/Catalog/Isbn.cs 7 Use 'GeneratedRegexAttribute' to generate the regular expression implementation at compile
csharpsquid:S6966 src/Bookstore.Api/Program.cs 48 Await RunAsync instead.
Web:InputWithoutLabelCheck web/bookstore-web/src/app/cart/cart-page.html 19 Add an "id" attribute to this input field and associate it with a label.
Web:InputWithoutLabelCheck web/bookstore-web/src/app/cart/cart-page.html 32 Add an "id" attribute to this input field and associate it with a label.
Web:InputWithoutLabelCheck web/bookstore-web/src/app/cart/cart-page.html 33 Add an "id" attribute to this input field and associate it with a label.

Six issues from three sources. Two are Sonar's own C# rules, the same ones we saw when trying the SonarAnalyzer package in the third post. Three are inputs without labels on the cart page: in a template that passed angular-eslint's accessibility set in the fourth post, Sonar's HTML analysis found a gap. Tools' rule sets overlap but aren't the same. One has the external_roslyn prefix: it isn't a Sonar rule but a diagnostic produced by the SDK's analyzer during the build, imported as an external issue. It's SYSLIB1045, a suggestion-level diagnostic shown only in the IDE, not even a warning in the second post's .globalconfig. If you don't want these, /d:sonar.cs.roslyn.ignoreIssues=true turns them off; we tried it, and the open issue count went from 6 to 5.

Be careful when reading metrics: cognitive_complexity 72 is the total for the whole project and means nothing on its own. What's valuable is the per-method distribution and the change over time. The same goes for duplication: 1.6% looks low, but if the same 30 lines of validation logic were copied into two modules, that's far more important information than the percentage.

Quality Profiles and Quality Gates

A quality profile decides which rules are active for a language. With Community Build 26.10 the built-in profiles were split into three. According to the documentation, "Sonar way core" is the default and contains security rules, reliability rules of Major severity and above, and Blocker maintainability rules; "Sonar way extended" is stricter on reliability and maintainability; "Sonar way comprehensive" enables all recommended rules and replaces the old "Sonar way" profile. On Bookstore's server the C# numbers are 121, 173 and 321 rules. Keeping the default profile narrow is a deliberate choice, but it has a consequence: the cognitive complexity rule S3776 and the unused parameter rule S1172 are off in the core profile. "We installed SonarQube, so complexity is being checked" is not true with the default settings.

A quality gate answers the question "is this analysis acceptable?". The conditions of the default "Sonar way" gate, from the server (api/qualitygates/show, with the id fields removed):

[
  {"metric": "new_violations", "op": "GT", "error": "0", "isCaycCondition": true},
  {"metric": "new_coverage", "op": "LT", "error": "80", "isCaycCondition": true},
  {"metric": "new_duplicated_lines_density", "op": "GT", "error": "3", "isCaycCondition": true},
  {"metric": "new_security_hotspots_reviewed", "op": "LT", "error": "100", "isCaycCondition": true}
]

All four conditions start with new_: the gate looks only at new code, not at all of it. isCaycCondition says it's a condition of Sonar's "Clean as You Code" approach. This is Sonar's version of the ratchet idea from the first post: old code may have 6 issues and 79.3% coverage, but new code must have zero new issues and 80% coverage. The definition of "new code" is a project setting; in Bookstore it's the default PREVIOUS_VERSION, meaning the lines changed since the previous version when the version passed with /v: changes.

A Gate on New Code: Similar Books

The answer to the guess. With the similar books endpoint, re-running the tests and analyzing with /v:1.1, the gate status (a summary of the api/qualitygates/project_status response):

ERROR {'mode': 'PREVIOUS_VERSION', 'date': '2026-11-14T11:54:09+0000', 'parameter': '1.0'}
ERROR new_coverage 10.3 LT 80
OK new_duplicated_lines_density 0.0 GT 3
OK new_violations 0 GT 0

The gate is red, but not because of an issue: the new code has 23 lines to cover and 22 of them aren't exercised by any test. Sonar saw neither the .Result nor the separate stock query for every candidate book (an N+1) as an issue. The default profile has no rule that catches this call blocking a thread pool thread; the SDK and third-party analyzers from the third post were silent too. A reviewer catches this. The coverage condition forced something else: writing two tests for the endpoint. While writing them we also turned the .Result into a loop with await:

List<BookSummary> similar = [];
foreach (Book candidate in candidates)
{
    if (similar.Count == 4)
    {
        break;
    }
    if (await inventory.GetAvailableAsync(candidate.Id, ct) > 0)
    {
        similar.Add(new BookSummary(candidate.Isbn, candidate.Title, candidate.Author.Name,
            candidate.Category.Name, candidate.Price, candidate.PublishedYear));
    }
}
return TypedResults.Ok(similar);
[Fact]
public async Task Similar_books_share_the_author_or_the_category()
{
    // Emma (Jane Austen, Classics): Pride and Prejudice comes first because of the author.
    List<BookSummary>? similar = await client.GetFromJsonAsync<List<BookSummary>>(
        "/api/books/9780141439587/similar", TestContext.Current.CancellationToken);

    Assert.NotNull(similar);
    Assert.InRange(similar.Count, 1, 4);
    Assert.Equal("Pride and Prejudice", similar[0].Title);
    Assert.All(similar, b => Assert.True(b.Author == "Jane Austen" || b.Category == "Classics"));
}

The file also has a second test that returns 404 for an unknown ISBN. Analyzing again with the same version:

OK {'mode': 'PREVIOUS_VERSION', 'date': '2026-11-14T11:54:09+0000', 'parameter': '1.0'}
OK new_coverage 100.0 LT 80
OK new_duplicated_lines_density 0.0 GT 3
OK new_violations 0 GT 0

The project history tells the same story; since the version didn't change, "new code" is always the lines changed since 1.0:

2026-11-14T11:58:47+0000 1.1 ['Passed', '1.1']
2026-11-14T11:57:55+0000 1.1 ['Failed']
2026-11-14T11:54:09+0000 1.0 ['26.11.0.134175', '1.0']

A warning about the coverage threshold. 80% is not a target but an alarm threshold. Once the team starts gaming it, for example with tests that have no assertions or only call the happy path, the metric goes up but confidence doesn't. The test above really verifies the ordering and the filter; tests like that are what should raise coverage. We covered what coverage measures and what it doesn't, together with mutation testing, in the same post of the Test Strategy series.

CI Integration and the IDE

For the gate to mean something, CI must wait for its result. With /d:sonar.qualitygate.wait=true the end step waits for the server to process the analysis and exits with a non-zero code if the gate is red. In Bookstore, with the version that broke the gate:

INFO: ------------- Check Quality Gate status
ERROR: QUALITY GATE STATUS: FAILED - View details on http://127.0.0.1:19000/dashboard?id=bookstore
The scanner engine did not complete successfully
Post-processing failed. Exit code: 1

Since the Community Build analyzes a single branch, the workflow runs only on pushes to main; the SQL Server the tests need is a service container:

name: sonarqube

# Community Build analyzes a single branch, so this runs on main only.
on:
  push:
    branches: [main]

permissions:
  contents: read

jobs:
  analyze:
    runs-on: ubuntu-latest
    services:
      sql:
        image: mcr.microsoft.com/mssql/server:2025-latest
        env:
          ACCEPT_EULA: "Y"
          MSSQL_SA_PASSWORD: ${{ secrets.CI_SQL_PASSWORD }}
        ports:
          - 1433:1433
    env:
      ConnectionStrings__BookstoreDb: Server=localhost,1433;Database=BookstoreDb;User Id=sa;Password=${{ secrets.CI_SQL_PASSWORD }};TrustServerCertificate=True
    steps:
      - uses: actions/checkout@v7
        with:
          # Full history: SonarQube uses blame to tell new lines from old ones.
          fetch-depth: 0
      - uses: actions/setup-dotnet@v6
        with:
          global-json-file: global.json
      - run: dotnet tool restore
      - name: Begin analysis
        run: >
          dotnet sonarscanner begin /k:bookstore /n:Bookstore
          /d:sonar.host.url=${{ vars.SONAR_HOST_URL }}
          /d:sonar.token=${{ secrets.SONAR_TOKEN }}
          "/d:sonar.cs.cobertura.reportsPaths=TestResults/*.cobertura.xml"
          /d:sonar.qualitygate.wait=true
      - run: dotnet build Bookstore.slnx
      - run: dotnet test --project tests/Bookstore.Api.Tests --no-build --coverage --coverage-output-format cobertura --coverage-output Bookstore.Api.Tests.cobertura.xml
      - run: dotnet test --project tests/Bookstore.Analyzers.Tests --no-build --coverage --coverage-output-format cobertura --coverage-output Bookstore.Analyzers.Tests.cobertura.xml
      - name: End analysis and wait for the quality gate
        run: dotnet sonarscanner end /d:sonar.token=${{ secrets.SONAR_TOKEN }}

The workflow has no /v:; instead of treating every push as a separate version, in a project that flows continuously into main it makes more sense to define new code in the project settings as a number of days, such as "the last 30 days". For a team that releases with version tags, PREVIOUS_VERSION is the natural choice. fetch-depth: 0 matters too: SonarQube works out when a line changed from Git blame information, and in a shallow clone every line can look new.

On the developer's machine there's SonarQube for IDE, the new name of SonarLint, available as a Visual Studio, VS Code and Rider plugin. In connected mode the plugin takes the quality profile from the server and runs the same rules in the editor; according to the documentation, the Community Build can be connected in this mode too. We didn't try this part in an IDE. In Bookstore the main feedback in the editor is still the Roslyn analyzers from the third post; the plugin is for those who want to see Sonar's own rules before the pull request.

In Production

Manage the server itself. SonarQube is a database, an Elasticsearch and an application that needs regular updates. The Community Build ships a new version every month and old versions get no maintenance; Server's LTA versions get patches for 12 or 18 months depending on the edition. Who updates it and who backs up the database must be decided before installation.

Don't put the gate on all of the code. Adding a condition such as violations > 0 on "overall code" turns a legacy project red on day one, and the team either switches the gate off or starts suppressing in bulk. Keep the default gate focused on new code; follow the number of issues in old code as a trend on the dashboard.

Create tokens with the least privilege. A GLOBAL_ANALYSIS_TOKEN or a project analysis token is enough; putting an admin token in CI gives SonarQube's administration to anyone who compromises CI. The admin password and the token we created during setup were deleted together with the containers when the experiment was over.

Common Pitfalls

Analyzing with an old coverage report. When we added the similar books code and analyzed with the coverage file left over from the previous run, without re-running the tests, the gate passed with new_coverage 100.0: the line numbers in the old report landed on different lines of the changed file. The coverage report must be produced from the same commit, in the same pipeline, for every analysis.

Sonar and Roslyn reporting the same problem twice. For line 7 of Isbn.cs, both Sonar's S6444 and the imported SYSLIB1045 showed up; in the third post we had also seen Meziantou's MA0009 on the same line. A problem should have a single owning tool: switch off the Sonar counterpart of a rule you made an error in Roslyn, or control external issues with sonar.cs.roslyn.ignoreIssues.

Not knowing what the scanner writes to the user profile. In a local run, the begin step put the JRE it downloaded into .sonar/cache in the user folder, and an MSBuild targets file (SonarQube.Integration.ImportBefore.targets) into the Microsoft/MSBuild folder of the user profile. The targets file stayed there after end. That doesn't matter in CI, but if you tried an analysis on a developer machine, be aware of them; for the JRE's location there is the /d:sonar.userHome=... parameter.

Expecting pull request analysis. There is no gate in pull requests with the Community Build. In a setup built without knowing this, the team faces a main branch that turns red after the merge. Either let other checks (the build and lint from the second and fourth posts) be the gate in pull requests, or evaluate Developer Edition or SonarQube Cloud.

A Small Experiment

★☆☆ Bring up SonarQube on your own machine with the compose file, analyze your own project with /v:1.0 and look at the language distribution. Is the scanner analyzing a folder you didn't expect (generated code, wwwroot/lib and so on)? Exclude it with sonar.exclusions.

★★☆ Switch the project to the "Sonar way comprehensive" profile and analyze again. How many new issues appeared, and which of them are already covered by a CA rule in the second post's .globalconfig? Derive your own profile by switching off the overlapping ones.

★★★ Change the new code definition from PREVIOUS_VERSION to "30 days", simulate a week of changes with a few analyses, and compare how the gate behaves. Which one is right for a team that releases with tags?

With that, the backbone of the series is complete: analyzers whose findings and blind spots we know, our own rule, type-aware lint for Angular, and a gate focused on new code. In the last post we look back: analyzers from .NET 5 to 11, the move from TSLint to ESLint 10, TypeScript 7 and next-generation linters such as Oxlint, and the changes in Sonar's versioning.