ActionBoxBlog

BackendDevOpsPlatformData engineer

Database migration approval workflow: block destructive PostgreSQL DDL

A copy-paste database migration approval workflow that blocks destructive PostgreSQL DDL in CI with risk detection, lock budgets, and audit history.

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 classExamplesGate
Additivenew nullable column, new table, CREATE INDEX CONCURRENTLYAuto — no approval needed
DestructiveDROP, RENAME, type narrowing, FK changes, backfillsAsk a human with evidence
Emergencyhotfix, storage-engine change, data cleanupAsk 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:

bash
#!/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:

yaml
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 migrate

lock_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:

yaml
      - 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

markdown
## 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 clonehard 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

  1. Install the CLI on your runner
  2. Create a Source → ACTIONBOX_TOKEN
  3. 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

Try this workflow in minutes

Create a free Source, then run the exact commands from this post against the live API — no approval infrastructure to build.

S
Suson Sapkota

Founder, Actionbox