Ctrl K
ComunidadGuías…

scripts/check-supabase-migration-order.sh

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env bash

set -euo pipefail

extract_timestamp() {
  local file_name="$1"
  local out_var="${2:-}"
  local base_name
  base_name="${file_name##*/}"

  if [[ "$base_name" =~ ^([0-9]{14})_.+\.sql$ ]]; then
    if [[ -n "$out_var" ]]; then
      printf -v "$out_var" '%s' "${BASH_REMATCH[1]}"
    else
      printf '%s\n' "${BASH_REMATCH[1]}"
    fi
  else
    if [[ -n "$out_var" ]]; then
      printf -v "$out_var" ''
    fi
    return 1
  fi
}

resolve_github_merge_base_ref() {
  case "${GITHUB_EVENT_NAME:-}" in
    pull_request | merge_group) ;;
    *) return 1 ;;
  esac

  if git rev-parse --verify --quiet "HEAD^1^{commit}" >/dev/null \
    && git rev-parse --verify --quiet "HEAD^2^{commit}" >/dev/null; then
    echo 'HEAD^1'
    return
  fi

  return 1
}

resolve_target_branch() {
  if [[ -n "${GITHUB_BASE_REF:-}" ]]; then
    echo "${GITHUB_BASE_REF}"
    return
  fi

  if [[ -n "${GITHUB_REF_NAME:-}" ]]; then
    if [[ "${GITHUB_REF_NAME}" == gh-readonly-queue/* ]]; then
      local queued_ref
      queued_ref="${GITHUB_REF_NAME#gh-readonly-queue/}"
      echo "${queued_ref%/pr-*}"
      return
    fi

    if [[ "${GITHUB_REF_NAME}" == */pr-* ]]; then
      echo "${GITHUB_REF_NAME%/pr-*}"
      return
    fi
  fi

  if [[ -n "${GITHUB_EVENT_PATH:-}" ]] && command -v jq >/dev/null 2>&1; then
    local branch
    branch="$(jq -r '.pull_request.base.ref // .merge_group.base_ref // .repository.default_branch // empty' "${GITHUB_EVENT_PATH}")"
    if [[ -n "$branch" && "$branch" != "null" ]]; then
      echo "$branch"
      return
    fi
  fi

  echo 'main'
}

target_branch="$(resolve_target_branch)"
base_ref="origin/${target_branch}"
base_label="${base_ref}"
tmp_dir="$(mktemp -d)"
base_timestamps_file="${tmp_dir}/base_timestamps.tsv"
added_timestamps_file="${tmp_dir}/added_timestamps.tsv"

trap 'rm -rf "${tmp_dir}"' EXIT

local_base_ref=''
if local_base_ref="$(resolve_github_merge_base_ref)"; then
  base_ref="${local_base_ref}"
  base_label="local merge base parent (${target_branch})"
  echo "Checking Supabase migrations against ${base_label}"
else
  echo "Checking Supabase migrations against ${base_ref}"
  if ! git fetch --no-tags origin "+refs/heads/${target_branch}:refs/remotes/origin/${target_branch}"; then
    if git rev-parse --verify --quiet "${base_ref}^{commit}" >/dev/null; then
      echo "⚠️  Could not fetch ${base_ref}; using existing local ref."
    elif git rev-parse --verify --quiet "HEAD^1^{commit}" >/dev/null \
      && git rev-parse --verify --quiet "HEAD^2^{commit}" >/dev/null; then
      base_ref='HEAD^1'
      base_label="local merge base parent (${target_branch})"
      echo "⚠️  Could not fetch origin/${target_branch}; using PR merge base parent."
    else
      echo "❌ Could not fetch ${base_ref} and no local fallback was available."
      exit 1
    fi
  fi
fi

if ! git merge-base "${base_ref}" HEAD >/dev/null; then
  if git rev-parse --verify --quiet "HEAD^1^{commit}" >/dev/null \
    && git rev-parse --verify --quiet "HEAD^2^{commit}" >/dev/null; then
    base_ref='HEAD^1'
    base_label="local merge base parent (${target_branch})"
    echo "⚠️  Could not find a merge base for ${target_branch}; using PR merge base parent."
  else
    echo "❌ Could not find a merge base between ${base_ref} and HEAD."
    exit 1
  fi
fi

