Skip to content

Staging Deployment

Staging Deployment #23

Workflow file for this run

name: Staging Deployment
on:
workflow_run:
workflows: ["Publish Main Artifacts"] # must be the exact name of the workflow in publish-main.yml
types:
- completed
branches:
- main
# Only ever deploy the newest staging state. If publishes pile up, supersede the
# in-flight staging deploy with the latest.
concurrency:
group: deploy-stg
cancel-in-progress: true
jobs:
deploy-staging:
name: Deploy to Staging
runs-on: ubuntu-latest
if: ${{ github.event.workflow_run.conclusion == 'success' }}
permissions:
id-token: write
contents: read
env:
AWS_STG_ACCOUNT_ID: "071308038858"
SHA: ${{ github.event.workflow_run.head_sha }} # the exact commit the publish-main run built the artifact set for
ARTIFACT_BUCKET: "apiforge-artifacts-071308038858" # artifacts for backend and frontend
FRONTEND_BUCKET: "apiforge-frontend-071308038858" # frontend artifact copied from ARTIFACT_BUCKET
BUILD_PREFIX: "s3://apiforge-artifacts-071308038858/builds/${{ github.event.workflow_run.head_sha }}"
steps:
# check out the exact commit that was built, so the deployed SAM template /
# scripts match the downloaded artifacts
- uses: actions/checkout@v4
with:
ref: ${{ github.event.workflow_run.head_sha }}
- uses: ./.github/actions/setup-just
- uses: ./.github/actions/setup-node
with:
module-name: web
- name: Install frontend dependencies
run: just web install
- name: Install Playwright browsers
run: cd web && npx playwright install --with-deps chromium
- name: Configure AWS Staging Credentials (OIDC)
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::${{ env.AWS_STG_ACCOUNT_ID }}:role/GitHubActionsDeploymentRole # role name should match the one in bootstrap/template.yml
aws-region: eu-central-1
audience: sts.amazonaws.com
- name: Setup AWS SAM CLI
uses: aws-actions/setup-sam@v2
- name: Download Backend Artifacts from S3
run: |
for MODULE in backend-api-lambda sqs-dispatcher-lambda function-deployer-lambda; do
echo "Downloading $MODULE ($SHA)..."
mkdir -p lambda/$MODULE/target
aws s3 cp "$BUILD_PREFIX/$MODULE/function.zip" "lambda/$MODULE/target/function.zip"
done
# the frontend sync depends on the bucket existing, so it must come after this step.
- name: Deploy to Staging
run: just deploy-stack stg
- name: Deploy Frontend Artifact to Frontend S3 Bucket
run: |
aws s3 cp "$BUILD_PREFIX/frontend.zip" ./frontend-local.zip
echo "Unzipping frontend package..."
unzip -q frontend-local.zip -d ./frontend-dist
echo "Synchronizing files with the web S3 bucket..."
# CRITICAL: We must use --exclude "config.js" here.
# config.js is dynamically generated by the `connect-frontend.sh` script during deploy-stack above,
# so it is missing from our local './frontend-dist' artifact. Without the exclude flag,
# the `--delete` parameter would wipe out the config.js that connect-frontend.sh just wrote to the bucket,
# breaking the app until the next deploy.
aws s3 sync "./frontend-dist/" "s3://$FRONTEND_BUCKET/" --delete --exclude "config.js"
# we need to invalidate the CloudFront cache after deployment to make sure the new frontend version is served, otherwise users might still get the old cached version.
- name: Invalidate CloudFront CDN Cache
run: |
STACK_NAME="apiforge-stg"
echo "Fetching CloudFront Distribution ID from CloudFormation Outputs..."
DISTRIBUTION_ID=$(aws cloudformation describe-stacks \
--stack-name "$STACK_NAME" \
--query "Stacks[0].Outputs[?OutputKey=='CloudFrontDistributionId'].OutputValue" \
--output text)
if [ -z "$DISTRIBUTION_ID" ] || [ "$DISTRIBUTION_ID" == "None" ]; then
echo "❌ ERROR: Could not find CloudFrontDistributionId in Stack Outputs"
exit 1
fi
echo "🚀 Triggering cache invalidation for Distribution: $DISTRIBUTION_ID"
INVALIDATION_ID=$(aws cloudfront create-invalidation \
--distribution-id "$DISTRIBUTION_ID" \
--paths "/*" \
--query "Invalidation.Id" \
--output text)
echo "✅ Invalidation submitted successfully. ID: $INVALIDATION_ID"
echo "Note: It usually takes 1-3 minutes for AWS CloudFront to apply this globally."
# Hand the IDs to later steps so the e2e run can wait for the new build to go live.
echo "DISTRIBUTION_ID=$DISTRIBUTION_ID" >> "$GITHUB_ENV"
echo "INVALIDATION_ID=$INVALIDATION_ID" >> "$GITHUB_ENV"
- name: Smoke Test
run: |
API_ID=$(aws cloudformation describe-stacks \
--stack-name "apiforge-stg" \
--query "Stacks[0].Outputs[?OutputKey=='ApiId'].OutputValue" \
--output text)
echo "Checking health endpoint..."
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://$API_ID.execute-api.eu-central-1.amazonaws.com/stg/api/v1/health")
if [ "$STATUS" -ne 200 ]; then
echo "❌ ERROR: Smoke Test failed - API responded with status $STATUS"
exit 1
fi
echo "✅ API is healthy (200 OK)"
- name: E2E Tests against Staging
run: |
# CloudFront invalidation is async; wait for it so we don't test a stale frontend build.
echo "Waiting for CloudFront invalidation $INVALIDATION_ID to complete..."
aws cloudfront wait invalidation-completed \
--distribution-id "$DISTRIBUTION_ID" \
--id "$INVALIDATION_ID"
DOMAIN=$(aws cloudformation describe-stacks \
--stack-name "apiforge-stg" \
--query "Stacks[0].Outputs[?OutputKey=='CloudFrontDistributionName'].OutputValue" \
--output text)
if [ -z "$DOMAIN" ] || [ "$DOMAIN" == "None" ]; then
echo "❌ ERROR: Could not resolve CloudFront domain from stack outputs"
exit 1
fi
echo "Running e2e against https://$DOMAIN"
E2E_BASE_URL="https://$DOMAIN" just web test-e2e
- name: Upload Playwright Report
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report-stg
path: web/playwright-report/
retention-days: 7
# Runs only if every previous step (incl. the smoke test and e2e) passed. The marker is
# the gate for production: trigger-release.yml / deploy-prod.yml only accept a commit that
# has staged/<sha>. staged/latest is the default release target.
- name: Mark commit as staged
run: |
printf '%s' "$SHA" > sha.txt
echo "Writing staging marker staged/$SHA ..."
aws s3 cp sha.txt "s3://$ARTIFACT_BUCKET/staged/$SHA"
aws s3 cp sha.txt "s3://$ARTIFACT_BUCKET/staged/latest"
echo "✅ Marked $SHA as staged."