44. CI/CD Pipeline

Continuous integration and deployment with GitHub Actions: automated builds, tests, and deployments

About this chapter

Automate your release process using CI/CD pipelines to build, test, containerize, and deploy your API automatically on every code push.

  • CI/CD fundamentals: Continuous integration and continuous deployment benefits
  • GitHub Actions: Building workflows triggered by code changes
  • Build automation: Compiling and building applications automatically
  • Test automation: Running tests as part of the pipeline
  • Docker integration: Building and pushing container images
  • Deployment automation: Automatically deploying to staging and production

Learning outcomes:

  • Understand CI/CD concepts and benefits
  • Create GitHub Actions workflows
  • Automate builds and tests
  • Build and push Docker images in CI/CD
  • Deploy automatically to environments
  • Handle secrets securely in pipelines

44.1 Why CI/CD?

Without CI/CD (manual process):

Developer pushes code
    ↓
Manual: Build locally
    ↓
Manual: Run tests locally
    ↓
Manual: Push to registry
    ↓
Manual: Deploy to server
    ↓
Manual: Verify in production
    ↓
Something breaks? Repeat manually

Slow, error-prone, inconsistent.

With CI/CD (automated pipeline):

Developer pushes code
    ↓ (automatic)
Build
    ↓ (automatic)
Run tests
    ↓ (automatic)
Run code quality checks
    ↓ (automatic)
Build Docker image
    ↓ (automatic)
Push to registry
    ↓ (automatic)
Deploy to staging
    ↓ (automatic)
Run smoke tests
    ↓ (automatic)
Deploy to production
    ↓ (automatic)
Notify team of success

Fast, consistent, reliable. Pipelines catch issues before they reach production.

Benefits:

  • Speed: 5-minute deploy vs 1-hour manual process
  • Consistency: Same steps every time
  • Reliability: Tests catch bugs before production
  • Traceability: Every deployment tracked and audited
  • Scalability: Deploy multiple services simultaneously
  • Confidence: Automated checks increase confidence in code quality

44.2 GitHub Actions Overview

GitHub Actions is GitHub’s built-in CI/CD platform.

How it works:

.github/workflows/              (YAML files defining pipelines)
    ├── build.yml              (build and test on push)
    ├── deploy-staging.yml     (deploy to staging)
    └── deploy-prod.yml        (deploy to production)

When you push to GitHub, workflows run automatically.

Pricing:

  • Free tier: 2,000 actions minutes/month (plenty for small projects)
  • Paid: $0.008/minute after free tier

44.3 Build and Test Pipeline

Create workflow file:

.github/workflows/build.yml

Workflow content:

name: Build and Test

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

jobs:
  build:
    runs-on: ubuntu-latest
    
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_DB: commandapi_test
          POSTGRES_USER: postgres
          POSTGRES_PASSWORD: testpass
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5          
        ports:
          - 5432:5432

    steps:
      # Check out code
      - name: Checkout code
        uses: actions/checkout@v4

      # Setup .NET
      - name: Setup .NET
        uses: actions/setup-dotnet@v4
        with:
          dotnet-version: '10.0'

      # Restore dependencies
      - name: Restore dependencies
        run: dotnet restore

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

      # Run unit tests
      - name: Run unit tests
        run: dotnet test CommandAPI.Tests/CommandAPI.Tests.csproj \
          --configuration Release \
          --no-build \
          --logger "trx;LogFileName=test-results.trx"
        env:
          ConnectionStrings__PostgreSqlConnection: >-
            Host=localhost;
            Database=commandapi_test;
            Username=postgres;
            Password=testpass            

      # Run integration tests
      - name: Run integration tests
        run: dotnet test CommandAPI.IntegrationTests/CommandAPI.IntegrationTests.csproj \
          --configuration Release \
          --no-build \
          --logger "trx;LogFileName=integration-test-results.trx"
        env:
          ConnectionStrings__PostgreSqlConnection: >-
            Host=localhost;
            Database=commandapi_test;
            Username=postgres;
            Password=testpass            

      # Upload test results
      - name: Upload test results
        if: always()
        uses: actions/upload-artifact@v3
        with:
          name: test-results
          path: '**/test-results.trx'

      # Code coverage
      - name: Generate code coverage
        run: dotnet test CommandAPI.Tests/CommandAPI.Tests.csproj \
          --configuration Release \
          /p:CollectCoverage=true \
          /p:CoverageFormat=opencover \
          /p:CoverageFileName=coverage.xml

      # Upload coverage to Codecov
      - name: Upload coverage to Codecov
        uses: codecov/codecov-action@v3
        with:
          files: './coverage.xml'
          fail_ci_if_error: false

      # Notify on failure
      - name: Notify on failure
        if: failure()
        run: echo "Build failed! Check the logs above."

44.4 Docker Build and Push

Build and push Docker image:

name: Build and Push Docker Image

