name: CD — SAM Deploy on: workflow_call: inputs: python-version: description: "Python version to use" type: string default: "3.12" stack-name: description: "CloudFormation stack name" type: string required: true sam-template: description: "Path to SAM template file" type: string default: "template.yaml" region: description: "AWS region" type: string default: "us-east-1" cfn-role-arn: description: "CloudFormation execution role ARN" type: string required: true secrets: deploy-role-arn: description: "OIDC deploy role ARN" required: true parameter-overrides: description: "SAM parameter overrides (e.g. 'Key1=Value1 Key2=Value2')" required: false permissions: id-token: write contents: read jobs: deploy: runs-on: ubuntu-latest timeout-minutes: 15 # Serialise per stack so two pushes cannot deploy over each other. # stack-name is required and region always defaults, so the group is never # empty; the pair is exactly what identifies a CloudFormation stack, so # different stacks in the same caller repo still deploy in parallel. # cancel-in-progress is FALSE on purpose: unlike CI, aborting midway can # leave a stack mid-update. concurrency: group: cd-sam-${{ inputs.region }}-${{ inputs.stack-name }} cancel-in-progress: false steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: ${{ inputs.python-version }} - uses: aws-actions/setup-sam@89ddb14d60e682855e3fea4be85b3c56485de310 # v3.0.0 - uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6.3.0 with: role-to-assume: ${{ secrets.deploy-role-arn }} aws-region: ${{ inputs.region }} - name: Pre-flight checks run: | echo "Pre-flight: checking stack ${{ inputs.stack-name }}..." STATUS=$(aws cloudformation describe-stacks \ --stack-name "${{ inputs.stack-name }}" \ --query 'Stacks[0].StackStatus' --output text 2>/dev/null || echo "NOT_FOUND") case "$STATUS" in ROLLBACK_COMPLETE|*FAILED) echo "::error::Stack ${{ inputs.stack-name }} is in $STATUS — manual intervention required." exit 1 ;; *IN_PROGRESS) echo "::error::Stack ${{ inputs.stack-name }} has an operation in progress ($STATUS) — wait for it to complete." exit 1 ;; NOT_FOUND) echo "Pre-flight: stack not found — will be created on first deploy." ;; *) echo "Pre-flight: stack status is $STATUS — OK to deploy." ;; esac - name: SAM build run: sam build --template ${{ inputs.sam-template }} - name: SAM deploy # Env-var indirection (not inline expression interpolation) so shell # metacharacters in the secret are never parsed as script; unquoted # $PARAM_OVERRIDES deliberately word-splits multiple Key=Value pairs. env: PARAM_OVERRIDES: ${{ secrets.parameter-overrides }} run: | PARAMS="" if [ -n "$PARAM_OVERRIDES" ]; then PARAMS="--parameter-overrides $PARAM_OVERRIDES" fi # $PARAMS is deliberately unquoted: it is either empty (no overrides, # so no flag at all) or "--parameter-overrides Key=Value [Key=Value…]", # which must reach `sam deploy` as separate argv entries. Quoting it # would pass one empty or one concatenated argument and break every # parameterised deploy. # shellcheck disable=SC2086 sam deploy \ --stack-name ${{ inputs.stack-name }} \ --template-file .aws-sam/build/template.yaml \ --resolve-s3 \ --capabilities CAPABILITY_IAM \ --no-confirm-changeset \ --no-fail-on-empty-changeset \ --role-arn ${{ inputs.cfn-role-arn }} \ $PARAMS - name: Post-deploy health check run: | echo "Health check: verifying stack ${{ inputs.stack-name }}..." STATUS=$(aws cloudformation describe-stacks \ --stack-name "${{ inputs.stack-name }}" \ --query 'Stacks[0].StackStatus' --output text) if [[ "$STATUS" != *"COMPLETE" ]] || [[ "$STATUS" == *"ROLLBACK"* ]]; then echo "::error::Stack ${{ inputs.stack-name }} ended in $STATUS after deploy." exit 1 fi echo "Stack status: $STATUS" echo "Stack outputs:" aws cloudformation describe-stacks \ --stack-name "${{ inputs.stack-name }}" \ --query 'Stacks[0].Outputs[*].[OutputKey,OutputValue]' --output table # Run project-specific health check if it exists if [[ -f scripts/health-check.sh ]]; then echo "Running project health check..." bash scripts/health-check.sh "${{ inputs.stack-name }}" "${{ inputs.region }}" fi echo "Health check passed."