From 090507204886e009cccd820a2b400f98fc7b4eb9 Mon Sep 17 00:00:00 2001 From: nourshoreibah Date: Wed, 29 Jul 2026 01:11:55 -0400 Subject: [PATCH] fix(ci): skip the snapshot and apply when no migrations are pending The migrate job runs whenever a push touches db/migrations/** and on every manual dispatch (a dispatch has no diff, so detect-changes assumes migrate=true). Neither means anything is actually pending -- CI can't know that until it connects. With nothing pending the apply was already a harmless no-op, but the job still took an RDS snapshot and waited for it: 1-2 minutes per deploy, and junk churning through the 5-snapshot retention. The migrate:status preflight already knows the count, so parse it and gate the snapshot and the apply on it. Unparseable output falls through as "1" so the safe path (snapshot first) is taken. Pruning now only runs when a snapshot was actually created. `up` being skipped is a success as far as the deploy is concerned, so the result artifact and the Slack step report success rather than the raw 'skipped' -- otherwise slack-deploy's finalize would mark the whole run failed. The Slack step and the job summary say "nothing pending" instead of claiming migrations were applied. The baseline tripwire still runs unconditionally. Co-Authored-By: Claude Opus 5 --- .github/workflows/lambda-deploy.yml | 37 +++++++++++++++++++++++++---- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/.github/workflows/lambda-deploy.yml b/.github/workflows/lambda-deploy.yml index f012f7a..f5143f2 100644 --- a/.github/workflows/lambda-deploy.yml +++ b/.github/workflows/lambda-deploy.yml @@ -213,11 +213,19 @@ jobs: echo "DB_SSL=true" >> "$GITHUB_ENV" echo "DB_SSL_CA=$RUNNER_TEMP/rds-ca.pem" >> "$GITHUB_ENV" + # This job runs whenever a push touched db/migrations/** or on any manual + # dispatch -- neither of which means anything is actually pending. Count what + # is, so the snapshot and the apply can be skipped when there is nothing to do. - name: Pending migrations (read-only preflight) id: status run: | set -o pipefail npm run migrate:status --prefix apps/backend/db 2>&1 | tee status.log + pending=$(grep -oE '[0-9]+ pending' status.log | tail -1 | cut -d' ' -f1) + # Unknown (unexpected output) falls through as "1" so we take the safe + # path and snapshot before touching anything. + echo "pending=${pending:-1}" >> "$GITHUB_OUTPUT" + echo "$pending migration(s) pending" # 0000_baseline_schema is idempotent, but "idempotent" is one typo away from # DROP SCHEMA. It is adopted once by hand against production (see @@ -230,8 +238,11 @@ jobs: exit 1 fi + # Only when something will actually change. Snapshotting a no-op wasted 1-2 + # minutes per deploy and churned the 5-snapshot retention with junk. - name: Snapshot production before migrating id: snapshot + if: steps.status.outputs.pending != '0' run: | set -euo pipefail INSTANCE=$(aws rds describe-db-instances \ @@ -249,18 +260,23 @@ jobs: - name: Apply migrations id: up + if: steps.status.outputs.pending != '0' run: | set -o pipefail { echo "Pending before this run:"; cat status.log; echo; \ npm run migrate --prefix apps/backend/db; } 2>&1 | tee migrate.log + - name: Nothing to apply + if: steps.status.outputs.pending == '0' + run: echo "Database is already up to date; skipped the snapshot and the apply." + # Manual snapshots never expire on their own and survive instance deletion, # so without this every deploy would leave one behind forever. Five is # plenty: anything older than the last few migrations is better served by # point-in-time recovery (backup_retention_period = 7 in # infrastructure/aws/main.tf). - name: Prune old pre-migration snapshots (keep 5) - if: always() + if: always() && steps.snapshot.outcome == 'success' continue-on-error: true run: | aws rds describe-db-snapshots --snapshot-type manual \ @@ -271,11 +287,17 @@ jobs: - name: Summarize if: always() + env: + PENDING: ${{ steps.status.outputs.pending }} run: | { echo "### Database migration" echo "" - echo "Snapshot: \`${{ steps.snapshot.outputs.id || 'not taken' }}\`" + if [ "$PENDING" = "0" ]; then + echo "Nothing pending -- no snapshot taken, nothing applied." + else + echo "Snapshot: \`${{ steps.snapshot.outputs.id || 'not taken' }}\`" + fi echo "" echo '```' cat status.log 2>/dev/null || echo '(no status captured)' @@ -289,7 +311,12 @@ jobs: if: always() run: | mkdir -p lambda-results - echo "migrate|${{ steps.up.outcome }}" > lambda-results/migrate.meta + # `up` is skipped only when nothing was pending, which is a success as + # far as the deploy is concerned. Reporting the raw 'skipped' would make + # slack-deploy's finalize mark the whole run failed. + RESULT="${{ steps.up.outcome }}" + [ "$RESULT" = "skipped" ] && RESULT=success + echo "migrate|$RESULT" > lambda-results/migrate.meta cp migrate.log lambda-results/migrate.log 2>/dev/null \ || cp status.log lambda-results/migrate.log 2>/dev/null \ || echo "(no migrate output captured)" > lambda-results/migrate.log @@ -310,8 +337,8 @@ jobs: component: backend total-steps: ${{ needs.prep.outputs.total }} job-name: migrate - step-name: Applied DB migrations - step-status: ${{ steps.up.outcome }} + step-name: ${{ steps.status.outputs.pending == '0' && 'DB migrations (nothing pending)' || 'Applied DB migrations' }} + step-status: ${{ steps.up.outcome == 'skipped' && 'success' || steps.up.outcome }} details-file: migrate.log ts: ${{ needs.prep.outputs.ts }} slack-bot-token: ${{ secrets.SLACK_BOT_TOKEN }}