GitHub Actions CI Pipeline — Complete Multi-Stage Workflow

A well-structured CI pipeline runs fast (under 10 minutes for the BlogApp), provides useful feedback (failed test names, coverage diff, deployment preview), and protects the main branch (branch protection requiring green CI before merge). GitHub Actions’ job dependencies (needs:) enable parallel execution where safe and sequential execution where required — unit tests can run in parallel with building, but integration tests need the build to finish first.

Complete GitHub Actions Pipeline

// ── .github/workflows/ci.yml ──────────────────────────────────────────────
// name: CI Pipeline
// on:
//   push:
//     branches: [main, develop]
//   pull_request:
//     branches: [main]
//
// env:
//   DOTNET_VERSION: '8.0.x'
//   NODE_VERSION:   '20'
//
// jobs:
//   # ── Stage 1: Build (parallel) ─────────────────────────────────────────
//   build-api:
//     runs-on: ubuntu-latest
//     steps:
//       - uses: actions/checkout@v4
//       - uses: actions/setup-dotnet@v4
//         with: { dotnet-version: '${{ env.DOTNET_VERSION }}' }
//       - name: Restore
//         run: dotnet restore
//       - name: Build
//         run: dotnet build --no-restore --configuration Release
//       - name: Upload build artifact
//         uses: actions/upload-artifact@v4
//         with: { name: api-build, path: BlogApp.Api/bin/Release }
//
//   build-angular:
//     runs-on: ubuntu-latest
//     steps:
//       - uses: actions/checkout@v4
//       - uses: actions/setup-node@v4
//         with: { node-version: '${{ env.NODE_VERSION }}' }
//       - name: Cache node_modules
//         uses: actions/cache@v4
//         with:
//           path: node_modules
//           key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
//       - run: npm ci
//       - run: npm run build -- --configuration production
//
//   # ── Stage 2: Unit Tests (parallel, after build) ───────────────────────
//   unit-tests-dotnet:
//     needs: build-api
//     runs-on: ubuntu-latest
//     steps:
//       - uses: actions/checkout@v4
//       - uses: actions/setup-dotnet@v4
//         with: { dotnet-version: '${{ env.DOTNET_VERSION }}' }
//       - run: dotnet restore
//       - run: dotnet test BlogApp.UnitTests
//           --collect:"XPlat Code Coverage"
//           --results-directory ./coverage
//           --filter "Category=Unit"
//       - uses: codecov/codecov-action@v4
//         with: { files: ./coverage/**/coverage.cobertura.xml, flags: unit }
//
//   unit-tests-angular:
//     needs: build-angular
//     runs-on: ubuntu-latest
//     steps:
//       - uses: actions/checkout@v4
//       - uses: actions/setup-node@v4
//         with: { node-version: '${{ env.NODE_VERSION }}' }
//       - run: npm ci
//       - run: npm test -- --coverage --watchAll=false
//       - uses: codecov/codecov-action@v4
//         with: { files: ./coverage/lcov.info, flags: angular }
//
//   # ── Stage 3: Integration Tests (after unit tests) ─────────────────────
//   integration-tests:
//     needs: [unit-tests-dotnet, unit-tests-angular]
//     runs-on: ubuntu-latest
//     services:
//       mssql:
//         image: mcr.microsoft.com/mssql/server:2022-latest
//         env:
//           SA_PASSWORD: TestPassword1234!
//           ACCEPT_EULA: Y
//         ports: ['1433:1433']
//     steps:
//       - uses: actions/checkout@v4
//       - uses: actions/setup-dotnet@v4
//         with: { dotnet-version: '${{ env.DOTNET_VERSION }}' }
//       - run: dotnet test BlogApp.IntegrationTests
//           --collect:"XPlat Code Coverage"
//           --results-directory ./coverage
//         env:
//           ConnectionStrings__Default: >
//             Server=localhost,1433;Database=BlogApp_Test;
//             User Id=sa;Password=TestPassword1234!;Encrypt=false
//
//   # ── Stage 4: E2E Tests (after integration tests) ──────────────────────
//   e2e-tests:
//     needs: integration-tests
//     runs-on: ubuntu-latest
//     steps:
//       - uses: actions/checkout@v4
//       - uses: actions/setup-node@v4
//         with: { node-version: '${{ env.NODE_VERSION }}' }
//       - run: npm ci
//       - uses: cypress-io/github-action@v6
//         with:
//           start: npm run start:ci   # starts API + Angular server
//           wait-on: 'http://localhost:4200, http://localhost:5000/api/health'
//           browser: chrome
//         env:
//           CYPRESS_ADMIN_PASSWORD: ${{ secrets.ADMIN_PASSWORD }}
//       - uses: actions/upload-artifact@v4
//         if: failure()
//         with: { name: cypress-screenshots, path: cypress/screenshots }
Note: Running SQL Server in GitHub Actions via the services: block starts a Docker container automatically — no manual setup needed. The mcr.microsoft.com/mssql/server:2022-latest image spins up a full SQL Server instance in about 30 seconds. The integration tests connect via localhost:1433 with the SA credentials set in the env block. This is more reliable than SQLite for integration tests that use SQL Server-specific features (rowversion, JSON functions, computed columns).
Tip: Use actions/cache for node_modules (keyed by package-lock.json hash) and NuGet packages (keyed by *.csproj hashes). Cache hits eliminate the 1-2 minute npm install and NuGet restore steps on every CI run. The cache key ensures the cache is invalidated when dependencies change. For a large Angular project, a cache hit saves 90 seconds per build — multiplied by 20 daily CI runs, that’s 30 minutes of developer waiting time saved per day.
Warning: Never put database connection strings, API keys, JWT signing keys, or any secrets directly in the workflow YAML file — even if the repository is private. Use GitHub Actions Secrets (Settings → Secrets and variables → Actions) and reference them as ${{ secrets.SECRET_NAME }}. GitHub automatically masks secret values in logs. For environment-specific configurations (staging vs production connection strings), use GitHub Environments with environment-specific secrets and protection rules requiring manual approval for production deployments.

CI Pipeline Timing Target

Stage Parallel? Target Time
Build (.NET + Angular) Yes — in parallel ~2 min
Unit tests (.NET + Angular) Yes — in parallel ~1 min
Integration tests No — needs unit tests ~3 min
E2E tests No — needs integration ~5 min
Total elapsed (critical path) ~11 min

Common Mistakes

Mistake 1 — All jobs sequential (slow CI, poor feedback)

❌ Wrong — every job runs after the previous; build → unit → integration → E2E: 15+ minutes, no parallelism.

✅ Correct — parallel where safe (unit tests after respective builds); sequential only where dependencies exist.

Mistake 2 — Secrets in workflow YAML (committed to Git)

❌ Wrong — SA_PASSWORD: MyDatabasePassword123! in workflow file; password in Git history forever.

✅ Correct — SA_PASSWORD: ${{ secrets.DB_SA_PASSWORD }}; stored in GitHub Actions Secrets.

🧠 Test Yourself

The pipeline has needs: [unit-tests-dotnet, unit-tests-angular] on the integration-tests job. Unit .NET tests take 1 min; Unit Angular tests take 2 min. When does integration-tests start?