Skip to content

.NET CI with GitHub Actions: Complete Pipeline

Lesson 5 of 8Beginner → Intermediate12 min readGitHub Actions & CI/CD · Continuous IntegrationVerified: actions/setup-dotnet v6, actions/cache v6, actions/upload-artifact v7, August 2026

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.

CommandDoesImplies
dotnet restoreResolves and downloads NuGet packages
dotnet buildCompilesrestore
dotnet testRuns testsrestore and build
dotnet publishProduces deployable outputrestore 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 Release

The 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-build

Keeping 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

The 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.

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.

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.

- 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: 14

The 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.

.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 -warnaserror

The 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.

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: ./publish

dotnet 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.

.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 = warning
dotnet_diagnostic.CA1062.severity = error

Then -warnaserror in CI turns the warnings into a merge gate while leaving them as warnings locally — the split described earlier on this page.

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.

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
fi

This 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.

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 matrixMulti-targeting
VariesThe toolchain that buildsThe runtime that is targeted
Configured inThe workflowThe 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.

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.

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: 14

MarkdownSummaryGithub 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_*.xml

Set 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.

.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=WindowsOnly
runs-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.

- run: |
dotnet publish ./src/App/App.csproj \
--configuration Release \
--runtime linux-x64 \
--self-contained \
-p:PublishTrimmed=true \
-p:PublishAot=true \
--output ./publish

These 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 -warnaserror
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 credentialGITHUB_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.

  1. Test job, per SDK. Checkout, setup-dotnet, restore cache, restore, build --no-restore, test --no-build, upload results with if: always().

  2. Publish job, needs: test. Publish on the primary SDK and upload the output directory.

  3. Branch protection. Require the test job for each matrix leg.

  1. Copy examples/github-actions/dotnet-ci/ci.yml into a .NET solution at .github/workflows/ci.yml and push a branch.

  2. Confirm the second run restores the NuGet cache. Look for Cache restored from key and compare the restore step’s duration between runs.

  3. Remove --configuration Release from the test step only, leaving it on the build step. Push and read the failure — this is the --no-build configuration trap, and seeing its error message once makes it recognisable forever.

  4. Restore it, then add a package reference and push. Confirm the cache key changed and that restore-keys still gave you a partial hit rather than a full download.

Check your understanding

4 questions — each one asks you to predict what Git or GitHub will do, not to recall a flag.

The repository contains a `global.json` and the workflow sets `dotnet-version: 9.x`. Which wins?
Show answer

`global.json` — and a mismatch errors at the first `dotnet` invocation — `global.json` pins the SDK for the repository. `setup-dotnet` installs what you asked for, but the SDK resolver honours `global.json` when `dotnet` runs.

Why does the lesson hand-write a NuGet cache step when the Go and Java lessons do not?
Show answer

`actions/setup-dotnet` does not cache packages by default, unlike `setup-go` and `setup-java` — Most setup actions cache for you. `setup-dotnet` does not, so an explicit `actions/cache` step keyed on project and lock files is correct here.

What does committing `packages.lock.json` (via `RestorePackagesWithLockFile`) do for the cache key?
Show answer

Makes `hashFiles` change if and only if the resolved dependency set changes, and makes restore deterministic — The lock file pins the full transitive graph, so its hash is the ideal cache key — the same argument `npm ci` makes for Node.

What is the difference between an SDK matrix and multi-targeting?
Show answer

An SDK matrix varies the toolchain that builds (in the workflow); multi-targeting varies the runtime targeted (in the project file) — A library usually needs multi-targeting and rarely an SDK matrix; an application deployed to one runtime needs neither.

GitHub Actions Security ChecklistAudit your workflows against the failure modes that actually cause incidents. Free and complete.

Skip the boilerplate — the Professional Toolkit has five production-ready workflow templates.