Every manual deployment step is a mistake waiting to happen at 2 AM on a Friday. Every CI check that doesn’t run is a bug that reaches production. This post builds the automation layer that ensures TechShop’s code reaches production reliably, repeatably, and without surprises.

Part 10 of the Angular Ecommerce Playbook. We build the GitHub Actions CI/CD pipeline for Angular 21 + .NET 10, including the API codegen drift check, Docker containerization, and Azure Container Apps deployment.

📊 Download: CI/CD Pipeline Diagram (draw.io)

Pipeline Overview

On Pull Request:
  ┌─────────────────────────────────────────────────────┐
  │  1. Angular: ESLint + TypeScript check              │
  │  2. Angular: Vitest unit tests (affected libraries) │
  │  3. .NET: build + xUnit tests                       │
  │  4. API codegen drift check                         │
  │  5. Playwright E2E (smoke tests only on PR)         │
  └─────────────────────────────────────────────────────┘

On Merge to main:
  ┌─────────────────────────────────────────────────────┐
  │  1. All PR checks pass                              │
  │  2. Angular SSR Docker build                        │
  │  3. .NET Docker build                               │
  │  4. Push to Azure Container Registry                │
  │  5. Deploy to Azure Container Apps (staging)        │
  │  6. Playwright E2E (full suite on staging)          │
  │  7. Manual approval gate                            │
  │  8. Deploy to production                            │
  └─────────────────────────────────────────────────────┘

The PR Workflow

# .github/workflows/pr.yml
name: Pull Request Checks

on:
  pull_request:
    branches: [main, develop]

env:
  NODE_VERSION: '22'
  DOTNET_VERSION: '10.0.x'

jobs:
  angular-checks:
    name: 'Angular: Lint + Test'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0  # Required for Nx affected calculation

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: 'pnpm'

      - name: Install pnpm
        uses: pnpm/action-setup@v4
        with: { version: 9 }

      - name: Install dependencies
        run: pnpm install --frozen-lockfile

      - name: Set Nx SHAs for affected calculation
        uses: nrwl/nx-set-shas@v4

      - name: TypeScript type check (affected)
        run: pnpm nx affected --target=type-check --base=$NX_BASE --head=$NX_HEAD

      - name: ESLint (affected)
        run: pnpm nx affected --target=lint --base=$NX_BASE --head=$NX_HEAD

      - name: Vitest unit tests (affected, with coverage)
        run: |
          pnpm nx affected --target=test \
            --base=$NX_BASE --head=$NX_HEAD \
            --parallel=4 \
            --coverage \
            --coverageThreshold='{"statements":80,"branches":80}'

  dotnet-checks:
    name: '.NET: Build + Test'
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_DB: techshop_test
          POSTGRES_USER: postgres
          POSTGRES_PASSWORD: postgres_test
        ports: ['5432:5432']
        options: --health-cmd pg_isready --health-interval 10s

    steps:
      - uses: actions/checkout@v4

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

      - name: Restore packages
        run: dotnet restore
        working-directory: ./api

      - name: Build
        run: dotnet build --no-restore --configuration Release
        working-directory: ./api

      - name: Unit Tests
        run: dotnet test --no-build --configuration Release --filter "Category=Unit"
        working-directory: ./api

      - name: Integration Tests
        run: |
          dotnet test --no-build --configuration Release \
            --filter "Category=Integration" \
            --collect:"XPlat Code Coverage"
        working-directory: ./api
        env:
          ConnectionStrings__DefaultConnection: "Host=localhost;Database=techshop_test;Username=postgres;Password=postgres_test"

  api-codegen-check:
    name: 'API Codegen: Check for Drift'
    runs-on: ubuntu-latest
    needs: dotnet-checks
    steps:
      - uses: actions/checkout@v4

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

      - name: Setup Node.js + Kiota
        uses: actions/setup-node@v4
        with: { node-version: '${{ env.NODE_VERSION }}' }

      - run: pnpm add -g @microsoft/kiota@latest

      - name: Start .NET API (background)
        run: |
          dotnet run --project api/src/TechShop.Api \
            --launch-profile Development &
          npx wait-on http://localhost:5000/openapi/v1.json --timeout 60000

      - name: Regenerate Kiota client
        run: |
          kiota generate \
            --openapi http://localhost:5000/openapi/v1.json \
            --language typescript \
            --class-name TechShopApiClient \
            --output libs/shared/api-client/src/lib/generated \
            --clean-output

      - name: Check for changes (fail if drift detected)
        run: |
          if ! git diff --exit-code libs/shared/api-client/src/lib/generated; then
            echo "❌ API client is out of date. Run 'kiota generate...' and commit the result."
            exit 1
          fi
          echo "✅ API client is up to date."

The Merge-to-Main Deployment Workflow

# .github/workflows/deploy.yml
name: Deploy

on:
  push:
    branches: [main]

env:
  REGISTRY: techshopacr.azurecr.io
  ACR_NAME: techshopacr
  RESOURCE_GROUP: techshop-prod

