Skip to content

Merge pull request #3130 from kikora/codex/deploy-infra.yml-to-dev-fo… #1101

Merge pull request #3130 from kikora/codex/deploy-infra.yml-to-dev-fo…

Merge pull request #3130 from kikora/codex/deploy-infra.yml-to-dev-fo… #1101

Workflow file for this run

name: Deploy infrastructure
permissions:
contents: read
id-token: write
on:
push:
branches: [prod]
workflow_dispatch:
inputs:
cloudfront_invalidation_paths:
description: "Space-separated list of paths to invalidate (defaults to /*)"
required: false
default: "/*"
jobs:
deploy-iac:
name: Deploy CloudFormation stacks
runs-on: ubuntu-latest
if: ${{ github.event_name == 'push' || github.event_name == 'workflow_dispatch' || github.event.pull_request.merged }}
env:
# Required GitHub secrets for AWS access and stack configuration
AWS_REGION: ${{ secrets.AWS_REGION }}
AWS_IAC_ROLE_ARN: ${{ secrets.AWS_IAC_ROLE_ARN }}
# Required when provisioning the data stack internally; pass the Redis password directly from GitHub Secrets.
REDIS_PASSWORD: ${{ secrets.REDIS_PASSWORD || '' }}
# Prod-static bucket must be reused (see deployment-notes.md). Default to the
# existing bucket so deploys don't create a new one if the secret is missing.
STATIC_SITE_BUCKET_NAME: ${{ secrets.STATIC_SITE_BUCKET_NAME || 'math-visuals-static-site-796063593326-202512172100921' }}
STATIC_SITE_CLOUDFRONT_DISTRIBUTION_ID: ${{ secrets.STATIC_SITE_CLOUDFRONT_DISTRIBUTION_ID }}
STATIC_SITE_API_DOMAIN: ${{ secrets.STATIC_SITE_API_DOMAIN }}
STATIC_SITE_API_ORIGIN_PATH: ${{ secrets.STATIC_SITE_API_ORIGIN_PATH }}
STATIC_SITE_CLOUDFRONT_PRICE_CLASS: ${{ secrets.STATIC_SITE_CLOUDFRONT_PRICE_CLASS }}
API_ARTIFACT_BUCKET: ${{ secrets.API_ARTIFACT_BUCKET }}
API_ARTIFACT_KEY: ${{ secrets.API_ARTIFACT_KEY }}
REDIS_ENDPOINT: ${{ secrets.REDIS_ENDPOINT || '' }}
REDIS_PORT: ${{ secrets.REDIS_PORT || '' }}
REDIS_PASSWORD_SECRET_ARN: ${{ secrets.REDIS_PASSWORD_SECRET_ARN || '' }}
PRIVATE_SUBNET_1_ID: ${{ secrets.PRIVATE_SUBNET_1_ID || '' }}
PRIVATE_SUBNET_2_ID: ${{ secrets.PRIVATE_SUBNET_2_ID || '' }}
LAMBDA_SECURITY_GROUP_ID: ${{ secrets.LAMBDA_SECURITY_GROUP_ID || '' }}
# Default to the template's prod stage when the secret is missing so the
# deploy step doesn't fail with an empty StageName parameter.
API_STAGE_NAME: ${{ secrets.API_STAGE_NAME || 'prod' }}
CLOUDFRONT_INVALIDATION_PATHS: ${{ github.event.inputs.cloudfront_invalidation_paths || secrets.CLOUDFRONT_INVALIDATION_PATHS || '/*' }}
# Stack names and defaults
STATIC_STACK_NAME: math-visuals-static-site
API_STACK_NAME: math-visuals-api
DATA_STACK_NAME: ${{ secrets.DATA_STACK_NAME || 'math-visuals-redis' }}
DATA_ENVIRONMENT_NAME: prod
SHARED_PARAMETERS_STACK_NAME: math-visuals-shared
SKIP_EXAMPLES_SEEDING: "true"
USE_EXTERNAL_REDIS: ${{ secrets.USE_EXTERNAL_REDIS || 'false' }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: Install workspace dependencies
run: npm ci
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v2
with:
role-to-assume: ${{ env.AWS_IAC_ROLE_ARN }}
aws-region: ${{ env.AWS_REGION }}
- name: Debug CloudFront invalidation paths
run: |
if [ -z "${STATIC_SITE_CLOUDFRONT_DISTRIBUTION_ID:-}" ]; then
echo "STATIC_SITE_CLOUDFRONT_DISTRIBUTION_ID is empty; CloudFront invalidation will be resolved from stack outputs."
else
echo "STATIC_SITE_CLOUDFRONT_DISTRIBUTION_ID is set."
fi
if [ -n "${{ github.event.inputs.cloudfront_invalidation_paths }}" ]; then
echo "Using workflow_dispatch input for invalidation paths."
elif [ -n "${CLOUDFRONT_INVALIDATION_PATHS:-}" ]; then
echo "Using CLOUDFRONT_INVALIDATION_PATHS from secrets/default."
else
echo "CLOUDFRONT_INVALIDATION_PATHS is empty."
fi
if [ -z "${CLOUDFRONT_INVALIDATION_PATHS:-}" ]; then
echo "CLOUDFRONT_INVALIDATION_PATHS length: 0"
else
echo "CLOUDFRONT_INVALIDATION_PATHS length: ${#CLOUDFRONT_INVALIDATION_PATHS}"
fi
- name: Ensure App Runner service-linked role exists
run: |
# App Runner stacks require the AWSServiceRoleForAppRunner role. See infra/README.md for IAM guidance.
bash scripts/ensure-apprunner-slr.sh
- name: Deploy shared parameters stack
run: |
set -euo pipefail
echo "Ensuring $SHARED_PARAMETERS_STACK_NAME is ready for an update..."
scripts/helpers/wait-for-stable-stack.sh "$SHARED_PARAMETERS_STACK_NAME"
PARAM_OVERRIDES="EnvironmentName=$DATA_ENVIRONMENT_NAME"
if [ -n "${REDIS_ENDPOINT:-}" ]; then
PARAM_OVERRIDES="$PARAM_OVERRIDES RedisEndpoint=$REDIS_ENDPOINT"
fi
if [ -n "${REDIS_PORT:-}" ]; then
PARAM_OVERRIDES="$PARAM_OVERRIDES RedisPort=$REDIS_PORT"
fi
if [ -n "${REDIS_PASSWORD_SECRET_ARN:-}" ]; then
PARAM_OVERRIDES="$PARAM_OVERRIDES RedisPasswordSecretName=$REDIS_PASSWORD_SECRET_ARN"
fi
if [ -n "${PRIVATE_SUBNET_1_ID:-}" ]; then
PARAM_OVERRIDES="$PARAM_OVERRIDES PrivateSubnet1Id=$PRIVATE_SUBNET_1_ID"
fi
if [ -n "${PRIVATE_SUBNET_2_ID:-}" ]; then
PARAM_OVERRIDES="$PARAM_OVERRIDES PrivateSubnet2Id=$PRIVATE_SUBNET_2_ID"
fi
if [ -n "${LAMBDA_SECURITY_GROUP_ID:-}" ]; then
PARAM_OVERRIDES="$PARAM_OVERRIDES LambdaSecurityGroupId=$LAMBDA_SECURITY_GROUP_ID"
fi
aws cloudformation deploy \
--stack-name "$SHARED_PARAMETERS_STACK_NAME" \
--template-file infra/shared-parameters.yaml \
--parameter-overrides $PARAM_OVERRIDES
- name: Compute Lambda artefact key
run: |
set -euo pipefail
base_key="${API_ARTIFACT_KEY:-lambda/api-lambda.zip}"
base_without_zip="${base_key%.zip}"
new_key="${base_without_zip}-${GITHUB_SHA}.zip"
echo "LAMBDA_CODE_KEY=$new_key" >> "$GITHUB_ENV"
- name: Build Lambda artefact
run: |
set -euo pipefail
scripts/package-api-lambda.sh
- name: Upload Lambda artefact to S3
run: |
set -euo pipefail
artefact_path="infra/api/api-lambda.zip"
aws s3 cp "$artefact_path" "s3://$API_ARTIFACT_BUCKET/$LAMBDA_CODE_KEY"
version_id=$(aws s3api head-object --bucket "$API_ARTIFACT_BUCKET" --key "$LAMBDA_CODE_KEY" --query 'VersionId' --output text 2>/dev/null || true)
if [ -n "${version_id:-}" ] && [ "$version_id" != "None" ]; then
echo "LAMBDA_CODE_VERSION=$version_id" >> "$GITHUB_ENV"
fi
- name: Deploy data stack when missing
if: env.USE_EXTERNAL_REDIS != 'true'
run: |
set -euo pipefail
if [ -z "${REDIS_PASSWORD:-}" ]; then
echo "REDIS_PASSWORD secret is required when provisioning the data stack internally." >&2
exit 1
fi
if aws cloudformation describe-stacks --stack-name "$DATA_STACK_NAME" >/dev/null 2>&1; then
echo "Stack $DATA_STACK_NAME already exists; skipping creation."
exit 0
fi
echo "Ensuring $DATA_STACK_NAME is ready for creation..."
scripts/helpers/wait-for-stable-stack.sh "$DATA_STACK_NAME"
aws cloudformation deploy \
--stack-name "$DATA_STACK_NAME" \
--template-file infra/data/template.yaml \
--parameter-overrides \
EnvironmentName=$DATA_ENVIRONMENT_NAME \
SharedParametersStackName=$SHARED_PARAMETERS_STACK_NAME \
RedisAuthToken="$REDIS_PASSWORD"
echo "Waiting for $DATA_STACK_NAME to stabilize after creation..."
scripts/helpers/wait-for-stable-stack.sh "$DATA_STACK_NAME"
- name: Validate external Redis exports
if: env.USE_EXTERNAL_REDIS == 'true'
run: |
set -euo pipefail
require_export() {
local export_name="$1"
local value
value=$(aws cloudformation list-exports \
--query "Exports[?Name=='${export_name}'].Value" \
--output text)
if [ -z "$value" ] || [ "$value" = "None" ]; then
echo "Fant ikke eksporten '${export_name}'. Sørg for at datasstacken '${DATA_STACK_NAME}' har denne eksporten før workflowen fortsetter." >&2
return 1
fi
}
require_export "${SHARED_PARAMETERS_STACK_NAME}-PrivateSubnet1Id"
require_export "${SHARED_PARAMETERS_STACK_NAME}-PrivateSubnet2Id"
require_export "${SHARED_PARAMETERS_STACK_NAME}-LambdaSecurityGroupId"
require_export "${SHARED_PARAMETERS_STACK_NAME}-RedisEndpointParameterName"
require_export "${SHARED_PARAMETERS_STACK_NAME}-RedisPortParameterName"
require_export "${SHARED_PARAMETERS_STACK_NAME}-RedisPasswordSecretName"
- name: Recover data stack from ROLLBACK_COMPLETE
run: |
set -euo pipefail
aws cloudformation describe-stacks --stack-name "$DATA_STACK_NAME" >/dev/null 2>&1 \
&& bash scripts/helpers/delete-rollback-complete-stack.sh "$DATA_STACK_NAME" \
|| echo "Data stack not found; skipping cleanup."
- name: Deploy API stack
run: |
set -euo pipefail
echo "Ensuring $API_STACK_NAME is ready for an update..."
scripts/helpers/wait-for-stable-stack.sh "$API_STACK_NAME"
log_stack_events() {
echo "Capturing recent events for $API_STACK_NAME after failure..." >&2
aws cloudformation describe-stack-events \
--stack-name "$API_STACK_NAME" \
--max-items 20 \
--query 'StackEvents[?ResourceStatus!="DELETE_COMPLETE"][].{Time:Timestamp,LogicalId:LogicalResourceId,Status:ResourceStatus,Reason:ResourceStatusReason}' \
--output table || true
}
trap log_stack_events ERR
PARAM_OVERRIDES="LambdaCodeS3Bucket=$API_ARTIFACT_BUCKET LambdaCodeS3Key=$LAMBDA_CODE_KEY StageName=$API_STAGE_NAME"
if [ -n "${LAMBDA_CODE_VERSION:-}" ]; then
PARAM_OVERRIDES="$PARAM_OVERRIDES LambdaCodeS3ObjectVersion=$LAMBDA_CODE_VERSION"
fi
data_exports_stack="$DATA_STACK_NAME"
network_params="false"
if [ "${USE_EXTERNAL_REDIS}" = "true" ]; then
data_exports_stack="$SHARED_PARAMETERS_STACK_NAME"
fi
if [ "$data_exports_stack" = "$SHARED_PARAMETERS_STACK_NAME" ]; then
network_params="true"
elif [ -n "${PRIVATE_SUBNET_1_ID:-}" ] || [ -n "${PRIVATE_SUBNET_2_ID:-}" ] || [ -n "${LAMBDA_SECURITY_GROUP_ID:-}" ]; then
# Shared stack writes the provided VPC IDs into SSM parameters and exports the parameter names
network_params="true"
fi
PARAM_OVERRIDES="$PARAM_OVERRIDES DataStackName=$data_exports_stack SharedParametersStackName=$SHARED_PARAMETERS_STACK_NAME ResolveNetworkFromParameters=$network_params"
if [ -n "${LAMBDA_SECURITY_GROUP_ID:-}" ]; then
PARAM_OVERRIDES="$PARAM_OVERRIDES LambdaSecurityGroupIdOverride=$LAMBDA_SECURITY_GROUP_ID"
fi
aws cloudformation deploy \
--stack-name "$API_STACK_NAME" \
--template-file infra/api/template.yaml \
--capabilities CAPABILITY_IAM CAPABILITY_AUTO_EXPAND \
--parameter-overrides $PARAM_OVERRIDES
- name: Resolve API endpoint for static site
run: |
set -euo pipefail
endpoint=$(aws cloudformation describe-stacks \
--stack-name "$API_STACK_NAME" \
--query "Stacks[0].Outputs[?OutputKey=='ApiEndpoint'].OutputValue" \
--output text)
if [ -z "$endpoint" ] || [ "$endpoint" = "None" ]; then
echo "Fant ikke ApiEndpoint-output fra $API_STACK_NAME" >&2
exit 1
fi
endpoint="${endpoint%%/}"
export API_ENDPOINT_URL="$endpoint"
echo "API_ENDPOINT_URL=$endpoint" >> "$GITHUB_ENV"
API_ENDPOINT_URL="$endpoint" python - <<'PY' >> "$GITHUB_ENV"
import os
from urllib.parse import urlparse
endpoint = os.environ["API_ENDPOINT_URL"].rstrip("/")
parsed = urlparse(endpoint)
domain = parsed.netloc
path = parsed.path
if not path or path == "/":
path = ""
elif not path.startswith('/'):
path = '/' + path
if path.endswith('/'):
path = path.rstrip('/')
examples = endpoint.rstrip('/') + '/api/examples'
print(f"API_GATEWAY_DOMAIN_NAME={domain}")
print(f"API_GATEWAY_ORIGIN_PATH={path}")
print(f"API_EXAMPLES_URL={examples}")
PY
- name: Run examples API health check
run: |
set -euo pipefail
npm run check-examples-api -- --url="${API_EXAMPLES_URL}"
- name: Materialize examples dataset for seeding
env:
EXAMPLES_SEED_JSON: ${{ secrets.EXAMPLES_SEED_JSON }}
EXAMPLES_SEED_JSON_PROD: ${{ secrets.EXAMPLES_SEED_JSON_PROD }}
EXAMPLES_SEED_JSON_DEV: ${{ secrets.EXAMPLES_SEED_JSON_DEV }}
EXAMPLES_SEED_JSON_PREVIEW: ${{ secrets.EXAMPLES_SEED_JSON_PREVIEW }}
run: |
set -euo pipefail
env_name="${DATA_ENVIRONMENT_NAME:-prod}"
select_dataset() {
local selected="$1"
case "$env_name" in
prod) echo "${EXAMPLES_SEED_JSON_PROD:-$selected}" ;;
dev) echo "${EXAMPLES_SEED_JSON_DEV:-$selected}" ;;
preview) echo "${EXAMPLES_SEED_JSON_PREVIEW:-$selected}" ;;
*) echo "$selected" ;;
esac
}
dataset=$(select_dataset "${EXAMPLES_SEED_JSON:-}" )
if [ -z "$dataset" ]; then
echo "Ingen EXAMPLES_SEED_JSON*-secret er konfigurert for miljøet '$env_name'. Hopper over seeding." >&2
echo "SKIP_EXAMPLES_SEEDING=true" >> "$GITHUB_ENV"
exit 0
fi
mkdir -p docs
printf '%s' "$dataset" > docs/examples-seed.json
echo "SKIP_EXAMPLES_SEEDING=false" >> "$GITHUB_ENV"
- name: Validate examples dataset (--dry-run)
if: env.SKIP_EXAMPLES_SEEDING != 'true'
env:
REGION: ${{ env.AWS_REGION }}
DATA_STACK: ${{ env.DATA_STACK_NAME }}
API_URL: ${{ env.API_EXAMPLES_URL }}
run: |
set -euo pipefail
bash scripts/cloudshell-seed-examples.sh --dataset=docs/examples-seed.json --dry-run
- name: Seed examples dataset
if: env.SKIP_EXAMPLES_SEEDING != 'true'
env:
REGION: ${{ env.AWS_REGION }}
DATA_STACK: ${{ env.DATA_STACK_NAME }}
API_URL: ${{ env.API_EXAMPLES_URL }}
run: |
set -euo pipefail
bash scripts/cloudshell-seed-examples.sh --dataset=docs/examples-seed.json
- name: Recover static site stack from rollback failures
run: |
set -euo pipefail
bash scripts/helpers/recover-stack-from-rollback.sh "$STATIC_STACK_NAME"
- name: Validate shared exports for static site
run: |
set -euo pipefail
require_export() {
local export_name="$1"
local value
value=$(aws cloudformation list-exports \
--query "Exports[?Name=='${export_name}'].Value" \
--output text)
if [ -z "$value" ] || [ "$value" = "None" ]; then
echo "Fant ikke eksporten '${export_name}'. Sørg for at stacken '${SHARED_PARAMETERS_STACK_NAME}' har publisert den før statisk-side deployen." >&2
return 1
fi
}
require_export "${SHARED_PARAMETERS_STACK_NAME}-ExamplesAllowedOriginsParameterName"
require_export "${SHARED_PARAMETERS_STACK_NAME}-SvgAllowedOriginsParameterName"
- name: Detect static site bucket
run: |
set -euo pipefail
if aws s3api head-bucket --bucket "$STATIC_SITE_BUCKET_NAME" >/dev/null 2>&1; then
echo "Bucket $STATIC_SITE_BUCKET_NAME already exists; reusing it."
echo "CREATE_SITE_BUCKET=false" >> "$GITHUB_ENV"
else
echo "Bucket $STATIC_SITE_BUCKET_NAME does not exist; creating it via CloudFormation."
echo "CREATE_SITE_BUCKET=true" >> "$GITHUB_ENV"
fi
- name: Deploy static site stack
run: |
set -euo pipefail
log_stack_events() {
echo "Capturing recent events for $STATIC_STACK_NAME after failure..." >&2
aws cloudformation describe-stack-events \
--stack-name "$STATIC_STACK_NAME" \
--max-items 20 \
--query 'StackEvents[?ResourceStatus!="DELETE_COMPLETE"][].{Time:Timestamp,LogicalId:LogicalResourceId,Status:ResourceStatus,Reason:ResourceStatusReason}' \
--output table || true
}
trap log_stack_events ERR
api_domain="${STATIC_SITE_API_DOMAIN:-$API_GATEWAY_DOMAIN_NAME}"
api_origin_path="${STATIC_SITE_API_ORIGIN_PATH:-$API_GATEWAY_ORIGIN_PATH}"
if [ -n "${STATIC_SITE_API_DOMAIN:-}" ]; then
api_domain="${api_domain#http://}"
api_domain="${api_domain#https://}"
api_domain="${api_domain%%/*}"
if [[ ! "$api_domain" =~ ^[A-Za-z0-9.-]+\.execute-api\.[A-Za-z0-9-]+\.amazonaws\.com$ ]]; then
echo "STATIC_SITE_API_DOMAIN='$STATIC_SITE_API_DOMAIN' er ikke et execute-api-domene; bruker ApiEndpoint i stedet." >&2
api_domain="$API_GATEWAY_DOMAIN_NAME"
fi
fi
if [[ ! "$api_domain" =~ ^[A-Za-z0-9.-]+\.execute-api\.[A-Za-z0-9-]+\.amazonaws\.com$ ]]; then
echo "ApiGatewayDomainName must be an execute-api hostname without protocol or path (got '$api_domain')" >&2
exit 1
fi
if [ -z "$api_domain" ]; then
echo "Fant ikke API-domene for CloudFront-oppsettet" >&2
exit 1
fi
if [ -z "$api_origin_path" ] || [ "$api_origin_path" == "/" ]; then
api_origin_path=""
fi
create_site_bucket="${CREATE_SITE_BUCKET:-false}"
aws cloudformation deploy \
--stack-name "$STATIC_STACK_NAME" \
--template-file infra/static-site/template.yaml \
--capabilities CAPABILITY_IAM \
--parameter-overrides \
SiteBucketName=$STATIC_SITE_BUCKET_NAME \
CreateSiteBucket=$create_site_bucket \
SiteEnvironmentName=$DATA_ENVIRONMENT_NAME \
ApiGatewayDomainName=$api_domain \
ApiGatewayOriginPath=$api_origin_path \
CloudFrontPriceClass=$STATIC_SITE_CLOUDFRONT_PRICE_CLASS \
SharedParametersStackName=$SHARED_PARAMETERS_STACK_NAME
- name: Resolve static site outputs
run: |
set -euo pipefail
site_bucket=$(aws cloudformation describe-stacks \
--stack-name "$STATIC_STACK_NAME" \
--query "Stacks[0].Outputs[?OutputKey=='StaticSiteBucketName'].OutputValue" \
--output text)
distribution_id=$(aws cloudformation describe-stacks \
--stack-name "$STATIC_STACK_NAME" \
--query "Stacks[0].Outputs[?OutputKey=='CloudFrontDistributionId'].OutputValue" \
--output text)
if [ -n "$site_bucket" ] && [ "$site_bucket" != "None" ]; then
echo "STATIC_SITE_BUCKET_NAME=$site_bucket" >> "$GITHUB_ENV"
fi
if [ -n "$distribution_id" ] && [ "$distribution_id" != "None" ]; then
echo "STATIC_SITE_CLOUDFRONT_DISTRIBUTION_ID=$distribution_id" >> "$GITHUB_ENV"
fi
- name: Build and upload static assets
env:
SITE_BUCKET_NAME: ${{ env.STATIC_SITE_BUCKET_NAME }}
run: |
set -euo pipefail
echo "🧹 Sletter gamle builds og cache (Nuclear Option)..."
rm -rf public dist packages/*/dist .turbo node_modules/.cache
echo "🏗️ Bygger applikasjonen på nytt fra bunnen..."
npm run build
echo "🔍 VERIFISERER at fixen er med..."
if grep -q "svgDataUrl" public/svg-export-helper.js; then
echo "✅ SUKSESS: Bygget inneholder den nye fixen!"
else
echo "❌ FEIL: Bygget inneholder fortsatt gammel kode! Stopper deploy."
exit 1
fi
echo "🚀 Laster opp ferske filer til S3..."
aws s3 sync public/ "s3://$SITE_BUCKET_NAME/" \
--delete \
--exclude "index.html" \
--exclude ".git/*" \
--exclude "infra/*" \
--exclude "**/node_modules/*" \
--exclude "node_modules/*" \
--cache-control "max-age=31536000,public"
echo "📄 Laster opp index.html..."
aws s3 cp public/index.html "s3://$SITE_BUCKET_NAME/index.html" \
--cache-control "no-cache,no-store,must-revalidate"
- name: Invalidate CloudFront cache
if: ${{ env.STATIC_SITE_CLOUDFRONT_DISTRIBUTION_ID != '' }}
run: |
set -o noglob
PATHS="${CLOUDFRONT_INVALIDATION_PATHS:-/*}"
set +o noglob
if [[ " $PATHS " != *" /* "* ]]; then
PATHS="$PATHS /*"
fi
echo "Using invalidation paths: $PATHS"
IFS=' ' read -r -a path_args <<< "$PATHS"
aws cloudfront create-invalidation \
--distribution-id "$STATIC_SITE_CLOUDFRONT_DISTRIBUTION_ID" \
--paths "${path_args[@]}"