Companion to
../MICROSERVICES_TEACHING_GUIDE.md§16.
Goals
- Fast PRs. Build/test only what changed.
- Reproducible artefacts. Same image deployed across all environments.
- Safe production deploys. Approval gate, automated smoke tests, automatic rollback on failure.
- 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:validatestep 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