CI/CD Pipeline — GitHub Actions for Build, Test and Deploy

A CI/CD (Continuous Integration / Continuous Deployment) pipeline automates the path from code to production: every pull request is built and tested automatically, every merge to main produces a deployable artefact, and deployment to staging (and optionally production) is triggered automatically or with a manual approval gate. GitHub Actions provides a generous free tier for public repositories and 2,000 minutes/month for private — more than sufficient for a typical .NET project. The pipeline built in this lesson is the deployment foundation for the entire series.

Complete GitHub Actions CI/CD Pipeline

// ── .github/workflows/ci-cd.yml ──────────────────────────────────────────

name: CI/CD Pipeline

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

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}/blogapp-api
  DOTNET_VERSION: "8.0.x"

jobs:

  # ── Job 1: Build and Test ─────────────────────────────────────────────────
  build-and-test:
    name: Build and Test
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

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

      - name: Restore NuGet packages
        run: dotnet restore

      - name: Check for vulnerable packages
        run: dotnet list package --vulnerable --include-transitive
        # Note: This exits with code 0 even if vulnerabilities found.
        # For stricter enforcement, parse the output.

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

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

      - name: Publish test results
        uses: dorny/test-reporter@v1
        if: success() || failure()
        with:
          name: .NET Tests
          path: coverage/*.trx
          reporter: dotnet-trx

      - name: Upload coverage to Codecov
        uses: codecov/codecov-action@v4
        with:
          directory: ./coverage
          token: ${{ secrets.CODECOV_TOKEN }}

  # ── Job 2: Containerise and Push (main branch only) ───────────────────────
  docker-build-push:
    name: Build and Push Docker Image
    needs: build-and-test
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'

    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 metadata for Docker
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=sha,prefix=sha-
            type=raw,value=latest

      - name: Build and push Docker image
        uses: docker/build-push-action@v5
        with:
          context: .
          file: src/BlogApp.Api/Dockerfile
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

  # ── Job 3: Deploy to Staging ──────────────────────────────────────────────
  deploy-staging:
    name: Deploy to Staging
    needs: docker-build-push
    runs-on: ubuntu-latest
    environment:
      name: staging
      url: https://staging.blogapp.com

    steps:
      - name: Deploy to Azure Container Apps
        uses: azure/container-apps-deploy-action@v1
        with:
          appSourcePath: ${{ github.workspace }}
          acrName: blogappregistry
          acrUsername: ${{ secrets.ACR_USERNAME }}
          acrPassword: ${{ secrets.ACR_PASSWORD }}
          containerAppName: blogapp-api-staging
          resourceGroup: blogapp-staging-rg
          imageToBuild: blogappregistry.azurecr.io/blogapp-api:${{ github.sha }}
Note: GitHub Environments add protection rules to deployments. The staging environment can be configured to require specific reviewers, allow deployment only from certain branches, and wait for environment-specific secrets. The production environment typically requires manual approval from a team lead before deployment proceeds. Configure environments in your GitHub repository settings under Settings → Environments. This gate prevents accidental or unapproved production deployments.
Tip: Use GitHub Actions cache for Docker layer caching (cache-from: type=gha) and for .NET NuGet restore caching (actions/cache with the NuGet cache path). Without caching, every pipeline run downloads all NuGet packages and rebuilds all Docker layers from scratch — adding 5–15 minutes to each run. With caching, these steps complete in seconds when nothing has changed. CI speed directly affects developer productivity — faster pipelines mean faster feedback loops.
Warning: Store all secrets (connection strings, API keys, container registry credentials) in GitHub Secrets (Settings → Secrets and variables → Actions), never in the workflow YAML file or in appsettings.json in the repository. GitHub Secrets are encrypted, not visible in logs (even if accidentally printed), and not included in repository forks. For production deployments, consider using GitHub Environments with environment-specific secrets and requiring protected branches for production deployments.

Rollback Strategy

// ── Rollback approaches ───────────────────────────────────────────────────

// 1. Docker image tags — each deployment uses a SHA-tagged image
//    Roll back: re-deploy with the previous SHA tag
//    az containerapp update --name blogapp-api \
//      --image blogappregistry.azurecr.io/blogapp-api:sha-{previous-sha}

// 2. GitHub Environments — re-run a previous successful deployment workflow
//    GitHub Actions → workflow run history → re-run a specific run

// 3. Azure Container Apps revisions — each deployment creates a new revision
//    az containerapp revision activate --name blogapp-api --revision {previous-revision}
//    Zero-downtime rollback by switching traffic between revisions

// ── Canary deployment ──────────────────────────────────────────────────────
// Send 10% of traffic to new version, 90% to stable
// Verify metrics → gradually increase to 100% → decommission old revision

Common Mistakes

Mistake 1 — Storing secrets in GitHub Actions workflow YAML

❌ Wrong — hardcoded credentials in workflow file are visible in repository history and logs.

✅ Correct — use ${{ secrets.SECRET_NAME }} references; store values in GitHub Secrets.

Mistake 2 — No deployment gate between staging and production

❌ Wrong — every merge to main automatically deploys to production without review.

✅ Correct — configure a GitHub Environment for production with required reviewers and branch protection rules.

🧠 Test Yourself

The CI pipeline builds the Docker image using cache-from: type=gha. A developer only changes a .cs file. What Docker layers are rebuilt?