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
31 changes: 31 additions & 0 deletions .github/workflows/cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,29 @@ 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; a non-commit `version` or an
unreadable banner fails OPEN with a warning (never a false block).
`freshness-module-id` is REQUIRED when enforce-fresh (missing it fails
the infra apply closed).
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 +106,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 +127,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 }}
68 changes: 68 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,58 @@ 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. Fail-open on an unreadable /health;
# fail-closed on a live commit unresolvable in git history.
# 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 }}
HEALTH_URL: ${{ inputs.stage-url }}
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
health="$(curl -fsS --max-time 15 --retry 3 --retry-delay 5 "$HEALTH_URL" || true)"
# `|| true`: grep exits 1 when the banner is absent (curl failed / body
# lacks the token — common when /health flaps during an ECS cycle); under
# `set -euo pipefail` that would kill the step BEFORE the fail-open branch,
# turning warn-and-proceed into a red deploy. Keep it fail-open.
short="$(printf '%s' "$health" | grep -oiE 'commit hash: [0-9a-f]{7,40}' | head -n1 | awk '{print $3}' || true)"
running=""; [ -z "$short" ] || running="$(git rev-parse "$short" 2>/dev/null || echo "$short")"
skip=false
if [ -z "$running" ] || [ "$running" = "None" ]; then
echo "::warning::assert-fresh(app): live version unreadable at ${HEALTH_URL} — proceeding UNGUARDED (fail-open)"
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(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
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
80 changes: 80 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,60 @@ 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.
# continue-on-error on the creds step: a transient STS/OIDC blip must not
# wedge the deploy — the SSM read then fails and the guard fails OPEN.
- name: Configure AWS Credentials for freshness read
if: ${{ inputs.enforce-fresh && !inputs.freshness-allow-stale }}
continue-on-error: true
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
running="$(aws ssm get-parameter --name "/${MODULE_ID}/deployed_infra_commit" --query 'Parameter.Value' --output text --region "$AWS_REGION" 2>/dev/null || true)"
skip=false
if [ -z "$running" ] || [ "$running" = "None" ] || [ "$running" = "unknown" ]; then
echo "::warning::assert-fresh(infra): live marker unreadable (/${MODULE_ID}/deployed_infra_commit) — proceeding UNGUARDED (fail-open)"
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 +184,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 Down