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:

.github/workflows/ci.yml
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:

config.yaml
      - 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:

Directory.Build.props
<PropertyGroup>
  <RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
</PropertyGroup>

Alternatively, use actions/cache directly to cache the global NuGet packages folder:

config.yaml
      - 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:

config.yaml
  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:

config.yaml
      - 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:

config.yaml
  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:

config.yaml
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.