name: CD — CDK Deploy on: workflow_call: inputs: node-version: description: "Node.js version to use" type: string default: "24" python-version: description: "Python version for Python CDK repos (leave empty for TypeScript CDK)" type: string default: "" dotnet-version: description: "Optional .NET SDK version for repos with .NET assets" type: string default: "" dotnet-publish-project: description: "Optional .NET project path to publish before CDK deploy" type: string default: "" region: description: "AWS region" type: string default: "us-east-1" cdk-dir: description: "Directory containing cdk.json" type: string default: "." enable-qemu: description: "Enable QEMU for cross-platform Docker builds (arm64 on x86 runners)" type: boolean default: false stack-name: description: "CloudFormation stack name (for pre-flight checks)" type: string default: "" stacks: description: "CDK stack selector(s) to deploy (space-separated construct ids/patterns). Default deploys every stack in the app; set per job when a multi-account app splits deploys across roles." type: string default: "--all" post-deploy-script: description: "Optional path to a script to run after CDK deploy (e.g. web build, S3 sync)" type: string default: "" secrets: deploy-role-arn: description: "OIDC deploy role ARN" required: true permissions: id-token: write contents: read jobs: deploy: runs-on: ubuntu-latest timeout-minutes: 30 # Serialise per deploy target so two pushes cannot deploy over each other. # The target is the stack selector, NOT stack-name: a multi-account app can # call this workflow from several jobs in one run (seahaven-org-baseline # runs five, one per AWS account) and stack-name is optional, so keying on # it alone would collapse the selector-only jobs into one group and # serialise deploys that are genuinely independent. `stacks` always defaults # to "--all", so the group is never empty and back-to-back deploys of the # same target still queue. # cancel-in-progress is FALSE on purpose: unlike CI, aborting midway can # leave a stack mid-update. concurrency: group: cd-cdk-${{ inputs.region }}-${{ inputs.stacks }}-${{ inputs.stack-name }} cancel-in-progress: false steps: - uses: actions/checkout@v7 - uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4 if: ${{ inputs.enable-qemu }} - uses: actions/setup-dotnet@v6 if: ${{ inputs.dotnet-version != '' }} with: dotnet-version: ${{ inputs.dotnet-version }} - name: Publish .NET project if: ${{ inputs.dotnet-publish-project != '' }} # Env-var indirection (not inline expression interpolation) so shell # metacharacters in the input are never parsed as script; the input is a # single project path, so it stays quoted (no word-split) — an unquoted # $(dirname ...) split the output path on whitespace. env: DOTNET_PUBLISH_PROJECT: ${{ inputs.dotnet-publish-project }} run: | dotnet publish "$DOTNET_PUBLISH_PROJECT" \ --configuration Release \ --runtime linux-arm64 \ --self-contained false \ --output "$(dirname "$DOTNET_PUBLISH_PROJECT")/bin/Release/net8.0/linux-arm64/publish" - uses: actions/setup-node@v7 with: node-version: ${{ inputs.node-version }} - uses: actions/setup-python@v7 if: ${{ inputs.python-version != '' }} with: python-version: ${{ inputs.python-version }} - name: Install Node dependencies # Only when a lockfile exists (TS CDK repos). Python CDK repos have no # package.json and use `npx -y cdk`, so skip rather than fail npm ci. if: ${{ hashFiles(format('{0}/package-lock.json', inputs.cdk-dir)) != '' }} working-directory: ${{ inputs.cdk-dir }} run: npm ci - name: Install Python dependencies if: ${{ inputs.python-version != '' }} # NUL-delimited read loop rather than `for req in $(find ...)`: the # command-substitution form word-splits and globs every path it finds. shell: bash run: | while IFS= read -r -d '' req; do pip install -r "$req" done < <(find . -name requirements.txt -not -path '*/node_modules/*' -print0) - uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6 with: role-to-assume: ${{ secrets.deploy-role-arn }} aws-region: ${{ inputs.region }} - name: Pre-flight checks if: ${{ inputs.stack-name != '' }} 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: CDK deploy working-directory: ${{ inputs.cdk-dir }} # Env-var indirection (not inline expression interpolation) so shell # metacharacters in the input are never parsed as script; unquoted # $STACKS deliberately word-splits multiple selectors. env: STACKS: ${{ inputs.stacks }} run: | # $STACKS is deliberately unquoted: it carries one or more # space-separated CDK stack selectors (default "--all") that must reach # `cdk deploy` as separate argv entries. Quoting it would collapse them # into one bogus selector and break every multi-stack deploy. # shellcheck disable=SC2086 npx -y cdk deploy $STACKS --require-approval never - name: Post-deploy script if: ${{ inputs.post-deploy-script != '' }} # Env-var indirection (not inline expression interpolation) so shell # metacharacters in the input are never parsed as script; the input is a # single script path, so $POST_DEPLOY_SCRIPT is quoted (no word-split). env: POST_DEPLOY_SCRIPT: ${{ inputs.post-deploy-script }} run: bash "$POST_DEPLOY_SCRIPT" - name: Post-deploy health check if: ${{ inputs.stack-name != '' }} 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."