mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 06:03:12 +00:00
405 lines
16 KiB
YAML
405 lines
16 KiB
YAML
name: Deploy API
|
|
|
|
# GitHub owns Elastic Beanstalk application versions. Terraform ignores
|
|
# version_label. Do not put path filters on tag events; those live in
|
|
# deploy-tag.yaml.
|
|
|
|
on:
|
|
workflow_call:
|
|
inputs:
|
|
environment:
|
|
required: true
|
|
type: string
|
|
ref:
|
|
required: true
|
|
type: string
|
|
workflow_dispatch:
|
|
inputs:
|
|
environment:
|
|
description: Target Environment
|
|
required: true
|
|
type: choice
|
|
options: [dev, staging, prod]
|
|
ref:
|
|
description: Git ref to build (tag, branch, or SHA). Empty means this run's SHA.
|
|
required: false
|
|
type: string
|
|
default: ""
|
|
push:
|
|
branches: [main]
|
|
paths-ignore:
|
|
- "terraform/**"
|
|
- "**/*.md"
|
|
- ".github/workflows/ci.yml"
|
|
- ".github/workflows/release.yaml"
|
|
- ".github/workflows/deploy-tag.yaml"
|
|
|
|
permissions:
|
|
contents: read
|
|
|
|
jobs:
|
|
target:
|
|
name: Resolve target
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 5
|
|
outputs:
|
|
environment: ${{ steps.resolve.outputs.environment }}
|
|
ref: ${{ steps.resolve.outputs.ref }}
|
|
steps:
|
|
- id: resolve
|
|
env:
|
|
EVENT_NAME: ${{ github.event_name }}
|
|
CALL_ENVIRONMENT: ${{ inputs.environment }}
|
|
CALL_REF: ${{ inputs.ref }}
|
|
INPUT_ENVIRONMENT: ${{ github.event.inputs.environment }}
|
|
INPUT_REF: ${{ github.event.inputs.ref }}
|
|
GITHUB_SHA_IN: ${{ github.sha }}
|
|
run: |
|
|
set -euo pipefail
|
|
# A called reusable workflow keeps the caller's github.event_name
|
|
# (push or workflow_dispatch), not workflow_call. Prefer the call
|
|
# inputs whenever they are set.
|
|
if [ -n "${CALL_ENVIRONMENT}" ]; then
|
|
environment="${CALL_ENVIRONMENT}"
|
|
ref="${CALL_REF:-${GITHUB_SHA_IN}}"
|
|
else
|
|
case "${EVENT_NAME}" in
|
|
workflow_dispatch)
|
|
environment="${INPUT_ENVIRONMENT}"
|
|
ref="${INPUT_REF:-${GITHUB_SHA_IN}}"
|
|
;;
|
|
push)
|
|
environment=dev
|
|
ref="${GITHUB_SHA_IN}"
|
|
;;
|
|
*)
|
|
echo "unsupported event ${EVENT_NAME}" >&2
|
|
exit 1
|
|
;;
|
|
esac
|
|
fi
|
|
case "${environment}" in
|
|
dev|staging|prod) ;;
|
|
*)
|
|
echo "unknown environment ${environment}" >&2
|
|
exit 1
|
|
;;
|
|
esac
|
|
{
|
|
echo "environment=${environment}"
|
|
echo "ref=${ref}"
|
|
} >> "${GITHUB_OUTPUT}"
|
|
echo "Deploying ${ref} to ${environment}"
|
|
|
|
deploy:
|
|
name: Deploy ${{ needs.target.outputs.environment }}
|
|
needs: target
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 180
|
|
environment: ${{ needs.target.outputs.environment }}
|
|
concurrency:
|
|
group: deploy-api-${{ needs.target.outputs.environment }}
|
|
cancel-in-progress: false
|
|
permissions:
|
|
contents: read
|
|
id-token: write
|
|
checks: read
|
|
env:
|
|
AWS_REGION: us-east-1
|
|
DEPLOY_ROLE_ARN: ${{ vars.DEPLOY_ROLE_ARN }}
|
|
TARGET_ENVIRONMENT: ${{ needs.target.outputs.environment }}
|
|
TARGET_REF: ${{ needs.target.outputs.ref }}
|
|
steps:
|
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
with:
|
|
ref: ${{ needs.target.outputs.ref }}
|
|
persist-credentials: false
|
|
fetch-depth: 0
|
|
|
|
- name: Resolve commit
|
|
id: commit
|
|
run: |
|
|
set -euo pipefail
|
|
sha="$(git rev-parse HEAD)"
|
|
echo "sha=${sha}" >> "${GITHUB_OUTPUT}"
|
|
echo "Building ${sha}"
|
|
|
|
- name: Require tag on main
|
|
if: needs.target.outputs.environment != 'dev'
|
|
env:
|
|
REPO: ${{ github.repository }}
|
|
GH_TOKEN: ${{ github.token }}
|
|
TAG_OR_REF: ${{ needs.target.outputs.ref }}
|
|
run: |
|
|
set -euo pipefail
|
|
status="$(gh api "repos/${REPO}/compare/main...${TAG_OR_REF}" --jq .status)"
|
|
if [ "${status}" != "behind" ] && [ "${status}" != "identical" ]; then
|
|
echo "ref ${TAG_OR_REF} is not on main (compare status: ${status})" >&2
|
|
exit 1
|
|
fi
|
|
|
|
- name: Require CI on the SHA
|
|
if: needs.target.outputs.environment != 'dev'
|
|
env:
|
|
GITHUB_TOKEN: ${{ github.token }}
|
|
run: |
|
|
python3 scripts/require_commit_checks.py \
|
|
--repo "${{ github.repository }}" \
|
|
--sha "${{ steps.commit.outputs.sha }}" \
|
|
--timeout-seconds 60
|
|
|
|
- name: Skip prod AWS until live/prod exists
|
|
id: prod-gate
|
|
if: needs.target.outputs.environment == 'prod'
|
|
run: |
|
|
set -euo pipefail
|
|
if [ "${{ vars.PROD_APP_CD_ENABLED }}" = "true" ]; then
|
|
echo "skip_aws=false" >> "${GITHUB_OUTPUT}"
|
|
else
|
|
echo "PROD_APP_CD_ENABLED is not true; reviewers already approved; skipping AWS."
|
|
echo "skip_aws=true" >> "${GITHUB_OUTPUT}"
|
|
fi
|
|
|
|
- name: Set up .NET
|
|
if: needs.target.outputs.environment != 'prod' || steps.prod-gate.outputs.skip_aws != 'true'
|
|
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
|
|
with:
|
|
dotnet-version: "8.0.x"
|
|
|
|
- name: Build Elastic Beanstalk source bundle
|
|
if: needs.target.outputs.environment != 'prod' || steps.prod-gate.outputs.skip_aws != 'true'
|
|
run: bash scripts/package-elastic-beanstalk.sh
|
|
|
|
- name: Validate exact release bundle
|
|
if: needs.target.outputs.environment != 'prod' || steps.prod-gate.outputs.skip_aws != 'true'
|
|
run: bash scripts/validate-elastic-beanstalk-bundle.sh
|
|
|
|
- name: Configure AWS credentials using OIDC
|
|
if: needs.target.outputs.environment != 'prod' || steps.prod-gate.outputs.skip_aws != 'true'
|
|
uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6.3.0
|
|
with:
|
|
role-to-assume: ${{ env.DEPLOY_ROLE_ARN }}
|
|
aws-region: us-east-1
|
|
audience: sts.amazonaws.com
|
|
|
|
- name: Get deploy parameters
|
|
id: deploy
|
|
if: needs.target.outputs.environment != 'prod' || steps.prod-gate.outputs.skip_aws != 'true'
|
|
run: |
|
|
set -euo pipefail
|
|
prefix="/shoc-backend/${TARGET_ENVIRONMENT}/deploy"
|
|
APPLICATION=$(aws ssm get-parameter --name "${prefix}/application-name" --query Parameter.Value --output text)
|
|
ENVIRONMENT_NAME=$(aws ssm get-parameter --name "${prefix}/environment-name" --query Parameter.Value --output text)
|
|
ARTIFACTS_BUCKET=$(aws ssm get-parameter --name "${prefix}/artifacts-bucket" --query Parameter.Value --output text)
|
|
SMOKE_URL=$(aws ssm get-parameter --name "${prefix}/smoke-url" --query Parameter.Value --output text)
|
|
{
|
|
echo "application=${APPLICATION}"
|
|
echo "environment_name=${ENVIRONMENT_NAME}"
|
|
echo "artifacts_bucket=${ARTIFACTS_BUCKET}"
|
|
echo "smoke_url=${SMOKE_URL}"
|
|
} >> "${GITHUB_OUTPUT}"
|
|
|
|
- name: Capture current environment version
|
|
if: needs.target.outputs.environment != 'prod' || steps.prod-gate.outputs.skip_aws != 'true'
|
|
env:
|
|
ENVIRONMENT_NAME: ${{ steps.deploy.outputs.environment_name }}
|
|
run: |
|
|
set -euo pipefail
|
|
prev="$(aws elasticbeanstalk describe-environments \
|
|
--environment-names "${ENVIRONMENT_NAME}" \
|
|
--region us-east-1 \
|
|
--query 'Environments[0].VersionLabel' \
|
|
--output text)"
|
|
echo "previous_version_label=${prev}" >> "${GITHUB_ENV}"
|
|
echo "Previous version label: ${prev}"
|
|
|
|
- name: Upload bundle and update environment
|
|
if: needs.target.outputs.environment != 'prod' || steps.prod-gate.outputs.skip_aws != 'true'
|
|
env:
|
|
APPLICATION: ${{ steps.deploy.outputs.application }}
|
|
ENVIRONMENT_NAME: ${{ steps.deploy.outputs.environment_name }}
|
|
ARTIFACTS_BUCKET: ${{ steps.deploy.outputs.artifacts_bucket }}
|
|
GIT_SHA: ${{ steps.commit.outputs.sha }}
|
|
run: |
|
|
set -euo pipefail
|
|
version_label="${GIT_SHA}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
|
s3_key="shoc-backend/releases/${TARGET_ENVIRONMENT}/${GIT_SHA}/${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}/site.zip"
|
|
aws s3 cp .artifacts/elastic-beanstalk/site.zip \
|
|
"s3://${ARTIFACTS_BUCKET}/${s3_key}" \
|
|
--region us-east-1
|
|
aws elasticbeanstalk create-application-version \
|
|
--application-name "${APPLICATION}" \
|
|
--version-label "${version_label}" \
|
|
--description "GitHub Actions ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" \
|
|
--source-bundle "S3Bucket=${ARTIFACTS_BUCKET},S3Key=${s3_key}" \
|
|
--process \
|
|
--region us-east-1
|
|
status="UNPROCESSED"
|
|
for _ in $(seq 1 36); do
|
|
status="$(aws elasticbeanstalk describe-application-versions \
|
|
--application-name "${APPLICATION}" \
|
|
--version-labels "${version_label}" \
|
|
--region us-east-1 \
|
|
--query 'ApplicationVersions[0].Status' \
|
|
--output text)"
|
|
echo "application version status: $status"
|
|
if [ "$status" = "PROCESSED" ]; then
|
|
break
|
|
fi
|
|
if [ "$status" = "FAILED" ]; then
|
|
echo "Elastic Beanstalk failed to process ${version_label}." >&2
|
|
exit 1
|
|
fi
|
|
sleep 5
|
|
done
|
|
if [ "$status" != "PROCESSED" ]; then
|
|
echo "Application version did not become PROCESSED." >&2
|
|
exit 1
|
|
fi
|
|
aws elasticbeanstalk update-environment \
|
|
--environment-name "${ENVIRONMENT_NAME}" \
|
|
--version-label "${version_label}" \
|
|
--region us-east-1
|
|
echo "version_label=${version_label}" >> "${GITHUB_ENV}"
|
|
echo "environment_updated=true" >> "${GITHUB_ENV}"
|
|
|
|
- name: Verify exact application version is active
|
|
if: needs.target.outputs.environment != 'prod' || steps.prod-gate.outputs.skip_aws != 'true'
|
|
env:
|
|
ENVIRONMENT_NAME: ${{ steps.deploy.outputs.environment_name }}
|
|
run: |
|
|
set -euo pipefail
|
|
expected="${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 "${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" ]; then
|
|
echo "Environment became Ready on version $current, not the expected $expected." >&2
|
|
exit 1
|
|
fi
|
|
if [ "$health" = "Green" ] || [ "$health" = "Yellow" ]; then
|
|
echo "Expected application version is Ready and healthy."
|
|
exit 0
|
|
fi
|
|
echo "Expected version is active; waiting for health to leave $health."
|
|
fi
|
|
sleep 15
|
|
done
|
|
echo "Expected application version did not become Ready and healthy within the deployment window (last seen: status=$status version=$current health=$health)." >&2
|
|
exit 1
|
|
|
|
- name: Post-deploy smoke
|
|
if: needs.target.outputs.environment != 'prod' || steps.prod-gate.outputs.skip_aws != 'true'
|
|
run: bash scripts/smoke-elastic-beanstalk.sh "${{ steps.deploy.outputs.smoke_url }}"
|
|
|
|
- name: Verify webhook secret source is operational
|
|
if: needs.target.outputs.environment != 'prod' || steps.prod-gate.outputs.skip_aws != 'true'
|
|
env:
|
|
SMOKE_URL: ${{ steps.deploy.outputs.smoke_url }}
|
|
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 401 from enabled webhook; 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() && !cancelled() && (needs.target.outputs.environment != 'prod' || steps.prod-gate.outputs.skip_aws != 'true') }}
|
|
env:
|
|
ENVIRONMENT_NAME: ${{ steps.deploy.outputs.environment_name }}
|
|
run: |
|
|
set -euo pipefail
|
|
prev="${previous_version_label:-}"
|
|
if [ "${environment_updated:-}" != "true" ]; then
|
|
echo "Environment was not updated; nothing to roll back."
|
|
exit 0
|
|
fi
|
|
if [ -z "$prev" ] || [ "$prev" = "null" ] || [ "$prev" = "None" ] || [ "$prev" = "N/A" ]; then
|
|
echo "No previous version recorded; nothing to roll back." >&2
|
|
exit 0
|
|
fi
|
|
if [ "$prev" = "${version_label:-}" ]; then
|
|
echo "Previous version is the failed release; 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 "${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 previous application version $prev."
|
|
aws elasticbeanstalk update-environment \
|
|
--environment-name "${ENVIRONMENT_NAME}" \
|
|
--version-label "${prev}" \
|
|
--region us-east-1
|
|
|
|
for _ in $(seq 1 80); do
|
|
read -r status current health < <(
|
|
aws elasticbeanstalk describe-environments \
|
|
--environment-names "${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" ]; then
|
|
echo "Environment became Ready on version $current, not the previous $prev." >&2
|
|
exit 1
|
|
fi
|
|
if [ "$health" = "Green" ] || [ "$health" = "Yellow" ]; then
|
|
echo "Previous application version is Ready and healthy."
|
|
exit 0
|
|
fi
|
|
echo "Previous version is active; waiting for health to leave $health."
|
|
fi
|
|
sleep 15
|
|
done
|
|
echo "Environment did not return to Ready and healthy within the rollback window (last seen: status=$status version=$current health=$health)." >&2
|
|
exit 1
|