Learnixo
Back to blog
AI Systemsintermediate

GitHub Actions for .NET — CI Pipeline for ASP.NET Core

Build a production-grade CI pipeline for .NET with GitHub Actions: build, test, code coverage, linting, Dockerfile builds, and caching for fast feedback loops.

Asma Hafeez KhanMay 16, 20264 min read
GitHub ActionsCI/CD.NETTestingDevOps
Share:š•

Complete .NET CI Pipeline

YAML
# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

env:
  DOTNET_VERSION: '8.x'
  DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true
  DOTNET_NOLOGO: true

jobs:
  build-and-test:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          fetch-depth: 0  # full history for SonarCloud / GitVersion

      - name: Setup .NET
        uses: actions/setup-dotnet@v4
        with:
          dotnet-version: ${{ env.DOTNET_VERSION }}

      # Cache NuGet packages — large speed improvement on subsequent runs
      - name: Cache NuGet packages
        uses: actions/cache@v4
        with:
          path: ~/.nuget/packages
          key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj', '**/*.props') }}
          restore-keys: |
            ${{ runner.os }}-nuget-

      - name: Restore dependencies
        run: dotnet restore Clinical.sln

      - name: Build
        run: dotnet build Clinical.sln --no-restore --configuration Release

      - name: Run unit tests
        run: |
          dotnet test Clinical.sln \
            --no-build \
            --configuration Release \
            --filter "Category!=Integration" \
            --collect:"XPlat Code Coverage" \
            --results-directory ./coverage \
            --logger "trx;LogFileName=test-results.trx"

      - name: Upload test results
        uses: actions/upload-artifact@v4
        if: always()  # upload even if tests fail
        with:
          name: test-results
          path: ./coverage/**/*.trx

      - name: Upload coverage to Codecov
        uses: codecov/codecov-action@v4
        with:
          token: ${{ secrets.CODECOV_TOKEN }}
          files: ./coverage/**/coverage.cobertura.xml
          fail_ci_if_error: true

Separate Integration Test Job

YAML
  integration-tests:
    runs-on: ubuntu-latest
    needs: build-and-test  # run after unit tests pass

    services:
      sqlserver:
        image: mcr.microsoft.com/mssql/server:2022-latest
        env:
          ACCEPT_EULA: Y
          SA_PASSWORD: YourStrong!Passw0rd
        ports:
          - 1433:1433
        options: >-
          --health-cmd "/opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P YourStrong!Passw0rd -Q 'SELECT 1'"
          --health-interval 10s
          --health-timeout 5s
          --health-retries 10

    steps:
      - uses: actions/checkout@v4

      - name: Setup .NET
        uses: actions/setup-dotnet@v4
        with:
          dotnet-version: ${{ env.DOTNET_VERSION }}

      - name: Restore
        run: dotnet restore Clinical.sln

      - name: Build
        run: dotnet build Clinical.sln --no-restore --configuration Release

      - name: Run integration tests
        run: |
          dotnet test Clinical.sln \
            --no-build \
            --configuration Release \
            --filter "Category=Integration" \
            --logger "trx;LogFileName=integration-results.trx"
        env:
          ConnectionStrings__Clinical: "Server=localhost,1433;Database=ClinicalTest;User Id=sa;Password=YourStrong!Passw0rd;TrustServerCertificate=True"

Code Quality Checks

YAML
  code-quality:
    runs-on: ubuntu-latest
    needs: build-and-test

    steps:
      - uses: actions/checkout@v4

      - name: Setup .NET
        uses: actions/setup-dotnet@v4
        with:
          dotnet-version: ${{ env.DOTNET_VERSION }}

      - name: Install dotnet-format
        run: dotnet tool install --global dotnet-format

      - name: Check formatting
        run: dotnet format --verify-no-changes --no-restore Clinical.sln
        # Fails CI if code is not formatted — enforce EditorConfig rules

      - name: Check for outdated packages
        run: |
          dotnet list Clinical.sln package --outdated --format json > outdated.json
          # Optional: fail if critical packages are outdated

      - name: Run architecture tests
        run: |
          dotnet test tests/ArchitectureTests/ArchitectureTests.csproj \
            --no-restore \
            --configuration Release
        # Runs ArchUnitNET tests that enforce module boundaries

Docker Image Build Job

YAML
  build-docker:
    runs-on: ubuntu-latest
    needs: build-and-test

    steps:
      - uses: actions/checkout@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Login to GitHub Container Registry
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build and push Docker image
        uses: docker/build-push-action@v5
        with:
          context: .
          file: src/Host/SystemForge.Api/Dockerfile
          push: ${{ github.ref == 'refs/heads/main' }}  # push only from main
          tags: |
            ghcr.io/${{ github.repository }}/clinical-api:latest
            ghcr.io/${{ github.repository }}/clinical-api:${{ github.sha }}
          cache-from: type=gha         # GitHub Actions cache for Docker layers
          cache-to:   type=gha,mode=max

Optimised Dockerfile for .NET

DOCKERFILE
# Multi-stage build — small final image, fast layer caching
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src

# Copy only project files first — layer cache hit if no .csproj changes
COPY ["src/Host/SystemForge.Api/SystemForge.Api.csproj",
      "src/Host/SystemForge.Api/"]
COPY ["src/Modules/Prescriptions/Prescriptions.Application/Prescriptions.Application.csproj",
      "src/Modules/Prescriptions/Prescriptions.Application/"]
# ... other projects

RUN dotnet restore "src/Host/SystemForge.Api/SystemForge.Api.csproj"

# Now copy source and build
COPY . .
RUN dotnet publish "src/Host/SystemForge.Api/SystemForge.Api.csproj" \
    --configuration Release \
    --no-restore \
    --output /app/publish

FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime
WORKDIR /app
COPY --from=build /app/publish .

# Non-root user for security
RUN addgroup --system appgroup && adduser --system appuser --ingroup appgroup
USER appuser

EXPOSE 8080
ENV ASPNETCORE_URLS=http://+:8080

ENTRYPOINT ["dotnet", "SystemForge.Api.dll"]

Production issue I've seen: A team's CI pipeline ran dotnet restore and dotnet build without caching NuGet packages. Each CI run took 8-12 minutes just for restore and build. Developers stopped pushing frequently because "CI takes forever" — they accumulated large changes between pushes and PRs became hard to review. Adding actions/cache@v4 for the ~/.nuget/packages directory reduced restore time from 6 minutes to under 30 seconds (cache hit). Build time dropped from 12 minutes to 3 minutes. Developers started pushing smaller, more frequent commits. The total time investment: 15 minutes to add the cache step.


Key Takeaway

Structure the CI pipeline as separate jobs: build+unit-tests (fast, blocks everything), integration-tests (slower, runs in parallel or after), code-quality (format check, architecture tests), docker-build. Cache NuGet packages with actions/cache@v4 — this is the single highest-impact performance improvement for .NET CI. Run integration tests against a real database using GitHub Actions services. Enforce formatting with dotnet format --verify-no-changes to prevent formatting debates in code review. Build and push Docker images only from the main branch.

Enjoyed this article?

Explore the AI Systems learning path for more.

Found this helpful?

Share:š•

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.