Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .github/workflows/cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: <sha>` 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 }}

Expand All @@ -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
Expand All @@ -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 }}
82 changes: 82 additions & 0 deletions .github/workflows/deploy-app.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}

Expand Down Expand Up @@ -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)) — refusing to deploy blind (fail-closed). Rerun once ECS is reachable, or set freshness-allow-stale."
exit 1
fi
set +e
image="$(aws ecs describe-task-definition --task-definition "$td" --query "taskDefinition.containerDefinitions[?name=='${NAME}'].image | [0]" --output text --region "$AWS_REGION" 2>/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' </tmp/ecs_err)) — refusing to deploy blind (fail-closed). Rerun once ECS is reachable, or set freshness-allow-stale."
exit 1
fi
running="${image##*:}"
skip=false
if ! git cat-file -e "${running}^{commit}" 2>/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 }}
Expand Down
147 changes: 147 additions & 0 deletions .github/workflows/deploy-infra.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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' </tmp/ssm_err)) — refusing to apply blind (fail-closed). Rerun once SSM is reachable, or set freshness-allow-stale for a deliberate override."
exit 1
fi
fi
if [ -z "$running" ] || [ "$running" = "None" ] || [ "$running" = "unknown" ]; then
echo "::warning::assert-fresh(infra): no usable live marker (empty/None/unknown, e.g. first apply or a never-completed prior apply) — proceeding UNGUARDED (nothing to compare against)"
else
if ! git cat-file -e "${running}^{commit}" 2>/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:
Expand Down Expand Up @@ -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

Expand All @@ -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' </tmp/ssm_err)) — refusing to risk regressing it"
exit 0
fi
if [ -z "$current" ] || [ "$current" = "None" ] || [ "$current" = "unknown" ]; then
aws ssm put-parameter --name "/${MODULE_ID}/deployed_infra_commit" --type String --overwrite --value "$SHA" --region "$AWS_REGION" >/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