diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 41355f2..58e62c9 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -59,6 +59,31 @@ on: description: 'The run label to use for the actions' type: string default: 'ubuntu-latest' + enforce-fresh: + description: > + Opt-in re-run-safe freshness guard. When true, the infra apply and the + app deploy each re-read the live version immediately before applying and + skip (green no-op) if a newer/divergent version is already live — making + the deploy idempotent under any re-run. App is checked against + `version` (via `stage-url`/health), infra against `github.sha` (via the + deployed_infra_commit SSM marker). Default false = unchanged behaviour. + Opting in expects `version` to be a git commit SHA and `stage-url` to + serve the `commit hash: ` banner. Read-failure policy: the app + `/health` read fails OPEN (it flaps during ECS cycling — a warning, never + a false block), but the reliable infra SSM-marker read fails CLOSED on a + genuine error (access denied / throttle / timeout — refuses to apply + blind), while a not-yet-created marker still proceeds. `freshness-module-id` + is REQUIRED when enforce-fresh; `freshness-allow-stale` overrides all of it. + type: boolean + default: false + freshness-module-id: + description: 'Terraform module.this.id prefix, for the infra SSM marker read. Required when enforce-fresh.' + type: string + default: '' + freshness-allow-stale: + description: 'Bypass the freshness guard (deliberate rollback from an older ref).' + type: boolean + default: false concurrency: cd-${{ inputs.stage }} @@ -83,6 +108,11 @@ jobs: tf-directory: ${{ inputs.tf-directory }} tf-variables: ${{ inputs.tf-variables }} run-label: ${{ inputs.run-label }} + enforce-fresh: ${{ inputs.enforce-fresh }} + freshness-target-commit: ${{ github.sha }} + freshness-module-id: ${{ inputs.freshness-module-id }} + freshness-aws-role-arn: ${{ inputs.aws-role-arn }} + freshness-allow-stale: ${{ inputs.freshness-allow-stale }} deploy-app: name: Deploy App @@ -99,3 +129,6 @@ jobs: aws-role-arn: ${{ inputs.aws-role-arn }} aws-region: ${{ inputs.aws-region }} run-label: ${{ inputs.run-label }} + enforce-fresh: ${{ inputs.enforce-fresh }} + freshness-target-commit: ${{ inputs.version }} + freshness-allow-stale: ${{ inputs.freshness-allow-stale }} diff --git a/.github/workflows/deploy-app.yml b/.github/workflows/deploy-app.yml index ca0c90a..818e75d 100644 --- a/.github/workflows/deploy-app.yml +++ b/.github/workflows/deploy-app.yml @@ -35,6 +35,24 @@ on: description: 'The run label to use for the actions' type: string default: 'ubuntu-latest' + enforce-fresh: + description: > + Opt-in re-run-safe guard. When true, immediately before the ECS deploy + this job re-reads the live app version and SKIPS the deploy (green + no-op) if it is NOT an ancestor-or-equal of `freshness-target-commit` — + i.e. a newer/divergent version is already live, so deploying would roll + it back. Being a step inside this job, it re-evaluates on any re-run. + Default false = unchanged behaviour for callers that do not opt in. + type: boolean + default: false + freshness-target-commit: + description: 'Commit this run intends to make live (the image tag/sha). Required when enforce-fresh.' + type: string + default: '' + freshness-allow-stale: + description: 'Bypass the guard (deliberate rollback from an older ref).' + type: boolean + default: false concurrency: deploy-${{ inputs.stage }} @@ -74,8 +92,72 @@ jobs: id: login-ecr uses: aws-actions/amazon-ecr-login@v2 + # Re-run-safe freshness guard (opt-in). Compare-and-set: skip the deploy if + # the live app version is already newer/divergent vs the version this run + # would ship — so a stale re-run (or a superseded run's "re-run this job") + # cannot roll the app back. A STEP here (not an upstream gate job) so it + # re-evaluates whenever this job runs. The ECS control plane is a reliable + # read, like the infra SSM marker, so a read error fails CLOSED rather than + # allowing a deploy to proceed blind. + # allow-stale bypasses the guard entirely (step skipped → skip output empty + # → deploy runs), so a deliberate rollback can't be wedged by the guard's own + # machinery (unresolvable target, etc.). + - name: Assert fresh + id: freshness + if: ${{ inputs.enforce-fresh && !inputs.freshness-allow-stale }} + shell: bash + env: + TARGET: ${{ inputs.freshness-target-commit }} + NAME: ${{ steps.build_task_name.outputs.name }} + AWS_REGION: ${{ inputs.aws-region }} + run: | + set -euo pipefail + git fetch --no-tags --prune --unshallow origin 2>/dev/null || git fetch --no-tags origin 2>/dev/null || true + if ! git cat-file -e "${TARGET}^{commit}" 2>/dev/null; then git fetch --no-tags origin "$TARGET" 2>/dev/null || true; fi + if ! git cat-file -e "${TARGET}^{commit}" 2>/dev/null; then + # Target isn't a git commit (e.g. a semver/'latest' ECR tag from a + # non-pay-core consumer). Can't ancestry-check → fail OPEN, LOUDLY, + # so it never masquerades as protection. + echo "::warning::assert-fresh(app): target '${TARGET}' is not a git commit — cannot evaluate freshness; proceeding UNGUARDED" + echo "skip=false" >> "$GITHUB_OUTPUT"; exit 0 + fi + set +e + td="$(aws ecs describe-services --cluster "${NAME}-cluster" --services "${NAME}-service" --query "services[0].deployments[?status=='PRIMARY'].taskDefinition | [0]" --output text --region "$AWS_REGION" 2>/tmp/ecs_err)" + ecs_rc=$? + set -e + if [ "$ecs_rc" -ne 0 ] || [ -z "$td" ] || [ "$td" = "None" ]; then + echo "::error::assert-fresh(app): could not read the PRIMARY ECS task definition ($(tr -d '\n' /tmp/ecs_err)" + image_rc=$? + set -e + if [ "$image_rc" -ne 0 ] || [ -z "$image" ] || [ "$image" = "None" ]; then + set +e + image="$(aws ecs describe-task-definition --task-definition "$td" --query "taskDefinition.containerDefinitions[0].image" --output text --region "$AWS_REGION" 2>/tmp/ecs_err)" + image_rc=$? + set -e + fi + if [ "$image_rc" -ne 0 ] || [ -z "$image" ] || [ "$image" = "None" ]; then + echo "::error::assert-fresh(app): could not read the PRIMARY ECS task definition ($(tr -d '\n' /dev/null; then git fetch --no-tags origin "$running" 2>/dev/null || true; fi + if ! git cat-file -e "${running}^{commit}" 2>/dev/null; then + echo "::warning::assert-fresh(app): live ${running:0:7} unresolvable in history — treating as stale"; skip=true + elif git merge-base --is-ancestor "$running" "$TARGET"; then + echo "assert-fresh(app): live ${running:0:7} is an ancestor of ${TARGET:0:7} — fresh" + else + echo "::warning::assert-fresh(app): live ${running:0:7} is NOT an ancestor of ${TARGET:0:7} — newer/divergent already live; skipping ECS deploy"; skip=true + fi + echo "skip=$skip" >> "$GITHUB_OUTPUT" + - name: Deploy image to ECS id: deploy + if: ${{ steps.freshness.outputs.skip != 'true' }} uses: WalletConnect/actions/aws/ecs/deploy-image/@2.5.4 with: aws-role-arn: ${{ inputs.aws-role-arn }} diff --git a/.github/workflows/deploy-infra.yml b/.github/workflows/deploy-infra.yml index 6703b75..eb875fc 100644 --- a/.github/workflows/deploy-infra.yml +++ b/.github/workflows/deploy-infra.yml @@ -39,6 +39,31 @@ on: description: 'The run label to use for the actions' type: string default: 'ubuntu-latest' + enforce-fresh: + description: > + Opt-in re-run-safe guard. When true, immediately before `terraform + apply` this job re-reads the live infra commit (the deployed_infra_commit + SSM marker) and SKIPS the apply (green no-op) if it is NOT an + ancestor-or-equal of `freshness-target-commit`. A step inside this job, + so it re-evaluates on any re-run. Default false = unchanged behaviour. + type: boolean + default: false + freshness-target-commit: + description: 'Commit this run intends to apply (github.sha). Required when enforce-fresh.' + type: string + default: '' + freshness-module-id: + description: 'Terraform module.this.id prefix, for the deployed_infra_commit SSM marker read. Required when enforce-fresh.' + type: string + default: '' + freshness-aws-role-arn: + description: 'AWS role to assume to read the SSM marker (the app account, not monitoring). Required when enforce-fresh.' + type: string + default: '' + freshness-allow-stale: + description: 'Bypass the guard (deliberate rollback from an older ref).' + type: boolean + default: false secrets: TF_API_TOKEN: required: true @@ -67,6 +92,78 @@ jobs: submodules: recursive token: ${{ secrets.PRIVATE_SUBMODULE_ACCESS_TOKEN || github.token }} + # Re-run-safe freshness guard (opt-in), placed BEFORE the monitoring-creds + # config so that config restores monitoring creds for the Grafana steps + # (this guard needs app-account creds to read the SSM marker). Compare-and- + # set: skip `terraform apply` if the live infra commit is already + # newer/divergent vs what this run would apply, so a stale re-run cannot + # roll infra back. A step inside this job → re-evaluates on any re-run. + # allow-stale bypasses the guard entirely (steps skipped → skip output empty + # → apply runs), so a deliberate rollback isn't wedged by the guard itself. + # No continue-on-error: the marker read below fails CLOSED, so a creds + # failure should surface loudly at THIS step (with the OIDC error) and block + # the apply — not be swallowed and re-surface as a confusing "can't read + # marker" at the next step. Rerun, or set freshness-allow-stale to override. + - name: Configure AWS Credentials for freshness read + if: ${{ inputs.enforce-fresh && !inputs.freshness-allow-stale }} + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ inputs.freshness-aws-role-arn }} + aws-region: ${{ inputs.aws-region }} + + - name: Assert fresh + id: freshness + if: ${{ inputs.enforce-fresh && !inputs.freshness-allow-stale }} + shell: bash + env: + TARGET: ${{ inputs.freshness-target-commit }} + MODULE_ID: ${{ inputs.freshness-module-id }} + AWS_REGION: ${{ inputs.aws-region }} + run: | + set -euo pipefail + # A guard that can't locate its marker would silently NOT protect — the + # exact failure mode this exists to prevent. Fail CLOSED on missing config. + if [ -z "$MODULE_ID" ]; then + echo "::error::assert-fresh(infra): enforce-fresh is set but freshness-module-id is empty — refusing to apply unguarded"; exit 1 + fi + git fetch --no-tags --prune --unshallow origin 2>/dev/null || git fetch --no-tags origin 2>/dev/null || true + if ! git cat-file -e "${TARGET}^{commit}" 2>/dev/null; then git fetch --no-tags origin "$TARGET" 2>/dev/null || true; fi + if ! git cat-file -e "${TARGET}^{commit}" 2>/dev/null; then + echo "::error::assert-fresh(infra): target ${TARGET} is not a resolvable commit"; exit 1 + fi + # Read the marker distinguishing "not created yet" (legit first apply → + # fail OPEN) from a genuine read ERROR — access denied, throttle, timeout + # (anomalous → fail CLOSED). SSM is a reliable read, so an error here is a + # real red flag and we will NOT apply blind and risk a rollback. The app + # guard likewise uses the reliable ECS control plane and fails closed. + # `freshness-allow-stale` overrides for a deliberate run. + set +e + running="$(aws ssm get-parameter --name "/${MODULE_ID}/deployed_infra_commit" --query 'Parameter.Value' --output text --region "$AWS_REGION" 2>/tmp/ssm_err)" + ssm_rc=$? + set -e + skip=false + if [ "$ssm_rc" -ne 0 ]; then + if grep -q "ParameterNotFound" /tmp/ssm_err; then + running="" # marker not created yet (first apply) — the open check below logs it + else + echo "::error::assert-fresh(infra): could not read the deployed_infra_commit marker ($(tr -d '\n' /dev/null; then git fetch --no-tags origin "$running" 2>/dev/null || true; fi + if ! git cat-file -e "${running}^{commit}" 2>/dev/null; then + echo "::warning::assert-fresh(infra): live ${running:0:7} unresolvable in history — treating as stale"; skip=true + elif git merge-base --is-ancestor "$running" "$TARGET"; then + echo "assert-fresh(infra): live ${running:0:7} is an ancestor of ${TARGET:0:7} — fresh" + else + echo "::warning::assert-fresh(infra): live ${running:0:7} is NOT an ancestor of ${TARGET:0:7} — newer/divergent already applied; skipping terraform apply"; skip=true + fi + fi + echo "skip=$skip" >> "$GITHUB_OUTPUT" + - name: Configure AWS Credentials for Monitoring account uses: aws-actions/configure-aws-credentials@v4 with: @@ -105,6 +202,7 @@ jobs: ${{ inputs.tf-variables }} - name: Apply on ${{ inputs.stage }} + if: ${{ steps.freshness.outputs.skip != 'true' }} working-directory: ${{ inputs.tf-directory }} run: terraform apply -auto-approve -no-color @@ -114,3 +212,52 @@ jobs: with: workspace-id: ${{ steps.grafana-get-key.outputs.workspace-id }} key-name: ${{ steps.grafana-get-key.outputs.key-name }} + + - name: Configure AWS Credentials for marker write + if: ${{ inputs.enforce-fresh && !inputs.freshness-allow-stale && steps.freshness.outputs.skip != 'true' }} + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ inputs.freshness-aws-role-arn }} + aws-region: ${{ inputs.aws-region }} + + - name: Advance infra marker + if: ${{ inputs.enforce-fresh && !inputs.freshness-allow-stale && steps.freshness.outputs.skip != 'true' }} + shell: bash + env: + SHA: ${{ inputs.freshness-target-commit }} + MODULE_ID: ${{ inputs.freshness-module-id }} + AWS_REGION: ${{ inputs.aws-region }} + run: | + set -euo pipefail + set +e + current="$(aws ssm get-parameter --name "/${MODULE_ID}/deployed_infra_commit" --query 'Parameter.Value' --output text --region "$AWS_REGION" 2>/tmp/ssm_err)" + ssm_rc=$? + set -e + if [ "$ssm_rc" -ne 0 ]; then + if grep -q "ParameterNotFound" /tmp/ssm_err; then + aws ssm put-parameter --name "/${MODULE_ID}/deployed_infra_commit" --type String --overwrite --value "$SHA" --region "$AWS_REGION" >/dev/null + exit 0 + fi + echo "::warning::advance-infra-marker: could not read the deployed_infra_commit marker ($(tr -d '\n' /dev/null + exit 0 + fi + if ! git cat-file -e "${SHA}^{commit}" 2>/dev/null; then git fetch --no-tags origin "$SHA" 2>/dev/null || true; fi + if ! git cat-file -e "${current}^{commit}" 2>/dev/null; then git fetch --no-tags origin "$current" 2>/dev/null || true; fi + if ! git cat-file -e "${SHA}^{commit}" 2>/dev/null; then + echo "::warning::advance-infra-marker: target ${SHA:0:7} is unresolvable in history — marker not advanced" + exit 0 + fi + if ! git cat-file -e "${current}^{commit}" 2>/dev/null; then + echo "::warning::advance-infra-marker: current marker ${current:0:7} is unresolvable in history — marker not advanced" + exit 0 + fi + if git merge-base --is-ancestor "$current" "$SHA"; then + aws ssm put-parameter --name "/${MODULE_ID}/deployed_infra_commit" --type String --overwrite --value "$SHA" --region "$AWS_REGION" >/dev/null + else + echo "::warning::advance-infra-marker: marker ${current:0:7} newer/divergent than ${SHA:0:7} — refusing to regress" + exit 0 + fi