jobs:
  build-and-push:
    name: 'Docker: Build & Push'
    runs-on: ubuntu-latest
    outputs:
      image-tag: ${{ steps.meta.outputs.tags }}

    steps:
      - uses: actions/checkout@v4

      - name: Azure login
        uses: azure/login@v2
        with:
          creds: ${{ secrets.AZURE_CREDENTIALS }}

      - name: Login to ACR
        run: az acr login --name ${{ env.ACR_NAME }}

      - name: Generate Docker image metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/techshop-angular
          tags: |
            type=sha,prefix=sha-
            type=ref,event=branch

      # Build Angular SSR app
      - name: Build Angular SSR Docker image
        run: |
          docker build \
            -f apps/shell/Dockerfile \
            -t ${{ env.REGISTRY }}/techshop-angular:${{ github.sha }} \
            .
          docker push ${{ env.REGISTRY }}/techshop-angular:${{ github.sha }}

      # Build .NET API
      - name: Build .NET API Docker image
        run: |
          docker build \
            -f api/src/TechShop.Api/Dockerfile \
            -t ${{ env.REGISTRY }}/techshop-api:${{ github.sha }} \
            ./api
          docker push ${{ env.REGISTRY }}/techshop-api:${{ github.sha }}

  deploy-staging:
    name: 'Deploy: Staging'
    runs-on: ubuntu-latest
    needs: build-and-push
    environment: staging

    steps:
      - uses: azure/login@v2
        with: { creds: '${{ secrets.AZURE_CREDENTIALS }}' }

      - name: Deploy Angular to Azure Container Apps (staging)
        run: |
          az containerapp update \
            --name techshop-angular-staging \
            --resource-group ${{ env.RESOURCE_GROUP }} \
            --image ${{ env.REGISTRY }}/techshop-angular:${{ github.sha }}

      - name: Deploy .NET API to Azure Container Apps (staging)
        run: |
          az containerapp update \
            --name techshop-api-staging \
            --resource-group ${{ env.RESOURCE_GROUP }} \
            --image ${{ env.REGISTRY }}/techshop-api:${{ github.sha }}

  e2e-staging:
    name: 'E2E: Full Suite on Staging'
    runs-on: ubuntu-latest
    needs: deploy-staging

    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '22' }

      - run: pnpm install --frozen-lockfile
      - run: pnpm playwright install chromium

      - name: Run Playwright E2E against staging
        run: pnpm playwright test
        env:
          E2E_BASE_URL: https://staging.techshop.com
          E2E_TEST_USER: ${{ secrets.E2E_TEST_USER }}
          E2E_TEST_PASSWORD: ${{ secrets.E2E_TEST_PASSWORD }}

      - name: Upload test report
        uses: actions/upload-artifact@v4
        if: always()
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 14

  deploy-production:
    name: 'Deploy: Production (Manual Approval)'
    runs-on: ubuntu-latest
    needs: e2e-staging
    environment: production  # Requires manual reviewer approval in GitHub

    steps:
      - uses: azure/login@v2
        with: { creds: '${{ secrets.AZURE_CREDENTIALS }}' }

      - name: Deploy Angular to Production
        run: |
          az containerapp update \
            --name techshop-angular-prod \
            --resource-group ${{ env.RESOURCE_GROUP }} \
            --image ${{ env.REGISTRY }}/techshop-angular:${{ github.sha }}

      - name: Deploy .NET API to Production
        run: |
          az containerapp update \
            --name techshop-api-prod \
            --resource-group ${{ env.RESOURCE_GROUP }} \
            --image ${{ env.REGISTRY }}/techshop-api:${{ github.sha }}

Dockerfiles

Angular SSR Dockerfile

# apps/shell/Dockerfile
FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable pnpm && pnpm install --frozen-lockfile

COPY . .
RUN pnpm nx build shell --configuration=production

# Production image — minimal Node runtime
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production

# Only copy the built output
COPY --from=builder /app/dist/apps/shell /app/dist

EXPOSE 4000
CMD ["node", "dist/server/server.mjs"]

.NET 10 API Dockerfile

# api/src/TechShop.Api/Dockerfile
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
WORKDIR /app
EXPOSE 8080

FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY ["src/TechShop.Api/TechShop.Api.csproj", "src/TechShop.Api/"]
COPY ["src/TechShop.Application/TechShop.Application.csproj", "src/TechShop.Application/"]
COPY ["src/TechShop.Domain/TechShop.Domain.csproj", "src/TechShop.Domain/"]
COPY ["src/TechShop.Infrastructure/TechShop.Infrastructure.csproj", "src/TechShop.Infrastructure/"]
RUN dotnet restore "src/TechShop.Api/TechShop.Api.csproj"

COPY . .
RUN dotnet build "src/TechShop.Api/TechShop.Api.csproj" -c Release -o /app/build

FROM build AS publish
RUN dotnet publish "src/TechShop.Api/TechShop.Api.csproj" \
    -c Release -o /app/publish \
    --no-restore \
    /p:UseAppHost=false

FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "TechShop.Api.dll"]

Dependency Automation

# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: npm
    directory: /
    schedule: { interval: weekly, day: monday }
    groups:
      angular:
        patterns: ['@angular/*', '@angular-eslint/*']
      nx:
        patterns: ['@nx/*', 'nx']
      dev-tooling:
        patterns: ['vitest', 'playwright', 'typescript', 'eslint']
    ignore:
      - dependency-name: '*'
        update-types: ['version-update:semver-major']  # Manual for majors

  - package-ecosystem: nuget
    directory: /api
    schedule: { interval: weekly, day: monday }

  - package-ecosystem: docker
    directory: /
    schedule: { interval: monthly }
    directories:
      - /apps/shell
      - /api/src/TechShop.Api

References


This is Part 10 of 11 in the Angular Ecommerce Playbook. ← Part 9: Testing Strategy | Part 11: Tech Lead Playbook →

Export for reading

Comments