mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 07:13:12 +00:00
Merge pull request #150 from Sea-Haven-Industries/dev
chore: promote GitHub-owned Elastic Beanstalk CD onto main
This commit is contained in:
commit
68ad7362df
23 changed files with 1303 additions and 172 deletions
6
.github/workflows/architecture-quality.yml
vendored
6
.github/workflows/architecture-quality.yml
vendored
|
|
@ -2,6 +2,8 @@ name: Architecture and changed-file quality
|
|||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
|
@ -24,6 +26,6 @@ jobs:
|
|||
- name: Repository quality gate
|
||||
shell: bash
|
||||
env:
|
||||
BASE_REF: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_REF: ${{ github.event.pull_request.head.sha }}
|
||||
BASE_REF: ${{ github.event.pull_request.base.sha || github.event.before }}
|
||||
HEAD_REF: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
run: bash scripts/governance-check.sh
|
||||
|
|
|
|||
60
.github/workflows/ci-terraform.yaml
vendored
Normal file
60
.github/workflows/ci-terraform.yaml
vendored
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
name: Terraform CI
|
||||
|
||||
# Static checks only. Plans run in HCP Terraform as speculative VCS runs on the
|
||||
# PR (shoc-backend-dev and shoc-backend-staging). Applies are HCP auto-apply
|
||||
# on merge to main (dev) and on a vX.Y.Z-staging tag (staging).
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
paths:
|
||||
- "terraform/**"
|
||||
- "scripts/**"
|
||||
- ".github/workflows/ci-terraform.yaml"
|
||||
- ".github/workflows/deploy.yaml"
|
||||
- ".github/workflows/deploy-tag.yaml"
|
||||
- ".github/workflows/release.yaml"
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "terraform/**"
|
||||
- "scripts/**"
|
||||
- ".github/workflows/ci-terraform.yaml"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
terraform:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1
|
||||
with:
|
||||
terraform_version: "1.9.8"
|
||||
terraform_wrapper: false
|
||||
|
||||
- name: Terraform fmt
|
||||
run: terraform fmt -check -recursive terraform
|
||||
|
||||
- name: Validate live/dev
|
||||
run: |
|
||||
terraform -chdir=terraform/live/dev init -backend=false
|
||||
terraform -chdir=terraform/live/dev validate
|
||||
|
||||
- name: Validate live/staging
|
||||
run: |
|
||||
terraform -chdir=terraform/live/staging init -backend=false
|
||||
terraform -chdir=terraform/live/staging validate
|
||||
|
||||
- name: Import plan guard tests
|
||||
run: python3 scripts/test-terraform-import-plan-check.py
|
||||
|
||||
- name: Release promotion script tests
|
||||
run: |
|
||||
python3 scripts/test_next_release_tag.py
|
||||
python3 scripts/test_require_commit_checks.py
|
||||
python3 scripts/test_check_app_terraform_isolation.py
|
||||
33
.github/workflows/ci.yml
vendored
33
.github/workflows/ci.yml
vendored
|
|
@ -3,6 +3,8 @@ name: Backend CI
|
|||
on:
|
||||
pull_request:
|
||||
branches: [main, dev, staging]
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
|
@ -24,11 +26,6 @@ jobs:
|
|||
with:
|
||||
dotnet-version: "8.0.x"
|
||||
|
||||
- name: Set up Terraform
|
||||
uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1
|
||||
with:
|
||||
terraform_version: "1.9.8"
|
||||
|
||||
- name: Restore
|
||||
run: dotnet restore SeaHavenIndustries.sln
|
||||
|
||||
|
|
@ -37,29 +34,3 @@ jobs:
|
|||
|
||||
- name: Test
|
||||
run: dotnet test SeaHavenIndustries.sln --no-build --configuration Release
|
||||
|
||||
- name: Terraform fmt and validate
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
directories=()
|
||||
case "${{ github.base_ref }}" in
|
||||
dev)
|
||||
directories+=(terraform/live/dev)
|
||||
;;
|
||||
staging)
|
||||
directories+=(terraform/live/staging)
|
||||
;;
|
||||
esac
|
||||
|
||||
for dir in "${directories[@]}"; do
|
||||
terraform -chdir="$dir" fmt -check -recursive
|
||||
terraform -chdir="$dir" init -backend=false
|
||||
terraform -chdir="$dir" validate
|
||||
done
|
||||
|
||||
- name: Terraform import plan guard tests
|
||||
run: python scripts/test-terraform-import-plan-check.py
|
||||
|
||||
- name: Terraform release plan guard tests
|
||||
run: python scripts/test-terraform-release-plan-check.py
|
||||
|
|
|
|||
43
.github/workflows/deploy-tag.yaml
vendored
Normal file
43
.github/workflows/deploy-tag.yaml
vendored
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
name: Deploy API from tag
|
||||
|
||||
# Human CLI escape hatch. GITHUB_TOKEN tag pushes from release.yaml do not
|
||||
# start this workflow. No path filters.
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
target:
|
||||
name: Resolve tag
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
environment: ${{ steps.resolve.outputs.environment }}
|
||||
ref: ${{ steps.resolve.outputs.ref }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- id: resolve
|
||||
env:
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
environment="$(python3 -c 'import os, sys; sys.path.insert(0, "scripts"); from next_release_tag import parse_environment_from_tag; print(parse_environment_from_tag(os.environ["TAG"]))')"
|
||||
{
|
||||
echo "environment=${environment}"
|
||||
echo "ref=${TAG}"
|
||||
} >> "${GITHUB_OUTPUT}"
|
||||
|
||||
deploy:
|
||||
needs: target
|
||||
uses: ./.github/workflows/deploy.yaml
|
||||
with:
|
||||
environment: ${{ needs.target.outputs.environment }}
|
||||
ref: ${{ needs.target.outputs.ref }}
|
||||
secrets: inherit
|
||||
407
.github/workflows/deploy.yaml
vendored
Normal file
407
.github/workflows/deploy.yaml
vendored
Normal file
|
|
@ -0,0 +1,407 @@
|
|||
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/ci-terraform.yaml"
|
||||
- ".github/workflows/architecture-quality.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@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3
|
||||
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
|
||||
90
.github/workflows/release.yaml
vendored
Normal file
90
.github/workflows/release.yaml
vendored
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
name: Release
|
||||
|
||||
# Cut a SemVer tag from main HEAD, then call deploy. GITHUB_TOKEN is enough;
|
||||
# it cannot start other workflows via the tag push, so this file calls deploy.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
environment:
|
||||
description: Target environment
|
||||
required: true
|
||||
type: choice
|
||||
options: [staging, prod]
|
||||
bump:
|
||||
description: SemVer bump from the last prod core tag
|
||||
required: true
|
||||
type: choice
|
||||
options: [patch, minor, major]
|
||||
message:
|
||||
description: Annotated tag message and GitHub Release body
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
checks: read
|
||||
|
||||
jobs:
|
||||
cut:
|
||||
name: Cut tag
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 40
|
||||
outputs:
|
||||
tag: ${{ steps.tag.outputs.tag }}
|
||||
sha: ${{ steps.tag.outputs.sha }}
|
||||
environment: ${{ github.event.inputs.environment }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: true
|
||||
|
||||
- name: Require main
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "${GITHUB_REF}" != "refs/heads/main" ]; then
|
||||
echo "Release must run from main (Use workflow from: main). Got ${GITHUB_REF}." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Require CI
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
python3 scripts/require_commit_checks.py \
|
||||
--repo "${{ github.repository }}" \
|
||||
--sha "$(git rev-parse HEAD)" \
|
||||
--timeout-seconds 1200
|
||||
|
||||
- name: Compute and push tag
|
||||
id: tag
|
||||
env:
|
||||
ENVIRONMENT: ${{ github.event.inputs.environment }}
|
||||
BUMP: ${{ github.event.inputs.bump }}
|
||||
MESSAGE: ${{ github.event.inputs.message }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git fetch --tags origin
|
||||
sha="$(git rev-parse HEAD)"
|
||||
tag="$(git tag | python3 scripts/next_release_tag.py --environment "${ENVIRONMENT}" --bump "${BUMP}")"
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git tag -a "${tag}" -m "${MESSAGE}" "${sha}"
|
||||
git push origin "refs/tags/${tag}"
|
||||
gh release create "${tag}" --target "${sha}" --notes "${MESSAGE}" --title "${tag}"
|
||||
{
|
||||
echo "tag=${tag}"
|
||||
echo "sha=${sha}"
|
||||
} >> "${GITHUB_OUTPUT}"
|
||||
echo "Created ${tag} at ${sha}"
|
||||
|
||||
deploy:
|
||||
name: Deploy release
|
||||
needs: cut
|
||||
uses: ./.github/workflows/deploy.yaml
|
||||
with:
|
||||
environment: ${{ needs.cut.outputs.environment }}
|
||||
ref: ${{ needs.cut.outputs.tag }}
|
||||
secrets: inherit
|
||||
|
|
@ -25,8 +25,8 @@
|
|||
| G8 | Error disclosure | §5 | `SanitizedErrorsTests` (part of G5) | `ci` |
|
||||
| G9 | Board-backed regression | review framework | `REVIEW_AND_PR_FRAMEWORK.md` inventory | review-enforced |
|
||||
| G10 | Terraform import plan safety | live infrastructure adoption | `python scripts/test-terraform-import-plan-check.py` | `architecture-quality` → `governance-check.sh` |
|
||||
| G11 | Terraform static validation | import configuration integrity | commands below | `ci` on the matching PR base |
|
||||
| G12 | Terraform release plan safety | dev application CD version_label | `python scripts/test-terraform-release-plan-check.py` | `architecture-quality` → `governance-check.sh` |
|
||||
| G11 | Terraform static validation | import configuration integrity | commands below | `ci-terraform` on PRs to `main` or `dev` |
|
||||
| G13 | App/Terraform isolation | separate delivery lanes | `python3 scripts/check_app_terraform_isolation.py` | `architecture-quality` → `governance-check.sh` |
|
||||
|
||||
## How to run locally
|
||||
|
||||
|
|
@ -36,44 +36,49 @@ bash scripts/governance-check.sh
|
|||
|
||||
# By default it compares against the default branch for changed-file formatting.
|
||||
# Override the comparison point:
|
||||
BASE_REF=origin/dev bash scripts/governance-check.sh
|
||||
BASE_REF=origin/main bash scripts/governance-check.sh
|
||||
BASE_REF=main bash scripts/governance-check.sh
|
||||
```
|
||||
|
||||
The script:
|
||||
1. `dotnet restore` (G1)
|
||||
2. runs the `ArchitectureTests` filter with `--no-restore` (G2)
|
||||
3. computes changed `.cs` files vs `BASE_REF` (default `origin/dev`) and runs
|
||||
3. computes changed `.cs` files vs `BASE_REF` (default `origin/main`) and runs
|
||||
`dotnet format --verify-no-changes --include ...` (G3). When there are no
|
||||
changed C# files it skips G3 with an explicit "skipped: no changed C#" line.
|
||||
4. builds the complete solution in Release with no restore (G4).
|
||||
5. runs the complete solution test suite in Release with no rebuild (G5).
|
||||
6. verifies that the Terraform plan guard rejects create, delete, replacement,
|
||||
unmanaged resource types, and updates not allowlisted by exact address (G10).
|
||||
7. verifies that the release plan guard accepts only a version-only update of
|
||||
`module.environment.aws_elastic_beanstalk_environment.this` (G12).
|
||||
7. runs the release-tag, commit-check, and isolation unit tests.
|
||||
8. rejects a diff that contains both `terraform/` and deployable application
|
||||
files (G13).
|
||||
|
||||
G10 permits only exact approved resource address/type pairs for the
|
||||
environment-owned boundary: Elastic
|
||||
Beanstalk environment, IAM role/inline policy/managed-policy attachment/
|
||||
instance profile, Secrets Manager secret metadata, and Route 53 record.
|
||||
Initial mode permits no update. Controlled mode requires one
|
||||
SSM `/shoc-backend/<env>/deploy/*` parameters are created after adoption and
|
||||
are not part of the import allowlist. Initial mode permits no update.
|
||||
Controlled mode requires one
|
||||
`--allow-update-address` argument per reviewed in-place update. Every invocation
|
||||
also requires `--environment dev` or `--environment staging`; an empty or
|
||||
incomplete environment plan fails.
|
||||
|
||||
G11 runs `terraform fmt -check -recursive`, `terraform init -backend=false`,
|
||||
and `terraform validate`. PRs to `dev` validate `live/dev`.
|
||||
PRs to `staging` validate only `live/staging`. Org-baseline CloudFormation owns
|
||||
the HCP role substrate, and Terraform owns the dev deploy role, so no backend
|
||||
CDK or bootstrap root remains in the matrix.
|
||||
G11 runs `terraform fmt -check -recursive terraform`, `terraform init -backend=false`,
|
||||
and `terraform validate` for both `live/dev` and `live/staging` on every PR to
|
||||
`main` or `dev`. Org-baseline CloudFormation owns the HCP role substrate, and Terraform
|
||||
owns the environment GitHub deploy roles, so no backend CDK or bootstrap root
|
||||
remains in the matrix. HCP plan/apply roles stay in org-baseline; this
|
||||
repository never manages `hcptf-*` roles.
|
||||
|
||||
G12 accepts only a local or downloaded plan JSON whose sole managed update is
|
||||
`module.environment.aws_elastic_beanstalk_environment.this` with
|
||||
`version_label` as the only changed attribute. Counts of `0` add / `1` change /
|
||||
`0` destroy are not a substitute. The optional download uses
|
||||
`GET /api/v2/plans/:id/json-output` on `app.terraform.io` with one redirect to
|
||||
`archivist.terraform.io` and does not create, apply, discard, or poll runs.
|
||||
The former G12 version-only HCP apply guard is not part of the repository gate.
|
||||
`scripts/check-terraform-release-plan.py` remains only while
|
||||
`.github/workflows/deploy.yml` is still present.
|
||||
|
||||
G13 fails when the same diff contains both `terraform/` and deployable
|
||||
application files. Workflow, documentation, and gate-script changes may share
|
||||
a PR with either side.
|
||||
|
||||
## Migration gates (G6)
|
||||
|
||||
|
|
|
|||
|
|
@ -109,9 +109,12 @@ Suppressions are single-diagnostic and cite the ADR — **no wildcard
|
|||
suppressions** (no global `[SuppressMessage]`, no `.editorconfig` severity
|
||||
sweeps, no `#pragma` swaths). See architecture §10.
|
||||
|
||||
Do not mix deployable application changes with Terraform or CDK changes. The
|
||||
first Terraform-owned application-CD change is the allowed exception because it
|
||||
introduces `release_version_label`. Later PRs must keep those diffs separate.
|
||||
Infra and application **PRs** stay separate. GitHub Actions owns Elastic
|
||||
Beanstalk application versions. HCP Terraform owns infrastructure and ignores
|
||||
`version_label`. A change set that includes both `terraform/` and deployable
|
||||
application files (`.cs`, `.csproj`, `.razor`, `.ebextensions/`, or the
|
||||
Elastic Beanstalk package/smoke scripts) fails the isolation check. Workflow,
|
||||
docs, and gate-script changes may travel with either side.
|
||||
|
||||
## 8. PR description contract (minimal)
|
||||
|
||||
|
|
|
|||
69
scripts/check_app_terraform_isolation.py
Normal file
69
scripts/check_app_terraform_isolation.py
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Fail when a change set mixes Terraform with deployable application files.
|
||||
|
||||
Workflow, docs, and gate-script changes may travel with either side.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
APP_SCRIPT_NAMES = {
|
||||
"scripts/package-elastic-beanstalk.sh",
|
||||
"scripts/validate-elastic-beanstalk-bundle.sh",
|
||||
"scripts/smoke-elastic-beanstalk.sh",
|
||||
}
|
||||
|
||||
APP_SUFFIXES = (".cs", ".csproj", ".razor")
|
||||
|
||||
|
||||
def is_terraform_path(path: str) -> bool:
|
||||
return path == "terraform" or path.startswith("terraform/")
|
||||
|
||||
|
||||
def is_app_path(path: str) -> bool:
|
||||
normalized = path.replace("\\", "/")
|
||||
if normalized in APP_SCRIPT_NAMES:
|
||||
return True
|
||||
if normalized.startswith(".ebextensions/"):
|
||||
return True
|
||||
return normalized.endswith(APP_SUFFIXES)
|
||||
|
||||
|
||||
def isolation_violation(paths: list[str]) -> tuple[list[str], list[str]] | None:
|
||||
terraform_files = sorted({path for path in paths if is_terraform_path(path)})
|
||||
app_files = sorted({path for path in paths if is_app_path(path)})
|
||||
if terraform_files and app_files:
|
||||
return terraform_files, app_files
|
||||
return None
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument(
|
||||
"paths",
|
||||
nargs="*",
|
||||
help="Changed paths. Omit and pass newline-separated paths on stdin.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
paths = list(args.paths)
|
||||
if not paths and not sys.stdin.isatty():
|
||||
paths = [line.strip() for line in sys.stdin if line.strip()]
|
||||
violation = isolation_violation(paths)
|
||||
if violation is None:
|
||||
print("PASS: application and Terraform changes are isolated")
|
||||
return 0
|
||||
terraform_files, app_files = violation
|
||||
print("FAIL: do not mix deployable application files with terraform/", file=sys.stderr)
|
||||
print("terraform:", file=sys.stderr)
|
||||
for path in terraform_files:
|
||||
print(f" {path}", file=sys.stderr)
|
||||
print("application:", file=sys.stderr)
|
||||
for path in app_files:
|
||||
print(f" {path}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -8,7 +8,7 @@
|
|||
#
|
||||
# Usage:
|
||||
# bash scripts/governance-check.sh
|
||||
# BASE_REF=origin/dev bash scripts/governance-check.sh
|
||||
# BASE_REF=origin/main bash scripts/governance-check.sh
|
||||
# BASE_REF=<base-sha> HEAD_REF=<head-sha> bash scripts/governance-check.sh
|
||||
set -euo pipefail
|
||||
|
||||
|
|
@ -30,9 +30,9 @@ else
|
|||
exit 1
|
||||
fi
|
||||
|
||||
# Comparison point for changed-file formatting. Default to the dev integration
|
||||
# branch locally; CI overrides BASE_REF/HEAD_REF with the PR base/head SHAs.
|
||||
BASE_REF="${BASE_REF:-origin/dev}"
|
||||
# Comparison point for changed-file formatting. Default to main locally; CI
|
||||
# overrides BASE_REF/HEAD_REF with the PR base/head SHAs.
|
||||
BASE_REF="${BASE_REF:-origin/main}"
|
||||
HEAD_REF="${HEAD_REF:-HEAD}"
|
||||
|
||||
# Resolve the base ref before using it for a diff.
|
||||
|
|
@ -83,8 +83,16 @@ log "G10: Terraform import plan safety"
|
|||
python scripts/test-terraform-import-plan-check.py
|
||||
ok "G10: Terraform import plan safety"
|
||||
|
||||
log "G12: Terraform release plan safety"
|
||||
python scripts/test-terraform-release-plan-check.py
|
||||
ok "G12: Terraform release plan safety"
|
||||
log "Release promotion scripts"
|
||||
python3 scripts/test_next_release_tag.py
|
||||
python3 scripts/test_require_commit_checks.py
|
||||
python3 scripts/test_check_app_terraform_isolation.py
|
||||
ok "Release promotion scripts"
|
||||
|
||||
log "G13: application and Terraform isolation (${BASE_REF}..${HEAD_REF})"
|
||||
python3 scripts/check_app_terraform_isolation.py < <(
|
||||
git diff --name-only --diff-filter=ACMR "${BASE_REF}" "${HEAD_REF}"
|
||||
)
|
||||
ok "G13: application and Terraform isolation"
|
||||
|
||||
log "governance-check: all required repository gates passed"
|
||||
|
|
|
|||
95
scripts/next_release_tag.py
Normal file
95
scripts/next_release_tag.py
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Compute the next SemVer git tag for a staging or prod cut.
|
||||
|
||||
Base version is the highest existing core prod tag vX.Y.Z (not -staging).
|
||||
Missing tags start at 0.0.0. Staging gets v{next}-staging; prod gets v{next}.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import sys
|
||||
|
||||
PROD_TAG = re.compile(r"^v(\d+)\.(\d+)\.(\d+)$")
|
||||
STAGING_TAG = re.compile(r"^v(\d+)\.(\d+)\.(\d+)-staging$")
|
||||
|
||||
|
||||
def parse_environment_from_tag(tag: str) -> str:
|
||||
if STAGING_TAG.fullmatch(tag):
|
||||
return "staging"
|
||||
if PROD_TAG.fullmatch(tag):
|
||||
return "prod"
|
||||
raise ValueError(
|
||||
f"tag {tag!r} is not vX.Y.Z or vX.Y.Z-staging"
|
||||
)
|
||||
|
||||
|
||||
def highest_prod_core(tags: list[str]) -> tuple[int, int, int]:
|
||||
cores: list[tuple[int, int, int]] = []
|
||||
for tag in tags:
|
||||
match = PROD_TAG.fullmatch(tag)
|
||||
if match:
|
||||
cores.append(tuple(int(part) for part in match.groups())) # type: ignore[arg-type]
|
||||
if not cores:
|
||||
return (0, 0, 0)
|
||||
return max(cores)
|
||||
|
||||
|
||||
def bump_core(core: tuple[int, int, int], bump: str) -> tuple[int, int, int]:
|
||||
major, minor, patch = core
|
||||
if bump == "major":
|
||||
return (major + 1, 0, 0)
|
||||
if bump == "minor":
|
||||
return (major, minor + 1, 0)
|
||||
if bump == "patch":
|
||||
return (major, minor, patch + 1)
|
||||
raise ValueError(f"bump must be major, minor, or patch, got {bump!r}")
|
||||
|
||||
|
||||
def format_tag(core: tuple[int, int, int], environment: str) -> str:
|
||||
name = f"v{core[0]}.{core[1]}.{core[2]}"
|
||||
if environment == "staging":
|
||||
return f"{name}-staging"
|
||||
if environment == "prod":
|
||||
return name
|
||||
raise ValueError(f"environment must be staging or prod, got {environment!r}")
|
||||
|
||||
|
||||
def next_release_tag(
|
||||
tags: list[str], environment: str, bump: str
|
||||
) -> str:
|
||||
if environment not in {"staging", "prod"}:
|
||||
raise ValueError(f"environment must be staging or prod, got {environment!r}")
|
||||
nxt = bump_core(highest_prod_core(tags), bump)
|
||||
tag = format_tag(nxt, environment)
|
||||
if tag in tags:
|
||||
raise ValueError(f"tag {tag} already exists")
|
||||
return tag
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--environment", required=True, choices=("staging", "prod"))
|
||||
parser.add_argument("--bump", required=True, choices=("major", "minor", "patch"))
|
||||
parser.add_argument(
|
||||
"--tag",
|
||||
action="append",
|
||||
default=[],
|
||||
dest="tags",
|
||||
help="Existing git tag. Repeat, or omit and pass tags on stdin.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
tags = list(args.tags)
|
||||
if not tags and not sys.stdin.isatty():
|
||||
tags = [line.strip() for line in sys.stdin if line.strip()]
|
||||
try:
|
||||
print(next_release_tag(tags, args.environment, args.bump))
|
||||
except ValueError as exc:
|
||||
print(f"FAIL: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
123
scripts/require_commit_checks.py
Normal file
123
scripts/require_commit_checks.py
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Fail unless required GitHub check runs succeeded on a commit SHA."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
REQUIRED_CHECK_NAMES = (
|
||||
"Build and test",
|
||||
"architecture",
|
||||
)
|
||||
|
||||
|
||||
def _run_recency(run: dict) -> tuple:
|
||||
"""Order check runs so a later rerun wins over an earlier result."""
|
||||
started = run.get("started_at") or ""
|
||||
completed = run.get("completed_at") or ""
|
||||
run_id = run.get("id") or 0
|
||||
return (started, completed, run_id)
|
||||
|
||||
|
||||
def classify_checks(
|
||||
check_runs: list[dict], required_names: tuple[str, ...] = REQUIRED_CHECK_NAMES
|
||||
) -> tuple[str, list[str]]:
|
||||
"""Return ('success'|'pending'|'failure', detail lines)."""
|
||||
by_name: dict[str, dict] = {}
|
||||
for run in check_runs:
|
||||
name = run.get("name")
|
||||
if name not in required_names:
|
||||
continue
|
||||
current = by_name.get(name)
|
||||
if current is None or _run_recency(run) > _run_recency(current):
|
||||
by_name[name] = run
|
||||
|
||||
missing = [name for name in required_names if name not in by_name]
|
||||
if missing:
|
||||
return "pending", [f"missing: {name}" for name in missing]
|
||||
|
||||
details: list[str] = []
|
||||
pending = False
|
||||
failed = False
|
||||
for name in required_names:
|
||||
run = by_name[name]
|
||||
status = run.get("status")
|
||||
conclusion = run.get("conclusion")
|
||||
details.append(f"{name} status={status} conclusion={conclusion}")
|
||||
if status != "completed":
|
||||
pending = True
|
||||
elif conclusion != "success":
|
||||
failed = True
|
||||
if failed:
|
||||
return "failure", details
|
||||
if pending:
|
||||
return "pending", details
|
||||
return "success", details
|
||||
|
||||
|
||||
def fetch_check_runs(repo: str, sha: str, token: str) -> list[dict]:
|
||||
url = (
|
||||
f"https://api.github.com/repos/{repo}/commits/{urllib.parse.quote(sha)}"
|
||||
"/check-runs?per_page=100"
|
||||
)
|
||||
request = urllib.request.Request(
|
||||
url,
|
||||
headers={
|
||||
"Accept": "application/vnd.github+json",
|
||||
"Authorization": f"Bearer {token}",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request) as response:
|
||||
payload = json.load(response)
|
||||
except urllib.error.HTTPError as exc:
|
||||
body = exc.read().decode("utf-8", "replace")
|
||||
raise SystemExit(f"GitHub check-runs HTTP {exc.code}: {body}") from exc
|
||||
return payload.get("check_runs") or []
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--repo", required=True)
|
||||
parser.add_argument("--sha", required=True)
|
||||
parser.add_argument("--timeout-seconds", type=int, default=1200)
|
||||
parser.add_argument("--poll-seconds", type=int, default=15)
|
||||
args = parser.parse_args()
|
||||
token = __import__("os").environ.get("GITHUB_TOKEN") or __import__("os").environ.get(
|
||||
"GH_TOKEN"
|
||||
)
|
||||
if not token:
|
||||
print("FAIL: GITHUB_TOKEN or GH_TOKEN is required", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
deadline = time.time() + args.timeout_seconds
|
||||
while True:
|
||||
runs = fetch_check_runs(args.repo, args.sha, token)
|
||||
state, details = classify_checks(runs)
|
||||
for line in details:
|
||||
print(line)
|
||||
if state == "success":
|
||||
print(f"PASS: required checks succeeded on {args.sha}")
|
||||
return 0
|
||||
if state == "failure":
|
||||
print(f"FAIL: required checks did not succeed on {args.sha}", file=sys.stderr)
|
||||
return 1
|
||||
if time.time() >= deadline:
|
||||
print(
|
||||
f"FAIL: timed out waiting for required checks on {args.sha}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
print(f"waiting {args.poll_seconds}s for checks...")
|
||||
time.sleep(args.poll_seconds)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
59
scripts/test_check_app_terraform_isolation.py
Normal file
59
scripts/test_check_app_terraform_isolation.py
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Tests for check_app_terraform_isolation.isolation_violation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from check_app_terraform_isolation import isolation_violation
|
||||
|
||||
|
||||
class IsolationTests(unittest.TestCase):
|
||||
def test_terraform_only(self) -> None:
|
||||
self.assertIsNone(
|
||||
isolation_violation(
|
||||
[
|
||||
"terraform/live/dev/main.tf",
|
||||
"terraform/live/README.md",
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
def test_app_only(self) -> None:
|
||||
self.assertIsNone(
|
||||
isolation_violation(
|
||||
[
|
||||
"Api.SeaHavenIndustries/Controllers/WorkOrderController.cs",
|
||||
".ebextensions/01_migrations.config",
|
||||
"scripts/package-elastic-beanstalk.sh",
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
def test_docs_and_workflows_with_terraform(self) -> None:
|
||||
self.assertIsNone(
|
||||
isolation_violation(
|
||||
[
|
||||
"terraform/live/modules/environment-owned/main.tf",
|
||||
".github/workflows/deploy.yaml",
|
||||
"QUALITY_GATES.md",
|
||||
"scripts/governance-check.sh",
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
def test_mixed_app_and_terraform_fails(self) -> None:
|
||||
violation = isolation_violation(
|
||||
[
|
||||
"terraform/live/dev/main.tf",
|
||||
"SeaHaven.Services/Implementation/WorkOrderService.cs",
|
||||
]
|
||||
)
|
||||
self.assertIsNotNone(violation)
|
||||
terraform_files, app_files = violation or ([], [])
|
||||
self.assertEqual(terraform_files, ["terraform/live/dev/main.tf"])
|
||||
self.assertTrue(any(path.endswith(".cs") for path in app_files))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
62
scripts/test_next_release_tag.py
Normal file
62
scripts/test_next_release_tag.py
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Tests for next_release_tag.py."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from next_release_tag import (
|
||||
next_release_tag,
|
||||
parse_environment_from_tag,
|
||||
)
|
||||
|
||||
|
||||
class NextReleaseTagTests(unittest.TestCase):
|
||||
def test_first_patch_staging(self) -> None:
|
||||
self.assertEqual(next_release_tag([], "staging", "patch"), "v0.0.1-staging")
|
||||
|
||||
def test_first_minor_prod(self) -> None:
|
||||
self.assertEqual(next_release_tag([], "prod", "minor"), "v0.1.0")
|
||||
|
||||
def test_first_major_prod(self) -> None:
|
||||
self.assertEqual(next_release_tag([], "prod", "major"), "v1.0.0")
|
||||
|
||||
def test_staging_then_prod_same_core(self) -> None:
|
||||
tags = ["v1.2.3"]
|
||||
staging = next_release_tag(tags, "staging", "patch")
|
||||
self.assertEqual(staging, "v1.2.4-staging")
|
||||
prod = next_release_tag(tags + [staging], "prod", "patch")
|
||||
self.assertEqual(prod, "v1.2.4")
|
||||
|
||||
def test_staging_prerelease_does_not_raise_prod_base(self) -> None:
|
||||
tags = ["v1.2.3", "v9.9.9-staging"]
|
||||
self.assertEqual(next_release_tag(tags, "staging", "patch"), "v1.2.4-staging")
|
||||
|
||||
def test_duplicate_staging_fails(self) -> None:
|
||||
tags = ["v1.2.3", "v1.2.4-staging"]
|
||||
with self.assertRaises(ValueError):
|
||||
next_release_tag(tags, "staging", "patch")
|
||||
|
||||
def test_prod_after_staging_uses_same_core(self) -> None:
|
||||
tags = ["v1.2.3", "v9.9.9-staging"]
|
||||
self.assertEqual(next_release_tag(tags, "prod", "patch"), "v1.2.4")
|
||||
|
||||
def test_parse_environment(self) -> None:
|
||||
self.assertEqual(parse_environment_from_tag("v1.2.3-staging"), "staging")
|
||||
self.assertEqual(parse_environment_from_tag("v1.2.3"), "prod")
|
||||
|
||||
def test_reject_prefixed_and_prod_prerelease(self) -> None:
|
||||
for tag in (
|
||||
"staging-v1.2.3",
|
||||
"prod-v1.2.3",
|
||||
"v1.2.3-prod",
|
||||
"v1.2.3-staging.1",
|
||||
"v1.2",
|
||||
"1.2.3",
|
||||
):
|
||||
with self.assertRaises(ValueError):
|
||||
parse_environment_from_tag(tag)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
112
scripts/test_require_commit_checks.py
Normal file
112
scripts/test_require_commit_checks.py
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Tests for require_commit_checks.classify_checks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from require_commit_checks import classify_checks
|
||||
|
||||
|
||||
class ClassifyChecksTests(unittest.TestCase):
|
||||
def test_success(self) -> None:
|
||||
state, _ = classify_checks(
|
||||
[
|
||||
{"name": "Build and test", "status": "completed", "conclusion": "success"},
|
||||
{"name": "architecture", "status": "completed", "conclusion": "success"},
|
||||
]
|
||||
)
|
||||
self.assertEqual(state, "success")
|
||||
|
||||
def test_pending_missing(self) -> None:
|
||||
state, details = classify_checks(
|
||||
[
|
||||
{"name": "Build and test", "status": "completed", "conclusion": "success"},
|
||||
]
|
||||
)
|
||||
self.assertEqual(state, "pending")
|
||||
self.assertTrue(any("architecture" in line for line in details))
|
||||
|
||||
def test_pending_in_progress(self) -> None:
|
||||
state, _ = classify_checks(
|
||||
[
|
||||
{"name": "Build and test", "status": "in_progress", "conclusion": None},
|
||||
{"name": "architecture", "status": "completed", "conclusion": "success"},
|
||||
]
|
||||
)
|
||||
self.assertEqual(state, "pending")
|
||||
|
||||
def test_failure(self) -> None:
|
||||
state, _ = classify_checks(
|
||||
[
|
||||
{"name": "Build and test", "status": "completed", "conclusion": "failure"},
|
||||
{"name": "architecture", "status": "completed", "conclusion": "success"},
|
||||
]
|
||||
)
|
||||
self.assertEqual(state, "failure")
|
||||
|
||||
def test_latest_rerun_success_wins_over_older_failure(self) -> None:
|
||||
state, _ = classify_checks(
|
||||
[
|
||||
{
|
||||
"name": "Build and test",
|
||||
"id": 1,
|
||||
"started_at": "2026-09-16T12:00:00Z",
|
||||
"completed_at": "2026-09-16T12:05:00Z",
|
||||
"status": "completed",
|
||||
"conclusion": "failure",
|
||||
},
|
||||
{
|
||||
"name": "Build and test",
|
||||
"id": 3,
|
||||
"started_at": "2026-09-16T12:10:00Z",
|
||||
"completed_at": "2026-09-16T12:12:00Z",
|
||||
"status": "completed",
|
||||
"conclusion": "success",
|
||||
},
|
||||
{
|
||||
"name": "architecture",
|
||||
"id": 2,
|
||||
"started_at": "2026-09-16T12:00:00Z",
|
||||
"completed_at": "2026-09-16T12:04:00Z",
|
||||
"status": "completed",
|
||||
"conclusion": "success",
|
||||
},
|
||||
]
|
||||
)
|
||||
self.assertEqual(state, "success")
|
||||
|
||||
def test_latest_rerun_failure_wins_over_older_success(self) -> None:
|
||||
state, _ = classify_checks(
|
||||
[
|
||||
{
|
||||
"name": "architecture",
|
||||
"id": 9,
|
||||
"started_at": "2026-09-16T13:00:00Z",
|
||||
"completed_at": "2026-09-16T13:02:00Z",
|
||||
"status": "completed",
|
||||
"conclusion": "failure",
|
||||
},
|
||||
{
|
||||
"name": "architecture",
|
||||
"id": 4,
|
||||
"started_at": "2026-09-16T12:00:00Z",
|
||||
"completed_at": "2026-09-16T12:01:00Z",
|
||||
"status": "completed",
|
||||
"conclusion": "success",
|
||||
},
|
||||
{
|
||||
"name": "Build and test",
|
||||
"id": 5,
|
||||
"started_at": "2026-09-16T12:00:00Z",
|
||||
"completed_at": "2026-09-16T12:03:00Z",
|
||||
"status": "completed",
|
||||
"conclusion": "success",
|
||||
},
|
||||
]
|
||||
)
|
||||
self.assertEqual(state, "failure")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -36,17 +36,20 @@ update, delete, or replacement actions. The second reviewed phase may update
|
|||
only explicitly allowlisted ownership metadata and the narrowed dev deploy S3
|
||||
policy.
|
||||
|
||||
The GitHub Environment secret `AWS_DEPLOY_ROLE_ARN` retains the existing role
|
||||
ARN throughout adoption.
|
||||
The GitHub Environment **variable** `DEPLOY_ROLE_ARN` is the OIDC role used
|
||||
by application CD after cutover. Adoption may still have the older
|
||||
`AWS_DEPLOY_ROLE_ARN` secret until that cutover.
|
||||
|
||||
## Local validation
|
||||
|
||||
```bash
|
||||
terraform -chdir=terraform fmt -check -recursive
|
||||
terraform fmt -check -recursive terraform
|
||||
terraform -chdir=terraform/live/dev init -backend=false
|
||||
terraform -chdir=terraform/live/dev validate
|
||||
terraform -chdir=terraform/live/staging init -backend=false
|
||||
terraform -chdir=terraform/live/staging validate
|
||||
python scripts/test-terraform-import-plan-check.py
|
||||
python scripts/test-terraform-release-plan-check.py
|
||||
python3 scripts/test_next_release_tag.py
|
||||
python3 scripts/test_require_commit_checks.py
|
||||
python3 scripts/test_check_app_terraform_isolation.py
|
||||
```
|
||||
|
|
|
|||
|
|
@ -127,59 +127,67 @@ The measured self-contained .NET/EF bundle is approximately 199.5 MB and
|
|||
separate builds are not byte-identical. Each deploy job therefore validates the
|
||||
exact bundle it uploads; bundle bytes never enter Terraform plans or state.
|
||||
|
||||
## Dev application CD
|
||||
## Application CD
|
||||
|
||||
GitHub compiles, validates, and uploads the bundle, then creates the immutable
|
||||
Elastic Beanstalk application version. HCP Terraform is the only caller of
|
||||
`UpdateEnvironment`, by setting `version_label` on
|
||||
`module.environment.aws_elastic_beanstalk_environment.this`. GitHub then
|
||||
health-checks, smokes, and requests one guarded Terraform rollback. Terraform
|
||||
does not manage `aws_elastic_beanstalk_application_version`; retained versions
|
||||
are the rollback inventory.
|
||||
GitHub Actions owns application versions. It compiles the bundle, uploads it,
|
||||
creates the Elastic Beanstalk application version, and calls
|
||||
`UpdateEnvironment`. Terraform ignores `version_label` so those deploys are not
|
||||
drift. If health, smoke, or the webhook probe fails after that update, the job
|
||||
restores the previous Elastic Beanstalk version label. Database migrations
|
||||
already applied by the failed bundle are not reverted. Deploy parameters are read from `/shoc-backend/<env>/deploy/*` SSM
|
||||
parameters this module writes.
|
||||
|
||||
`release_version_label` is a nullable root and module variable. Null VCS plans
|
||||
leave the live version unchanged. Application-CD runs pass the immutable
|
||||
`<full-sha>-<run-id>-<attempt>` label only as a run-specific
|
||||
`TF_VAR_release_version_label` HCL string. Do not set this variable on the
|
||||
workspace, in a variable set, or in `terraform.tfvars`. Do not upload a new
|
||||
configuration version on application releases; `create-run` reuses the
|
||||
workspace's last applied VCS config. Global auto-apply stays off. GitHub
|
||||
`apply-run` treats an already-applied run as success so a mis-set auto-apply
|
||||
cannot start a false-failure rollback. The workspace stays branch-based on
|
||||
`dev` with Automatic Speculative Plans enabled and trigger patterns
|
||||
`terraform/live/dev/**` and `terraform/live/modules/**`. GitHub discards a
|
||||
leftover non-speculative VCS run before `create-run`, so a merge to `dev`
|
||||
cannot lock the workspace out from under GitHub CD. GitHub applies only after
|
||||
`plan-output` counts are `0/1/0` and
|
||||
`scripts/check-terraform-release-plan.py` accepts a version-only plan JSON.
|
||||
Merge to `main` deploys **dev** unless the push is terraform-only. Staging is
|
||||
cut from **Actions → Release** (`environment`, `bump`, `message`). That
|
||||
workflow waits for CI, tags `vX.Y.Z-staging` from main HEAD with
|
||||
`GITHUB_TOKEN`, then calls deploy. Do not cut prod yet; leave
|
||||
`PROD_APP_CD_ENABLED` unset and do not create the `prod` GitHub Environment.
|
||||
Staging remains `adoption_complete=false` with a pinned API CNAME until its
|
||||
import apply is proven.
|
||||
|
||||
Staging application CD uses the same guarded lane against workspace
|
||||
`shoc-backend-staging`. The workspace stays branch-based on `staging` with
|
||||
trigger patterns `terraform/live/staging/**` and `terraform/live/modules/**`,
|
||||
and GitHub deploys on pushes to `staging` and on manual `workflow_dispatch`.
|
||||
HCP workspaces stay VCS-driven with auto-apply on after cutover. Speculative
|
||||
plans on every PR are the infra gate. Do not point `TFC_AWS_*` at
|
||||
`hcptf-bootstrap`. Org-baseline CloudFormation owns the HCP plan/apply roles.
|
||||
GitHub Actions does not create, wait on, or apply HCP runs. Application and
|
||||
Terraform changes stay in separate PRs so a merge cannot race an HCP apply
|
||||
against an app deploy. Terraform-only merges skip `deploy.yaml`. App-only
|
||||
tags skip HCP when trigger patterns do not match.
|
||||
|
||||
### Credentials and enablement
|
||||
Until cutover, `.github/workflows/deploy.yml` still uses `TF_API_TOKEN` and
|
||||
`TERRAFORM_APP_CD_ENABLED`. Keep those secrets and the version-only plan guard
|
||||
on that leftover path only.
|
||||
|
||||
Store dedicated HCP team tokens as the GitHub environment secret `TF_API_TOKEN`:
|
||||
### Credentials
|
||||
|
||||
- `dev`: use a token scoped only to workspace `shoc-backend-dev`.
|
||||
- `staging`: use a separate token scoped only to workspace
|
||||
`shoc-backend-staging`.
|
||||
Store `DEPLOY_ROLE_ARN` as a GitHub Environment **variable** (`dev`,
|
||||
`staging`). OIDC trust is
|
||||
`repo:Sea-Haven-Industries/shoc-backend:environment:<env>` plus
|
||||
`job_workflow_ref` for `.github/workflows/deploy.yaml` at `refs/heads/main`
|
||||
and `refs/tags/v*`. Adding another deploy workflow is a cross-family IAM
|
||||
change. After cutover, drop `TF_API_TOKEN` from GitHub Environments. The new
|
||||
CD path does not use it.
|
||||
|
||||
Plan JSON download requires workspace admin on the corresponding workspace. Do
|
||||
not grant project admin or workspace create/move/delete permissions. Do not rely
|
||||
on a repository-level token or reuse the dev-scoped token for staging. Rotate
|
||||
each token at least every 90 days.
|
||||
GitHub Environment deployment branch and tag policies are repository
|
||||
settings, not this diff. Update them before the first merge to `main` and
|
||||
the first staging cut. The policy matches `GITHUB_REF` of the workflow run.
|
||||
Branch patterns never match tag refs; adding `v*` as a branch pattern fails
|
||||
the same way as an empty allowlist.
|
||||
|
||||
Repository variable `TERRAFORM_APP_CD_ENABLED` starts unset/false so pushes to
|
||||
`dev` do not deploy. `workflow_dispatch` on `dev` still runs a release for the
|
||||
first manual proof. Set the variable to `true` only after that proof confirms
|
||||
the exact version, a version-only plan, apply, `efbundle`, Ready/Green, smokes,
|
||||
and a retained previous version.
|
||||
1. `dev` — allow branch `main`. Keep `dev` allowed while leftover
|
||||
`.github/workflows/deploy.yml` still deploys from that branch.
|
||||
2. `staging` — add a **tag-type** policy matching `v*.*.*-staging` for
|
||||
`deploy-tag.yaml`. Allow branch `main` because Actions → Release is
|
||||
`workflow_dispatch` on `main` and then calls `deploy.yaml`
|
||||
(`GITHUB_TOKEN` tag pushes do not start `deploy-tag.yaml`). Keep
|
||||
`staging` allowed while leftover `deploy.yml` still deploys from that
|
||||
branch.
|
||||
|
||||
This change is the allowed exception that mixes deployable application CD with
|
||||
the Terraform variable that application CD needs. Later PRs must not mix
|
||||
deployable application changes with Terraform or CDK changes.
|
||||
Do not create the `prod` environment yet. Leave `PROD_APP_CD_ENABLED`
|
||||
unset. Until the `prod` environment exists with reviewers, do not run
|
||||
Actions → Release with `environment=prod`, do not push a bare `vX.Y.Z`
|
||||
tag, and do not `workflow_dispatch` deploy with `environment=prod`. Any
|
||||
of those declares `environment: prod` and would auto-create an
|
||||
unprotected environment.
|
||||
|
||||
## Pinned live identities
|
||||
|
||||
|
|
@ -194,11 +202,12 @@ identifiers make accidental cross-environment reuse fail review and planning.
|
|||
|
||||
## Safety invariants
|
||||
|
||||
- Auto-apply remains off.
|
||||
- VCS stays branch-based on `dev` for `shoc-backend-dev` and on `staging` for
|
||||
`shoc-backend-staging`, with speculative PR plans enabled and trigger
|
||||
patterns `terraform/live/dev/**` (dev) and `terraform/live/staging/**`
|
||||
(staging), each alongside `terraform/live/modules/**`. Do not switch
|
||||
Automatic Run Triggering to tag-based.
|
||||
- Org baseline owns final HCP plan/apply permissions and manager tags.
|
||||
- After cutover, auto-apply is on. Speculative PR plans stay on.
|
||||
- `shoc-backend-dev` is branch-based on `main` with trigger patterns
|
||||
`terraform/live/dev/**` and `terraform/live/modules/**`.
|
||||
- `shoc-backend-staging` is tag-based on `^v\d+\.\d+\.\d+-staging$` with
|
||||
trigger patterns `terraform/live/staging/**` and `terraform/live/modules/**`.
|
||||
- Org baseline owns HCP plan/apply permissions and manager tags. Never manage
|
||||
`hcptf-*` in this repository.
|
||||
- Every imported Terraform resource has `prevent_destroy`.
|
||||
- Elastic Beanstalk `version_label` is ignored so GitHub deploys are not drift.
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ module "environment" {
|
|||
github_deploy_role_name = "githubdeploy-shoc-backend-dev"
|
||||
github_deploy_policy_name = "GithubDeployRoleDefaultPolicyE8F540D1"
|
||||
legacy_dev_s3_policy = true
|
||||
release_version_label = var.release_version_label
|
||||
smoke_url = "https://api.dev.seahaven.com"
|
||||
hosted_zone_id = "Z07671212N75U4YLPWZR8"
|
||||
api_domain = local.api_domain
|
||||
api_record_type = "A"
|
||||
|
|
|
|||
|
|
@ -1,15 +0,0 @@
|
|||
variable "release_version_label" {
|
||||
type = string
|
||||
default = null
|
||||
nullable = true
|
||||
|
||||
description = "Immutable Elastic Beanstalk application version. Null VCS plans leave the live version unchanged."
|
||||
|
||||
validation {
|
||||
condition = (
|
||||
var.release_version_label == null ||
|
||||
can(regex("^[0-9a-f]{40}-[0-9]+-[0-9]+$", var.release_version_label))
|
||||
)
|
||||
error_message = "release_version_label must be <full-sha>-<run-id>-<attempt>."
|
||||
}
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ locals {
|
|||
eb_bucket_name = "elasticbeanstalk-${var.aws_region}-${var.aws_account_id}"
|
||||
use_legacy_s3_policy = var.legacy_dev_s3_policy
|
||||
app_config_secret_pattern = "arn:aws:secretsmanager:${var.aws_region}:${var.aws_account_id}:secret:${var.app_config_secret_name}-*"
|
||||
deploy_ssm_prefix = "/shoc-backend/${var.environment}/deploy"
|
||||
}
|
||||
|
||||
data "aws_iam_policy_document" "runtime_assume" {
|
||||
|
|
@ -177,6 +178,18 @@ data "aws_iam_policy_document" "deploy_assume" {
|
|||
variable = "token.actions.githubusercontent.com:sub"
|
||||
values = ["repo:${var.github_repo}:environment:${var.github_environment}"]
|
||||
}
|
||||
|
||||
# StringLike: a tag-loaded reusable workflow uses @refs/tags/v*, while
|
||||
# push and workflow_dispatch use @refs/heads/main. Adding a deploy
|
||||
# workflow means adding its ref here (cross-family IAM).
|
||||
condition {
|
||||
test = "StringLike"
|
||||
variable = "token.actions.githubusercontent.com:job_workflow_ref"
|
||||
values = [
|
||||
"${var.github_repo}/.github/workflows/deploy.yaml@refs/heads/main",
|
||||
"${var.github_repo}/.github/workflows/deploy.yaml@refs/tags/v*",
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -293,6 +306,18 @@ data "aws_iam_policy_document" "deploy" {
|
|||
resources = ["arn:aws:s3:::${local.eb_bucket_name}"]
|
||||
}
|
||||
}
|
||||
|
||||
statement {
|
||||
sid = "ReadDeployParameters"
|
||||
effect = "Allow"
|
||||
actions = [
|
||||
"ssm:GetParameter",
|
||||
"ssm:GetParameters",
|
||||
]
|
||||
resources = [
|
||||
"arn:aws:ssm:${var.aws_region}:${var.aws_account_id}:parameter/shoc-backend/${var.environment}/deploy/*",
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_iam_role_policy" "github_deploy" {
|
||||
|
|
@ -305,6 +330,34 @@ resource "aws_iam_role_policy" "github_deploy" {
|
|||
}
|
||||
}
|
||||
|
||||
resource "aws_ssm_parameter" "deploy_application_name" {
|
||||
name = "${local.deploy_ssm_prefix}/application-name"
|
||||
type = "String"
|
||||
value = var.eb_application_name
|
||||
description = "Elastic Beanstalk application name; GitHub CD creates application versions here"
|
||||
}
|
||||
|
||||
resource "aws_ssm_parameter" "deploy_environment_name" {
|
||||
name = "${local.deploy_ssm_prefix}/environment-name"
|
||||
type = "String"
|
||||
value = var.eb_environment_name
|
||||
description = "Elastic Beanstalk environment name; GitHub CD calls UpdateEnvironment"
|
||||
}
|
||||
|
||||
resource "aws_ssm_parameter" "deploy_artifacts_bucket" {
|
||||
name = "${local.deploy_ssm_prefix}/artifacts-bucket"
|
||||
type = "String"
|
||||
value = local.eb_bucket_name
|
||||
description = "Bucket for release zips; GitHub CD uploads site.zip here"
|
||||
}
|
||||
|
||||
resource "aws_ssm_parameter" "deploy_smoke_url" {
|
||||
name = "${local.deploy_ssm_prefix}/smoke-url"
|
||||
type = "String"
|
||||
value = var.smoke_url
|
||||
description = "HTTPS origin for post-deploy smoke checks"
|
||||
}
|
||||
|
||||
locals {
|
||||
managed_eb_settings = concat(
|
||||
[
|
||||
|
|
@ -444,16 +497,13 @@ locals {
|
|||
}
|
||||
|
||||
resource "aws_elastic_beanstalk_environment" "this" {
|
||||
# Null VCS plans omit this Optional+Computed argument, so the provider
|
||||
# refreshes the live label without reverting releases. Application-CD runs
|
||||
# pass an immutable <full-sha>-<run-id>-<attempt> value as a run-specific
|
||||
# TF_VAR_release_version_label.
|
||||
name = var.eb_environment_name
|
||||
application = var.eb_application_name
|
||||
platform_arn = var.platform_arn
|
||||
version_label = var.release_version_label
|
||||
tier = "WebServer"
|
||||
cname_prefix = var.eb_environment_name
|
||||
# GitHub Actions owns application versions via UpdateEnvironment.
|
||||
# version_label is ignored so app deploys are not Terraform drift.
|
||||
name = var.eb_environment_name
|
||||
application = var.eb_application_name
|
||||
platform_arn = var.platform_arn
|
||||
tier = "WebServer"
|
||||
cname_prefix = var.eb_environment_name
|
||||
|
||||
dynamic "setting" {
|
||||
for_each = var.manage_eb_settings ? local.managed_eb_settings : []
|
||||
|
|
@ -471,6 +521,7 @@ resource "aws_elastic_beanstalk_environment" "this" {
|
|||
prevent_destroy = true
|
||||
ignore_changes = [
|
||||
wait_for_ready_timeout,
|
||||
version_label,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -200,20 +200,9 @@ variable "legacy_dev_s3_policy" {
|
|||
default = false
|
||||
}
|
||||
|
||||
variable "release_version_label" {
|
||||
type = string
|
||||
default = null
|
||||
nullable = true
|
||||
|
||||
description = "Immutable Elastic Beanstalk application version. Null VCS plans leave the live version unchanged."
|
||||
|
||||
validation {
|
||||
condition = (
|
||||
var.release_version_label == null ||
|
||||
can(regex("^[0-9a-f]{40}-[0-9]+-[0-9]+$", var.release_version_label))
|
||||
)
|
||||
error_message = "release_version_label must be <full-sha>-<run-id>-<attempt>."
|
||||
}
|
||||
variable "smoke_url" {
|
||||
type = string
|
||||
description = "HTTPS origin used by post-deploy smoke checks. Written to SSM for GitHub Actions."
|
||||
}
|
||||
|
||||
variable "hosted_zone_id" {
|
||||
|
|
|
|||
|
|
@ -58,11 +58,11 @@ module "environment" {
|
|||
github_deploy_role_name = "githubdeploy-shoc-backend-staging"
|
||||
github_deploy_policy_name = "GithubDeployRoleDefaultPolicyE8F540D1"
|
||||
legacy_dev_s3_policy = false
|
||||
smoke_url = "https://api.staging.seahaven.com"
|
||||
hosted_zone_id = "Z02602739VQWBWCAGXP4"
|
||||
api_domain = local.api_domain
|
||||
api_record_type = "CNAME"
|
||||
api_cname_target = "awseb--AWSEB-pPXqiRgNnZe8-16996010.us-east-1.elb.amazonaws.com"
|
||||
release_version_label = var.release_version_label
|
||||
metadata_before_adoption = {
|
||||
runtime_role_description = "SHOC backend staging compute role (EB instance profile)"
|
||||
runtime_role_tags = {
|
||||
|
|
|
|||
|
|
@ -1,15 +0,0 @@
|
|||
variable "release_version_label" {
|
||||
type = string
|
||||
default = null
|
||||
nullable = true
|
||||
|
||||
description = "Immutable Elastic Beanstalk application version. Null VCS plans leave the live version unchanged."
|
||||
|
||||
validation {
|
||||
condition = (
|
||||
var.release_version_label == null ||
|
||||
can(regex("^[0-9a-f]{40}-[0-9]+-[0-9]+$", var.release_version_label))
|
||||
)
|
||||
error_message = "release_version_label must be <full-sha>-<run-id>-<attempt>."
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue