The riskiest migration is not the one that fails. It's the one that completes, quietly holds an ACCESS EXCLUSIVE lock for ten minutes, and leaves the team to discover the damage after the release window closes. A migration approval gate decides before any DDL reaches production: additive changes flow automatically, destructive ones stop until a human signs off — with the checksum, the lock budget, and the rollback plan attached.
The pattern: classify, then decide
| Change class | Examples | Gate |
|---|---|---|
| Additive | new nullable column, new table, CREATE INDEX CONCURRENTLY | Auto — no approval needed |
| Destructive | DROP, RENAME, type narrowing, FK changes, backfills | Ask a human with evidence |
| Emergency | hotfix, storage-engine change, data cleanup | Ask a human — named owner + restore point |
Step 1: classify the migration in CI
The gate scans the SQL before anything applies. A static parse is enough — it never connects to a database:
#!/usr/bin/env bash
# classify-migration.sh — exits with risk level for the migration directory
set -euo pipefail
MIGRATION_DIR="${1:-migrations}"
RISK=additive
if grep -rniE "^\s*(drop|alter table .* (drop|rename|alter .* type))" "$MIGRATION_DIR"; then
RISK=destructive
fi
if grep -rniE "delete from|truncate|update .* where" "$MIGRATION_DIR"; then
RISK=destructive
fi
echo "risk=$RISK"
echo "risk=$RISK" >> "$GITHUB_ENV"Over-fires on legitimate expand-contract drops? That's the point — the drop is the contract phase, and it should be explicit and traceable, not silently allowed.
Step 2: gate destructive migrations with evidence
The approver sees what a DBA would want: what's changing, the lock budget, and whether the rehearsal passed:
name: migration-deploy
on:
push:
branches: [main]
permissions:
contents: read
jobs:
gate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Classify migration
id: classify
run: bash ./classify-migration.sh
- name: Rehearse on an ephemeral database
if: env.risk == 'destructive'
run: |
docker run --rm -d -e POSTGRES_PASSWORD=test -p 5433:5432 postgres:16
# Apply the migration to a throwaway DB and measure the lock window.
timeout 60 psql -h 127.0.0.1 -p 5433 -U postgres \
-v ON_ERROR_STOP=1 -f migrations/V_latest.sql
echo "rehearsal=passed" >> "$GITHUB_ENV"
- name: Install Actionbox CLI
if: env.risk == 'destructive'
run: curl -fsSL https://actionbox.cloud/install.sh | sh
- name: Request approval for destructive migration
if: env.risk == 'destructive'
id: approval
env:
ACTIONBOX_TOKEN: ${{ secrets.ACTIONBOX_TOKEN }}
run: |
result=$(actionbox ask "Apply destructive migration to production?" \
--option approve="Approve migration" \
--option reject="Reject migration" \
--context-json "[{\"type\":\"key_value\",\"items\":{\"risk\":\"${{ env.risk }}\",\"lock_budget\":\"5s ceiling\",\"rehearsal\":\"passed on ephemeral clone\",\"rollback\":\"down.sql tested — not auto-run\"}}]" \
--wait --timeout 30m --json)
echo "decision=$(jq -r '.decision // empty' <<< "$result")" >> "$GITHUB_OUTPUT"
- name: Apply only on approval
if: env.risk == 'additive' || steps.approval.outputs.decision == 'approve'
run: |
# Never auto-run down.sql. If the apply fails, stop and follow the recovery path.
SET lock_timeout = '5s'; SET statement_timeout = '30s';
flyway migratelock_timeout is the safety net under the gate: a migration that would block on a long-running transaction fails fast instead of queueing every query behind it. A five-second ceiling is a practical starting point for busy OLTP workloads in 2026 — set it from your application timeout, not from a template.
Step 3: overrides are per-change, never blanket
A gate that blocks a legitimately reviewed DROP must have an exit — but never a "disable the gate" button. The override is the approval:
- name: Per-change override requires a recorded approval
if: contains(github.event.pull_request.labels.*.name, 'migration-approved')
run: |
echo "Label present — human approval was recorded at review time."
echo "The approval decision is linked in the PR conversation."If you find yourself overriding the same gate repeatedly, the assertion is miscalibrated — tighten the rule, don't route around it.
Full example: rollback is a plan, not a script
## Rollback plan (required for every migration PR)
1. Down migration / recovery path: <exact SQL or restore-from-backup steps>
2. Feature flag gate: <which flag controls the new code path>
3. Backfill reversal: <is the data write reversible? how?>
4. Estimated lock window: <measured on the rehearsal clone — hard fail > 5s>A file called down.sql is not a rollback plan until it has been tested against the state a failure can create — partially completed backfill, new writes against the expanded schema. The plan, not the script, is what matters.
Why this beats "DBA reviews every PR"
- Additive changes ship frictionless — safe DDL flows through the pipeline without a human
- Destructive changes get judgment — the approver sees the diff, the lock budget, and the rehearsal result
- Rollback stays explicit — auto-running down migrations is how you corrupt data; the gate makes recovery a decision
- The override is on record — exceptions are approvals, not escape hatches
Try it
- Install the CLI on your runner
- Create a Source →
ACTIONBOX_TOKEN - Copy the classifier + gate job into your migration pipeline
Create a free Source · GitHub Actions reference · Terraform destroy approval gate · Deploy approval gates in GitHub Actions