Skip to content

[GPCAPIM-260]-[Steel Thread integration testing]-[RP] #383

[GPCAPIM-260]-[Steel Thread integration testing]-[RP]

[GPCAPIM-260]-[Steel Thread integration testing]-[RP] #383

Workflow file for this run

name: Preview Environment
on:
pull_request:
types: [opened, reopened, synchronize, closed]
env:
AWS_REGION: eu-west-2
AWS_ACCOUNT_ID: "900119715266"
ECR_REPOSITORY_NAME: "whoami"
TF_STATE_BUCKET: "cds-cdg-dev-tfstate-900119715266"
PREVIEW_STATE_PREFIX: "dev/preview/"
python_version: "3.14"
jobs:
preview:
name: Manage preview environment
runs-on: ubuntu-latest
# Needed for OIDC → AWS (recommended)
permissions:
id-token: write
contents: read
pull-requests: write
# One job per branch at a time
concurrency:
group: preview-${{ github.head_ref || github.ref_name }}
cancel-in-progress: true
env:
AWS_ROLE_ARN: ${{ secrets.DEV_AWS_CREDENTIALS }}
steps:
- name: Checkout repo
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8
# Configure AWS credentials (OIDC recommended)
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@4c2b9cc816c86555b61460789ac95da17d7e829b
with:
role-to-assume: ${{ env.AWS_ROLE_ARN }}
aws-region: ${{ env.AWS_REGION }}
- name: Login to Amazon ECR
id: ecr-login
uses: aws-actions/amazon-ecr-login@062b18b96a7aff071d4dc91bc00c4c1a7945b076
- name: Compute branch metadata
id: meta
run: |
# For PRs, head_ref is the source branch name
RAW_BRANCH="${GITHUB_HEAD_REF:-${GITHUB_REF_NAME}}"
# Sanitize branch name for tags / hostnames (lowercase, only allowed chars)
SANITIZED_BRANCH=$(
printf '%s' "$RAW_BRANCH" \
| tr '[:upper:]' '[:lower:]' \
| tr '._' '-' \
| tr -c 'a-z0-9-' '-' \
| sed -E 's/-{2,}/-/g; s/^-+//; s/-+$//'
)
# Last resort fallback if everything got stripped
if [ -z "$SANITIZED_BRANCH" ]; then
SANITIZED_BRANCH="invalid-branch-name"
fi
echo "raw_branch=$RAW_BRANCH" >> $GITHUB_OUTPUT
echo "branch_name=$SANITIZED_BRANCH" >> $GITHUB_OUTPUT
# ECR repo URL (must match core stack's ECR repo)
ECR_URL="${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com/${ECR_REPOSITORY_NAME}"
echo "ecr_url=$ECR_URL" >> $GITHUB_OUTPUT
# Terraform state key for this preview env
TF_STATE_KEY="${PREVIEW_STATE_PREFIX}${SANITIZED_BRANCH}.tfstate"
echo "tf_state_key=$TF_STATE_KEY" >> $GITHUB_OUTPUT
# ALB listener rule priority - derive from PR number (must be unique per listener)
if [ -n "${{ github.event.number }}" ]; then
PRIORITY=$(( 1000 + ${{ github.event.number }} ))
else
PRIORITY=1999
fi
echo "alb_rule_priority=$PRIORITY" >> $GITHUB_OUTPUT
- name: Setup Python project
if: github.event.action != 'closed'
uses: ./.github/actions/setup-python-project
with:
python-version: ${{ env.python_version }}
- name: Build Docker image
if: github.event.action != 'closed'
env:
PYTHON_VERSION: ${{ env.python_version }}
run: |
IMAGE_TAG="${{ steps.meta.outputs.branch_name }}"
ECR_URL="${{ steps.meta.outputs.ecr_url }}"
make build IMAGE_TAG="${IMAGE_TAG}" ECR_URL="${ECR_URL}"
- name: Push Docker image to ECR
if: github.event.action != 'closed'
run: |
IMAGE_TAG="${{ steps.meta.outputs.branch_name }}"
ECR_URL="${{ steps.meta.outputs.ecr_url }}"
docker push "${ECR_URL}:${IMAGE_TAG}"
- name: Setup Terraform
uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd
with:
terraform_version: 1.14.0
# ---------- APPLY (PR opened / updated) ----------
- name: Terraform init (apply)
if: github.event.action != 'closed'
working-directory: infrastructure/environments/preview
run: |
terraform init \
-backend-config="bucket=${TF_STATE_BUCKET}" \
-backend-config="key=${{ steps.meta.outputs.tf_state_key }}" \
-backend-config="region=${AWS_REGION}"
- name: Terraform apply preview env
if: github.event.action != 'closed'
working-directory: infrastructure/environments/preview
env:
TF_VAR_branch_name: ${{ steps.meta.outputs.branch_name }}
TF_VAR_image_tag: ${{ steps.meta.outputs.branch_name }}
TF_VAR_alb_rule_priority: ${{ steps.meta.outputs.alb_rule_priority }}
run: |
terraform apply \
-auto-approve
- name: Capture preview TF outputs
if: github.event.action != 'closed'
id: tf-output
working-directory: infrastructure/environments/preview
run: |
terraform output -json > tf-output.json
URL=$(jq -r '.url.value' tf-output.json)
echo "preview_url=$URL" >> $GITHUB_OUTPUT
TG=$(jq -r '.target_group_arn.value' tf-output.json)
echo "target_group=$TG" >> $GITHUB_OUTPUT
ECS_SERVICE=$(jq -r '.ecs_service_name.value' tf-output.json)
echo "ecs_service=$ECS_SERVICE" >> $GITHUB_OUTPUT
ECS_CLUSTER=$(jq -r '.ecs_cluster_name.value' tf-output.json)
echo "ecs_cluster=$ECS_CLUSTER" >> $GITHUB_OUTPUT
- name: Compute preview host
id: set-host
if: github.event.action != 'closed'
run: |
PREVIEW_URL='${{ steps.tf-output.outputs.preview_url }}'
if [ -z "$PREVIEW_URL" ] || [ "$PREVIEW_URL" = "null" ]; then
echo "host=missing" >> "$GITHUB_OUTPUT"
exit 0
fi
HOST=$(printf '%s' "$PREVIEW_URL" | sed -E 's#^https?://##' | sed -E 's#/.*$##')
echo "host=${HOST}" >> "$GITHUB_OUTPUT"
- name: Get proxygen machine user details
id: proxygen-machine-user
uses: aws-actions/aws-secretsmanager-get-secrets@a9a7eb4e2f2871d30dc5b892576fde60a2ecc802
with:
secret-ids: /cds/gateway/dev/proxygen/proxygen-key-secret
name-transformation: lowercase
- name: Deploy preview API proxy
if: github.event.action != 'closed'
uses: ./.github/actions/proxy/deploy-proxy
with:
mtls-secret-name: ${{ vars.PREVIEW_ENV_MTLS_SECRET_NAME}}
target-url: ${{ steps.tf-output.outputs.preview_url }}
proxy-base-path: "clinical-data-gateway-api-poc-pr-${{ github.event.pull_request.number }}"
proxygen-key-secret: ${{ env._cds_gateway_dev_proxygen_proxygen_key_secret }}
proxygen-key-id: ${{ vars.PREVIEW_ENV_PROXYGEN_KEY_ID }}
proxygen-api-name: ${{ vars.PROXYGEN_API_NAME }}
proxygen-client-id: ${{ vars.PREVIEW_ENV_PROXYGEN_CLIENT_ID }}
- name: Tear down preview API proxy
if: github.event.action == 'closed'
uses: ./.github/actions/proxy/tear-down-proxy
with:
proxy-base-path: "clinical-data-gateway-api-poc-pr-${{ github.event.pull_request.number }}"
proxygen-key-secret: ${{ env._cds_gateway_dev_proxygen_proxygen_key_secret }}
proxygen-key-id: ${{ vars.PREVIEW_ENV_PROXYGEN_KEY_ID }}
proxygen-api-name: ${{ vars.PROXYGEN_API_NAME }}
proxygen-client-id: ${{ vars.PREVIEW_ENV_PROXYGEN_CLIENT_ID }}
# ---------- Ensure re-deployment (PR updated) ----------
- name: Force ECS service redeployment
if: github.event.action == 'synchronize'
id: await-redeployment
run: |
aws ecs update-service \
--cluster ${{ steps.tf-output.outputs.ecs_cluster }} \
--service ${{ steps.tf-output.outputs.ecs_service }} \
--force-new-deployment \
--region ${{ env.AWS_REGION }}
# ---------- DESTROY (PR closed) ----------
- name: Terraform init (destroy)
if: github.event.action == 'closed'
working-directory: infrastructure/environments/preview
run: |
terraform init \
-backend-config="bucket=${TF_STATE_BUCKET}" \
-backend-config="key=${{ steps.meta.outputs.tf_state_key }}" \
-backend-config="region=${AWS_REGION}"
- name: Terraform destroy preview env
if: github.event.action == 'closed'
working-directory: infrastructure/environments/preview
env:
TF_VAR_branch_name: ${{ steps.meta.outputs.branch_name }}
TF_VAR_image_tag: ${{ steps.meta.outputs.branch_name }}
TF_VAR_alb_rule_priority: ${{ steps.meta.outputs.alb_rule_priority }}
run: |
terraform destroy -auto-approve
# ---------- Wait on AWS tasks and notify ----------
- name: Await deployment completion
if: github.event.action != 'closed'
run: |
aws ecs wait services-stable \
--cluster ${{ steps.tf-output.outputs.ecs_cluster }} \
--services ${{ steps.tf-output.outputs.ecs_service }} \
--region ${{ env.AWS_REGION }}
- name: Get mTLS certs for testing
if: github.event.action != 'closed'
id: mtls-certs
uses: aws-actions/aws-secretsmanager-get-secrets@a9a7eb4e2f2871d30dc5b892576fde60a2ecc802
with:
secret-ids: |
/cds/gateway/dev/mtls/client1-key-secret
/cds/gateway/dev/mtls/client1-key-public
name-transformation: lowercase
# Prepare cert files for the following test suites
- name: Prepare mTLS cert files for tests
if: github.event.action != 'closed'
run: |
printf '%s' "$_cds_gateway_dev_mtls_client1_key_secret" > /tmp/client1-key.pem
printf '%s' "$_cds_gateway_dev_mtls_client1_key_public" > /tmp/client1-cert.pem
chmod 600 /tmp/client1-key.pem /tmp/client1-cert.pem
- name: Smoke test preview URL
if: github.event.action != 'closed'
id: smoke-test
env:
PREVIEW_URL: ${{ steps.tf-output.outputs.preview_url }}
run: |
if [ -z "$PREVIEW_URL" ] || [ "$PREVIEW_URL" = "null" ]; then
echo "Preview URL missing"
echo "http_status=missing" >> "$GITHUB_OUTPUT"
echo "http_result=missing-url" >> "$GITHUB_OUTPUT"
exit 0
fi
STATUS=$(curl \
--cert /tmp/client1-cert.pem \
--key /tmp/client1-key.pem \
--silent \
--output /tmp/preview.headers \
--write-out '%{http_code}' \
--head \
--max-time 30 "$PREVIEW_URL"/health || true)
if [ "$STATUS" = "404" ]; then
echo "Preview responded with expected 404"
echo "http_status=404" >> "$GITHUB_OUTPUT"
echo "http_result=allowed-404" >> "$GITHUB_OUTPUT"
exit 0
fi
if [[ "$STATUS" =~ ^[0-9]{3}$ ]] && [ "$STATUS" -ge 200 ] && [ "$STATUS" -lt 400 ]; then
echo "Preview responded with status $STATUS"
echo "http_status=$STATUS" >> "$GITHUB_OUTPUT"
echo "http_result=success" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "Preview responded with unexpected status $STATUS"
if [ -f /tmp/preview.headers ]; then
echo "Response headers:"
cat /tmp/preview.headers
fi
echo "http_status=$STATUS" >> "$GITHUB_OUTPUT"
echo "http_result=unexpected-status" >> "$GITHUB_OUTPUT"
exit 0
# ---------- QUALITY CHECKS (Test Suites) ----------
# UNIT TESTS
- name: Run unit tests against preview
if: github.event.action != 'closed'
env:
MTLS_CERT: /tmp/client1-cert.pem
MTLS_KEY: /tmp/client1-key.pem
run: |
make test-unit
- name: Upload unit test results
if: always()
uses: actions/upload-artifact@v5
with:
name: unit-test-results
path: gateway-api/test-artefacts/
retention-days: 30
- name: Check unit-tests.xml exists
id: check-unit
if: always()
run: |
TARGET="gateway-api/test-artefacts/unit-tests.xml"
echo "Checking for $TARGET"
if [ -f "$TARGET" ]; then
echo "exists=true" >> "$GITHUB_OUTPUT"
echo "Found $TARGET"
else
echo "exists=false" >> "$GITHUB_OUTPUT"
echo "Missing $TARGET" >&2
echo "Listing gateway-api/test-artefacts for debugging:"
ls -la gateway-api/test-artefacts || true
fi
- name: Publish unit test results to summary
if: ${{ always() && steps.check-unit.outputs.exists == 'true' }}
uses: test-summary/action@31493c76ec9e7aa675f1585d3ed6f1da69269a86
with:
paths: gateway-api/test-artefacts/unit-tests.xml
# CONTRACT TESTS
- name: Run contract tests
if: github.event.action != 'closed'
env:
BASE_URL: ${{ steps.tf-output.outputs.preview_url }}
MTLS_CERT: /tmp/client1-cert.pem
MTLS_KEY: /tmp/client1-key.pem
run: |
make test-contract
- name: Upload contract test results
if: always()
uses: actions/upload-artifact@v5
with:
name: contract-test-results
path: gateway-api/test-artefacts/
retention-days: 30
- name: Check contract-tests.xml exists
id: check-contract
if: always()
run: |
TARGET="gateway-api/test-artefacts/contract-tests.xml"
echo "Checking for $TARGET"
if [ -f "$TARGET" ]; then
echo "exists=true" >> "$GITHUB_OUTPUT"
echo "Found $TARGET"
else
echo "exists=false" >> "$GITHUB_OUTPUT"
echo "Missing $TARGET" >&2
echo "Listing gateway-api/test-artefacts for debugging:"
ls -la gateway-api/test-artefacts || true
fi
- name: Publish contract test results to summary
if: ${{ always() && steps.check-contract.outputs.exists == 'true' }}
uses: test-summary/action@31493c76ec9e7aa675f1585d3ed6f1da69269a86
with:
paths: gateway-api/test-artefacts/contract-tests.xml
# SCHEMA TESTS
- name: Run schema validation tests against preview
if: github.event.action != 'closed'
env:
BASE_URL: ${{ steps.tf-output.outputs.preview_url }}
MTLS_CERT: /tmp/client1-cert.pem
MTLS_KEY: /tmp/client1-key.pem
run: |
make test-schema
- name: Upload schema test results
if: always()
uses: actions/upload-artifact@v5
with:
name: schema-test-results
path: gateway-api/test-artefacts/
retention-days: 30
- name: Check schema-tests.xml exists
id: check-schema
if: always()
run: |
TARGET="gateway-api/test-artefacts/schema-tests.xml"
echo "Checking for $TARGET"
if [ -f "$TARGET" ]; then
echo "exists=true" >> "$GITHUB_OUTPUT"
echo "Found $TARGET"
else
echo "exists=false" >> "$GITHUB_OUTPUT"
echo "Missing $TARGET" >&2
echo "Listing gateway-api/test-artefacts for debugging:"
ls -la gateway-api/test-artefacts || true
fi
- name: Publish schema test results to summary
if: ${{ always() && steps.check-schema.outputs.exists == 'true' }}
uses: test-summary/action@31493c76ec9e7aa675f1585d3ed6f1da69269a86
with:
paths: gateway-api/test-artefacts/schema-tests.xml
# INTEGRATION TESTS
- name: Run integration tests against preview
if: github.event.action != 'closed'
env:
BASE_URL: ${{ steps.tf-output.outputs.preview_url }}
MTLS_CERT: /tmp/client1-cert.pem
MTLS_KEY: /tmp/client1-key.pem
run: |
make test-integration
- name: Upload integration test results
if: always()
uses: actions/upload-artifact@v5
with:
name: integration-test-results
path: gateway-api/test-artefacts/
retention-days: 30
- name: Check integration-tests.xml exists
id: check-integration
if: always()
run: |
TARGET="gateway-api/test-artefacts/integration-tests.xml"
echo "Checking for $TARGET"
if [ -f "$TARGET" ]; then
echo "exists=true" >> "$GITHUB_OUTPUT"
echo "Found $TARGET"
else
echo "exists=false" >> "$GITHUB_OUTPUT"
echo "Missing $TARGET" >&2
echo "Listing gateway-api/test-artefacts for debugging:"
ls -la gateway-api/test-artefacts || true
fi
- name: Publish integration test results to summary
if: ${{ always() && steps.check-integration.outputs.exists == 'true' }}
uses: test-summary/action@31493c76ec9e7aa675f1585d3ed6f1da69269a86
with:
paths: gateway-api/test-artefacts/integration-tests.xml
# ACCEPTANCE TESTS
- name: Run acceptance tests against preview
if: github.event.action != 'closed'
env:
BASE_URL: ${{ steps.tf-output.outputs.preview_url }}
HOST: ${{ steps.set-host.outputs.host }}
MTLS_CERT: /tmp/client1-cert.pem
MTLS_KEY: /tmp/client1-key.pem
run: |
make test-acceptance
- name: Upload acceptance test results
if: always()
uses: actions/upload-artifact@v5
with:
name: acceptance-test-results
path: gateway-api/test-artefacts/
retention-days: 30
- name: Check acceptance-tests.xml exists
id: check-acceptance
if: always()
run: |
TARGET="gateway-api/test-artefacts/acceptance-tests.xml"
echo "Checking for $TARGET"
if [ -f "$TARGET" ]; then
echo "exists=true" >> "$GITHUB_OUTPUT"
echo "Found $TARGET"
else
echo "exists=false" >> "$GITHUB_OUTPUT"
echo "Missing $TARGET" >&2
echo "Listing gateway-api/test-artefacts for debugging:"
ls -la gateway-api/test-artefacts || true
fi
- name: Publish acceptance test results to summary
if: ${{ always() && steps.check-acceptance.outputs.exists == 'true' }}
uses: test-summary/action@31493c76ec9e7aa675f1585d3ed6f1da69269a86
with:
paths: gateway-api/test-artefacts/acceptance-tests.xml
# Cleanup after tests
- name: Remove mTLS temp files
if: github.event.action != 'closed'
run: |
rm -f /tmp/client1-key.pem /tmp/client1-cert.pem || true
- name: Comment function name on PR
if: github.event_name == 'pull_request' && github.event.action != 'closed'
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd
with:
script: |
const alb = '${{ steps.tf-output.outputs.target_group }}';
const url = '${{ steps.tf-output.outputs.preview_url }}';
const proxy_url = 'https://internal-dev.api.service.nhs.uk/clinical-data-gateway-api-poc-pr-${{ github.event.pull_request.number }}';
const cluster = '${{ steps.tf-output.outputs.ecs_cluster }}';
const service = '${{ steps.tf-output.outputs.ecs_service }}';
const owner = context.repo.owner;
const repo = context.repo.repo;
const issueNumber = context.issue.number;
const smokeStatus = '${{ steps.smoke-test.outputs.http_status }}' || 'n/a';
const smokeResult = '${{ steps.smoke-test.outputs.http_result }}' || 'not-run';
const smokeLabels = {
success: ':white_check_mark: Passed',
'allowed-404': ':white_check_mark: Allowed 404',
'unexpected-status': ':x: Unexpected status',
'missing-url': ':x: Missing URL',
};
const smokeReadable = smokeLabels[smokeResult] ?? smokeResult;
const { data: comments } = await github.rest.issues.listComments({
owner,
repo,
issue_number: issueNumber,
per_page: 100,
});
for (const comment of comments) {
const isBot = comment.user?.login === 'github-actions[bot]';
const isPreviewUpdate = comment.body?.includes('Deployment Complete');
if (isBot && isPreviewUpdate) {
await github.rest.issues.deleteComment({
owner,
repo,
comment_id: comment.id,
});
}
}
const lines = [
'**Deployment Complete**',
`- Preview URL: [${url}](${url}) — [Health endpoint](${url}/health)`,
` - Smoke Test: ${smokeReadable} (HTTP ${smokeStatus})`,
`- Proxy URL: [${proxy_url}](${proxy_url})`,
`- ECS Cluster: \`${cluster}\``,
`- ECS Service: \`${service}\``,
`- ALB Target: \`${alb}\``,
];
await github.rest.issues.createComment({
owner,
repo,
issue_number: issueNumber,
body: lines.join('\n'),
});
# ---------- Security scanning ----------
- name: Trivy IaC scan
if: github.event.action != 'closed'
uses: nhs-england-tools/trivy-action/iac-scan@3456c1657a37d500027fd782e6b08911725392da
with:
scan-ref: infrastructure/environments/preview
artifact-name: trivy-iac-scan-${{ steps.meta.outputs.branch_name }}
- name: Trivy filesystem scan
if: github.event.action != 'closed'
uses: nhs-england-tools/trivy-action/image-scan@3456c1657a37d500027fd782e6b08911725392da
with:
image-ref: ${{steps.meta.outputs.ecr_url}}:${{steps.meta.outputs.branch_name}}
artifact-name: trivy-scan-${{ steps.meta.outputs.branch_name }}
- name: Generate SBOM
uses: nhs-england-tools/trivy-action/sbom-scan@3456c1657a37d500027fd782e6b08911725392da
if: github.event.action != 'closed'
with:
image-ref: ${{steps.meta.outputs.ecr_url}}:${{steps.meta.outputs.branch_name}}
artifact-name: trivy-sbom-${{ steps.meta.outputs.branch_name }}