Skip to content

Add alpha integration environment workflow #792

Add alpha integration environment workflow

Add alpha integration environment workflow #792

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/"
BASE_URL: "https://internal-dev.api.service.nhs.uk/clinical-data-gateway-api-poc-pr-${{ github.event.pull_request.number }}"
python_version: "3.14"
PROXYGEN_API_NAME: ${{ vars.PROXYGEN_API_NAME }}
PR_NUMBER: ${{ github.event.pull_request.number }}
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:
DEV_AWS_ROLE_ARN: ${{ secrets.DEV_AWS_CREDENTIALS }}
steps:
- name: Checkout repo
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8
- name: Select AWS role inputs
id: role-select
env:
DEPENDABOT_AWS_ROLE_ARN: ${{ secrets.DEPENDABOT_AWS_ROLE_ARN }}
AWS_ROLE_ARN: ${{ secrets.DEV_AWS_CREDENTIALS }}
run: |
if [ "${{ github.actor }}" = "dependabot[bot]" ]; then
echo "aws_role=$DEPENDABOT_AWS_ROLE_ARN" >> "$GITHUB_OUTPUT"
else
echo "aws_role=$AWS_ROLE_ARN" >> "$GITHUB_OUTPUT"
fi
# Configure AWS credentials (OIDC recommended)
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@b1257c400167d727708335212f95607835cd03fd
with:
role-to-assume: ${{ steps.role-select.outputs.aws_role }}
aws-region: ${{ env.AWS_REGION }}
- name: Login to Amazon ECR
id: ecr-login
uses: aws-actions/amazon-ecr-login@c962da2960ed15f492addc26fffa274485265950
- name: Compute branch metadata
id: meta
run: |
# For PRs, head_ref is the source branch name
RAW_BRANCH="${GITHUB_HEAD_REF:-${GITHUB_REF_NAME}}"
# Dependabot PRs should use a stable preview identifier based on PR number,
# not the branch name (which contains slashes and can be long).
if [ "${GITHUB_ACTOR}" = "dependabot[bot]" ] || [[ "$RAW_BRANCH" == dependabot/* ]]; then
SANITIZED_BRANCH="dependabot-${{ github.event.pull_request.number }}"
else
# 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
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@5e8dbf3c6d9deaf4193ca7a8fb23f2ac83bb6c85
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 \
-var-file="preview.tfvars" \
-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: 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 \
-var-file="preview.tfvars" \
-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) ----------
- name: Retrieve Apigee Token
id: apigee-token
shell: bash
run: |
set -euo pipefail
APIGEE_TOKEN="$(proxygen pytest-nhsd-apim get-token | jq -r '.pytest_nhsd_apim_token' 2>/dev/null)"
if [ -z "$APIGEE_TOKEN" ] || [ "$APIGEE_TOKEN" = "null" ]; then
echo "::error::Failed to retrieve Apigee token"
exit 1
fi
echo "::add-mask::$APIGEE_TOKEN"
printf 'apigee-access-token=%s\n' "$APIGEE_TOKEN" >> "$GITHUB_OUTPUT"
echo "Token retrieved successfully (length: ${#APIGEE_TOKEN})"
- name: "Run unit tests"
if: github.event.action != 'closed'
uses: ./.github/actions/run-test-suite
with:
test-type: unit
env: local
- name: "Run contract tests"
if: github.event.action != 'closed'
uses: ./.github/actions/run-test-suite
with:
test-type: contract
apigee-access-token: ${{ steps.apigee-token.outputs.apigee-access-token }}
base-url: ${{ env.BASE_URL }}
- name: "Run schema validation tests"
if: github.event.action != 'closed'
uses: ./.github/actions/run-test-suite
with:
test-type: schema
apigee-access-token: ${{ steps.apigee-token.outputs.apigee-access-token }}
base-url: ${{ env.BASE_URL }}
- name: "Run integration tests"
if: github.event.action != 'closed'
uses: ./.github/actions/run-test-suite
with:
test-type: integration
apigee-access-token: ${{ steps.apigee-token.outputs.apigee-access-token }}
base-url: ${{ env.BASE_URL }}
- name: "Run acceptance tests"
if: github.event.action != 'closed'
uses: ./.github/actions/run-test-suite
with:
test-type: acceptance
apigee-access-token: ${{ steps.apigee-token.outputs.apigee-access-token }}
base-url: ${{ env.BASE_URL }}
# Cleanup after tests
- name: Remove mTLS temp files
if: github.event.action != 'closed'
run: rm -f /tmp/client1-key.pem /tmp/client1-cert.pem
- 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@289984b2f03034233a347d6dbadecd5ca9ea9634
with:
scan-ref: infrastructure/environments/preview
artifact-name: trivy-iac-scan-${{ steps.meta.outputs.branch_name }}
- name: Trivy image scan
if: github.event.action != 'closed'
uses: nhs-england-tools/trivy-action/image-scan@289984b2f03034233a347d6dbadecd5ca9ea9634
with:
image-ref: ${{steps.meta.outputs.ecr_url}}:${{steps.meta.outputs.branch_name}}
artifact-name: trivy-image-scan-${{ steps.meta.outputs.branch_name }}
- name: Generate SBOM
if: github.event.action != 'closed'
uses: nhs-england-tools/trivy-action/image-scan@289984b2f03034233a347d6dbadecd5ca9ea9634
with:
image-ref: ${{steps.meta.outputs.ecr_url}}:${{steps.meta.outputs.branch_name}}
artifact-name: trivy-sbom-${{ steps.meta.outputs.branch_name }}