Learnixo

.NET & C# Development · Lesson 178 of 229

GitHub Actions CI/CD for .NET — Full Pipeline

GitHub Actions CI/CD for .NET — Full Pipeline

A production CI/CD pipeline for .NET does more than dotnet build and dotnet test — it enforces code coverage, builds Docker images, scans for vulnerabilities, deploys to staging automatically, and requires approval for production.


Pipeline Overview

Trigger: push to main OR pull_request

Jobs (in parallel where possible):
  build       — restore, build, test, upload coverage
  security    — run CodeQL analysis, Trivy dependency scan
  docker      — build and push image to registry
  ↓
  deploy-staging  — deploy to staging environment (auto)
  ↓
  deploy-prod     — deploy to production (requires manual approval)

Step 1: Core Build and Test

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

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

env:
  DOTNET_VERSION: '9.x'
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  build:
    runs-on: ubuntu-latest
    outputs:
      version: ${{ steps.version.outputs.version }}

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

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

      - name: Cache NuGet packages
        uses: actions/cache@v4
        with:
          path: ~/.nuget/packages
          key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }}
          restore-keys: ${{ runner.os }}-nuget-

      - name: Restore dependencies
        run: dotnet restore

      - name: Build
        run: dotnet build --no-restore --configuration Release

      - name: Run tests with coverage
        run: |
          dotnet test \
            --no-build \
            --configuration Release \
            --collect:"XPlat Code Coverage" \
            --results-directory ./coverage \
            --logger "trx;LogFileName=test-results.trx" \
            /p:CoverletOutputFormat=cobertura

      - name: Enforce coverage threshold
        run: |
          dotnet tool install -g dotnet-reportgenerator-globaltool
          reportgenerator \
            -reports:"./coverage/**/coverage.cobertura.xml" \
            -targetdir:"./coverage-report" \
            -reporttypes:TextSummary
          COVERAGE=$(cat ./coverage-report/Summary.txt | grep "Line coverage:" | grep -oP '\d+\.\d+')
          echo "Line coverage: $COVERAGE%"
          if (( $(echo "$COVERAGE < 80" | bc -l) )); then
            echo "Coverage $COVERAGE% is below minimum 80%"
            exit 1
          fi

      - name: Upload test results
        uses: actions/upload-artifact@v4
        if: always()
        with:
          name: test-results
          path: |
            ./coverage
            ./coverage-report

      - name: Publish test results
        uses: dorny/test-reporter@v1
        if: always()
        with:
          name: Test Results
          path: '**/*.trx'
          reporter: dotnet-trx

      - name: Calculate version
        id: version
        run: |
          VERSION="${{ github.run_number }}.0.${{ github.sha }}"
          SHORT="${{ github.run_number }}.0.0"
          echo "version=$SHORT" >> $GITHUB_OUTPUT
          echo "Building version $SHORT"

Step 2: Security Scanning

YAML
  security:
    runs-on: ubuntu-latest
    permissions:
      security-events: write
      contents: read

    steps:
      - uses: actions/checkout@v4

      - name: Initialize CodeQL
        uses: github/codeql-action/init@v3
        with:
          languages: csharp

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

      - name: Build for CodeQL
        run: dotnet build --configuration Release

      - name: Perform CodeQL Analysis
        uses: github/codeql-action/analyze@v3

      # Scan NuGet dependencies for known CVEs
      - name: Run dependency audit
        run: |
          dotnet list package --vulnerable --include-transitive 2>&1 | tee vuln-report.txt
          if grep -q "has the following vulnerable packages" vuln-report.txt; then
            echo "Vulnerable packages detected — failing build"
            cat vuln-report.txt
            exit 1
          fi

      - name: Upload vulnerability report
        uses: actions/upload-artifact@v4
        if: always()
        with:
          name: vulnerability-report
          path: vuln-report.txt

Step 3: Docker Build and Push

YAML
  docker:
    runs-on: ubuntu-latest
    needs: [build, security]
    permissions:
      contents: read
      packages: write

    steps:
      - uses: actions/checkout@v4

      - name: Log in to GitHub Container Registry
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Extract Docker metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=ref,event=branch
            type=ref,event=pr
            type=semver,pattern={{version}}
            type=sha,prefix=sha-

      - name: Build and push Docker image
        uses: docker/build-push-action@v5
        with:
          context: .
          push: ${{ github.event_name != 'pull_request' }}
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
          build-args: |
            BUILD_VERSION=${{ needs.build.outputs.version }}

Step 4: Dockerfile (Optimised for CI)

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

# Copy project files first  cached layer if no .csproj changes
COPY ["src/OrderService/OrderService.csproj", "src/OrderService/"]
COPY ["src/OrderService.Core/OrderService.Core.csproj", "src/OrderService.Core/"]
RUN dotnet restore "src/OrderService/OrderService.csproj"

