Merge pull request #150 from Sea-Haven-Industries/dev
Some checks are pending
Architecture and changed-file quality / architecture (push) Waiting to run
Terraform CI / terraform (push) Waiting to run
Backend CI / Build and test (push) Waiting to run

chore: promote GitHub-owned Elastic Beanstalk CD onto main
This commit is contained in:
Adam Moussa 2026-09-18 11:52:35 -04:00 • committed by GitHub
commit 68ad7362df
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 1303 additions and 172 deletions

View file

@ -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
View 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

View file

@ -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
View 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
View 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
View 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

View file

@ -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)

View file

@ -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)

View 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())

View file

@ -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"

View 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())

View 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())

View 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()

View 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()

View 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()

View file

@ -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
```

View file

@ -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.

View file

@ -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"

View file

@ -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>."
}
}

View file

@ -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,
]
}
}

View file

@ -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" {

View file

@ -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 = {

View file

@ -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>."
}
}