on:
  push:
    branches: [main]
  release:
    types: [published]

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

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      # Set up Docker Buildx for multi-platform builds
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v2

      # Login to Docker Hub
      - name: Login to Docker Hub
        uses: docker/login-action@v2
        with:
          username: ${{ secrets.DOCKER_USERNAME }}
          password: ${{ secrets.DOCKER_PASSWORD }}

      # Get version from git tag or use timestamp
      - name: Get version
        id: version
        run: |
          if [[ "${{ github.ref }}" == refs/tags/* ]]; then
            VERSION=${GITHUB_REF#refs/tags/}
          else
            VERSION=$(date +%Y%m%d-%H%M%S)
          fi
          echo "version=$VERSION" >> $GITHUB_OUTPUT          

      # Build and push Docker image
      - name: Build and push Docker image
        uses: docker/build-push-action@v4
        with:
          context: .
          push: true
          tags: |
            ${{ secrets.DOCKER_USERNAME }}/commandapi:${{ steps.version.outputs.version }}
            ${{ secrets.DOCKER_USERNAME }}/commandapi:latest            
          cache-from: type=gha
          cache-to: type=gha,mode=max

Set secrets in GitHub:

  1. Go to repository settings
  2. Secrets and variables → Actions
  3. Add secrets:
    • DOCKER_USERNAME
    • DOCKER_PASSWORD

44.5 Deployment to Staging

Deploy to staging environment:

name: Deploy to Staging

on:
  push:
    branches: [develop]
  workflow_dispatch:  # Allow manual trigger

jobs:
  deploy-staging:
    runs-on: ubuntu-latest
    environment: staging

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      # Deploy to Azure App Service
      - name: Deploy to Azure App Service
        uses: azure/webapps-deploy@v2
        with:
          app-name: commandapi-staging
          publish-profile: ${{ secrets.AZURE_PUBLISH_PROFILE_STAGING }}
          package: '.'

      # Or deploy Docker image
      - name: Deploy Docker image to Azure Container Instances
        uses: azure/container-instances-deploy-action@v1
        with:
          resource-group: myresourcegroup
          name: commandapi-staging
          image: ${{ secrets.REGISTRY_LOGIN_SERVER }}/commandapi:latest
          registry-login-server: ${{ secrets.REGISTRY_LOGIN_SERVER }}
          registry-username: ${{ secrets.REGISTRY_USERNAME }}
          registry-password: ${{ secrets.REGISTRY_PASSWORD }}
          environment-variables:
            ASPNETCORE_ENVIRONMENT: Staging
            ConnectionStrings__PostgreSqlConnection: ${{ secrets.STAGING_DB_CONNECTION }}
            JwtSecret: ${{ secrets.STAGING_JWT_SECRET }}

      # Smoke test
      - name: Run smoke tests
        run: |
          sleep 10  # Wait for deployment
          curl -f https://staging-api.example.com/health || exit 1
          curl -f https://staging-api.example.com/api/commands || exit 1          

      # Notify Slack
      - name: Notify Slack
        if: success()
        uses: slackapi/slack-github-action@v1
        with:
          webhook-url: ${{ secrets.SLACK_WEBHOOK }}
          payload: |
            {
              "text": "✅ Deployed to staging: ${{ github.sha }}"
            }            

44.6 Production Deployment

Controlled production deployment:

name: Deploy to Production

on:
  release:
    types: [published]
  workflow_dispatch:  # Manual approval trigger

jobs:
  deploy-production:
    runs-on: ubuntu-latest
    environment:
      name: production
      url: https://api.example.com
    
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      # Get release version
      - name: Get version
        id: version
        run: echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT

      # Deploy with blue-green strategy
      - name: Deploy to production (blue-green)
        run: |
          # Deploy new version as "green"
          docker pull ${{ secrets.REGISTRY }}/commandapi:${{ steps.version.outputs.version }}
          docker run -d \
            --name commandapi-green \
            -p 8081:8080 \
            ${{ secrets.REGISTRY }}/commandapi:${{ steps.version.outputs.version }}
          
          # Test new version
          sleep 5
          curl -f http://localhost:8081/health || exit 1
          
          # Switch traffic to new version
          docker stop commandapi-blue
          docker rename commandapi-green commandapi-blue
          
          # Update old container
          docker run -d \
            --name commandapi-green \
            -p 8082:8080 \
            ${{ secrets.REGISTRY }}/commandapi:latest          

      # Post-deployment validation
      - name: Post-deployment health checks
        run: |
          for i in {1..30}; do
            if curl -f https://api.example.com/health; then
              echo "Health check passed"
              exit 0
            fi
            echo "Waiting for API to be healthy..."
            sleep 2
          done
          exit 1          

      # Notify success
      - name: Notify deployment success
        if: success()
        uses: slackapi/slack-github-action@v1
        with:
          webhook-url: ${{ secrets.SLACK_WEBHOOK }}
          payload: |
            {
              "text": "🚀 Production deployment successful: v${{ steps.version.outputs.version }}"
            }            

      # Notify failure
      - name: Notify deployment failure
        if: failure()
        uses: slackapi/slack-github-action@v1
        with:
          webhook-url: ${{ secrets.SLACK_WEBHOOK }}
          payload: |
            {
              "text": "❌ Production deployment failed: v${{ steps.version.outputs.version }}"
            }            

44.7 Matrix Testing (Multiple Versions)

Test against multiple .NET versions:

name: Test Multiple .NET Versions

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        dotnet-version: ['9.0', '10.0']

    steps:
      - uses: actions/checkout@v4

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

      - name: Build and test
        run: |
          dotnet build
          dotnet test          

44.8 Conditional Steps

Run steps based on conditions:

steps:
  # Only on main branch
  - name: Deploy to production
    if: github.ref == 'refs/heads/main'
    run: ./deploy-prod.sh

  # Only on pull request
  - name: Comment on PR
    if: github.event_name == 'pull_request'
    uses: actions/github-script@v6
    with:
      script: |
        github.rest.issues.createComment({
          issue_number: context.issue.number,
          owner: context.repo.owner,
          repo: context.repo.repo,
          body: '✅ Build passed!'
        })        

  # Only if previous step failed
  - name: Notify team
    if: failure()
    run: echo "Pipeline failed!"

  # On schedule (e.g., nightly builds)
  - name: Nightly build
    if: github.event_name == 'schedule'
    run: dotnet build -c Release

44.9 Managing Secrets

Secrets in GitHub:

  1. Go to Settings → Secrets and variables → Actions
  2. Click “New repository secret”
  3. Add secrets (not visible in logs):
    • DOCKER_USERNAME
    • DOCKER_PASSWORD
    • AZURE_PUBLISH_PROFILE
    • JWT_SECRET
    • DATABASE_PASSWORD

Use secrets in workflows:

env:
  ConnectionStrings__PostgreSqlConnection: >-
    Host=localhost;
    Database=commandapi;
    Username=postgres;
    Password=${{ secrets.DB_PASSWORD }}    

steps:
  - name: Build
    run: dotnet build
    env:
      API_KEY: ${{ secrets.API_KEY }}

Never log secrets:

# ❌ WRONG - Logs the secret
- name: Test
  run: echo "API_KEY=${{ secrets.API_KEY }}"

# ✓ CORRECT - Doesn't log secret
- name: Test
  run: dotnet test
  env:
    API_KEY: ${{ secrets.API_KEY }}

44.10 Deployment Strategies

Rolling deployment (no downtime):

steps:
  - name: Deploy rolling
    run: |
      # Deploy 1 new instance while keeping old running
      # Gradually replace old instances with new
      # Traffic shifts gradually      

Blue-green deployment (instant rollback):

Blue (current):   Running on :8080
Green (new):      Deploy to :8081, test
Switch:           Route traffic to :8081
Rollback:         Route back to :8080 if issues

Canary deployment (gradual rollout):

Send 10% traffic to new version
  ↓
Monitor metrics (errors, latency)
  ↓
If good, send 25% traffic
  ↓
If good, send 50% traffic
  ↓
If good, send 100% traffic
  ↓
If issues, rollback to 0%

44.11 Monitoring and Alerts

Add health check monitoring:

name: Production Health Monitoring

on:
  schedule:
    - cron: '*/5 * * * *'  # Every 5 minutes

jobs:
  health-check:
    runs-on: ubuntu-latest

    steps:
      - name: Check API health
        run: |
          curl -f https://api.example.com/health || exit 1
          curl -f https://api.example.com/api/commands || exit 1          

      - name: Alert on failure
        if: failure()
        uses: slackapi/slack-github-action@v1
        with:
          webhook-url: ${{ secrets.SLACK_WEBHOOK }}
          payload: |
            {
              "text": "🚨 Production API health check failed!"
            }            

44.12 Best Practices

1. Keep builds fast (< 10 minutes):

# Cache dependencies
- name: Cache NuGet packages
  uses: actions/setup-dotnet@v4
  with:
    cache: true  # Automatically caches .NET dependencies

2. Fail fast on critical issues:

# Run linting/analysis first (fast)
- name: Code quality
  run: dotnet format --verify-no-changes

# Then run tests (slower)
- name: Tests
  run: dotnet test

3. Test thoroughly before production:

Push to main
  ↓ Build + Test
  ↓ Deploy to staging
  ↓ Smoke tests
  ↓ Manual approval
  ↓ Deploy to production

4. Use environment protection:

In repository settings, require approval before deploying to production:

environment:
  name: production
  # Requires manual approval before running

5. Keep audit logs:

All deployments tracked and auditable:

# View deployment history in Actions tab
# See who deployed what, when, and why

44.13 What’s Next

You now have:

  • ✓ Understanding CI/CD benefits
  • ✓ GitHub Actions workflows
  • ✓ Build and test pipeline
  • ✓ Docker image build and push
  • ✓ Deployment to staging
  • ✓ Production deployment with blue-green strategy
  • ✓ Matrix testing across versions
  • ✓ Conditional steps and gates
  • ✓ Secret management
  • ✓ Deployment strategies (rolling, blue-green, canary)
  • ✓ Health monitoring and alerts
  • ✓ Best practices

Next: Advanced Topics—API versioning, real-time updates with SignalR, and beyond.