dotnet-gha-build-test
dotnet-gha-build-test
.NET build and test workflow patterns for GitHub Actions: actions/setup-dotnet@v4 configuration with multi-version
installs and NuGet authentication, NuGet restore caching for fast CI, dotnet test with result publishing via
dorny/test-reporter, code coverage upload to Codecov and Coveralls, multi-TFM matrix testing across net8.0 and net9.0,
and test sharding strategies for large projects.
Version assumptions: actions/setup-dotnet@v4 for .NET 8/9/10 support. dorny/test-reporter@v1 for test result
visualization. Codecov and Coveralls GitHub Apps for coverage reporting.
Scope
- setup-dotnet action configuration with multi-version installs
- NuGet restore caching for fast CI
- dotnet test with result publishing and coverage upload
- Multi-TFM matrix testing and test sharding
- NuGet authentication for private feeds in GitHub Actions
Out of scope
- Starter CI templates -- see [skill:dotnet-add-ci]
- Test architecture and strategy -- see [skill:dotnet-testing-strategy]
- Benchmark regression detection in CI -- see [skill:dotnet-ci-benchmarking]
- Publishing and deployment -- see [skill:dotnet-gha-publish] and [skill:dotnet-gha-deploy]
- Azure DevOps build/test pipelines -- see [skill:dotnet-ado-build-test]
- Reusable workflow and composite action patterns -- see [skill:dotnet-gha-patterns]
Cross-references: [skill:dotnet-add-ci] for starter build/test templates, [skill:dotnet-testing-strategy] for test architecture guidance, [skill:dotnet-ci-benchmarking] for benchmark CI integration, [skill:dotnet-artifacts-output] for artifact upload path adjustments when using centralized build output layout.
actions/setup-dotnet@v4 Configuration
Basic Setup
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
```text
### Multi-Version Install
Install multiple SDK versions for multi-TFM builds within a single job:
```yaml
- name: Setup .NET SDKs
uses: actions/setup-dotnet@v4
with:
dotnet-version: |
8.0.x
9.0.x
```text
The first listed version becomes the default `dotnet` on PATH. All installed versions are available via `--framework`
targeting.
### NuGet Authentication for Private Feeds
Configure NuGet source authentication via `actions/setup-dotnet@v4`:
```yaml
- name: Setup .NET with NuGet auth
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
source-url: https://nuget.pkg.github.com/${{ github.repository_owner }}/index.json
env:
NUGET_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
```json
For multiple private feeds, configure additional sources after setup:
```yaml
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- name: Add private NuGet feed
run: |
set -euo pipefail
dotnet nuget add source https://pkgs.dev.azure.com/myorg/_packaging/myfeed/nuget/v3/index.json \
--name AzureArtifacts \
--username az \
--password ${{ secrets.AZURE_ARTIFACTS_PAT }} \
--store-password-in-clear-text
```text
The `--store-password-in-clear-text` flag is required on Linux runners where DPAPI encryption is unavailable.
### Global.json SDK Version Pinning
When `global.json` exists in the repository root, `actions/setup-dotnet@v4` can read it automatically:
```yaml
- name: Setup .NET from global.json
uses: actions/setup-dotnet@v4
with:
global-json-file: global.json
```json
This ensures CI uses the same SDK version as local development.
---
## NuGet Restore Caching
### Standard Cache Configuration
```yaml
- name: Cache NuGet packages
uses: actions/cache@v4
with:
path: ~/.nuget/packages
key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj', '**/Directory.Packages.props') }}
restore-keys: |
nuget-${{ runner.os }}-
- name: Restore dependencies
run: dotnet restore MySolution.sln
```text
### Built-in Cache with setup-dotnet
`actions/setup-dotnet@v4` has built-in caching support using `packages.lock.json`:
```yaml
- name: Setup .NET with caching
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
cache: true
cache-dependency-path: '**/packages.lock.json'
```json
Generate lock files locally first: `dotnet restore --use-lock-file`. Commit `packages.lock.json` files for deterministic
restore.
### Cache Key Strategy
| Key Component | Purpose |
| ------------------------------------------ | ------------------------------------------------- |
| `runner.os` | Prevent cross-OS cache collisions |
| `hashFiles('**/*.csproj')` | Invalidate when package references change |
| `hashFiles('**/Directory.Packages.props')` | Invalidate when centrally managed versions change |
| `restore-keys` prefix | Partial match for incremental cache reuse |
---
## Test Result Publishing
### dorny/test-reporter
Publish `dotnet test` results as GitHub Actions check annotations with inline failure details:
```yaml
- name: Test
run: |
set -euo pipefail
dotnet test MySolution.sln \
--configuration Release \
--logger "trx;LogFileName=test-results.trx" \
--results-directory ./test-results
continue-on-error: true
id: test
- name: Publish test results
uses: dorny/test-reporter@v1
if: always()
with:
name: '.NET Test Results'
path: 'test-results/**/*.trx'
reporter: dotnet-trx
fail-on-error: true
```text
**Key decisions:**
- `continue-on-error: true` on the test step ensures the reporter step always runs, even on failures
- `if: always()` on the reporter step publishes results regardless of test outcome
- `fail-on-error: true` on the reporter marks the check as failed when tests fail
### Alternative: EnricoMi/publish-unit-test-result-action
For richer PR comment integration with test counts:
```yaml
- name: Publish test results
uses: EnricoMi/publish-unit-test-result-action@v2
if: always()
with:
files: 'test-results/**/*.trx'
check_name: 'Test Results'
```text
---
## Code Coverage Upload
### Codecov
```yaml
- name: Test with coverage
run: |
set -euo pipefail
dotnet test MySolution.sln \
--configuration Release \
--collect:"XPlat Code Coverage" \
--results-directory ./coverage
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
with:
directory: ./coverage
fail_ci_if_error: false
token: ${{ secrets.CODECOV_TOKEN }}
```text
### Coveralls
```yaml
- name: Test with coverage
run: |
set -euo pipefail
dotnet test MySolution.sln \
--configuration Release \
--collect:"XPlat Code Coverage" \
--results-directory ./coverage
- name: Upload coverage to Coveralls
uses: coverallsapp/github-action@v2
with:
file: coverage/**/coverage.cobertura.xml
format: cobertura
github-token: ${{ secrets.GITHUB_TOKEN }}
```xml
### Coverage Report Generation with ReportGenerator
Generate human-readable HTML coverage reports alongside CI upload:
```yaml
- name: Generate coverage report
run: |
set -euo pipefail
dotnet tool install -g dotnet-reportgenerator-globaltool
reportgenerator \
-reports:coverage/**/coverage.cobertura.xml \
-targetdir:coverage-report \
-reporttypes:HtmlInline_AzurePipelines\;Cobertura
- name: Upload coverage report
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: coverage-report/
retention-days: 30
```text
---
## Multi-TFM Matrix Testing
### Matrix Strategy for TFMs
```yaml
jobs:
test:
strategy:
fail-fast: false
matrix:
tfm: [net8.0, net9.0]
os: [ubuntu-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: |
8.0.x
9.0.x
- name: Cache NuGet
uses: actions/cache@v4
with:
path: ~/.nuget/packages
key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj', '**/Directory.Packages.props') }}
restore-keys: |
nuget-${{ runner.os }}-
- name: Test ${{ matrix.tfm }}
run: |
set -euo pipefail
dotnet test MySolution.sln \
--framework ${{ matrix.tfm }} \
--configuration Release \
--logger "trx;LogFileName=${{ matrix.tfm }}-results.trx" \
--results-directory ./test-results
- name: Publish test results
uses: dorny/test-reporter@v1
if: always()
with:
name: 'Tests (${{ matrix.os }} / ${{ matrix.tfm }})'
path: 'test-results/**/*.trx'
reporter: dotnet-trx
```text
### Install All Required SDKs
When running multi-TFM tests in a single job instead of a matrix, install all required SDKs upfront:
```yaml
- name: Setup .NET SDKs
uses: actions/setup-dotnet@v4
with:
dotnet-version: |
8.0.x
9.0.x
- name: Test all TFMs
run: dotnet test MySolution.sln --configuration Release
```text
Without the matching SDK installed, `dotnet test` cannot build for that TFM and fails with `NETSDK1045`.
---
## Test Sharding for Large Projects
### Splitting Tests Across Parallel Jobs
For large test suites, split test projects across parallel runners to reduce total CI time:
```yaml
jobs:
discover:
runs-on: ubuntu-latest
outputs:
projects: ${{ steps.find.outputs.projects }}
steps:
- uses: actions/checkout@v4
- id: find
shell: bash
run: |
set -euo pipefail
PROJECTS=$(find tests -name '*.csproj' | jq -R . | jq -sc .)
echo "projects=$PROJECTS" >> "$GITHUB_OUTPUT"
test:
needs: discover
strategy:
fail-fast: false
matrix:
project: ${{ fromJson(needs.discover.outputs.projects) }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- name: Test ${{ matrix.project }}
run: |
set -euo pipefail
dotnet test ${{ matrix.project }} \
--configuration Release \
--logger "trx;LogFileName=results.trx" \
--results-directory ./test-results
- name: Publish test results
uses: dorny/test-reporter@v1
if: always()
with:
name: 'Tests - ${{ matrix.project }}'
path: 'test-results/**/*.trx'
reporter: dotnet-trx
```text
### Sharding by Test Class Within a Project
For a single large test project, use `dotnet test --filter` to split by namespace:
```yaml
jobs:
test:
strategy:
fail-fast: false
matrix:
shard: ['Unit', 'Integration', 'EndToEnd']
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- name: Test ${{ matrix.shard }}
run: |
set -euo pipefail
dotnet test tests/MyApp.Tests.csproj \
--configuration Release \
--filter "FullyQualifiedName~${{ matrix.shard }}" \
--logger "trx;LogFileName=${{ matrix.shard }}-results.trx" \
--results-directory ./test-results
```text
---
## Agent Gotchas
1. **Always set `set -euo pipefail` in multi-line bash `run` blocks** -- without `pipefail`, piped commands that fail do
not propagate the error, producing false-green CI.
2. **Use `continue-on-error: true` on the test step, not on the reporter** -- the test step must not fail the job
prematurely so the reporter can publish results, but the reporter should fail the check when tests fail.
3. **Include `runner.os` in NuGet cache keys** -- NuGet packages have OS-specific native assets; cross-OS cache hits
cause restore failures.
4. **Install all required SDK versions for multi-TFM** -- `dotnet test` without the matching SDK produces `NETSDK1045`;
list every required version in `dotnet-version`.
5. **Do not hardcode TFM strings in workflow files** -- use matrix variables to keep workflow files in sync with project
configuration; hardcoded `net8.0` in CI breaks when the project moves to `net9.0`.
6. **Coverage collection requires `--collect:"XPlat Code Coverage"`** -- the default `dotnet test` does not produce
coverage files; the `XPlat Code Coverage` collector is built into the .NET SDK.
7. **TRX logger path must match reporter glob** -- if the logger writes to `test-results/results.trx`, the reporter
`path` must include that directory in its glob pattern.
8. **Never commit NuGet credentials to workflow files** -- use `${{ secrets.* }}` references for all authentication
tokens; the `NUGET_AUTH_TOKEN` environment variable is the standard pattern.