A code review of a CI pipeline: redundant restores, missing --no-restore/--no-build, Release configuration, locked restore, dotnet format --verify-no-changes, test results and coverage, and publish options.
The pipeline restores three times, builds in Debug, runs dotnet format in a way that silently fixes files instead of failing, and publishes from a different build than the one it tested. I'd use global.json for the SDK, restore once with --locked-mode, build once in Release with --no-restore, then test and publish with --no-build. I'd run dotnet format --verify-no-changes so formatting drift fails the build, collect test results and coverage, and cache the NuGet folder.
"When I review a pipeline I check its guarantees: the binary we deploy is the one we tested, built by a known SDK from known packages, and the code meets our agreed standards. This one restores three times, builds and tests Debug, runs
dotnet formatin a way that fixes files and passes, and publishes from a separate build. So I'd pin the SDK withglobal.json, restore once with lock files and--locked-mode, build once in Release with--no-restore, then test and publish with--no-build.For example, at [company] our [orders] service pipeline took [14] minutes, and we shipped a bug that only appeared in Release because tests ran against a Debug build. I offered to fix the pipeline so we tested what we shipped. I found it restored [three] times, ran
dotnet formatwithout verification and published a separate build. I rewrote it to restore once, build Release once, and test and publish with--no-build, and made format verification a required check after a one-off cleanup commit. Pipeline time dropped to [N] minutes, and we had no more Release-only surprises.The mistake I see most is
dotnet formatwithout--verify-no-changes: it silently rewrites files on a throwaway agent and exits successfully. Its whitespace, style and analyzers subcommands are all driven by.editorconfig. I'd also write TRX results and coverage and upload them even when tests fail, cache the NuGet folder keyed on lock files, trigger on pull requests, and setpermissions: contents: read. If the service owns a database, the migration bundle becomes another artefact from the same build. Each flag buys one guarantee, and saves time as a side effect."
name: ci
on: [push]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.x'
- run: dotnet restore
- run: dotnet format
- run: dotnet build
- run: dotnet test
- run: dotnet publish src/Orders.Api -o out
- uses: actions/upload-artifact@v4
with:
name: api
path: out
| # | Issue | Why it matters | Fix |
|---|---|---|---|
| 1 | dotnet-version: '10.x' |
Any 10.x SDK; builds change when a new feature band ships | global-json-file: global.json |
| 2 | dotnet format with no verify flag |
It rewrites files on the agent and passes; nobody sees the drift | dotnet format --verify-no-changes |
| 3 | build, test, publish each restore again |
Wasted minutes, and a chance of resolving different packages | --no-restore on build; --no-build on test and publish |
| 4 | No -c Release on build/test |
Tests run against Debug code; publish then builds Release separately, so you ship a binary you never tested | -c Release everywhere |
| 5 | Plain dotnet restore |
Floating or transitive versions can change between runs | Lock files plus --locked-mode |
| 6 | No test results or coverage | Failures are hard to read; no trend | --logger trx, --collect "XPlat Code Coverage", upload results |
| 7 | No NuGet cache | Every run downloads every package | Cache ~/.nuget/packages keyed on lock files |
| 8 | on: [push] only |
No PR validation | Add pull_request |
| 9 | Publish without explicit options | Framework-dependent, portable, unknown RID | Decide: -r linux-x64, --self-contained or not, or container publish |
dotnet format ships in the .NET SDK and has three subcommands:
dotnet format whitespace: spacing, indentation, line endings, driven by .editorconfig.dotnet format style: code-style rules (IDExxxx), such as var preferences and file-scoped namespaces.dotnet format analyzers: fixes from analysers (CAxxxx and third-party), filtered with --severity.Plain dotnet format runs all three. In CI you want --verify-no-changes, which exits non-zero when anything would change. Use --severity warn (or error) to control which diagnostics count.
--locked-mode plus package source mapping in nuget.config limits dependency-confusion risk.permissions: contents: read by default.Tests practical CLI fluency and whether the candidate thinks about reproducibility.
Green flags: spots the triple restore and Debug/Release mismatch; knows --verify-no-changes; mentions global.json, lock files, caching, test result upload.
Red flags: only comments on YAML formatting; thinks dotnet format alone fails on drift; doesn't see that publish rebuilds.
Review a pipeline against what it promises, not how the YAML looks:
| Guarantee | What provides it |
|---|---|
| Same compiler everywhere | global.json plus setup-dotnet with global-json-file |
| Same packages everywhere | packages.lock.json plus dotnet restore --locked-mode, package source mapping |
| Agreed code standards | .editorconfig plus dotnet format --verify-no-changes, analysers with TreatWarningsAsErrors where agreed |
| The deployed binary is the tested binary | One Release build; --no-build on test and publish |
| Failures are readable | TRX results and coverage uploaded even on failure |
| Only reviewed code reaches main | pull_request trigger and a required status check |
| The pipeline can't be abused | Least-privilege permissions, pinned actions, secrets only where needed |
checkout
|
setup-dotnet (global.json) -> exact SDK, e.g. 10.0.1xx
|
dotnet tool restore -> pinned local tools
|
dotnet restore --locked-mode -> obj/project.assets.json
| fails with NU1004 if a lock file
| is out of date
dotnet format --verify-no-changes -> non-zero exit on any drift
| --no-restore (reads the restore output)
dotnet build -c Release --no-restore -> bin/Release/net10.0/*
|
dotnet test -c Release --no-build -> TestResults/*.trx, coverage
|
dotnet publish -c Release --no-build -> out/ (the same binaries)
|
upload artefact (+ test results with if: always())
Each --no-restore and --no-build removes an implicit step. Without them, build restores again, test builds and restores again, and publish builds a third time, which is slower and means the published files are not the ones the tests ran against.
| Line in the original | Problem | Change |
|---|---|---|
on: [push] |
No validation of pull requests, and every push to every branch runs the full job | push: branches: [main] plus pull_request, and add the job as a required check |
(no permissions) |
The GITHUB_TOKEN gets the repo's default, which may be write |
permissions: contents: read at the top, widened per job only when needed |
dotnet-version: '10.x' |
Any 10.x SDK; a new feature band changes the compiler and analysers | global-json-file: global.json |
dotnet restore |
Floating and transitive versions can change between runs | Lock files plus --locked-mode |
dotnet format |
Rewrites files on a throwaway agent and passes | dotnet format --verify-no-changes |
dotnet build |
Debug, and restores again | -c Release --no-restore |
dotnet test |
Builds Debug again; no results kept | -c Release --no-build, TRX logger, coverage, upload with if: always() |
dotnet publish src/Orders.Api -o out |
Builds again (Release by default since .NET 8), so the binary was never tested | -c Release --no-build, and decide the RID and deployment model |
| (no cache) | Every run downloads all packages | Cache the NuGet folder keyed on lock files |
dotnet format ships in the .NET SDK. It reads .editorconfig and the project's analyser settings, so it needs a restored project.
| Subcommand | Fixes | Rule ids | Notes |
|---|---|---|---|
whitespace |
Indentation, spacing, new lines, final newline | (formatting options) | Doesn't need a build; fastest |
style |
Code style such as var preference, file-scoped namespaces, this. qualification |
IDExxxx |
Needs restore; only rules with a severity of at least --severity |
analyzers |
Fixes offered by analysers | CAxxxx and third-party ids |
Needs restore; slowest |
| (none) | All three | What most CI pipelines run |
Useful options:
| Option | Purpose |
|---|---|
--verify-no-changes |
Make no edits; exit non-zero if anything would change |
--severity info\|warn\|error |
Minimum diagnostic severity to fix or report (default warn) |
--diagnostics IDE0005 IDE0161 |
Only these rule ids |
--include / --exclude |
Limit to files or folders, for example to check only changed files |
--no-restore |
Reuse the restore that already ran |
--report ./format-report.json |
Write a machine-readable list of issues |
-v diag |
Show which files and rules it processed |
A CI step fails only when its command returns a non-zero exit code. Plain dotnet format returns success after it has changed files, so the pipeline goes green and the fixes disappear with the agent. With --verify-no-changes it edits nothing and exits non-zero when any file would change, so the step fails and the log names each file and rule.
.editorconfig rules and their severities.dotnet format once and commit the result as a single "format only" commit..git-blame-ignore-revs so git blame skips it.--verify-no-changes in CI and make it a required check.dotnet publish --no-build copies the existing build output, so the build must match:
| Build | Publish | Result |
|---|---|---|
-c Release |
-c Release --no-build |
Works |
| (Debug) | -c Release --no-build |
Fails: no Release output to copy |
-c Release (portable) |
-c Release -r linux-x64 --no-build |
Fails or uses stale output: no RID-specific build exists |
-c Release -r linux-x64 |
-c Release -r linux-x64 --no-build |
Works, and tests can run against the same RID build |
If you publish for a RID, either build and test with that RID too, or accept that publish does its own build and that tests ran against the portable build. Make that trade-off on purpose.
<!-- Directory.Build.props -->
<Project>
<PropertyGroup>
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
</PropertyGroup>
</Project>
packages.lock.json recording the exact resolved version and content hash of every direct and transitive package.dotnet restore --locked-mode, which fails (NU1004) instead of silently resolving something different.hashFiles('**/packages.lock.json'), so the cache changes exactly when dependencies change.dotnet test -c Release --no-build \
--logger trx --results-directory TestResults \
--collect "XPlat Code Coverage"
TestResults with if: always() (GitHub Actions) or condition: succeededOrFailed() (Azure Pipelines), because you need the results most when tests fail.PublishTestResults@2 in Azure Pipelines) so developers see failing test names without reading logs.--blame-hang-timeout 10m so a hung test produces a dump instead of a silent timeout."test": { "runner": "Microsoft.Testing.Platform" } in global.json on .NET 10), the options differ, for example --report-trx and --coverage. Check which runner the repo uses before copying flags.| Change | Typical saving |
|---|---|
Restore once, --no-restore / --no-build |
Removes two restores and two builds |
| NuGet cache keyed on lock files | Restore drops from minutes to seconds on a warm cache |
dotnet format whitespace in a fast first job |
Feedback in under a minute for formatting-only problems |
| Split format and analysers into a parallel job | Developers see style failures while tests are still running |
concurrency group with cancel-in-progress: true for PRs |
Stops old runs when a new commit is pushed |
Path filters (paths-ignore: ['docs/**', '**/*.md']) |
No full build for documentation-only changes |
permissions: contents: read at the workflow level. Give packages: write or id-token: write only to the job that needs it.pull_request runs from forks. Never use pull_request_target to build untrusted code.nuget.config so each package can only come from its intended feed.The .NET 11 SDK is due in November 2026 as a standard-term (STS) release and is at the preview and release-candidate stage now. It keeps the same restore, build, test, publish and format commands. Expect more projects to move to Microsoft.Testing.Platform, which changes dotnet test options. Keep production pipelines on the .NET 10 SDK through global.json, and trial .NET 11 in a separate branch or job.
.editorconfig with csharp_style_namespace_declarations = file_scoped:warning.dotnet format style --verify-no-changes, then echo $?. Note the non-zero exit code.dotnet format style and look at the diff.RestorePackagesWithLockFile, restore, and commit the lock file. Change a package version in the project file without restoring, then run dotnet restore --locked-mode and read the NU1004 error.dotnet build && dotnet test && dotnet publish against dotnet build -c Release && dotnet test -c Release --no-build && dotnet publish -c Release --no-build.dotnet publish -c Release -r linux-x64 --no-build after a portable build, and see why the RID must match.name: ci
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
env:
DOTNET_NOLOGO: true
NUGET_PACKAGES: ${{ github.workspace }}/.nuget/packages
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
global-json-file: global.json
- uses: actions/cache@v4
with:
path: ${{ github.workspace }}/.nuget/packages
key: nuget-${{ runner.os }}-${{ hashFiles('**/packages.lock.json') }}
- run: dotnet tool restore
- run: dotnet restore --locked-mode
- run: dotnet format --verify-no-changes --no-restore --severity warn
- run: dotnet build -c Release --no-restore
- run: >
dotnet test -c Release --no-build
--logger trx --results-directory TestResults
--collect "XPlat Code Coverage"
- uses: actions/upload-artifact@v4
if: always()
with:
name: test-results
path: TestResults
- run: dotnet publish src/Orders.Api -c Release --no-build -o out
- uses: actions/upload-artifact@v4
with:
name: api
path: out
<!-- Directory.Build.props: enable lock files repo-wide -->
<Project>
<PropertyGroup>
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
</PropertyGroup>
</Project>
Mistake: running dotnet format in CI without --verify-no-changes.
It fixes the files on a throwaway agent and exits successfully, so the pipeline goes green and nobody ever sees the drift.
Instead: run dotnet format --verify-no-changes --no-restore so any drift fails the step, and make that step a required check.
Mistake: testing a Debug build and shipping a Release build.
dotnet build and dotnet test default to Debug, while publish defaults to Release since .NET 8. The deployed binary was never tested, and code inside #if DEBUG behaves differently.
Instead: build once with -c Release, then run dotnet test -c Release --no-build and dotnet publish -c Release --no-build.
Mistake: letting every command restore and build implicitly.
Each step repeats the work, which is slow and can resolve different package versions in each step.
Instead: restore once, then use --no-restore on build and --no-build on test and publish.
Mistake: having no lock files.
Floating and transitive versions can resolve differently on a laptop, on yesterday's agent and on today's agent, so a build that passed can fail or behave differently with no code change.
Instead: set RestorePackagesWithLockFile in Directory.Build.props, commit the lock files and use dotnet restore --locked-mode in CI.
Mistake: using --locked-mode without enabling lock files.
With no packages.lock.json there is nothing to lock against, so the flag gives a false sense of safety.
Instead: enable lock files first, check they are committed for every project, then add --locked-mode.
Mistake: using --no-build on publish with a different configuration or RID from the build.
Publish copies output that doesn't exist or is stale, giving "file not found" errors or an artefact built for the wrong target.
Instead: build with exactly the configuration and RID you publish, or let publish build and accept that tests ran against a different build.
Mistake: floating the SDK version (10.x).
A new feature band brings a newer compiler and analysers, so the build can change or break without any code change.
Instead: pin the SDK in global.json and point setup-dotnet at it with global-json-file.
Mistake: uploading test results only when tests pass.
The default step condition skips the upload after a failure, which is exactly when you need the results.
Instead: use if: always() in GitHub Actions, or condition: succeededOrFailed() in Azure Pipelines.
Mistake: triggers that miss work or waste it.
Running only on push means pull requests are never validated, and running the full pipeline for documentation changes wastes minutes.
Instead: trigger on pull_request and pushes to main, add path filters, and make the CI job a required status check on the protected branch.
Mistake: computing the version in several places, or editing it by hand. The assembly version, package version and artefact name drift apart, so you can't tell which build is deployed. Instead: compute the version once (from a Git tag or a tool such as Nerdbank.GitVersioning) and pass it to build and publish.
Mistake: leaving the workflow token with write access and actions on moving tags.
A compromised action or a malicious pull request can then push code or publish packages.
Instead: set permissions: contents: read by default, pin third-party actions to a commit SHA in sensitive repos, and keep secrets away from fork pull requests.
Mistake: using legacy Azure Pipelines tasks such as NuGetCommand or VSBuild instead of dotnet commands.
The pipeline then restores and builds differently from developers' machines, and problems can't be reproduced locally.
Instead: use the same dotnet commands in the pipeline that developers run locally, through DotNetCoreCLI@2 or plain script steps.
A good pipeline review is about guarantees, not style: the artefact we deploy is the one we tested, built by a known SDK from known packages, and the code meets our agreed standards. Each flag (--no-restore, --no-build, --locked-mode, --verify-no-changes) buys one of those guarantees and saves time as a side effect. I'd also push shared settings into Directory.Build.props and .editorconfig so the pipeline stays thin and local builds behave the same as CI. For larger repos, consider splitting format and analyser checks into a fast parallel job so developers get feedback within a couple of minutes.
dotnet test builds the test projects and runs them through the test platform, using the adapter the project references (xUnit, NUnit or MSTest). I use --filter to pick tests, for example --filter "Category=Unit&FullyQualifiedName~Orders". I use --logger trx so CI can show the results, and --collect "XPlat Code Coverage" with Coverlet for coverage, which I turn into a report. When tests hang or crash, --blame-hang-timeout and --blame-crash capture dumps. The command exits non-zero on failure, so the pipeline fails. In .NET 10 the SDK can also run in Microsoft.Testing.Platform mode, which you opt into in global.json.
First I read the exact error. NU1301 means a source couldn't be reached, 401 or 403 means authentication, and NU1101 means the package isn't on any configured source. It works on my machine because I have cached packages and stored credentials, while CI starts clean. Then I check which nuget.config CI actually uses, since config files are merged from the repo upwards. I make the repo's config explicit with <clear />, and I authenticate CI the supported way, for example the Azure Artifacts credential provider or a token from the pipeline's secret store, never a committed password. I also add package source mapping and lock files so restores are reproducible and protected against dependency confusion.
I generate migrations locally with dotnet ef migrations add, then make deployment a separate, reviewed step. The pipeline produces an artefact: either an idempotent SQL script (dotnet ef migrations script --idempotent) for a DBA to review, or a migration bundle (dotnet ef migrations bundle), a self-contained executable that applies pending migrations. That artefact runs once, before the new app version starts, with a dedicated deployment identity. I avoid Database.Migrate() at startup because several instances can race, the app needs DDL permissions it shouldn't have, and a failed migration turns into a crash loop.
How to tell it (STAR)
**Situation:** At [company], our [orders] service pipeline took [14] minutes, and we shipped a bug that only appeared in Release because tests ran against a Debug build. **Task:** I offered to review and fix the pipeline so we tested what we shipped. **Action:** I found it restored [three] times, ran dotnet format without --verify-no-changes, and published a separate build. I rewrote it to restore once with --locked-mode, build Release once, and test and publish with --no-build, and made format verification a required check after a one-off cleanup commit. **Result:** Pipeline time dropped to [N] minutes, whitespace noise disappeared from PRs, and we had no more Release-only surprises in [period]. The template was adopted by [N] other teams.
Last updated 1 Nov 2026.