Skip to content

fix(db): widen purchase_history.account_id to VARCHAR(255) (#1677) #813

fix(db): widen purchase_history.account_id to VARCHAR(255) (#1677)

fix(db): widen purchase_history.account_id to VARCHAR(255) (#1677) #813

# Deploy to AWS Lambda
#
# This workflow deploys the CUDly application to AWS Lambda with Function URL.
# It builds a Docker image, pushes to ECR, and deploys via Terraform.
#
# Required GitHub Secrets:
# - ADMIN_EMAIL: Admin email for notifications
# - FROM_EMAIL: SES-verified FROM address used for outbound approval emails.
# Required for purchase-approval emails on deployments that
# don't set subdomain_zone_name (bare Lambda Function URL).
# Must be verified in the target AWS account's SES identities.
#
# Required GitHub Variables:
# - AWS_REGION: AWS region (e.g., us-east-1)
# - AWS_ROLE_TO_ASSUME: IAM role ARN for OIDC keyless auth
#
# Triggered by:
# - Pushes to main branch
# - Manual workflow dispatch with environment selection
# - Release creation (auto-deploy to prod)
# - Workflow call from other workflows
name: Deploy to AWS Lambda
permissions:
id-token: write
contents: read
concurrency:
group: deploy-lambda-${{ github.ref }}
cancel-in-progress: false
on:
push:
branches: [main]
paths:
- 'cmd/**'
- 'internal/**'
- 'providers/**'
- 'frontend/**'
- 'Dockerfile'
- 'terraform/modules/compute/aws/lambda/**'
- 'terraform/modules/database/aws/**'
- 'terraform/modules/secrets/aws/**'
- 'terraform/modules/networking/aws/**'
- 'terraform/environments/aws/**'
- '.github/workflows/deploy-aws-lambda.yml'
workflow_dispatch:
inputs:
environment:
description: 'Deployment environment'
required: true
type: choice
options:
- dev
- staging
- prod
release:
types: [created]
workflow_call:
inputs:
environment:
required: true
type: string
secrets:
ADMIN_EMAIL:
required: true
DASHBOARD_URL:
required: false
FROM_EMAIL:
required: false
TF_BACKEND_AWS:
required: true
env:
AWS_REGION: ${{ vars.AWS_REGION || 'us-east-1' }}
TF_VERSION: '1.10.0'
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
# Determine deployment environment
prepare:
name: Prepare Deployment
runs-on: ubuntu-latest
outputs:
environment: ${{ steps.set-env.outputs.environment }}
image_tag: ${{ steps.set-tag.outputs.tag }}
steps:
- name: Determine environment
id: set-env
run: |
if [[ "${{ github.event_name }}" == "release" ]]; then
echo "environment=prod" >> $GITHUB_OUTPUT
elif [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
echo "environment=${{ inputs.environment }}" >> $GITHUB_OUTPUT
elif [[ "${{ github.event_name }}" == "workflow_call" ]]; then
echo "environment=${{ inputs.environment }}" >> $GITHUB_OUTPUT
else
echo "environment=dev" >> $GITHUB_OUTPUT
fi
- name: Set image tag
id: set-tag
run: |
if [[ "${{ github.event_name }}" == "release" ]]; then
echo "tag=${{ github.event.release.tag_name }}" >> $GITHUB_OUTPUT
else
echo "tag=${{ github.sha }}" >> $GITHUB_OUTPUT
fi
# Deploy infrastructure with Terraform (includes Docker build via build module)
build-and-deploy:
name: Build & Deploy
runs-on: ubuntu-24.04-arm # ARM runner: Docker build targets linux/arm64 (Lambda/Fargate Graviton2)
needs: prepare
# Bind to the named GitHub Environment matching the target so
# secrets.* resolve to environment-scoped values when defined,
# falling back to repo-scoped secrets otherwise. Without this,
# DASHBOARD_URL (and any other per-env secret like FROM_EMAIL,
# ADMIN_EMAIL) would be repo-wide and dev/staging/prod would
# all share one value — producing wrong email links in customer
# mailboxes. Per CR review on PR #368.
environment: ${{ needs.prepare.outputs.environment }}
outputs:
function_url: ${{ steps.outputs.outputs.function_url }}
function_name: ${{ steps.outputs.outputs.function_name }}
log_group: ${{ steps.outputs.outputs.log_group }}
steps:
- name: Checkout code
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@d979d5b3a71173a29b74b5b88418bfda9437d885 # v6.1.1
with:
role-to-assume: ${{ vars.AWS_ROLE_TO_ASSUME }}
aws-region: ${{ env.AWS_REGION }}
- name: Setup Terraform
uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1
with:
terraform_version: ${{ env.TF_VERSION }}
- name: Terraform Init
env:
TF_BACKEND: ${{ secrets.TF_BACKEND_AWS }}
ENVIRONMENT: ${{ needs.prepare.outputs.environment }}
run: |
printf '%s\nkey = "github-%s/terraform.tfstate"\n' "$TF_BACKEND" "$ENVIRONMENT" > /tmp/backend.tfbackend
cd terraform/environments/aws
terraform init -backend-config=/tmp/backend.tfbackend
- name: Terraform Plan
id: plan
env:
TF_VAR_admin_email: ${{ secrets.ADMIN_EMAIL }}
TF_VAR_from_email: ${{ secrets.FROM_EMAIL }}
# DASHBOARD_URL is the customer-facing dashboard origin used in
# email links (invite, password reset, welcome). For Lambda-
# Function-URL deployments (no custom domain), set the repo
# secret DASHBOARD_URL to the function URL — see #363 +
# github-dev.tfvars for the bootstrap pattern. Empty string is
# accepted by the var (the Terraform local then falls back to
# frontend_domain_names[0]).
TF_VAR_dashboard_url: ${{ secrets.DASHBOARD_URL }}
run: |
cd terraform/environments/aws
terraform plan \
-var-file="github-${{ needs.prepare.outputs.environment }}.tfvars" \
-var="compute_platform=lambda" \
-out=tfplan
- name: Terraform Apply
env:
TF_VAR_admin_email: ${{ secrets.ADMIN_EMAIL }}
TF_VAR_from_email: ${{ secrets.FROM_EMAIL }}
# DASHBOARD_URL is the customer-facing dashboard origin used in
# email links (invite, password reset, welcome). For Lambda-
# Function-URL deployments (no custom domain), set the repo
# secret DASHBOARD_URL to the function URL — see #363 +
# github-dev.tfvars for the bootstrap pattern. Empty string is
# accepted by the var (the Terraform local then falls back to
# frontend_domain_names[0]).
TF_VAR_dashboard_url: ${{ secrets.DASHBOARD_URL }}
run: |
cd terraform/environments/aws
terraform apply -auto-approve tfplan
- name: Release state lock on failure
if: failure() || cancelled()
env:
TF_BACKEND: ${{ secrets.TF_BACKEND_AWS }}
ENVIRONMENT: ${{ needs.prepare.outputs.environment }}
run: |
BUCKET=$(grep -E '^\s*bucket\s*=' /tmp/backend.tfbackend 2>/dev/null | tr -d ' "' | cut -d= -f2)
if [ -n "$BUCKET" ]; then
LOCK_KEY="github-${ENVIRONMENT}/terraform.tfstate.tflock"
echo "Releasing S3 state lock: s3://${BUCKET}/${LOCK_KEY}"
aws s3 rm "s3://${BUCKET}/${LOCK_KEY}" 2>/dev/null || echo "No lock file found (already clean)"
fi
- name: Get Terraform outputs
id: outputs
run: |
cd terraform/environments/aws
echo "function_url=$(terraform output -raw lambda_function_url 2>/dev/null | grep -v '::' || echo "")" >> $GITHUB_OUTPUT
echo "function_name=$(terraform output -raw lambda_function_name 2>/dev/null | grep -v '::' || echo "")" >> $GITHUB_OUTPUT
echo "log_group=$(terraform output -raw lambda_log_group_name 2>/dev/null | grep -v '::' || echo "")" >> $GITHUB_OUTPUT
- name: Save deployment info
run: |
cat <<EOF > deployment-info.json
{
"environment": "${{ needs.prepare.outputs.environment }}",
"image_tag": "${{ needs.prepare.outputs.image_tag }}",
"function_url": "${{ steps.outputs.outputs.function_url }}",
"function_name": "${{ steps.outputs.outputs.function_name }}",
"deployed_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"deployed_by": "${{ github.actor }}",
"commit": "${{ github.sha }}"
}
EOF
cat deployment-info.json
- name: Upload deployment info
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: deployment-info-lambda-${{ needs.prepare.outputs.environment }}
path: deployment-info.json
retention-days: 90
# Test the deployment
test-deployment:
name: Test Deployment
runs-on: ubuntu-latest
needs: [prepare, build-and-deploy]
if: always() && needs.build-and-deploy.result == 'success'
# Bind to the same named environment as build-and-deploy so that
# vars.AWS_ROLE_TO_ASSUME resolves to the per-environment scoped value.
environment: ${{ needs.prepare.outputs.environment }}
steps:
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@d979d5b3a71173a29b74b5b88418bfda9437d885 # v6.1.1 — pinned per SC review
with:
role-to-assume: ${{ vars.AWS_ROLE_TO_ASSUME }}
aws-region: ${{ env.AWS_REGION }}
- name: Get Function URL
id: get-url
env:
FUNCTION_NAME: ${{ needs.build-and-deploy.outputs.function_name }}
run: |
if [ -z "$FUNCTION_NAME" ]; then
echo "Failed to get function name from deploy outputs"
exit 1
fi
FUNCTION_URL=$(aws lambda get-function-url-config \
--function-name "$FUNCTION_NAME" \
--query 'FunctionUrl' \
--output text)
if [ -z "$FUNCTION_URL" ] || [ "$FUNCTION_URL" = "None" ]; then
echo "Failed to get function URL from AWS Lambda API for $FUNCTION_NAME"
exit 1
fi
echo "url=$FUNCTION_URL" >> $GITHUB_OUTPUT
- name: Wait for Lambda to be ready
run: |
echo "Waiting 30 seconds for Lambda to be fully ready..."
sleep 30
- name: Test health endpoint
run: |
URL="${{ steps.get-url.outputs.url }}"
URL="${URL%/}"
echo "Testing health endpoint: $URL/health"
for i in {1..5}; do
if curl -f -s "$URL/health" > /dev/null; then
RESPONSE=$(curl -s "$URL/health")
echo "Response: $RESPONSE"
if echo "$RESPONSE" | grep -q '"status"'; then
echo "Health check passed!"
exit 0
fi
fi
echo "Attempt $i failed, retrying in 10 seconds..."
sleep 10
done
echo "Health check failed after 5 attempts"
exit 1
- name: Run smoke tests
run: |
URL="${{ steps.get-url.outputs.url }}"
URL="${URL%/}"
echo "Running basic smoke tests..."
for i in {1..3}; do
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$URL/health")
if [ "$STATUS" -eq 200 ]; then
echo "Health check $i: passed (HTTP $STATUS)"
else
echo "Health check $i: failed (HTTP $STATUS)"
exit 1
fi
sleep 2
done
echo "All smoke tests passed!"
# Post deployment summary
summary:
name: Deployment Summary
runs-on: ubuntu-latest
needs: [prepare, build-and-deploy, test-deployment]
if: always()
steps:
- name: Download deployment info
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: deployment-info-lambda-${{ needs.prepare.outputs.environment }}
continue-on-error: true
- name: Post summary
run: |
echo "## AWS Lambda Deployment Summary" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Environment:** ${{ needs.prepare.outputs.environment }}" >> $GITHUB_STEP_SUMMARY
echo "**Image Tag:** ${{ needs.prepare.outputs.image_tag }}" >> $GITHUB_STEP_SUMMARY
echo "**Status:** ${{ needs.build-and-deploy.result }}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
if [ -f deployment-info.json ]; then
echo "### Deployment Details" >> $GITHUB_STEP_SUMMARY
echo "\`\`\`json" >> $GITHUB_STEP_SUMMARY
cat deployment-info.json >> $GITHUB_STEP_SUMMARY
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
fi
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Job Results" >> $GITHUB_STEP_SUMMARY
echo "- Deploy: ${{ needs.build-and-deploy.result }}" >> $GITHUB_STEP_SUMMARY
echo "- Test: ${{ needs.test-deployment.result }}" >> $GITHUB_STEP_SUMMARY
if [ "${{ needs.build-and-deploy.result }}" == "success" ] && [ "${{ needs.test-deployment.result }}" == "success" ]; then
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Deployment successful!**" >> $GITHUB_STEP_SUMMARY
else
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Deployment failed. Check logs for details.**" >> $GITHUB_STEP_SUMMARY
fi