: > "${base_timestamps_file}"
latest_base_timestamp='00000000000000'
while IFS= read -r file; do
  [[ "$file" != supabase/migrations/*.sql ]] && continue
  if ! extract_timestamp "$file" ts; then
    continue
  fi
  printf '%s\t%s\n' "$ts" "$file" >> "${base_timestamps_file}"
  if [[ "$ts" > "$latest_base_timestamp" ]]; then
    latest_base_timestamp="$ts"
  fi
done < <(git ls-tree -r --name-only "${base_ref}" -- supabase/migrations)

status=0

# Allow content-preserving re-stamps: a pure rename (100% identical content) of a
# migration to a timestamp NEWER than the latest on the base branch. This is the
# sanctioned way to repair an out-of-order migration (one whose timestamp sorts
# before a migration already applied on a remote, which `supabase db push`
# rejects) without altering its SQL. Content edits and deletions stay blocked.
restamped_files=''
while IFS=$'\t' read -r similarity _old_path new_path; do
  [[ -z "${new_path:-}" ]] && continue
  [[ "$similarity" != "R100" ]] && continue
  if ! extract_timestamp "$new_path" new_ts; then
    continue
  fi
  if (( 10#$new_ts > 10#$latest_base_timestamp )); then
    restamped_files+="${new_path}"$'\n'
  fi
done < <(git diff --name-status -M100% --diff-filter=R "${base_ref}...HEAD" -- 'supabase/migrations/*.sql')

# Migrations that failed before they were recorded in production. Permit only
# the audited repairs below; later edits stay blocked by the exact blob hash.
# - 20260713114104: first a legacy trigger referenced the removed column, then
#   sequential DDL locks deadlocked with live RLS traffic.
# - 20261027220500: one multi-minute silent statement lost its pooler
#   connection; the copy now runs in batches that report progress.
is_failed_migration_hotfix() {
  local file="$1"
  local blob
  blob="$(git hash-object "$file")"
  case "${file}:${blob}" in
    supabase/migrations/20260713114104_harden_rbac_compat_cleanup_after_rls.sql:17dfb3f478bff6b52bc71a85f0016cce11f1d789) return 0 ;;
    supabase/migrations/20261027220500_delta_storage_backfill.sql:0bf94d5123ca6e72cca0b72dd3013857846f20c4) return 0 ;;
    *) return 1 ;;
  esac
}

# Prod-applied migrations restored from remote schema_migrations.statements.
# Exact blob hashes keep this allowlist from accepting later edits.
is_prod_history_restore() {
  local file="$1"
  local blob
  blob="$(git hash-object "$file")"
  case "${file}:${blob}" in
    supabase/migrations/20260725111831_fix_reclaim_no_timeout_max_work.sql:375c5ec53b9037fc2c95b6c6f14505663fdba338) return 0 ;;
    supabase/migrations/20260725111921_fix_reclaim_encrypt_trigger_guc.sql:498056fe115946cfef691d9c16c299891a3e2076) return 0 ;;
    supabase/migrations/20260725111931_fix_reclaim_cron_catch_canceled.sql:4226e0cab969bbefc7ca622e34821ae12a0c8c3a) return 0 ;;
    supabase/migrations/20260725162854_skip_audit_dual_storage_manifest_count.sql:ea3b1c49c5469aa9ba0d74bef34eba35f78d934f) return 0 ;;
    supabase/migrations/20260725182614_slim_audit_logs_skip_bookkeeping.sql:1856f7dced19a929e4ea3548e75e2a17b8a99863) return 0 ;;
    *) return 1 ;;
  esac
}


modified_files="$(git diff --name-only --diff-filter=MR "${base_ref}...HEAD" -- 'supabase/migrations/*.sql')"
if [[ -n "$modified_files" ]]; then
  disallowed_modified_files=''

  while IFS= read -r file; do
    [[ -z "$file" ]] && continue

    ts=''
    extract_timestamp "$file" ts || true
    if [[ -n "$ts" && "$ts" == "$latest_base_timestamp" ]]; then
      echo "⚠️  Allowing fix to latest Supabase migration: $file"
      continue
    fi

    if [[ -n "$restamped_files" ]] && printf '%s' "$restamped_files" | grep -qxF "$file"; then
      echo "⚠️  Allowing content-preserving re-stamp to a newer timestamp: $file"
      continue


    fi

    if is_failed_migration_hotfix "$file"; then
      echo "⚠️  Allowing audited repair to failed unapplied migration: $file"
      continue
    fi

    disallowed_modified_files+="${file}"$'\n'
  done <<< "$modified_files"

  if [[ -n "$disallowed_modified_files" ]]; then
    echo '❌ Existing Supabase migrations were modified in this change.'
    echo '  Create a new migration instead of editing committed migration files.'
    while IFS= read -r file; do
      [[ -z "$file" ]] && continue
      echo "  - $file"
    done <<< "$disallowed_modified_files"
    status=1
  fi
fi

deleted_files="$(git diff --name-only --diff-filter=D "${base_ref}...HEAD" -- 'supabase/migrations/*.sql')"
added_files="$(git diff --name-only --diff-filter=A "${base_ref}...HEAD" -- 'supabase/migrations/*.sql')"

# Intentional squash: replace a large migration history with one *_baseline.sql dump.
# Local/CI reset then reapplies baseline + any migrations still ahead of prod.
allow_migration_squash=0
baseline_added_files=''
while IFS= read -r file; do
  [[ -z "$file" ]] && continue
  if [[ "${file##*/}" == *_baseline.sql ]]; then
    baseline_added_files+="${file}"$'
'
  fi
done <<< "${added_files}"
baseline_added_count=0
if [[ -n "$baseline_added_files" ]]; then
  baseline_added_count="$(printf '%s' "$baseline_added_files" | grep -c . || true)"
fi
deleted_count=0
if [[ -n "$deleted_files" ]]; then
  deleted_count="$(printf '%s' "$deleted_files" | grep -c . || true)"
fi
if [[ "$baseline_added_count" -eq 1 && "$deleted_count" -ge 50 ]]; then
  allow_migration_squash=1
  echo "⚠️  Allowing migration squash via baseline ($(echo "$baseline_added_files" | head -1); ${deleted_count} deletions)."
fi

if [[ -n "$deleted_files" && "$allow_migration_squash" -ne 1 ]]; then
  echo '❌ Existing Supabase migrations were deleted in this change.'
  echo '  Supabase migrations must remain append-only.'
  while IFS= read -r file; do
    [[ -z "$file" ]] && continue
    echo "  - $file"
  done <<< "$deleted_files"
  status=1
fi
if [[ -n "$added_files" ]]; then
  : > "${added_timestamps_file}"

  while IFS= read -r file; do
    [[ -z "$file" ]] && continue

    if ! extract_timestamp "$file" ts; then
      echo "❌ Invalid Supabase migration filename: $file"
      echo '  Expected format: YYYYMMDDHHMMSS_description.sql'
      status=1
      continue
    fi

    existing_base_file="$(awk -F '\t' -v ts="$ts" '$1 == ts { print $2; exit }' "${base_timestamps_file}")"
    if [[ -n "$existing_base_file" ]]; then
      echo "❌ Duplicate migration timestamp: ${ts}"
      echo "  New file: $file"
      echo "  Existing file: ${existing_base_file}"
      status=1
    fi

    existing_added_file="$(awk -F '\t' -v ts="$ts" '$1 == ts { print $2; exit }' "${added_timestamps_file}")"
    if [[ -n "$existing_added_file" ]]; then
      echo "❌ Duplicate migration timestamp in this change: ${ts}"
      echo "  First file: ${existing_added_file}"
      echo "  Second file: $file"
      status=1
    else
      printf '%s\t%s\n' "$ts" "$file" >> "${added_timestamps_file}"
    fi

    if (( 10#$ts < 10#$latest_base_timestamp )); then
      if [[ "$allow_migration_squash" -eq 1 && "${file##*/}" == *_baseline.sql ]]; then
        echo "⚠️  Allowing baseline timestamp before latest main migration during squash: $file"
      elif is_prod_history_restore "$file"; then
        echo "⚠️  Allowing audited prod-history restore with older timestamp: $file"
      else
        echo '❌ Migration timestamp regression detected'
        echo "  Latest timestamp on ${base_ref}: ${latest_base_timestamp}"
        echo "  New file: $file"
        echo "  New timestamp: ${ts}"
        status=1
      fi
    fi
  done <<< "$added_files"
fi

if [[ "$status" -ne 0 ]]; then
  exit 1
fi

echo '✅ Supabase migration filenames are unique and newer than the target branch.'