#!/usr/bin/env bash # check-migration-backward-compat.sh — pre-deploy gate for canary releases. # # Refuses to deploy when the latest migration is NOT backward-compatible # with the running schema. Backward-compat = the OLD code can still # read/write against the NEW schema for at least one canary window # (otherwise canary mode is meaningless ; the old node would crash on # the first request that touches a removed column). # # Heuristic : reject migrations that contain any of these patterns : # - DROP COLUMN # - DROP TABLE # - ALTER COLUMN ... TYPE (type change is rarely backward-compat) # - ADD COLUMN ... NOT NULL (without DEFAULT — old code can't INSERT) # - DROP CONSTRAINT # - DROP INDEX UNIQUE (existing data may already violate) # # This is a STATIC check ; some patterns are false-positives (e.g. # DROP COLUMN of a column that no code reads). When a real migration # is flagged, the operator either : # 1. Splits the migration : ship the additive part now, drop in v+1 # after old-version backends are decommissioned. # 2. Bypasses with FORCE_MIGRATE=1 + a justification in the commit # message of the migration file. # # v1.0.9 W5 Day 23. # # Usage : # bash scripts/check-migration-backward-compat.sh # # Required env : # MIGRATIONS_DIR default veza-backend-api/migrations # GIT_RANGE default origin/main..HEAD ; the range to inspect for # newly-added migration files # Optional env : # FORCE_MIGRATE=1 bypass with a logged warning. Use sparingly. # # Exit codes : # 0 — all new migrations are backward-compat (or FORCE_MIGRATE=1) # 1 — at least one migration carries a forbidden pattern # 3 — required tool missing / config error set -euo pipefail MIGRATIONS_DIR=${MIGRATIONS_DIR:-veza-backend-api/migrations} GIT_RANGE=${GIT_RANGE:-origin/main..HEAD} FORCE_MIGRATE=${FORCE_MIGRATE:-0} log() { printf '[%s] %s\n' "$(date +%H:%M:%S)" "$*" >&2; } fail() { log "FAIL: $*"; exit "${2:-1}"; } require() { command -v "$1" >/dev/null 2>&1 || fail "required tool missing: $1" 3 } require git require grep require date # Patterns that indicate non-backward-compat schema change. # Heredoc preserves the pipe characters as alternations. FORBIDDEN_PATTERNS='DROP COLUMN|DROP TABLE|ALTER COLUMN [A-Za-z_]+ TYPE|ADD COLUMN [A-Za-z_]+ [^,;]* NOT NULL[^,;]*(;|$)|DROP CONSTRAINT|DROP INDEX [A-Za-z_]*UNIQUE' # Identify newly-added migration files in the current range. new_migrations=$(git diff --name-only --diff-filter=A "$GIT_RANGE" -- "$MIGRATIONS_DIR" 2>/dev/null \ | grep -E "^${MIGRATIONS_DIR}/[0-9]+_.*\.sql$" || true) if [ -z "$new_migrations" ]; then log "no new migrations in $GIT_RANGE — nothing to check" exit 0 fi log "checking $(echo "$new_migrations" | wc -l) new migration(s) in $GIT_RANGE" findings=0 for f in $new_migrations; do log " scanning $f" # -i case-insensitive ; -E extended regex ; -n line numbers matches=$(grep -inE "$FORBIDDEN_PATTERNS" "$f" || true) if [ -n "$matches" ]; then findings=$((findings + 1)) log "" log " ⚠ NON-BACKWARD-COMPAT pattern in $f :" echo "$matches" | sed 's/^/ /' >&2 # Special case : ADD COLUMN ... NOT NULL ... DEFAULT is fine. # The regex above tries to exclude that but the match-then-filter # approach is more reliable than a single regex. Suppress matches # that include `DEFAULT` on the same line. real=$(echo "$matches" | grep -ivE "DEFAULT" || true) if [ -z "$real" ]; then log " ↳ all matches include DEFAULT clause — actually backward-compat" findings=$((findings - 1)) fi fi done if [ "$findings" -gt 0 ]; then log "" log "$findings migration(s) flagged as potentially non-backward-compat." if [ "$FORCE_MIGRATE" = "1" ]; then log "FORCE_MIGRATE=1 set — proceeding anyway." exit 0 fi log "" log "Options to proceed :" log " 1. Split the migration : ship the additive part now, drop the" log " non-compat part in v+1 after old backends are off." log " 2. Set FORCE_MIGRATE=1 if you accept the risk + document the" log " justification in the migration's commit message." exit 1 fi log "PASS : all new migrations are backward-compat" exit 0