From 63c7f5412b9d1c668c56068dd7e816ce89a4172a Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Tue, 28 Jul 2026 21:29:34 +0200 Subject: [PATCH 1/3] fix(ci): stop interpolating the release tag into a run block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `deploy-aws-lambda.yml` pasted `${{ github.event.release.tag_name }}` straight into the shell source of the `prepare` job. A git ref name may contain `;`, `$`, backtick, `(`, `)` and `|` — it only forbids spaces, which `$IFS` works around — so cutting a release on a crafted tag was arbitrary code execution. `prepare` had no `environment:` binding yet inherited workflow-level `id-token: write`. Since the AWS trust policy accepts the subject `repo::ref:refs/heads/main` independently of the `environment:*` subjects, injected code there could mint an OIDC token and assume the full production deploy role. The job looked gated because it declared `outputs: environment:` — an output *named* environment, not an `environment:` binding. That shape reads as a gate at a glance while gating nothing, so the output is renamed rather than merely commented. Changes: - Pass the release tag, event name, commit SHA and `inputs.environment` through `env:` and reference the quoted shell variables. No `${{ }}` referencing `inputs.*` or `github.event.*` remains in any `run:` block. - Constrain the tag to the OCI tag grammar, checked against a shell variable rather than an already-expanded expression, so unlike the original guard it actually validates something. - Allowlist the resolved environment to dev|staging|prod. The `workflow_call` input is a free-form string, unlike the `workflow_dispatch` `choice`, so it was previously unchecked. - Drop `id-token: write` to job level, granting it only to `build-and-deploy` and `test-deployment` — the two jobs that assume the deploy role, both of which are bound to an environment. `prepare` and `summary` authenticate to nothing and now hold `contents: read`. - Rename the `environment` output to `target_environment` (11 consumers) so it cannot be misread as a binding. - Replace the unquoted `cat < deployment-info.json` heredoc with `jq -n --arg`. The tag is charset-validated upstream, but that heredoc sits in a job holding `id-token: write` and would evaluate `$(...)` in any value reaching it. - Route the function URL and the `-var-file` environment through `env:` for consistency with the rest of the file. Verified with `actionlint` (shellcheck available): no new findings, and the six the rewritten `prepare` job used to produce are gone (SC2086 28 -> 22). The 26 that remain are pre-existing, sit outside the changed hunks, and are tracked in #1646. Closes #1649 --- .github/workflows/deploy-aws-lambda.yml | 164 ++++++++++++++++++------ 1 file changed, 127 insertions(+), 37 deletions(-) diff --git a/.github/workflows/deploy-aws-lambda.yml b/.github/workflows/deploy-aws-lambda.yml index d52d6dc13..6b9d01362 100644 --- a/.github/workflows/deploy-aws-lambda.yml +++ b/.github/workflows/deploy-aws-lambda.yml @@ -22,8 +22,12 @@ name: Deploy to AWS Lambda +# Least privilege by default: `id-token: write` is granted per job, only to the +# jobs that actually assume the AWS deploy role, and both of those are bound to +# a deployment environment. Declaring it here would hand it to `prepare` and +# `summary` too, neither of which authenticates and neither of which is bound +# to an environment. permissions: - id-token: write contents: read concurrency: @@ -82,38 +86,96 @@ jobs: prepare: name: Prepare Deployment runs-on: ubuntu-latest + permissions: + contents: read + # NOTE: `target_environment` below is an OUTPUT, not an `environment:` + # binding — this job is deliberately ungated and therefore must never hold + # `id-token: write`. It was previously named `environment`, which made the + # `outputs:` block read like a gate at a glance while gating nothing. Do not + # rename it back. outputs: - environment: ${{ steps.set-env.outputs.environment }} + target_environment: ${{ steps.set-env.outputs.target_environment }} image_tag: ${{ steps.set-tag.outputs.tag }} steps: - name: Determine environment id: set-env + env: + EVENT_NAME: ${{ github.event_name }} + INPUT_ENVIRONMENT: ${{ inputs.environment }} 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 + set -euo pipefail + INPUT_ENVIRONMENT="${INPUT_ENVIRONMENT:-}" + + # Inside a reusable workflow `github.event_name` is the CALLER's + # event, so it is never "workflow_call" — a caller's free-form + # `inputs.environment` arrives through the workflow_dispatch arm. + # Caveat: the `*` arm forces dev, so a push-triggered caller would + # have its requested environment silently ignored. deploy-all.yml + # triggers only on workflow_dispatch and release today. + case "$EVENT_NAME" in + release) TARGET_ENVIRONMENT=prod ;; + workflow_dispatch) TARGET_ENVIRONMENT="$INPUT_ENVIRONMENT" ;; + *) TARGET_ENVIRONMENT=dev ;; + esac + + # workflow_dispatch constrains this to the declared `choice` options, + # but the workflow_call input is typed as a free-form string, so the + # allowlist is enforced here rather than assumed. + case "$TARGET_ENVIRONMENT" in + dev|staging|prod) ;; + *) + echo "::error::Refusing unknown environment: $TARGET_ENVIRONMENT" + exit 1 + ;; + esac + + echo "target_environment=$TARGET_ENVIRONMENT" >> "$GITHUB_OUTPUT" - name: Set image tag id: set-tag + env: + EVENT_NAME: ${{ github.event_name }} + RELEASE_TAG: ${{ github.event.release.tag_name }} + COMMIT_SHA: ${{ github.sha }} run: | - if [[ "${{ github.event_name }}" == "release" ]]; then - echo "tag=${{ github.event.release.tag_name }}" >> $GITHUB_OUTPUT + set -euo pipefail + + if [ "$EVENT_NAME" = "release" ]; then + TAG="${RELEASE_TAG:-}" else - echo "tag=${{ github.sha }}" >> $GITHUB_OUTPUT + TAG="$COMMIT_SHA" fi + # A git ref name may contain ';', '$', '`', '(', ')' and '|', so a + # release tag is attacker-settable shell metacharacter soup. Constrain + # it to the OCI tag grammar so it cannot poison the $GITHUB_OUTPUT + # write below with a newline or a heredoc delimiter, and stays valid + # if it is ever wired to Terraform's custom_image_tag (it is not + # today; the image tag is derived inside the build module). The check + # runs against a shell variable rather than an already-expanded + # expression, so unlike the original it actually validates something. + if [[ ! "$TAG" =~ ^[a-zA-Z0-9_][a-zA-Z0-9._-]{0,127}$ ]]; then + echo "::error::Refusing unsafe image tag: must match ^[a-zA-Z0-9_][a-zA-Z0-9._-]{0,127}$" + exit 1 + fi + + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + # 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 + permissions: + # Assumes the AWS deploy role. The `environment:` binding below is what + # scopes this job's OIDC `sub` to `repo::environment:`, + # which is the subject the trust policy matches. NOTE it is not by itself + # a reviewer gate: an Environment only blocks a job once required-reviewer + # protection rules are configured on it in repo settings, and as of this + # change none of this repo's Environments have any. See #1648. + id-token: write + contents: read # 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, @@ -121,7 +183,7 @@ jobs: # 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 }} + environment: ${{ needs.prepare.outputs.target_environment }} outputs: function_url: ${{ steps.outputs.outputs.function_url }} function_name: ${{ steps.outputs.outputs.function_name }} @@ -145,7 +207,7 @@ jobs: - name: Terraform Init env: TF_BACKEND: ${{ secrets.TF_BACKEND_AWS }} - ENVIRONMENT: ${{ needs.prepare.outputs.environment }} + ENVIRONMENT: ${{ needs.prepare.outputs.target_environment }} run: | printf '%s\nkey = "github-%s/terraform.tfstate"\n' "$TF_BACKEND" "$ENVIRONMENT" > /tmp/backend.tfbackend cd terraform/environments/aws @@ -164,10 +226,11 @@ jobs: # accepted by the var (the Terraform local then falls back to # frontend_domain_names[0]). TF_VAR_dashboard_url: ${{ secrets.DASHBOARD_URL }} + ENVIRONMENT: ${{ needs.prepare.outputs.target_environment }} run: | cd terraform/environments/aws terraform plan \ - -var-file="github-${{ needs.prepare.outputs.environment }}.tfvars" \ + -var-file="github-${ENVIRONMENT}.tfvars" \ -var="compute_platform=lambda" \ -out=tfplan @@ -191,7 +254,7 @@ jobs: if: failure() || cancelled() env: TF_BACKEND: ${{ secrets.TF_BACKEND_AWS }} - ENVIRONMENT: ${{ needs.prepare.outputs.environment }} + ENVIRONMENT: ${{ needs.prepare.outputs.target_environment }} run: | BUCKET=$(grep -E '^\s*bucket\s*=' /tmp/backend.tfbackend 2>/dev/null | tr -d ' "' | cut -d= -f2) if [ -n "$BUCKET" ]; then @@ -209,24 +272,38 @@ jobs: echo "log_group=$(terraform output -raw lambda_log_group_name 2>/dev/null | grep -v '::' || echo "")" >> $GITHUB_OUTPUT - name: Save deployment info + env: + ENVIRONMENT: ${{ needs.prepare.outputs.target_environment }} + IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }} + FUNCTION_URL: ${{ steps.outputs.outputs.function_url }} + FUNCTION_NAME: ${{ steps.outputs.outputs.function_name }} + DEPLOYED_BY: ${{ github.actor }} + COMMIT: ${{ github.sha }} run: | - cat < 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 + set -euo pipefail + + # Built with jq rather than an unquoted `cat < deployment-info.json + 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 }} + name: deployment-info-lambda-${{ needs.prepare.outputs.target_environment }} path: deployment-info.json retention-days: 90 @@ -236,9 +313,18 @@ jobs: runs-on: ubuntu-latest needs: [prepare, build-and-deploy] if: always() && needs.build-and-deploy.result == 'success' + permissions: + # Assumes the AWS deploy role. The `environment:` binding below is what + # scopes this job's OIDC `sub` to `repo::environment:`, + # which is the subject the trust policy matches. NOTE it is not by itself + # a reviewer gate: an Environment only blocks a job once required-reviewer + # protection rules are configured on it in repo settings, and as of this + # change none of this repo's Environments have any. See #1648. + id-token: write + contents: read # 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 }} + environment: ${{ needs.prepare.outputs.target_environment }} steps: - name: Configure AWS credentials @@ -272,9 +358,10 @@ jobs: sleep 30 - name: Test health endpoint + env: + FUNCTION_URL_RAW: ${{ steps.get-url.outputs.url }} run: | - URL="${{ steps.get-url.outputs.url }}" - URL="${URL%/}" + URL="${FUNCTION_URL_RAW%/}" echo "Testing health endpoint: $URL/health" for i in {1..5}; do @@ -294,9 +381,10 @@ jobs: exit 1 - name: Run smoke tests + env: + FUNCTION_URL_RAW: ${{ steps.get-url.outputs.url }} run: | - URL="${{ steps.get-url.outputs.url }}" - URL="${URL%/}" + URL="${FUNCTION_URL_RAW%/}" echo "Running basic smoke tests..." @@ -319,19 +407,21 @@ jobs: runs-on: ubuntu-latest needs: [prepare, build-and-deploy, test-deployment] if: always() + permissions: + contents: read steps: - name: Download deployment info uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: - name: deployment-info-lambda-${{ needs.prepare.outputs.environment }} + name: deployment-info-lambda-${{ needs.prepare.outputs.target_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 "**Environment:** ${{ needs.prepare.outputs.target_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 From e56b99784da44e5c874f9ee9812257006d66a27b Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Tue, 28 Jul 2026 21:52:13 +0200 Subject: [PATCH 2/3] fix(ci): correct the release-tag guard and route summary through env MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses three findings from the independent review of this PR. The tag guard rejected valid tags. `^[a-zA-Z0-9_][a-zA-Z0-9._-]{0,127}$` excludes `/` and `+`, so two of this repo's four existing tags fail it, as do `release/1.0` and `v1.2.3+build.5`. A release cut on a slash-namespaced tag would have hard-failed `prepare` and blocked the whole production deploy. Filtering shell metacharacters was the wrong instinct anyway: they are already inert, because the value arrives via `env:` and is only ever referenced quoted. The structural threat is a NEWLINE, since the tag is written to `$GITHUB_OUTPUT` as a single `tag=` line and a newline would append attacker-chosen output keys that the two credentialed downstream jobs then consume. So the guard now rejects empty and control characters and allows the rest of the git ref charset, with a note that an OCI-grammar check belongs here if the value is ever wired to Terraform's `custom_image_tag` (it is not today — the real image tag is derived from the git commit inside the build module). `summary` still interpolated six expressions directly into its `run:` block, including `needs.prepare.outputs.image_tag`, whose origin is the release tag and which was safe only because a guard in a *different* job had run. That makes the operative rule "raw interpolation is fine when someone upstream validated", an implicit invariant that breaks silently the moment the upstream guard moves. All six now go through `env:`, so the file has one uniform rule: no `${{ }}` in any `run:` block. Also quote the `$GITHUB_OUTPUT` redirect targets in the two steps that still wrote to them unquoted, which is the same output-integrity concern this PR is about. actionlint (with shellcheck) now reports **0** findings on this file, down from 32 before the PR and 26 at the previous commit. --- .github/workflows/deploy-aws-lambda.yml | 107 +++++++++++++++--------- 1 file changed, 69 insertions(+), 38 deletions(-) diff --git a/.github/workflows/deploy-aws-lambda.yml b/.github/workflows/deploy-aws-lambda.yml index 6b9d01362..f52ac1c5b 100644 --- a/.github/workflows/deploy-aws-lambda.yml +++ b/.github/workflows/deploy-aws-lambda.yml @@ -147,16 +147,32 @@ jobs: TAG="$COMMIT_SHA" fi - # A git ref name may contain ';', '$', '`', '(', ')' and '|', so a - # release tag is attacker-settable shell metacharacter soup. Constrain - # it to the OCI tag grammar so it cannot poison the $GITHUB_OUTPUT - # write below with a newline or a heredoc delimiter, and stays valid - # if it is ever wired to Terraform's custom_image_tag (it is not - # today; the image tag is derived inside the build module). The check - # runs against a shell variable rather than an already-expanded - # expression, so unlike the original it actually validates something. - if [[ ! "$TAG" =~ ^[a-zA-Z0-9_][a-zA-Z0-9._-]{0,127}$ ]]; then - echo "::error::Refusing unsafe image tag: must match ^[a-zA-Z0-9_][a-zA-Z0-9._-]{0,127}$" + # Shell metacharacters in a tag (';', '$', '`', '|') are already inert: + # the value arrives via `env:` and is only ever referenced quoted, so + # it is data, not code. What a charset filter must actually stop is a + # NEWLINE, because this value is written to $GITHUB_OUTPUT as a single + # `tag=` line — a newline would let a crafted tag append extra + # attacker-chosen output keys, and those outputs are consumed by the + # two credentialed downstream jobs. + # + # So reject control characters and reject empty, and allow the rest of + # the git ref charset. A stricter OCI-grammar filter would reject + # `release/1.0` and `v1.2.3+build.5` — both legitimate git tags, and + # this repo already uses slash-namespaced ones — hard-failing a + # production deploy for no security gain. + # + # NOTE: this deliberately does NOT enforce the OCI tag grammar, + # because the value is cosmetic today: it reaches only + # deployment-info.json (via `jq --arg`) and the step summary. + # Terraform derives the real image tag from the git commit in + # modules/build. If this is ever wired to `custom_image_tag`, add an + # OCI-grammar check HERE at the same time. + if [ -z "$TAG" ]; then + echo "::error::Refusing empty image tag" + exit 1 + fi + if [[ "$TAG" =~ [[:cntrl:]] ]]; then + echo "::error::Refusing image tag containing control characters or newlines" exit 1 fi @@ -267,9 +283,11 @@ jobs: 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 + { + echo "function_url=$(terraform output -raw lambda_function_url 2>/dev/null | grep -v '::' || echo "")" + echo "function_name=$(terraform output -raw lambda_function_name 2>/dev/null | grep -v '::' || echo "")" + echo "log_group=$(terraform output -raw lambda_log_group_name 2>/dev/null | grep -v '::' || echo "")" + } >> "$GITHUB_OUTPUT" - name: Save deployment info env: @@ -350,7 +368,7 @@ jobs: echo "Failed to get function URL from AWS Lambda API for $FUNCTION_NAME" exit 1 fi - echo "url=$FUNCTION_URL" >> $GITHUB_OUTPUT + echo "url=$FUNCTION_URL" >> "$GITHUB_OUTPUT" - name: Wait for Lambda to be ready run: | @@ -417,31 +435,44 @@ jobs: name: deployment-info-lambda-${{ needs.prepare.outputs.target_environment }} continue-on-error: true + # Every value arrives via `env:`, including the ones whose safety is + # currently guaranteed by a check in a DIFFERENT job. Relying on "an + # upstream job validated this" makes the rule "raw interpolation is fine + # when someone else checked" — an implicit invariant that breaks silently + # the moment the upstream guard moves. One uniform rule instead. - name: Post summary + env: + TARGET_ENVIRONMENT: ${{ needs.prepare.outputs.target_environment }} + IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }} + DEPLOY_RESULT: ${{ needs.build-and-deploy.result }} + TEST_RESULT: ${{ needs.test-deployment.result }} run: | - echo "## AWS Lambda Deployment Summary" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "**Environment:** ${{ needs.prepare.outputs.target_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 + set -euo pipefail - 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 + { + echo "## AWS Lambda Deployment Summary" + echo "" + echo "**Environment:** $TARGET_ENVIRONMENT" + echo "**Image Tag:** $IMAGE_TAG" + echo "**Status:** $DEPLOY_RESULT" + echo "" + + if [ -f deployment-info.json ]; then + echo "### Deployment Details" + echo '```json' + cat deployment-info.json + echo '```' + fi - 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 + echo "" + echo "### Job Results" + echo "- Deploy: $DEPLOY_RESULT" + echo "- Test: $TEST_RESULT" + echo "" + + if [ "$DEPLOY_RESULT" = "success" ] && [ "$TEST_RESULT" = "success" ]; then + echo "**Deployment successful!**" + else + echo "**Deployment failed. Check logs for details.**" + fi + } >> "$GITHUB_STEP_SUMMARY" From 394915af3119b615458fc9ba222522692b217b0e Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Mon, 3 Aug 2026 18:26:26 +0200 Subject: [PATCH 3/3] docs(ci): correct push-trigger environment in deploy-aws-lambda README The workflow's fallback case intentionally maps a push event to dev, not staging (deploy-aws-lambda.yml's `*) TARGET_ENVIRONMENT=dev` arm), but the README still described push-to-main as deploying to staging. Correct the two doc references instead of changing the fallback: it is the safe default that stops an unrecognised trigger from landing in a production-adjacent environment, so weakening it to staging would make the failure mode worse, not the doc more accurate. --- .github/workflows/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/README.md b/.github/workflows/README.md index 5081a0655..de0f34067 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -83,7 +83,7 @@ Deploy CUDly to AWS Lambda with Function URL. Serverless, event-driven platform. ### Triggers -- Push to `main` (deploys to staging) +- Push to `main` (deploys to dev) - Release creation (deploys to prod) - Manual dispatch with environment selection @@ -104,7 +104,7 @@ Deploy CUDly to AWS Lambda with Function URL. Serverless, event-driven platform. # Deploy to dev gh workflow run deploy-aws-lambda.yml -f environment=dev -# Deploy to staging +# Push to main also deploys to dev git push origin main # Deploy to prod