The .NET CLI splits the build into four commands that each imply the previous one. Understanding what
--no-restore and --no-build actually prevent is the difference between a pipeline that takes two
minutes and one that takes six doing the same work three times.
The complete workflow is at examples/github-actions/dotnet-ci/ci.yml, validated by
npm run check:workflows.
The four commands, and why order matters
Section titled “The four commands, and why order matters”| Command | Does | Implies |
|---|---|---|
dotnet restore | Resolves and downloads NuGet packages | — |
dotnet build | Compiles | restore |
dotnet test | Runs tests | restore and build |
dotnet publish | Produces deployable output | restore and build |
Each command silently re-runs the ones it implies. Writing all four in sequence without suppression flags means restore runs four times and the compiler runs three times:
{/* Wasteful: each step redoes the previous step's work. */}- run: dotnet restore- run: dotnet build --configuration Release- run: dotnet test --configuration ReleaseThe fix is to state explicitly that the earlier stage already happened:
- run: dotnet restore- run: dotnet build --configuration Release --no-restore- run: dotnet test --configuration Release --no-buildKeeping restore as its own step also has a diagnostic benefit: a NuGet feed outage or an
authentication failure shows up as a failed restore step rather than as a confusing build error.
Configuration: Release, always
Section titled “Configuration: Release, always”--configuration ReleaseThe default is Debug, and CI should not use it. Debug builds disable most JIT optimisations, define
the DEBUG symbol — so any code inside #if DEBUG is compiled in — and emit different inlining
behaviour. Testing Debug output means testing an artifact you will never deploy, and race conditions
that only appear under optimisation stay hidden.
Which SDK versions to test
Section titled “Which SDK versions to test”strategy: fail-fast: false matrix: dotnet-version: ["8.0.x", "9.0.x"]The .x suffix is a floating patch: it asks for the newest patch of that feature band, which is what
you want in CI. Pinning an exact patch means a security release requires a workflow edit.
Global installs pin the SDK independently. If the repository has a global.json, it wins over
dotnet-version and a mismatch produces an error at the first dotnet invocation rather than at
setup time.
Caching NuGet properly
Section titled “Caching NuGet properly”Unlike setup-go and setup-java, actions/setup-dotnet does not cache packages for you by
default, so this is one of the few places in this cluster where a hand-written cache step is correct:
- name: Cache NuGet packages uses: actions/cache@v6 with: path: ~/.nuget/packages key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj', '**/packages.lock.json') }} restore-keys: | nuget-${{ runner.os }}-What it doesRestores the global NuGet package folder, keyed on the project and lock files that determine the dependency set.
Why we run itWithout a content hash in the key, the cache is written once and never updated — new dependencies pile on top of a stale tree and removed ones stay resolvable, so CI can pass on code that would not build from a clean checkout.
Expected resultA cache hit on repeat runs, and a miss the first time a .csproj changes.
restore-keys is the fallback ladder. On a miss for the exact key, Actions looks for the most recent
cache whose key starts with nuget-Linux- and restores that. The result is a warm-but-not-exact
cache: most packages are already present and dotnet restore fetches only the difference. Without
restore-keys, a single changed .csproj means downloading everything again.
runner.os belongs in the key because a cache restored from a Windows runner onto Linux is at best
useless and at worst confusing. See caching for the full key
design rules.
Tests, results and coverage
Section titled “Tests, results and coverage”- name: Test run: | dotnet test \ --configuration Release \ --no-build \ --logger "trx;LogFileName=results.trx" \ --collect:"XPlat Code Coverage"--logger trx writes Visual Studio’s test results XML. --collect:"XPlat Code Coverage" enables the
cross-platform coverage collector, which emits Cobertura XML under TestResults/. Both produce files
rather than console output, which is why the upload step matters:
- name: Upload test results if: always() uses: actions/upload-artifact@v7 with: name: results-dotnet-${{ matrix.dotnet-version }} path: | **/TestResults/**/*.trx **/TestResults/**/coverage.cobertura.xml retention-days: 14The multi-line path is a list of globs, and ** crosses directory boundaries — necessary because
each test project writes into its own TestResults directory under its own folder.
retention-days: 14 overrides the repository default, which can be up to 90 days. Test results are
worth keeping for a sprint, not a quarter; shorter retention is both cheaper and less storage to
audit. See artifacts for the storage implications.
Warnings as errors
Section titled “Warnings as errors”.NET makes it straightforward to fail a build on new warnings, and CI is the right place to enforce it:
- run: dotnet build --configuration Release --no-restore -warnaserrorThe argument for doing this in CI rather than in the .csproj is developer experience: a warning
should not block someone mid-refactor on their own machine, but it should block a merge. The argument
against is that a compiler or analyser upgrade can introduce new warnings and fail a pull request
that changed nothing — the same trade-off as pinning a linter version in
Go CI.
Publishing deployable output
Section titled “Publishing deployable output”publish: needs: test runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - uses: actions/setup-dotnet@v6 with: dotnet-version: "9.0.x"
- run: dotnet publish ./src/App/App.csproj --configuration Release --output ./publish
- uses: actions/upload-artifact@v7 with: name: app path: ./publishdotnet publish differs from build: it resolves the full set of runtime dependencies and lays out a
directory that can be copied to a server and run. That directory — not the source — is what
deployment should consume, so that the thing you tested is the thing you ship.
Formatting and analyzers
Section titled “Formatting and analyzers”.NET ships both a formatter and a large set of analyzers, and CI is where they become enforceable:
- name: Verify formatting run: dotnet format --verify-no-changes --verbosity diagnostic--verify-no-changes exits non-zero if anything would be reformatted, and prints what. Unlike Go’s
gofmt -l, this one really does fail on its own — but note it needs a restore first, because
dotnet format loads the project to resolve analyzers.
Analyzer severity belongs in .editorconfig rather than in the workflow, so the same rules apply in
the IDE:
[*.cs]dotnet_diagnostic.CA2007.severity = warningdotnet_diagnostic.CA1062.severity = errorThen -warnaserror in CI turns the warnings into a merge gate while leaving them as warnings locally —
the split described earlier on this page.
Central package management
Section titled “Central package management”In a solution with many projects, the same package pinned at three different versions across them is a recurring source of runtime binding failures that CI does not catch, because each project builds fine in isolation.
Directory.Packages.props moves every version to one file:
<Project> <PropertyGroup> <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> </PropertyGroup> <ItemGroup> <PackageVersion Include="Serilog" Version="4.4.0" /> </ItemGroup></Project>Individual projects then reference packages without a version. Beyond consistency, this improves the
cache key from this page’s earlier section: hashFiles over one file that changes only when a version
changes is a far more precise signal than hashing every .csproj.
Vulnerable and outdated packages
Section titled “Vulnerable and outdated packages”The .NET CLI reports both without any extra tooling:
- name: Check for vulnerable packages run: | dotnet restore dotnet list package --vulnerable --include-transitive--include-transitive is the flag that matters — most vulnerable packages in a .NET solution arrive
through a dependency rather than being referenced directly.
The command exits zero even when it finds something, which makes it useless as a gate on its own. To fail the build, inspect the output:
- name: Fail on vulnerable packages run: | dotnet list package --vulnerable --include-transitive 2>&1 | tee audit.txt if grep -q "has the following vulnerable packages" audit.txt; then echo "::error::vulnerable packages found" exit 1 fiThis is the same shape as the gofmt -l trap in Go CI: a command that
reports a problem on stdout while exiting successfully will pass CI silently. Whenever you add a
scanning step, check its exit code behaviour before trusting it as a gate.
dotnet list package --outdated answers a different question — which packages have newer versions —
and it does not belong on a pull request. Being behind is not a defect, and a build that fails
because a dependency published a release this morning fails for a reason the contributor cannot fix.
Run it on a schedule and let it open an issue, or leave it to Dependabot, which proposes the upgrade
as a reviewable pull request with the changelog attached.
Multi-targeting
Section titled “Multi-targeting”A library supporting several frameworks builds them all from one invocation:
<TargetFrameworks>net8.0;net9.0</TargetFrameworks>dotnet build then produces output for each. Crucially, dotnet test also runs the suite once per
target framework, so a single test job is already exercising both runtimes — which is a different
thing from the SDK matrix earlier on this page, and the distinction is worth being clear about:
| SDK matrix | Multi-targeting | |
|---|---|---|
| Varies | The toolchain that builds | The runtime that is targeted |
| Configured in | The workflow | The project file |
| Answers | “Does our build work with SDK N?” | “Does our code work on runtime N?” |
A library usually needs multi-targeting and rarely needs an SDK matrix. An application deployed to one runtime needs neither.
Integration tests
Section titled “Integration tests”Service containers work exactly as in the other languages on this cluster:
integration: runs-on: ubuntu-latest services: postgres: image: postgres:18 env: POSTGRES_PASSWORD: ci-only-not-a-real-secret ports: - 5432:5432 options: >- --health-cmd "pg_isready -U postgres" --health-interval 10s --health-timeout 5s --health-retries 5 steps: - uses: actions/checkout@v7 - uses: actions/setup-dotnet@v6 with: dotnet-version: "9.0.x" - run: dotnet test --filter Category=Integration env: ConnectionStrings__Default: "Host=localhost;Database=postgres;Username=postgres;Password=ci-only-not-a-real-secret"The double underscore in ConnectionStrings__Default is .NET’s configuration convention for nesting —
it maps to the ConnectionStrings:Default key. Environment variables cannot contain a colon portably,
which is why the convention exists, and getting it wrong produces a null connection string rather than
an error.
--filter Category=Integration requires the tests to carry that trait. Without a filter, the
integration job runs the unit tests again, which is wasted time and a confusing duplicate result.
Coverage reports people will read
Section titled “Coverage reports people will read”The Cobertura XML from --collect:"XPlat Code Coverage" is machine-readable and unpleasant to look at.
reportgenerator turns it into something legible, including a summary for the run page:
- name: Generate a coverage report if: always() run: | dotnet tool install --global dotnet-reportgenerator-globaltool reportgenerator \ -reports:"**/coverage.cobertura.xml" \ -targetdir:"coverage-report" \ -reporttypes:"Html;MarkdownSummaryGithub" cat coverage-report/SummaryGithub.md >> "$GITHUB_STEP_SUMMARY"
- uses: actions/upload-artifact@v7 if: always() with: name: coverage-report-${{ matrix.dotnet-version }} path: coverage-report/ retention-days: 14MarkdownSummaryGithub exists specifically for $GITHUB_STEP_SUMMARY, so the numbers appear on the
run page without anyone downloading an artifact. That single change is usually what makes coverage
something the team looks at rather than something the pipeline produces.
Note the **/ glob: with multiple test projects, each writes its own coverage file under its own
TestResults directory, and reportgenerator merges them.
Diagnosing a test run that hangs or crashes
Section titled “Diagnosing a test run that hangs or crashes”A .NET test job that hits its timeout with no useful output is usually one of two things: a test
deadlocked, or the test host crashed and took the results with it. dotnet test has built-in
diagnostics for both.
- name: Test run: | dotnet test \ --configuration Release \ --no-build \ --blame-hang \ --blame-hang-timeout 5m \ --blame-crash \ --logger "trx;LogFileName=results.trx"--blame-hang writes a sequence file naming the test that was executing when the timeout hit, and
--blame-hang-timeout bounds how long any single test may run. --blame-crash captures a dump when
the host process dies, which is the only way to diagnose a native crash in a test run.
Both write their output alongside the test results, so the existing if: always() upload collects them:
path: | **/TestResults/**/*.trx **/TestResults/**/*.dmp **/TestResults/**/Sequence_*.xmlSet timeout-minutes on the job as well. --blame-hang-timeout bounds one test; the job timeout
bounds everything else, and without it the default is six hours.
When you need a Windows runner
Section titled “When you need a Windows runner”.NET is cross-platform, and most .NET CI should run on ubuntu-latest — it starts faster and, on most
plans, is billed at a lower rate than Windows. Reach for a Windows runner only when something genuinely
requires it:
- WPF, WinForms and Windows-only target frameworks such as
net9.0-windows. These do not build on Linux at all. - Tests that exercise Windows-specific behaviour — registry access, ACLs, path semantics, or services.
- Packaging installers — MSI, MSIX, ClickOnce.
- Authenticode signing with a certificate in the Windows certificate store.
strategy: fail-fast: false matrix: include: - os: ubuntu-latest filter: Category!=WindowsOnly - os: windows-latest filter: Category=WindowsOnlyruns-on: ${{ matrix.os }}steps: - run: dotnet test --filter "${{ matrix.filter }}"include without axes gives exactly these two jobs rather than a product, and each runs only the
tests appropriate to it. Running the whole suite on both platforms doubles the cost to re-verify
something already known.
Two Windows-specific problems worth expecting. Path length: older Windows path limits still bite on
deeply nested bin/obj trees in a large solution, and the failure names a file rather than the cause.
And line endings: a repository without a .gitattributes normalising them will check out CRLF on
Windows and LF on Linux, which breaks any test asserting on exact file content or a hash of it.
Trimming and native AOT
Section titled “Trimming and native AOT”- run: | dotnet publish ./src/App/App.csproj \ --configuration Release \ --runtime linux-x64 \ --self-contained \ -p:PublishTrimmed=true \ -p:PublishAot=true \ --output ./publishThese produce a much smaller, faster-starting binary with no .NET runtime required on the target. They also change the semantics of your program, which is why they belong in CI rather than being applied only at release time.
Trimming removes assemblies the compiler cannot prove are used. Anything resolved by reflection —
serialisation of types discovered at run time, dependency injection registered by assembly scanning,
Type.GetType on a string — may be trimmed away, and the failure appears at run time as a missing type
rather than at build time.
The build emits trim warnings for patterns it cannot analyse. Treat them as errors:
- run: dotnet publish -p:PublishTrimmed=true -p:TrimmerSingleWarn=false -warnaserrorPublishing NuGet packages
Section titled “Publishing NuGet packages” publish: needs: test runs-on: ubuntu-latest environment: nuget permissions: contents: read packages: write steps: - uses: actions/checkout@v7 - uses: actions/setup-dotnet@v6 with: dotnet-version: "9.0.x"
- run: dotnet pack --configuration Release --output ./artifacts
- name: Push to GitHub Packages run: | dotnet nuget push "./artifacts/*.nupkg" \ --source "https://nuget.pkg.github.com/${{ github.repository_owner }}/index.json" \ --api-key "${GITHUB_TOKEN}" \ --skip-duplicate env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}Pushing to GitHub Packages needs no stored credential — GITHUB_TOKEN with packages: write is
sufficient, and it is minted and revoked per run.
--skip-duplicate prevents the push failing when a version already exists, which happens on any re-run
of a release workflow. Without it, re-running a partially failed release fails again on the first
package that did succeed.
Pushing to nuget.org still requires a stored API key. Treat it as removing long-lived credentials describes for the cases that cannot be migrated: an environment secret on an environment with required reviewers, scoped as narrowly as nuget.org allows, with a rotation reminder.
Include the source link and symbols in the package so consumers can debug into it:
<PublishRepositoryUrl>true</PublishRepositoryUrl><IncludeSymbols>true</IncludeSymbols><SymbolPackageFormat>snupkg</SymbolPackageFormat><ContinuousIntegrationBuild Condition="'$(GITHUB_ACTIONS)' == 'true'">true</ContinuousIntegrationBuild>ContinuousIntegrationBuild normalises the paths embedded in the assembly — the .NET equivalent of Go’s
-trimpath. Without it, the published package contains /home/runner/work/... paths and two builds of
the same commit differ.
The complete pipeline
Section titled “The complete pipeline”-
Test job, per SDK. Checkout,
setup-dotnet, restore cache,restore,build --no-restore,test --no-build, upload results withif: always(). -
Publish job,
needs: test. Publish on the primary SDK and upload the output directory. -
Branch protection. Require the test job for each matrix leg.
Exercise
Section titled “Exercise”-
Copy
examples/github-actions/dotnet-ci/ci.ymlinto a .NET solution at.github/workflows/ci.ymland push a branch. -
Confirm the second run restores the NuGet cache. Look for
Cache restored from keyand compare the restore step’s duration between runs. -
Remove
--configuration Releasefrom the test step only, leaving it on the build step. Push and read the failure — this is the--no-buildconfiguration trap, and seeing its error message once makes it recognisable forever. -
Restore it, then add a package reference and push. Confirm the cache key changed and that
restore-keysstill gave you a partial hit rather than a full download.
Then what?
Section titled “Then what?”Check your understanding
4 questions — each one asks you to predict what Git or GitHub will do, not to recall a flag.
Skip the boilerplate — the Professional Toolkit has five production-ready workflow templates.