Companion to ../MICROSERVICES_TEACHING_GUIDE.md §16.

Goals

  1. Fast PRs. Build/test only what changed.
  2. Reproducible artefacts. Same image deployed across all environments.
  3. Safe production deploys. Approval gate, automated smoke tests, automatic rollback on failure.
  4. Zero long-lived AWS keys. OIDC federation only.

Pipeline overview

PR opened ─▶ build-test.yml         (per-service tests, contract tests, SAST, image scan)
              │
merge main ─▶ release.yml           (build, push to ECR, deploy to dev → smoke → staging)
              │
git tag v* ─▶ promote-prod.yml      (manual approval, deploy to prod, smoke, monitor)

1. .github/workflows/build-test.yml

Triggered on PRs. Uses path filters so only changed services build.

name: Build & test

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

permissions:
  id-token: write
  contents: read
  pull-requests: write

jobs:
  detect-changes:
    runs-on: ubuntu-latest
    outputs:
      services: ${{ steps.filter.outputs.changes }}
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }
      - uses: dorny/paths-filter@v3
        id: filter
        with:
          list-files: json
          filters: |
            api-gateway:           services/api-gateway/**
            payment-service:       services/payment-service/**
            payment-query-service: services/payment-query-service/**
            bank-adapter-service:  services/bank-adapter-service/**
            fraud-check-service:   services/fraud-check-service/**
            notification-service:  services/notification-service/**
            shared:                shared/**
            infra:                 infra/**

  test:
    needs: detect-changes
    if: ${{ needs.detect-changes.outputs.services != '[]' }}
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        service: ${{ fromJSON(needs.detect-changes.outputs.services) }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with: { distribution: temurin, java-version: '17', cache: gradle }
      - name: Build & test ${{ matrix.service }}
        run: |
          if [[ "${{ matrix.service }}" == "shared" ]]; then
            ./gradlew :shared:payment-events:build
          elif [[ "${{ matrix.service }}" == "infra" ]]; then
            cd infra/terraform && terraform fmt -check && terraform validate
          else
            ./gradlew :services:${{ matrix.service }}:test \
                      :services:${{ matrix.service }}:bootBuildImage \
                      --imageName=${{ matrix.service }}:${{ github.sha }}
          fi
      - name: SAST (Semgrep)
        if: matrix.service != 'infra'
        uses: returntocorp/semgrep-action@v1
        with: { config: 'p/owasp-top-ten' }
      - name: Image scan (Trivy)
        if: matrix.service != 'shared' && matrix.service != 'infra'
        uses: aquasecurity/trivy-action@master
        with:
          image-ref: '${{ matrix.service }}:${{ github.sha }}'
          severity: HIGH,CRITICAL
          exit-code: 1

  contract-tests:
    needs: detect-changes
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with: { distribution: temurin, java-version: '17', cache: gradle }
      - name: Verify all consumer pacts
        run: ./gradlew pactVerify   # or contractTest

2. .github/workflows/release.yml

Runs on push to main. Builds all changed services, pushes to ECR, deploys to dev, runs smoke tests, then promotes to staging.

name: Release

on:
  push:
    branches: [main]

permissions:
  id-token: write
  contents: read

env:
  AWS_REGION: eu-west-2
  ECR_REGISTRY: 123456789012.dkr.ecr.eu-west-2.amazonaws.com

jobs:
  detect:
    uses: ./.github/workflows/build-test.yml      # reuse path-filter job

  build-and-push:
    needs: detect
    if: ${{ needs.detect.outputs.services != '[]' }}
    runs-on: ubuntu-latest
    strategy:
      matrix:
        service: ${{ fromJSON(needs.detect.outputs.services) }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with: { distribution: temurin, java-version: '17', cache: gradle }
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/gh-deployer
          aws-region: ${{ env.AWS_REGION }}
      - uses: aws-actions/amazon-ecr-login@v2
      - name: Build & push image
        if: matrix.service != 'shared' && matrix.service != 'infra'
        run: |
          IMAGE_TAG=${GITHUB_SHA::8}
          ./gradlew :services:${{ matrix.service }}:bootBuildImage \
            --imageName=${{ env.ECR_REGISTRY }}/${{ matrix.service }}:$IMAGE_TAG
          docker push ${{ env.ECR_REGISTRY }}/${{ matrix.service }}:$IMAGE_TAG

  deploy-dev:
    needs: build-and-push
    runs-on: ubuntu-latest
    environment: dev
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/gh-deployer
          aws-region: ${{ env.AWS_REGION }}
      - working-directory: infra/terraform/envs/dev
        run: |
          terraform init
          terraform apply -auto-approve -var image_tag=${GITHUB_SHA::8}

  smoke-dev:
    needs: deploy-dev
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: ./scripts/smoke.sh https://api.dev.paygw.example.com

  deploy-staging:
    needs: smoke-dev
    runs-on: ubuntu-latest
    environment: staging
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::234567890123:role/gh-deployer
          aws-region: ${{ env.AWS_REGION }}
      - working-directory: infra/terraform/envs/staging
        run: |
          terraform init
          terraform apply -auto-approve -var image_tag=${GITHUB_SHA::8}

3. .github/workflows/promote-prod.yml

Triggered by tagging vX.Y.Z. Manual approval inside the production GitHub environment.

name: Promote to Production

on:
  push:
    tags: ['v*.*.*']

permissions:
  id-token: write
  contents: read

jobs:
  promote:
    runs-on: ubuntu-latest
    environment: production           # protection rules require approver
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::345678901234:role/gh-deployer
          aws-region: ${{ env.AWS_REGION }}
      - name: Resolve image tag from staging
        id: tag
        run: |
          TAG=$(aws ssm get-parameter --name /paymentgw/staging/image_tag --query Parameter.Value --output text)
          echo "tag=$TAG" >> $GITHUB_OUTPUT
      - working-directory: infra/terraform/envs/prod
        run: |
          terraform init
          terraform apply -auto-approve -var image_tag=${{ steps.tag.outputs.tag }}
      - name: Smoke test prod
        run: ./scripts/smoke.sh https://api.paygw.example.com
      - name: Promote tag in SSM
        run: aws ssm put-parameter --name /paymentgw/prod/image_tag --type String --overwrite --value "${{ steps.tag.outputs.tag }}"

GitHub production environment protection:

  • Required reviewers: 2.
  • Wait timer: 5 minutes.
  • Branch restriction: tags only.

4. Smoke test script

scripts/smoke.sh:

#!/usr/bin/env bash
set -euo pipefail
BASE=$1
TOKEN=$(./scripts/issue-test-jwt.sh)

echo "▶ POST /api/payments"
RESP=$(curl -fsSL "$BASE/api/payments" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Idempotency-Key: smoke-$(date +%s)" \
  -H "Content-Type: application/json" \
  -d '{
    "card_number":"2222405343248877",
    "expiry_month":4,"expiry_year":2030,
    "currency":"GBP","amount":100,"cvv":"123"
  }')
echo "$RESP" | jq .

ID=$(echo "$RESP" | jq -r .id)
echo "▶ GET /api/payments/$ID (eventual consistency: retry up to 10s)"
for i in {1..10}; do
  if curl -fsSL "$BASE/api/payments/$ID" -H "Authorization: Bearer $TOKEN" -o /dev/null; then
    echo "✅ visible after ${i}s"; exit 0
  fi
  sleep 1
done
echo "❌ payment never appeared on read side"; exit 1

5. Rolling out database migrations safely

Migrations run on each service's startup via Flyway. Risk: a bad migration can wedge a service indefinitely. Mitigations:

  • flyway:validate step in CI before the deploy job.
  • Blue/green deploy with the new task definition. CodeDeploy traffic-shifts; if the new tasks fail health checks, traffic stays on old tasks and the deploy is rolled back automatically.
  • Forbid destructive DDL on the same release as code that depends on the old shape — use the expand/contract pattern.

Expand/contract example for a column rename:

Release Migration Code change
v1.0 (existing) card_last_four reads/writes card_last_four
v1.1 add column card_number_last_four, copy values, dual-write code writes both, reads new column
v1.2 drop column card_last_four code only references new column

Three releases — but zero downtime and trivially rollbackable.


6. Observability hooks in CI/CD

  • After every deploy, post a deployment marker to CloudWatch (and to Datadog/Grafana if used) so you can correlate metric anomalies with releases.
  • Annotate Sentry releases with the git SHA so error reports link back to the PR.
- name: Annotate deployment
  run: |
    aws cloudwatch put-metric-data --namespace deploys \
      --metric-name release \
      --value 1 \
      --dimensions Service=${{ matrix.service }},Env=prod