Skip to content

CI/CD and code quality

How code gets from a pull request to production: what steps a pipeline has, which quality checks go into it, how to manage packages and versions, and how to deploy so that rollback is a routine operation. Interviewers check this by asking about a pipeline the candidate has actually worked with.

Before this topic: Testing.

Junior+

What are CI and CD, and what steps make up a .NET pipeline?

Section titled “What are CI and CD, and what steps make up a .NET pipeline?”
Answer

What it is

  • CI — changes are merged into a shared branch often, and each one is built and tested automatically.
  • Continuous delivery — a verified build is ready to deploy at any moment; production is one button away.
  • Continuous deployment — every change that passes the checks goes to production with no button at all.

Steps of a .NET pipeline

  1. dotnet restore — download NuGet packages.
  2. dotnet build — build the solution.
  3. dotnet test — run the tests.
  4. Code analysis: analyzers, SonarQube.
  5. dotnet publish — prepare the application files.
  6. Save the output as an artifact.
  7. Deploy it to a test environment, then to production.

A pull request usually runs steps 1–4; a push to main runs all of them.

Common mistake

Rebuilding the project for every environment. Then production gets a different build from the one you tested.

Takeaway: CI checks every change; CD takes a verified build all the way to deployment.

How it works

  • The pipeline is described as code in the repository: .github/workflows/*.yml in GitHub Actions, a Jenkinsfile in Jenkins. It is versioned and reviewed like any other code.
  • Triggers: pull_request checks a change before the merge, push to main builds a release candidate, a manual run handles deployments and retries.
  • Jobs run on agents: cloud-hosted or your own (self-hosted), for example a Windows Server next to IIS. Separate jobs pass files to each other through artifacts.

When to choose what

Aspect Continuous delivery Continuous deployment
Production deploy after manual approval automatic
What you need reliable CI plus automated tests, monitoring, auto-rollback

Pitfalls

  • A slow pipeline. A 40-minute PR check means changes pile up and get merged in batches. Package caching, --no-restore and --no-build in later steps, and parallel jobs help.
  • Flaky tests. “Just rerun it, it fails sometimes” teaches people to ignore a red pipeline.
  • Secrets in YAML. Tokens belong in GitHub secrets or Jenkins Credentials, not in the repository.

Rule of thumb

On a PR: fast checks, and no merge without a green status. On main: build the artifact once and promote it through the environments.

Takeaway: a pipeline is versioned code: on a PR it verifies, on main it builds one artifact for every environment.

Under the hood

  • The artifact is immutable and tagged with a version and commit SHA. The version running in production leads you to the exact commit.
  • Environment configuration is not baked into the build; it arrives at deploy time: environment variables, appsettings.{Environment}.json, a secret store. That is how one artifact moves through dev, stage and prod.
  • Reproducibility: global.json pins the SDK, lock files pin the packages, and agents are built from a single image.

Cost

  • Feedback time is the key CI metric: a slow pipeline slows down every change.
  • Self-hosted agents can see the internal network and are cheaper at scale, but you patch and secure them yourself. Code from PRs runs on them, which is why GitHub advises against them for public repositories.

Trade-offs

  • GitHub Actions: YAML next to the code and ready-made actions. Jenkins: your own server, plugins and Groovy, full control at the price of constant plugin upgrades.
  • Continuous deployment needs a rollback measured in minutes and metrics to trigger it. Without them, a manual approval is the honest choice.
  • Fast checks on PRs and heavy ones on a schedule is a trade-off between speed and completeness.

How to check in production

  • DORA metrics: deployment frequency, lead time from commit to production, change failure rate, time to restore.
  • The version and commit SHA show up in the startup log or a service endpoint, so you know exactly what is running.

Takeaway: a pipeline is valuable because it is reproducible: one immutable artifact, configuration from outside and fast feedback.

Example
.github/workflows/ci.yml
name: ci
on:
pull_request:
branches: [main]
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-dotnet@v6
with:
global-json-file: global.json # SDK version from the repository
- run: dotnet restore
- run: dotnet build --no-restore -c Release
- run: dotnet test --no-build -c Release
- run: dotnet publish src/Shop.Api --no-build -c Release -o out
# Save the artifact only from main: that is what we deploy
- if: github.ref == 'refs/heads/main'
uses: actions/upload-artifact@v7
with:
name: shop-api-${{ github.sha }}
path: out
The same in a Jenkinsfile
// Declarative pipeline on a Windows agent (multibranch pipeline)
pipeline {
agent { label 'windows' }
stages {
stage('Build') {
steps {
bat 'dotnet restore'
bat 'dotnet build --no-restore -c Release'
}
}
stage('Test') {
steps { bat 'dotnet test --no-build -c Release' }
}
stage('Publish') {
when { branch 'main' } // artifact from main only
steps {
bat 'dotnet publish src/Shop.Api --no-build -c Release -o out'
archiveArtifacts artifacts: 'out/**'
}
}
}
}
Middle+

How do you keep track of code quality automatically?

Section titled “How do you keep track of code quality automatically?”
Answer

What it is

Automated quality checks are rules the pipeline applies to every PR.

What kinds there are

  • Compiler and analyzers. Warning CS8602 about a possible null, CAxxxx rules on reliability and performance. Built into the .NET SDK.
  • Style. .editorconfig sets indentation, naming and rule severities; dotnet format brings the code in line with it.
  • Coverage. The share of code that the tests executed.
  • SonarQube. Puts it all together and gives a verdict against the quality gate.
  • Code review. A person checks what tools can’t see: intent and design.

How to use

Turn on TreatWarningsAsErrors and make a green pipeline a required condition for merging into main.

Common mistake

Chasing 100% coverage. Coverage shows which code ran, not that its result was checked.

Takeaway: analyzers, tests and the quality gate check every PR; code review covers what can’t be automated.

How it works

  1. Build. Roslyn analyzers are on by default since .NET 5. AnalysisLevel picks the rule set: latest-recommended is stricter than the default. IDExxxx style rules run in a command-line build only with EnforceCodeStyleInBuild.
  2. Strictness. TreatWarningsAsErrors turns any warning into an error. A softer option is <WarningsAsErrors>Nullable</WarningsAsErrors>: only nullable warnings become errors.
  3. Format. dotnet format --verify-no-changes fails if the code doesn’t match .editorconfig.
  4. SonarQube. dotnet sonarscanner begin before the build, dotnet test with a coverage report, end afterwards. The server evaluates the quality gate.
  5. Branch. Branch protection requires green checks and a reviewer’s approval.

Pitfalls

  • TreatWarningsAsErrors on an old project produces hundreds of errors at once. Enable it project by project or start with WarningsAsErrors.
  • Branch and PR analysis in SonarQube starts at Developer Edition. The free Community Build analyzes only the main branch.
  • SonarQube doesn’t compute coverage itself: coverlet produces the report, and its path goes into sonar.cs.opencover.reportsPaths.

Rule of thumb

Whatever can be checked in the build, check in the build. SonarQube holds the thresholds on new code; branch protection makes the checks mandatory.

Takeaway: strictness lives in the build, thresholds in SonarQube, enforcement in branch protection.

Under the hood

  • A Roslyn analyzer subscribes to the compiler’s syntax and semantic events. It sees types and symbols, and the result is the same in the IDE and in CI.
  • Rule severity is set in layers: AnalysisLevel is the baseline, .editorconfig or .globalconfig sets a specific rule (dotnet_diagnostic.CA2016.severity), #pragma and [SuppressMessage] cover a specific spot.
  • SonarQube computes metrics against the new code period: since the previous version, over N days or relative to a branch. “Sonar way” checks only that period and ignores coverage and duplication while there are fewer than 20 new lines.

Cost

  • Analyzers make the build longer; latest-all on a large solution is noticeable.
  • A suppression nobody reviews is technical debt with no owner.

Trade-offs

  • 80% coverage on new code is Sonar’s default, not a law. Trivial DTOs are excluded with sonar.coverage.exclusions; complex logic is not.
  • A new SDK brings new rules. AnalysisLevel with a version number (10.0-recommended) pins the set, and upgrading becomes a separate PR.
  • A gate on all code is permanently red on a legacy project, and people stop noticing it. A gate on new code holds the bar for changes, and old code gets fixed when someone touches it.

How to check it works

  • The trend of suppressions (#pragma warning disable, NoWarn) per quarter.
  • The share of PRs merged around branch protection or with a red gate.

Takeaway: strictness lives in the build and branch protection, SonarQube holds the bar on new code, and suppressions are reviewed like code.

Example
<!-- Directory.Build.props at the repository root: applies to every project -->
<Project>
<PropertyGroup>
<Nullable>enable</Nullable>
<!-- Rule set is pinned: a new SDK won't turn on new rules by itself -->
<AnalysisLevel>10.0-recommended</AnalysisLevel>
<!-- IDExxxx style rules are checked in dotnet build too -->
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<!-- Any warning breaks the build -->
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
</Project>
.editorconfig
root = true
[*.cs]
# CA2016: forward the CancellationToken — a build error
dotnet_diagnostic.CA2016.severity = error
# IDE0161: file-scoped namespace — a warning (option:severity format, .NET 9+)
csharp_style_namespace_declarations = file_scoped:warning
SonarQube in a Jenkinsfile
// Requires the dotnet-sonarscanner global tool on the agent
// and a SonarQube webhook to <jenkins>/sonarqube-webhook/
stage('Build and analyze') {
steps {
// withSonarQubeEnv provides SONAR_HOST_URL and SONAR_AUTH_TOKEN
withSonarQubeEnv('SonarQube') {
bat '''
dotnet sonarscanner begin /k:"shop-api" ^
/d:sonar.host.url=%SONAR_HOST_URL% ^
/d:sonar.token=%SONAR_AUTH_TOKEN% ^
/d:sonar.cs.opencover.reportsPaths=**/coverage.opencover.xml
dotnet build -c Release --no-incremental
dotnet test -c Release --no-build --collect:"XPlat Code Coverage;Format=opencover"
dotnet sonarscanner end /d:sonar.token=%SONAR_AUTH_TOKEN%
'''
}
}
}
stage('Quality gate') {
steps {
timeout(time: 10, unit: 'MINUTES') {
waitForQualityGate abortPipeline: true // red gate means a red build
}
}
}
Middle+
Answer

What it is

  • SemVer — a MAJOR.MINOR.PATCH version such as 2.4.1. A breaking change gives 3.0.0, a new feature 2.5.0, a fix 2.4.2. A -beta.1 suffix marks a prerelease.
  • Private feed — the company’s NuGet server for internal packages: a shared logging library, clients for internal APIs.

How to use

  1. The library’s pipeline runs dotnet pack and gets a .nupkg.
  2. dotnet nuget push publishes the package to the feed.
  3. The feed is listed in nuget.config at the repository root, and the package installs like any other: dotnet add package Company.Logging.

Common mistake

Re-uploading a changed package under the same version. Everyone’s ~/.nuget/packages cache already holds the old one, and builds start to diverge.

Takeaway: SemVer tells consumers what changed, and a private feed serves internal packages the same way nuget.org does.

How it works

  • Feed. Azure Artifacts and GitHub Packages are cloud-hosted. BaGetter (the successor of BaGet), Nexus and ProGet are self-hosted. NuGet.Server is the old .NET Framework option. A good feed proxies nuget.org, so all packages go through one point.
  • Versions in one place. Central Package Management and Directory.Packages.props — see .NET platform.
  • Lock file. RestorePackagesWithLockFile creates packages.lock.json with the full graph. dotnet restore --locked-mode in CI fails if the graph changed but the file didn’t.
  • Audit. NuGetAudit checks the graph against a vulnerability database on restore and reports NU1901–NU1904, from low to critical.
  • Version from git. MinVer takes the version from the latest tag, GitVersion from tags and the branching scheme. No hand-written <Version> in the project.

When to choose what

Tool When
MinVer version = latest tag, a simple process
GitVersion release branches, GitFlow, complex rules
manual <Version> one package, rare releases

Pitfalls

  • A library’s lock file doesn’t help its consumers: the application resolves the graph. Commit it mainly for applications.
  • Floating versions (2.*) without a lock file make the build non-reproducible.
  • A fresh advisory breaks a build that uses TreatWarningsAsErrors. Decide up front which codes block.

Rule of thumb

One feed, versions in Directory.Packages.props, a lock file and --locked-mode for applications, the version from a tag.

Takeaway: lock files give reproducibility, the audit gives safety, SemVer from tags gives clarity.

Under the hood

  • NuGet supports SemVer 2.0: a prerelease sorts below the release (2.0.0-rc.1 is less than 2.0.0), and metadata after + is ignored in comparisons.
  • An assembly has several versions: the package version is for NuGet, AssemblyVersion for the loader, FileVersion for Windows, InformationalVersion for humans. Since .NET 8 the SDK appends the commit SHA to InformationalVersion: 2.4.0+a1b2c3d….
  • Since .NET 8 Source Link is built into the SDK for GitHub, Azure Repos, GitLab and Bitbucket. It writes the repository URL and commit into the PDB, and the debugger downloads the source of the exact version. ContinuousIntegrationBuild=true in CI normalizes paths for a deterministic build.

Cost

  • A .snupkg doesn’t bloat the package but isn’t supported by every feed: Azure Artifacts doesn’t accept it. The alternative is DebugType=embedded, with the PDB inside the assembly at the cost of size.
  • Auditing transitive packages finds more vulnerabilities — and more noise.

Trade-offs

  • Dependency confusion: a Company.Logging package with a high version on nuget.org can win over the internal one. The defence is Package Source Mapping or a single feed that proxies upstream.
  • A blocking audit in a separate pipeline (Microsoft’s recipe with -p:AuditPipeline=true) doesn’t hold up an urgent fix because of a fresh advisory.

How to check in production

  • The version and SHA from InformationalVersion in the startup log lead to the release commit.
  • dotnet package list --vulnerable --include-transitive (before .NET 10, dotnet list package) on the release commit shows vulnerabilities in what is running now.
  • NuGet Package Explorer shows whether a package has Source Link and symbols.

Takeaway: packages are a supply chain: one feed, a pinned graph, an audit, and a version that leads to the commit.

Example
<!-- Directory.Build.props: package rules for every project -->
<Project>
<PropertyGroup>
<!-- packages.lock.json with the full graph; in CI: dotnet restore --locked-mode -->
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
<!-- Audit transitive packages too (already the default for net10.0) -->
<NuGetAuditMode>all</NuGetAuditMode>
<!-- High and critical vulnerabilities break the build, low and moderate stay warnings -->
<WarningsAsErrors>$(WarningsAsErrors);NU1903;NU1904</WarningsAsErrors>
</PropertyGroup>
</Project>
<!-- nuget.config at the repository root -->
<configuration>
<packageSources>
<clear />
<add key="company" value="https://nuget.example.com/v3/index.json" />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
</packageSources>
<!-- Package Source Mapping: Company.* comes only from our own feed -->
<packageSourceMapping>
<packageSource key="company">
<package pattern="Company.*" />
</packageSource>
<packageSource key="nuget.org">
<package pattern="*" />
</packageSource>
</packageSourceMapping>
</configuration>
Senior+

What deployment strategies are there, and how do you roll back?

Section titled “What deployment strategies are there, and how do you roll back?”
Answer

What it is

A deployment strategy is the order in which the new version reaches the servers and starts getting traffic.

Strategy How it works
Stop and replace stop, replace files, start — there is downtime
Rolling servers behind a load balancer are updated one at a time
Blue-green a new copy next to the old one, traffic switches at once
Canary the new version first gets 1–5% of traffic

How to roll back

  • Keep the previous artifact and deploy it with a single command.
  • In blue-green, send traffic back to the old copy.
  • For a feature behind a feature flag, switch the flag off — no deployment needed.

Common mistake

Assuming rollback is easy until the database changes. If the new version dropped a column, the old code no longer works with that database.

Takeaway: pick a strategy by the cost of downtime, and test the rollback in advance.

How it works

  • Rolling. The load balancer takes a server out of rotation, the server is updated, passes a health check and comes back.
  • Blue-green. Two full copies. The new one is deployed and warmed up with no traffic, passes smoke tests, then the load balancer switches. The old one stays for rollback.
  • Canary. The new version gets a small share of traffic; its errors and latency are compared with the old one, and the share grows in steps.
  • Feature flags. Code ships switched off and is enabled for some users, for example with Microsoft.FeatureManagement.

When to choose what

Strategy Pro Con
Rolling no extra servers rollback is another slow rolling
Blue-green instant rollback double resources
Canary few users affected needs metrics and routing

Pitfalls

  • Database schema. The migration runs before the deployment and must work with the old code: add, don’t drop or rename. Details are in changing the schema without downtime.
  • In-memory state. After the switch a user lands on another instance, so sessions and caches live outside the process.
  • Two versions at once. Messages in queues and API responses must be understood by both versions.

Rule of thumb

Every release is compatible with the previous one in schema, API and messages. Then any strategy works, and rolling back the code doesn’t require rolling back the database.

Takeaway: a strategy is a trade-off between resource cost, rollback speed and the number of users affected.

Under the hood: IIS and containers

  • app_offline.htm. A file in the site root makes the ASP.NET Core Module shut the app down gracefully and serve the file’s contents. The DLLs are released, files are replaced, the file is removed, and the next request starts the new version. That is downtime: fine for a maintenance window or as a step of a rolling deployment behind a load balancer.
  • Two sites. Blue and green sites with their own pools and folders behind ARR or an external load balancer. Green is deployed and warmed up, /health/ready is checked, the load balancer switches, and blue is kept until the observation period ends.
  • Containers. The orchestrator sends traffic to a new instance only after a successful readiness check. Readiness and liveness are different checks: an unavailable database is no reason to restart the process.

Cost

Any zero-downtime strategy requires compatibility between versions N and N−1: expand/contract in the database, optional fields in the API, versioned messages.

Trade-offs

  • Rolling back is faster when the artifact and schema are compatible. Fixing forward (roll forward) is the way when a data migration can no longer be undone.
  • Feature flags separate deployment from release but pile up dead code paths. A flag needs an expiry date.

How to check in production

  • After the switch the pipeline waits out an observation period (bake time), compares the 5xx rate, p99 and business metrics against thresholds, and sends traffic back on its own if they are breached.
  • Each deployment is marked with its version in monitoring, so an error spike is tied to a release right away.
  • Rollbacks are rehearsed: a rollback nobody has ever run doesn’t work.

Takeaway: a safe deployment means small steps, N and N−1 compatibility, metric-driven decisions and a rollback that has already been tested.

Example
# Deploy job in ci.yml: blue-green on IIS (for simplicity the new copy is always green)
jobs:
deploy:
needs: build
if: github.ref == 'refs/heads/main'
runs-on: [self-hosted, windows, iis] # own agent inside the server network
environment: production # manual approval in the environment settings
defaults:
run:
shell: powershell
steps:
- uses: actions/checkout@v7 # deployment scripts live in the repository
- uses: actions/download-artifact@v8
with:
name: shop-api-${{ github.sha }}
path: drop
# 1. Deploy to the copy that has no traffic
- run: |
Import-Module WebAdministration
Stop-WebAppPool -Name shop-green
Copy-Item drop\* D:\sites\shop-green -Recurse -Force
Start-WebAppPool -Name shop-green
# 2. Warm up and check readiness before switching
- run: Invoke-WebRequest http://localhost:8081/health/ready -UseBasicParsing
# 3. Switch the load balancer and watch the metrics
- run: ./deploy/switch-traffic.ps1 -To green
- run: ./deploy/watch-metrics.ps1 -Minutes 15 -Max5xxPercent 1
# 4. Failure at any step: send traffic back to the previous copy
- if: failure()
run: ./deploy/switch-traffic.ps1 -To blue

switch-traffic.ps1 and watch-metrics.ps1 are project scripts: the first changes the ARR rule or the load balancer pool, the second reads metrics from monitoring. On the next release the colors swap.