Repository navigation
fix(db): widen purchase_history.account_id to VARCHAR(255) (#1677) #813
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # 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 |