Learnixo

GitHub Actions · Lesson 5 of 5

Complete .NET CI Pipeline — Build, Test, Publish

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.