GitHub Actions for .NET: A Practical CI/CD Pipeline
GitHub Actions has become the default CI/CD platform for projects hosted on GitHub, and it has excellent support for .NET workloads. In this article, we'll build a production-ready pipeline that restores, builds, tests, and publishes a .NET application.
The Basic Workflow
Every GitHub Actions workflow lives in .github/workflows/ as a YAML file. Here's a minimal starting point:
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '9.0.x'
- name: Restore
run: dotnet restore
- name: Build
run: dotnet build --no-restore --configuration Release
- name: Test
run: dotnet test --no-build --configuration Release --verbosity normal
The actions/setup-dotnet action handles SDK installation. You can target multiple SDK versions by passing a multi-line string to dotnet-version.
Caching NuGet Packages
Restoring packages on every run wastes time. The setup-dotnet action doesn't cache by default, but you can add caching easily:
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '9.0.x'
cache: true
cache-dependency-path: '**/packages.lock.json'
For this to work, you need lock files. Enable them in your Directory.Build.props:
<PropertyGroup>
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
</PropertyGroup>
Alternatively, use actions/cache directly to cache the global NuGet packages folder:
- name: Cache NuGet
uses: actions/cache@v4
with:
path: ~/.nuget/packages
key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }}
restore-keys: |
${{ runner.os }}-nuget-
Multi-Target Testing
If your library targets multiple frameworks, you'll want to test against all of them:
test:
runs-on: ubuntu-latest
strategy:
matrix:
dotnet-version: ['8.0.x', '9.0.x']
steps:
- uses: actions/checkout@v4
- name: Setup .NET ${{ matrix.dotnet-version }}
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ matrix.dotnet-version }}
- name: Test
run: dotnet test --configuration Release
The matrix strategy runs your tests in parallel across each specified SDK version.
Publishing Artefacts
After a successful build, you often want to produce a deployable artefact:
- name: Publish
run: dotnet publish src/MyApp/MyApp.csproj --configuration Release --output ./publish
- name: Upload artefact
uses: actions/upload-artifact@v4
with:
name: app
path: ./publish
Deploying to Azure App Service
A common deployment target for .NET applications is Azure App Service. You can chain a deployment job after your build:
deploy:
needs: build
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
environment: production
steps:
- name: Download artefact
uses: actions/download-artifact@v4
with:
name: app
- name: Deploy to Azure
uses: azure/webapps-deploy@v3
with:
app-name: my-dotnet-app
publish-profile: ${{ secrets.AZURE_PUBLISH_PROFILE }}
The needs: build ensures deployment only happens after a successful build. The if condition restricts deployment to pushes on main, and the environment setting enables environment-level protection rules and secrets.
Secrets and Configuration
Never hardcode credentials. Store them in GitHub repository or environment secrets, and reference them with ${{ secrets.SECRET_NAME }}. For configuration that isn't sensitive, use variables instead: ${{ vars.VARIABLE_NAME }}.
Workflow Tips
A few patterns that improve reliability in practice:
- Pin action versions to full SHAs rather than tags for supply chain security.
- Use
concurrencyto cancel in-progress runs when a new commit is pushed to the same branch, avoiding wasted runner minutes. - Split build and deploy into separate jobs so failures are easier to diagnose.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
Wrapping Up
GitHub Actions gives you a flexible, YAML-driven CI/CD pipeline that integrates tightly with your repository. For .NET projects, the combination of actions/setup-dotnet, NuGet caching, and matrix builds covers the vast majority of use cases. Start simple, add complexity only when you need it, and keep your workflows readable.