name: Validate and deploy on: pull_request: branches: [dev, staging, main] push: branches: [dev] workflow_dispatch: permissions: contents: read jobs: validate: name: Validate deployable source bundle runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 - name: Set up .NET uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: dotnet-version: "8.0.x" - name: Repository quality gate run: bash scripts/governance-check.sh - name: Build Elastic Beanstalk source bundle run: bash scripts/package-elastic-beanstalk.sh - name: Inspect source bundle contract run: bash scripts/validate-elastic-beanstalk-bundle.sh deploy-dev: name: Deploy shoc-backend-dev through Terraform if: > (github.event_name == 'push' && github.ref == 'refs/heads/dev' && vars.TERRAFORM_APP_CD_ENABLED == 'true') || (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/dev') needs: validate runs-on: ubuntu-latest timeout-minutes: 180 permissions: contents: read id-token: write environment: name: dev concurrency: group: deploy-dev cancel-in-progress: false env: TF_CLOUD_ORGANIZATION: seahaven TF_API_TOKEN: ${{ secrets.TF_API_TOKEN }} EB_APPLICATION_NAME: shoc-backend EB_ENVIRONMENT_NAME: shoc-backend-dev SMOKE_URL: https://api.dev.seahaven.com EB_BUCKET: elasticbeanstalk-us-east-1-396287094661 steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up .NET uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: dotnet-version: "8.0.x" - name: Build Elastic Beanstalk source bundle run: bash scripts/package-elastic-beanstalk.sh - name: Validate exact release bundle run: bash scripts/validate-elastic-beanstalk-bundle.sh - name: Configure AWS credentials (OIDC) uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3 with: role-to-assume: ${{ secrets.AWS_DEPLOY_ROLE_ARN }} aws-region: us-east-1 audience: sts.amazonaws.com - name: Capture current environment version run: | set -euo pipefail prev="$(aws elasticbeanstalk describe-environments \ --environment-names "${EB_ENVIRONMENT_NAME}" \ --region us-east-1 \ --query 'Environments[0].VersionLabel' \ --output text)" echo "$prev" > .artifacts/elastic-beanstalk/previous-version.txt echo "Previous version label: $prev" - name: Assign immutable release identity id: release run: | set -euo pipefail version_label="${GITHUB_SHA}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" s3_key="shoc-backend/releases/dev/${GITHUB_SHA}/${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}/site.zip" { echo "version_label=${version_label}" echo "s3_key=${s3_key}" } >> "${GITHUB_OUTPUT}" - name: Upload immutable bundle run: | set -euo pipefail aws s3 cp .artifacts/elastic-beanstalk/site.zip \ "s3://${EB_BUCKET}/${{ steps.release.outputs.s3_key }}" \ --region us-east-1 - name: Create Elastic Beanstalk application version run: | set -euo pipefail aws elasticbeanstalk create-application-version \ --application-name "${EB_APPLICATION_NAME}" \ --version-label "${{ steps.release.outputs.version_label }}" \ --description "GitHub Actions ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID} attempt ${GITHUB_RUN_ATTEMPT}" \ --source-bundle "S3Bucket=${EB_BUCKET},S3Key=${{ steps.release.outputs.s3_key }}" \ --process \ --region us-east-1 status="UNPROCESSED" for _ in $(seq 1 36); do status="$(aws elasticbeanstalk describe-application-versions \ --application-name "${EB_APPLICATION_NAME}" \ --version-labels "${{ steps.release.outputs.version_label }}" \ --region us-east-1 \ --query 'ApplicationVersions[0].Status' \ --output text)" echo "application version status: $status" if [ "$status" = "PROCESSED" ]; then exit 0 fi if [ "$status" = "FAILED" ]; then echo "Elastic Beanstalk failed to process ${{ steps.release.outputs.version_label }}." >&2 exit 1 fi sleep 5 done echo "Application version did not become PROCESSED." >&2 exit 1 - name: Create Terraform release run id: release-run uses: hashicorp/tfc-workflows-github/actions/create-run@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2 env: TF_VAR_release_version_label: '"${{ steps.release.outputs.version_label }}"' with: workspace: shoc-backend-dev message: "Release ${{ steps.release.outputs.version_label }} from GitHub Actions" - name: Read Terraform release plan counts id: release-plan uses: hashicorp/tfc-workflows-github/actions/plan-output@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2 with: plan: ${{ steps.release-run.outputs.plan_id }} - name: Reject non-version-only resource counts env: PLAN_ADD: ${{ steps.release-plan.outputs.add }} PLAN_CHANGE: ${{ steps.release-plan.outputs.change }} PLAN_DESTROY: ${{ steps.release-plan.outputs.destroy }} run: | set -euo pipefail if [ "$PLAN_ADD" != "0" ] || [ "$PLAN_CHANGE" != "1" ] || [ "$PLAN_DESTROY" != "0" ]; then echo "HCP plan counts are add=${PLAN_ADD} change=${PLAN_CHANGE} destroy=${PLAN_DESTROY}; expected 0/1/0." >&2 exit 1 fi - name: Guard version-only Terraform plan run: | set -euo pipefail python scripts/check-terraform-release-plan.py \ --plan-id "${{ steps.release-run.outputs.plan_id }}" \ --expected-version-label "${{ steps.release.outputs.version_label }}" - name: Discard release run when the guard fails if: failure() && steps.release-run.outcome == 'success' uses: hashicorp/tfc-workflows-github/actions/discard-run@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2 with: run: ${{ steps.release-run.outputs.run_id }} comment: Rejected by the version-only plan guard from GitHub Actions - name: Apply Terraform release run id: release-apply uses: hashicorp/tfc-workflows-github/actions/apply-run@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2 with: run: ${{ steps.release-run.outputs.run_id }} comment: Apply version-only release from GitHub Actions ${{ github.sha }} - name: Verify exact application version is active run: | set -euo pipefail expected="${{ steps.release.outputs.version_label }}" status="Unknown" current="Unknown" health="Unknown" for _ in $(seq 1 80); do read -r status current health < <( aws elasticbeanstalk describe-environments \ --environment-names "${EB_ENVIRONMENT_NAME}" \ --region us-east-1 \ --query 'Environments[0].[Status,VersionLabel,Health]' \ --output text ) echo "environment status: $status; version: $current; health: $health" if [ "$status" = "Ready" ]; then if [ "$current" = "$expected" ] && { [ "$health" = "Green" ] || [ "$health" = "Yellow" ]; }; then echo "Expected application version is Ready and healthy." exit 0 fi echo "Environment became Ready without activating expected version $expected." >&2 exit 1 fi sleep 15 done echo "Expected application version did not become Ready within the deployment window." >&2 exit 1 - name: Post-deploy smoke run: bash scripts/smoke-elastic-beanstalk.sh "${SMOKE_URL}" - name: Verify webhook secret source is operational run: | set -euo pipefail response_file="$(mktemp)" trap 'rm -f "$response_file"' EXIT status="$(curl --silent --show-error \ --output "$response_file" \ --write-out '%{http_code}' \ --request POST \ --header 'Content-Type: application/json' \ --header "X-SH-Timestamp: $(date +%s)" \ --header 'X-SH-Key-Id: deployment-smoke-invalid-key' \ --header "X-SH-Signature: v1=$(printf '0%.0s' {1..64})" \ --data '{}' \ "${SMOKE_URL}/api/webhooks/work-orders")" if [ "$status" != "401" ]; then echo "Expected enabled webhook with an operational secret source to reject the invalid probe with 401; received $status." >&2 sed -n '1,20p' "$response_file" >&2 exit 1 fi - name: Restore previous application version on failure (schema is not reverted) if: failure() run: | set -euo pipefail prev_file=".artifacts/elastic-beanstalk/previous-version.txt" if [ ! -f "$prev_file" ]; then echo "No previous version captured; nothing to roll back." >&2 exit 0 fi prev="$(cat "$prev_file")" if [ -z "$prev" ] || [ "$prev" = "null" ] || [ "$prev" = "None" ] || [ "$prev" = "N/A" ]; then echo "No previous version recorded; nothing to roll back." >&2 exit 0 fi echo "Waiting for any in-flight environment update to settle..." status="Unknown" current="Unknown" health="Unknown" for _ in $(seq 1 80); do read -r status current health < <( aws elasticbeanstalk describe-environments \ --environment-names "${EB_ENVIRONMENT_NAME}" \ --region us-east-1 \ --query 'Environments[0].[Status,VersionLabel,Health]' \ --output text ) echo "environment status: $status; version: $current; health: $health" if [ "$status" = "Ready" ]; then break fi sleep 15 done if [ "$status" != "Ready" ]; then echo "Environment did not settle before rollback." >&2 exit 1 fi if [ "$current" = "$prev" ]; then echo "Environment is already on previous version $prev." exit 0 fi if [[ ! "$prev" =~ ^[0-9a-f]{40}-[0-9]+-[0-9]+$ ]]; then echo "Previous version $prev is not a Terraform-managed release label; cannot roll back through HCP." >&2 exit 1 fi echo "rollback_label=$prev" >> "${GITHUB_OUTPUT}" id: rollback-prepare - name: Create Terraform rollback run id: rollback-run if: failure() && steps.rollback-prepare.outputs.rollback_label != '' uses: hashicorp/tfc-workflows-github/actions/create-run@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2 env: TF_VAR_release_version_label: '"${{ steps.rollback-prepare.outputs.rollback_label }}"' with: workspace: shoc-backend-dev message: "Rollback to ${{ steps.rollback-prepare.outputs.rollback_label }} from GitHub Actions" - name: Read Terraform rollback plan counts id: rollback-plan if: failure() && steps.rollback-run.outcome == 'success' uses: hashicorp/tfc-workflows-github/actions/plan-output@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2 with: plan: ${{ steps.rollback-run.outputs.plan_id }} - name: Reject non-version-only rollback counts id: rollback-count-guard if: failure() && steps.rollback-plan.outcome == 'success' env: PLAN_ADD: ${{ steps.rollback-plan.outputs.add }} PLAN_CHANGE: ${{ steps.rollback-plan.outputs.change }} PLAN_DESTROY: ${{ steps.rollback-plan.outputs.destroy }} run: | set -euo pipefail if [ "$PLAN_ADD" != "0" ] || [ "$PLAN_CHANGE" != "1" ] || [ "$PLAN_DESTROY" != "0" ]; then echo "Rollback HCP plan counts are add=${PLAN_ADD} change=${PLAN_CHANGE} destroy=${PLAN_DESTROY}; expected 0/1/0." >&2 exit 1 fi - name: Guard version-only Terraform rollback plan id: rollback-json-guard if: failure() && steps.rollback-count-guard.outcome == 'success' run: | set -euo pipefail python scripts/check-terraform-release-plan.py \ --plan-id "${{ steps.rollback-run.outputs.plan_id }}" \ --expected-version-label "${{ steps.rollback-prepare.outputs.rollback_label }}" - name: Discard rollback run when the guard fails if: failure() && steps.rollback-run.outcome == 'success' && steps.rollback-json-guard.outcome != 'success' uses: hashicorp/tfc-workflows-github/actions/discard-run@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2 with: run: ${{ steps.rollback-run.outputs.run_id }} comment: Rejected by the version-only rollback plan guard from GitHub Actions - name: Apply Terraform rollback run id: rollback-apply if: failure() && steps.rollback-json-guard.outcome == 'success' uses: hashicorp/tfc-workflows-github/actions/apply-run@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2 with: run: ${{ steps.rollback-run.outputs.run_id }} comment: Apply version-only rollback from GitHub Actions ${{ github.sha }} - name: Verify previous application version is active if: failure() && steps.rollback-apply.outcome == 'success' run: | set -euo pipefail prev="${{ steps.rollback-prepare.outputs.rollback_label }}" echo "Database migrations are not reverted; deployable migrations must follow the expand/contract policy." status="Unknown" current="Unknown" health="Unknown" for _ in $(seq 1 80); do read -r status current health < <( aws elasticbeanstalk describe-environments \ --environment-names "${EB_ENVIRONMENT_NAME}" \ --region us-east-1 \ --query 'Environments[0].[Status,VersionLabel,Health]' \ --output text ) echo "environment status: $status; version: $current; health: $health" if [ "$status" = "Ready" ]; then if [ "$current" = "$prev" ] && { [ "$health" = "Green" ] || [ "$health" = "Yellow" ]; }; then echo "Application version restore complete; previous code is Ready and healthy." exit 0 fi echo "Rollback reached Ready in an unexpected version/health state." >&2 exit 1 fi sleep 15 done echo "Environment did not return to Ready within rollback window." >&2 exit 1 deploy-staging: name: Deploy shoc-backend-staging to Elastic Beanstalk if: > github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/staging' needs: validate runs-on: ubuntu-latest permissions: contents: read id-token: write environment: name: staging concurrency: group: deploy-staging cancel-in-progress: false steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Resolve deploy target id: target run: | set -euo pipefail application=shoc-backend environment=shoc-backend-staging smoke_url=https://api.staging.seahaven.com { echo "application=${application}" echo "environment=${environment}" echo "smoke_url=${smoke_url}" } >> "${GITHUB_OUTPUT}" { echo "EB_APPLICATION_NAME=${application}" echo "EB_ENVIRONMENT_NAME=${environment}" echo "SMOKE_URL=${smoke_url}" } >> "${GITHUB_ENV}" - name: Set up .NET uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: dotnet-version: "8.0.x" - name: Build Elastic Beanstalk source bundle run: bash scripts/package-elastic-beanstalk.sh - name: Validate exact release bundle run: bash scripts/validate-elastic-beanstalk-bundle.sh - name: Configure AWS credentials (OIDC) uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3 with: role-to-assume: ${{ secrets.AWS_DEPLOY_ROLE_ARN }} aws-region: us-east-1 audience: sts.amazonaws.com - name: Capture current environment version run: | set -euo pipefail prev="$(aws elasticbeanstalk describe-environments \ --environment-names "${EB_ENVIRONMENT_NAME}" \ --region us-east-1 \ --query 'Environments[0].VersionLabel' \ --output text)" echo "$prev" > .artifacts/elastic-beanstalk/previous-version.txt echo "Previous version label: $prev" - name: Deploy prebuilt bundle to existing environment uses: aws-actions/aws-elasticbeanstalk-deploy@7883cdd454c162051bf6fc13389536b045149b4c # v1.0.8 with: aws-region: us-east-1 application-name: ${{ steps.target.outputs.application }} environment-name: ${{ steps.target.outputs.environment }} version-label: ${{ github.sha }}-${{ github.run_id }}-${{ github.run_attempt }} deployment-package-path: .artifacts/elastic-beanstalk/site.zip s3-bucket-name: elasticbeanstalk-us-east-1-396287094661 create-application-if-not-exists: "false" create-environment-if-not-exists: "false" create-s3-bucket-if-not-exists: "false" use-existing-application-version-if-available: "false" wait-for-deployment: "true" wait-for-environment-recovery: "true" - name: Verify exact application version is active run: | set -euo pipefail expected="${{ github.sha }}-${{ github.run_id }}-${{ github.run_attempt }}" status="Unknown" current="Unknown" health="Unknown" for _ in $(seq 1 80); do read -r status current health < <( aws elasticbeanstalk describe-environments \ --environment-names "${EB_ENVIRONMENT_NAME}" \ --region us-east-1 \ --query 'Environments[0].[Status,VersionLabel,Health]' \ --output text ) echo "environment status: $status; version: $current; health: $health" if [ "$status" = "Ready" ]; then if [ "$current" = "$expected" ] && { [ "$health" = "Green" ] || [ "$health" = "Yellow" ]; }; then echo "Expected application version is Ready and healthy." exit 0 fi echo "Environment became Ready without activating expected version $expected." >&2 exit 1 fi sleep 15 done echo "Expected application version did not become Ready within the deployment window." >&2 exit 1 - name: Post-deploy smoke run: bash scripts/smoke-elastic-beanstalk.sh "${SMOKE_URL}" - name: Verify webhook secret source is operational run: | set -euo pipefail response_file="$(mktemp)" trap 'rm -f "$response_file"' EXIT status="$(curl --silent --show-error \ --output "$response_file" \ --write-out '%{http_code}' \ --request POST \ --header 'Content-Type: application/json' \ --header "X-SH-Timestamp: $(date +%s)" \ --header 'X-SH-Key-Id: deployment-smoke-invalid-key' \ --header "X-SH-Signature: v1=$(printf '0%.0s' {1..64})" \ --data '{}' \ "${SMOKE_URL}/api/webhooks/work-orders")" if [ "$status" != "401" ]; then echo "Expected enabled webhook with an operational secret source to reject the invalid probe with 401; received $status." >&2 sed -n '1,20p' "$response_file" >&2 exit 1 fi - name: Restore previous application version on failure (schema is not reverted) if: failure() run: | set -euo pipefail prev_file=".artifacts/elastic-beanstalk/previous-version.txt" if [ ! -f "$prev_file" ]; then echo "No previous version captured; nothing to roll back." >&2 exit 0 fi prev="$(cat "$prev_file")" if [ -z "$prev" ] || [ "$prev" = "null" ] || [ "$prev" = "None" ] || [ "$prev" = "N/A" ]; then echo "No previous version recorded; nothing to roll back." >&2 exit 0 fi echo "Waiting for any in-flight environment update to settle..." status="Unknown" current="Unknown" health="Unknown" for _ in $(seq 1 80); do read -r status current health < <( aws elasticbeanstalk describe-environments \ --environment-names "${EB_ENVIRONMENT_NAME}" \ --region us-east-1 \ --query 'Environments[0].[Status,VersionLabel,Health]' \ --output text ) echo "environment status: $status; version: $current; health: $health" if [ "$status" = "Ready" ]; then break fi sleep 15 done if [ "$status" != "Ready" ]; then echo "Environment did not settle before rollback." >&2 exit 1 fi if [ "$current" = "$prev" ]; then echo "Environment is already on previous version $prev." exit 0 fi echo "Restoring ${EB_ENVIRONMENT_NAME} application code to version label: $prev" echo "Database migrations are not reverted; deployable migrations must follow the expand/contract policy." aws elasticbeanstalk update-environment \ --environment-name "${EB_ENVIRONMENT_NAME}" \ --version-label "$prev" \ --region us-east-1 echo "Waiting for previous version to become healthy..." for _ in $(seq 1 80); do read -r status current health < <( aws elasticbeanstalk describe-environments \ --environment-names "${EB_ENVIRONMENT_NAME}" \ --region us-east-1 \ --query 'Environments[0].[Status,VersionLabel,Health]' \ --output text ) echo "environment status: $status; version: $current; health: $health" if [ "$status" = "Ready" ]; then if [ "$current" = "$prev" ] && { [ "$health" = "Green" ] || [ "$health" = "Yellow" ]; }; then echo "Application version restore complete; previous code is Ready and healthy." exit 0 fi echo "Rollback reached Ready in an unexpected version/health state." >&2 exit 1 fi sleep 15 done echo "Environment did not return to Ready within rollback window." >&2 exit 1