# Copy source, build, publish
COPY . .
WORKDIR "/src/src/OrderService"
RUN dotnet build -c Release -o /app/build --no-restore
RUN dotnet publish -c Release -o /app/publish --no-restore

# Runtime image  no SDK
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS runtime
WORKDIR /app

# Non-root user
RUN addgroup --system --gid 1001 appgroup \
    && adduser --system --uid 1001 --ingroup appgroup appuser
USER appuser

COPY --from=build --chown=appuser:appgroup /app/publish .

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

HEALTHCHECK --interval=10s --timeout=5s --start-period=30s --retries=3 \
    CMD curl -f http://localhost:8080/health/live || exit 1

ENTRYPOINT ["dotnet", "OrderService.dll"]

Step 5: Staging Deployment

YAML
  deploy-staging:
    runs-on: ubuntu-latest
    needs: docker
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    environment:
      name: staging
      url: https://staging.example.com

    steps:
      - uses: actions/checkout@v4

      - name: Deploy to Azure Container Apps (Staging)
        uses: azure/container-apps-deploy-action@v1
        with:
          appSourcePath: ${{ github.workspace }}
          acrName: myregistry
          containerAppName: order-service-staging
          resourceGroup: rg-staging
          imageToDeploy: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ github.sha }}

      - name: Wait for deployment health
        run: |
          echo "Waiting for staging to become healthy..."
          for i in $(seq 1 12); do
            STATUS=$(curl -s -o /dev/null -w "%{http_code}" https://staging.example.com/health/ready)
            if [ "$STATUS" = "200" ]; then
              echo "Staging is healthy"
              exit 0
            fi
            echo "Attempt $i: status $STATUS — waiting 10s"
            sleep 10
          done
          echo "Staging did not become healthy in time"
          exit 1

      - name: Run smoke tests
        run: |
          curl -f https://staging.example.com/health/ready
          curl -f https://staging.example.com/api/orders/health

Step 6: Production Deployment with Approval

YAML
  deploy-production:
    runs-on: ubuntu-latest
    needs: deploy-staging
    environment:
      name: production        # This environment has required reviewers configured in GitHub
      url: https://app.example.com

    steps:
      - uses: actions/checkout@v4

      - name: Deploy to Azure Container Apps (Production)
        uses: azure/container-apps-deploy-action@v1
        with:
          containerAppName: order-service-prod
          resourceGroup: rg-production
          imageToDeploy: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ github.sha }}

      - name: Notify deployment
        uses: 8398a7/action-slack@v3
        with:
          status: ${{ job.status }}
          text: "Production deploy of ${{ env.IMAGE_NAME }}:${{ github.sha }} — ${{ job.status }}"
        env:
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}

Step 7: PR-Only Quality Checks

YAML
  pr-quality:
    runs-on: ubuntu-latest
    if: github.event_name == 'pull_request'

    steps:
      - uses: actions/checkout@v4

      - name: Validate branch name
        run: |
          BRANCH=${{ github.head_ref }}
          if ! echo "$BRANCH" | grep -qE '^(feature|fix|chore|docs)/[a-z0-9-]+$'; then
            echo "Branch name '$BRANCH' does not match convention: feature/|fix/|chore/|docs/ + kebab-case"
            exit 1
          fi

      - name: Check for TODO/FIXME comments
        run: |
          COUNT=$(grep -rn "TODO\|FIXME\|HACK" --include="*.cs" src/ | wc -l)
          echo "Found $COUNT TODO/FIXME comments"
          # Warn but don't fail (team decision)

      - name: Validate no secrets in code
        uses: trufflesecurity/trufflehog@main
        with:
          path: ./
          base: ${{ github.event.repository.default_branch }}
          head: HEAD

Interview Answer

"A production .NET CI/CD pipeline in GitHub Actions has five stages. Build: restore, build in Release mode, run tests with XPlat Code Coverage, fail if line coverage drops below threshold (80% is a common floor). Security: CodeQL static analysis plus dotnet list package --vulnerable --include-transitive to catch known CVE dependencies. Docker: multi-stage Dockerfile with SDK layer for build and aspnet runtime layer for the final image, pushed to GHCR with SHA-based tags for exact traceability. Staging: auto-deploy on merge to main, wait for health endpoint, run smoke tests. Production: requires a GitHub environment with required reviewers — the pipeline pauses until an approved person clicks Approve. Key optimisations: NuGet cache via actions/cache keyed on .csproj hash (restores in seconds instead of minutes), Docker layer caching via type=gha (skips unchanged layers), and parallel security scanning with the build job. All artifacts (test results, coverage, vulnerability report) are uploaded with upload-artifact for post-mortems."