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 }}
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.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.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.