shoc-backend/.github/workflows/deploy.yml
Alexandre Brandizzi 9a49a5c40a fix(deploy): apply the health-convergence fix to the dev Terraform rollback
The dev Terraform rollback verify was the one gate the previous commit
missed, and it is the copy that actually ran on 34293894914. It still
decided on the first Ready poll: previous version correctly restored,
health not yet converged, reported as a failed rollback.

Split the version and health conditions the same way the other three
gates now do — a Ready poll on the wrong version fails immediately and
names the version that came up, while the correct version with unsettled
health keeps polling inside the unchanged 80 x 15s budget. Timeout now
reports the last observed status, version and health.
2026-09-10 10:55:45 -03:00

864 lines
36 KiB
YAML

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
env:
BASE_REF: ${{ github.event.pull_request.base.sha || 'origin/dev' }}
HEAD_REF: ${{ github.event.pull_request.head.sha || github.sha }}
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: Discard blocking VCS run before GitHub CD
run: |
set -euo pipefail
python3 << 'PY'
import json, os, urllib.error, urllib.request
token = os.environ["TF_API_TOKEN"]
workspace = "shoc-backend-dev"
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/vnd.api+json",
}
def get(url):
req = urllib.request.Request(url, headers=headers)
with urllib.request.urlopen(req) as resp:
return json.load(resp)
def post(url, payload):
data = json.dumps(payload).encode()
req = urllib.request.Request(
url, data=data, method="POST", headers=headers
)
try:
with urllib.request.urlopen(req) as resp:
return resp.status
except urllib.error.HTTPError as exc:
if exc.code in (409, 404):
body = exc.read().decode("utf-8", "replace")
print(f"discard returned HTTP {exc.code}: {body}")
return exc.code
raise
ws = get(
f"https://app.terraform.io/api/v2/organizations/seahaven/workspaces/{workspace}"
)["data"]
attrs = ws["attributes"]
if attrs.get("auto-apply") is True:
raise SystemExit("shoc-backend-dev auto-apply is on; refuse to continue")
if not attrs.get("speculative-enabled"):
raise SystemExit("speculative plans are off; refuse to continue")
if (attrs.get("vcs-repo") or {}).get("tags-regex"):
raise SystemExit("tag-based VCS triggering is set; refuse to continue")
expected_patterns = [
"terraform/live/dev/**",
"terraform/live/modules/**",
]
if attrs.get("trigger-patterns") != expected_patterns:
raise SystemExit(
"trigger-patterns must be "
f"{expected_patterns}; got {attrs.get('trigger-patterns')}"
)
if not attrs.get("locked"):
print("workspace is unlocked")
raise SystemExit(0)
current = (
ws.get("relationships", {})
.get("current-run", {})
.get("data")
)
if not current:
raise SystemExit("workspace is locked without a current run")
run_id = current["id"]
run = get(f"https://app.terraform.io/api/v2/runs/{run_id}")["data"]
run_attrs = run["attributes"]
status = run_attrs.get("status")
plan_only = run_attrs.get("plan-only")
print(f"current run {run_id} status={status} plan-only={plan_only}")
if plan_only:
print("speculative run does not block GitHub CD")
raise SystemExit(0)
if status in {"applying", "apply_queued"}:
raise SystemExit(f"{run_id} is {status}; wait, do not discard an apply")
discardable = {
"pending", "planned", "cost_estimated", "policy_checked", "policy_override"
}
if status not in discardable:
raise SystemExit(f"{run_id} status {status} is not discardable")
code = post(
f"https://app.terraform.io/api/v2/runs/{run_id}/actions/discard",
{"comment": "Discarded so GitHub CD can create the version-only applyable run"},
)
print(f"discarded {run_id} http={code}")
PY
- 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
continue-on-error: true
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: Treat already-applied release run as success
env:
APPLY_OUTCOME: ${{ steps.release-apply.outcome }}
RUN_ID: ${{ steps.release-run.outputs.run_id }}
run: |
set -euo pipefail
if [ "$APPLY_OUTCOME" = "success" ]; then
echo "Apply succeeded."
exit 0
fi
python3 << 'PY'
import json, os, urllib.request
run_id = os.environ["RUN_ID"]
token = os.environ["TF_API_TOKEN"]
req = urllib.request.Request(
f"https://app.terraform.io/api/v2/runs/{run_id}",
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/vnd.api+json",
},
)
with urllib.request.urlopen(req) as resp:
status = json.load(resp)["data"]["attributes"]["status"]
print(f"HCP run {run_id} status={status}")
if status == "applied":
raise SystemExit(0)
raise SystemExit(
f"Apply failed: GitHub outcome={os.environ['APPLY_OUTCOME']} "
f"HCP status={status}"
)
PY
- 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" ]; 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
# The expected version IS active. Elastic Beanstalk reports Ready as
# soon as the rollout finishes, before enhanced health has converged,
# so deciding on the first Ready poll fails a good release on a health
# value that was always going to change. Keep polling; an environment
# that is genuinely unhealthy still fails when the window runs out.
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
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: Discard blocking VCS run before GitHub rollback
id: rollback-discard-vcs
if: failure() && steps.rollback-prepare.outputs.rollback_label != ''
run: |
set -euo pipefail
python3 << 'PY'
import json, os, urllib.error, urllib.request
token = os.environ["TF_API_TOKEN"]
workspace = "shoc-backend-dev"
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/vnd.api+json",
}
def get(url):
req = urllib.request.Request(url, headers=headers)
with urllib.request.urlopen(req) as resp:
return json.load(resp)
def post(url, payload):
data = json.dumps(payload).encode()
req = urllib.request.Request(
url, data=data, method="POST", headers=headers
)
try:
with urllib.request.urlopen(req) as resp:
return resp.status
except urllib.error.HTTPError as exc:
if exc.code in (409, 404):
body = exc.read().decode("utf-8", "replace")
print(f"discard returned HTTP {exc.code}: {body}")
return exc.code
raise
ws = get(
f"https://app.terraform.io/api/v2/organizations/seahaven/workspaces/{workspace}"
)["data"]
attrs = ws["attributes"]
if attrs.get("auto-apply") is True:
raise SystemExit("shoc-backend-dev auto-apply is on; refuse to continue")
if not attrs.get("speculative-enabled"):
raise SystemExit("speculative plans are off; refuse to continue")
if (attrs.get("vcs-repo") or {}).get("tags-regex"):
raise SystemExit("tag-based VCS triggering is set; refuse to continue")
expected_patterns = [
"terraform/live/dev/**",
"terraform/live/modules/**",
]
if attrs.get("trigger-patterns") != expected_patterns:
raise SystemExit(
"trigger-patterns must be "
f"{expected_patterns}; got {attrs.get('trigger-patterns')}"
)
if not attrs.get("locked"):
print("workspace is unlocked")
raise SystemExit(0)
current = (
ws.get("relationships", {})
.get("current-run", {})
.get("data")
)
if not current:
raise SystemExit("workspace is locked without a current run")
run_id = current["id"]
run = get(f"https://app.terraform.io/api/v2/runs/{run_id}")["data"]
run_attrs = run["attributes"]
status = run_attrs.get("status")
plan_only = run_attrs.get("plan-only")
print(f"current run {run_id} status={status} plan-only={plan_only}")
if plan_only:
print("speculative run does not block GitHub CD")
raise SystemExit(0)
if status in {"applying", "apply_queued"}:
raise SystemExit(f"{run_id} is {status}; wait, do not discard an apply")
discardable = {
"pending", "planned", "cost_estimated", "policy_checked", "policy_override"
}
if status not in discardable:
raise SystemExit(f"{run_id} status {status} is not discardable")
code = post(
f"https://app.terraform.io/api/v2/runs/{run_id}/actions/discard",
{"comment": "Discarded so GitHub CD can create the version-only applyable run"},
)
print(f"discarded {run_id} http={code}")
PY
- name: Create Terraform rollback run
id: rollback-run
if: failure() && steps.rollback-prepare.outputs.rollback_label != '' && steps.rollback-discard-vcs.outcome == 'success'
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'
continue-on-error: true
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: Treat already-applied rollback run as success
id: rollback-apply-result
if: failure() && steps.rollback-apply.outcome != 'skipped'
env:
APPLY_OUTCOME: ${{ steps.rollback-apply.outcome }}
RUN_ID: ${{ steps.rollback-run.outputs.run_id }}
run: |
set -euo pipefail
if [ "$APPLY_OUTCOME" = "success" ]; then
echo "Apply succeeded."
exit 0
fi
python3 << 'PY'
import json, os, urllib.request
run_id = os.environ["RUN_ID"]
token = os.environ["TF_API_TOKEN"]
req = urllib.request.Request(
f"https://app.terraform.io/api/v2/runs/{run_id}",
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/vnd.api+json",
},
)
with urllib.request.urlopen(req) as resp:
status = json.load(resp)["data"]["attributes"]["status"]
print(f"HCP run {run_id} status={status}")
if status == "applied":
raise SystemExit(0)
raise SystemExit(
f"Apply failed: GitHub outcome={os.environ['APPLY_OUTCOME']} "
f"HCP status={status}"
)
PY
- name: Verify previous application version is active
if: failure() && steps.rollback-apply-result.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" ]; then
echo "Rollback reached Ready on version $current, not the previous $prev." >&2
exit 1
fi
if [ "$health" = "Green" ] || [ "$health" = "Yellow" ]; then
echo "Application version restore complete; previous code is Ready and healthy."
exit 0
fi
# Same convergence gap as the release check above: the previous version
# is back, health has not settled yet, and reporting a failed rollback
# here hides the fact that the restore itself worked.
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
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" ]; 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
# The expected version IS active. Elastic Beanstalk reports Ready as
# soon as the rollout finishes, before enhanced health has converged,
# so deciding on the first Ready poll fails a good release on a health
# value that was always going to change. Keep polling; an environment
# that is genuinely unhealthy still fails when the window runs out.
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
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" ]; then
echo "Rollback reached Ready on version $current, not the previous $prev." >&2
exit 1
fi
if [ "$health" = "Green" ] || [ "$health" = "Yellow" ]; then
echo "Application version restore complete; previous code is Ready and healthy."
exit 0
fi
# Same convergence gap as the release check above: the previous version
# is back, health has not settled yet, and reporting a failed rollback
# here hides the fact that the restore itself worked.
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