Quality Gates
Fail a pull request on analyzer and style violations, vulnerable packages, breaking API changes or a coverage drop, without making CI flaky.
<AnalysisLevel>latest-recommended</AnalysisLevel>
<NuGetAuditLevel>moderate</NuGetAuditLevel>
<WarningsAsErrors Condition="'$(StrictAudit)' == 'true'">$(WarningsAsErrors);NU1903;NU1904</WarningsAsErrors>A quality gate is a check that blocks a merge. Each one must be cheap, deterministic and tied to a rule the team actually agreed on. A gate that fails randomly or for reasons nobody owns gets disabled within a month, and then it protects nothing.
| Gate | Tool | Fails when |
|---|---|---|
| Code analysis | .NET analyzers in the SDK | A CA rule at warning severity fires |
| Code style | EnforceCodeStyleInBuild, dotnet format | An IDE rule fires or formatting differs |
| Vulnerable packages | NuGet audit during restore | An advisory at or above the chosen level matches |
| API contract | OpenAPI diff against main | A breaking change to the document |
| Coverage | Cobertura report and a threshold check | Coverage is below the floor or dropped |
Analyzers and code style
The SDK runs the CA analyzers on every build; AnalysisLevel chooses the rule set (latest-recommended raises a curated set to warnings). IDE rules (IDExxxx) normally run only in the editor; EnforceCodeStyleInBuild makes the build run them too, and .editorconfig sets each rule's severity. A rule at suggestion never fails a build, so the gate is really the severities in .editorconfig plus TreatWarningsAsErrors. One known trap: IDE0005 (unnecessary usings) is only reported in the build when GenerateDocumentationFile is on. dotnet format --verify-no-changes exits non-zero when whitespace or style fixes are pending, which keeps formatting arguments out of review.
Vulnerable packages
Restore checks every package, transitive ones included, against the advisory database and reports NU1901 (low) to NU1904 (critical). NuGetAuditLevel sets the lowest severity reported. Advisories appear without any commit, so a blanket warnings-as-errors build turns into a red pipeline for everyone the morning one is published. The policy this course uses, the same one as in the build lesson: in pull-request and local builds all NU190x codes stay warnings (WarningsNotAsErrors), so they show up as annotations but block nothing. NU1903 and NU1904 become errors only in a scheduled audit job and in the main branch build, through a property only those jobs set. The scheduled run alerts the owning team the morning an advisory appears, and a release cannot ship with a known high or critical one. dotnet list package --vulnerable only prints; it exits 0 even when it finds something, so it is not a gate by itself.
API contract
Generate the OpenAPI document at build time, commit it, and diff it against main with a tool such as oasdiff; a removed operation or a newly required property fails the check unless the change is versioned. The OpenAPI stage covers the setup.
Coverage
Coverage measures which lines ran, not whether anything was asserted, so treat it as a floor that catches untested new code, never as a target. Three policies exist: an absolute floor (80% lines), no drop compared with main, and a stricter floor for critical packages such as payments. Combine the first two and exclude generated code (migrations, source generators) or the number means little. Several test projects produce several reports; merge them before checking, or each project is judged alone.
Rolling gates out
Turning on latest-recommended in a five-year-old service yields hundreds of warnings. Enable it, fix or explicitly suppress with a justification in one pull request, then lock it in. Never let a gate start in a state where it already fails on main.
Example
When to use it
- Turn the weekday audit run red when a critical advisory (NU1904) is published for a transitive package, while pull requests only show a warning.
- Block a merge that drops line coverage from 84.2% on main to 81.3%, or leaves Orders.Payments below its own 90% floor.
- Stop review comments about unused usings and formatting by running dotnet format --verify-no-changes and EnforceCodeStyleInBuild.
More examples
Directory.Build.props with the analyzer and audit gates
<code>CS1591</code> (missing XML comment) is suppressed because the documentation file is generated only to switch IDE0005 on. With <code>NuGetAuditLevel</code> at <code>moderate</code>, low advisories (NU1901) are not reported at all. <code>WarningsNotAsErrors</code> exempts every audit code from <code>TreatWarningsAsErrors</code>, which NuGet honours too. With <code>StrictAudit</code> set, the explicit <code>WarningsAsErrors</code> entry takes precedence, so high and critical advisories (NU1903, NU1904) fail that job's restore while moderate ones still only warn. <code>NuGetAuditMode</code> <code>all</code> includes transitive packages, which is where most advisories are found.
<Project>
<PropertyGroup>
<AnalysisLevel>latest-recommended</AnalysisLevel>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<!-- IDE0005 (unused usings) is only reported in the build with this on. -->
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<NuGetAudit>true</NuGetAudit>
<NuGetAuditMode>all</NuGetAuditMode>
<NuGetAuditLevel>moderate</NuGetAuditLevel>
<!-- Advisories never fail a pull request or a local build. -->
<WarningsNotAsErrors>$(WarningsNotAsErrors);NU1901;NU1902;NU1903;NU1904</WarningsNotAsErrors>
</PropertyGroup>
<!-- Set by the scheduled audit and the main-branch build: -p:StrictAudit=true -->
<PropertyGroup Condition="'$(StrictAudit)' == 'true'">
<WarningsAsErrors>$(WarningsAsErrors);NU1903;NU1904</WarningsAsErrors>
</PropertyGroup>
</Project>Gate job plus a scheduled audit
Only the scheduled and <code>main</code> runs pass <code>StrictAudit=true</code>: a high-severity advisory published overnight turns the weekday-morning run red and notifies the team even when nobody pushes, while pull requests keep merging with a warning. ReportGenerator merges the per-project Cobertura files and drops the test assemblies before the gate script judges the total. <code>MAIN_LINE_COVERAGE</code> is a repository variable updated by the job that runs on <code>main</code>; the script fails the pull request if coverage drops more than half a point below it.
name: quality
on:
pull_request:
push:
branches: [main]
schedule:
- cron: "0 6 * * 1-5"
permissions:
contents: read
jobs:
gates:
runs-on: ubuntu-24.04
timeout-minutes: 20
steps:
- uses: actions/checkout@v5
- uses: actions/setup-dotnet@v5
with:
global-json-file: global.json
# Advisories fail only the scheduled and main-branch runs, never a pull request.
- run: dotnet restore Orders.slnx -p:StrictAudit=${{ github.event_name != 'pull_request' }}
- run: dotnet format Orders.slnx --verify-no-changes --no-restore
- run: dotnet build Orders.slnx -c Release --no-restore
# MTP mode; --coverage comes from Microsoft.Testing.Extensions.CodeCoverage.
- run: >
dotnet test --solution Orders.slnx -c Release --no-build
--results-directory TestResults
--coverage --coverage-output-format cobertura
# ReportGenerator's version is pinned in .config/dotnet-tools.json.
- run: dotnet tool restore
- run: >
dotnet reportgenerator -reports:"TestResults/**/*.cobertura.xml"
-targetdir:coverage -reporttypes:Cobertura
-assemblyfilters:"-*.Tests;-*.UnitTests;-*.IntegrationTests"
- run: dotnet run tools/CoverageGate.cs -- coverage/Cobertura.xml ${{ vars.MAIN_LINE_COVERAGE }}A CA rule that catches a real bug
CA2016 (forward the CancellationToken) is enabled by default as a suggestion, which never fails a build. Adding <code>dotnet_diagnostic.CA2016.severity = warning</code> to <code>.editorconfig</code> makes the build report it, and <code>TreatWarningsAsErrors</code> turns that into a failed check. Raising specific high-value rules like this one is a better use of a gate than enabling every rule at once.
using System.Net.Http.Json;
public record ExchangeRate(string Currency, decimal Rate, DateTimeOffset AsOf);
public class ExchangeRateClient(HttpClient http)
{
// CA2016 fires here: the token is accepted but not forwarded, so a client that
// disconnects leaves the outbound call running until HttpClient's own timeout.
public Task<ExchangeRate?> GetRateUnforwardedAsync(string currency, CancellationToken cancellationToken) =>
http.GetFromJsonAsync<ExchangeRate>($"rates/{Uri.EscapeDataString(currency)}");
public Task<ExchangeRate?> GetRateAsync(string currency, CancellationToken cancellationToken) =>
http.GetFromJsonAsync<ExchangeRate>($"rates/{Uri.EscapeDataString(currency)}", cancellationToken);
}
Discussion