Playbook: Per-repo Supabase migration ledger (shared-project standard)
Status: standard as of #5653 (design brainstorm + decisions in #5645 → et-operational-platform#1500).
Applies to: every repo that writes schema migrations against the shared Supabase project
mwkqmgadqnkkihjdeqsi — currently p24-infra, et-operational-platform, whatsup-android-chat-puller.
The rule (one line)
One shared Supabase project, but each repo tracks its own applied migrations in its own dedicated
ledger table and applies them with its own small idempotent runner. supabase db push and the
apply_migration MCP tool are BANNED for this project.
Why
Supabase project mwkqmgadqnkkihjdeqsi is intentionally shared across repos (“1 project, separated
parts with their own repos” — by design, not a mistake to undo). Supabase’s built-in migration model
assumes one project = one repo = one supabase/migrations/ folder, tracked in the single system
ledger supabase_migrations.schema_migrations. That assumption structurally does not hold here:
supabase db push --linkedcompares the local repo’s files against the entire shared ledger (510+ rows, the large majority being p24-infra’s own internal schema). It has no concept of “this ledger row belongs to repo X”, so every repo’sdb pushperpetually fails with “Remote migration versions not found in local migrations directory”. This is not a one-off drift amigration repaircan fix — it recurs on every run as long as the ledger is shared and un-partitioned.et-operational-platform’ssupabase-migrations.ymlfailed on every run for weeks for exactly this reason.mcp__claude_ai_Supabase__apply_migrationstampsschema_migrationsat apply-time with a version that does not match the migration file’s own timestamp prefix. In #5645 this produced 4 genuinely-et-op rows recorded under the wrong version (e.g. file20260804140000_update_user_roles_rpc.sqlstamped as ledger version20260804195228). Any repo that needs precise file↔version correspondence is corrupted by this tool.
Both supabase db push and apply_migration are therefore banned for this project. The raw
Management API /database/query endpoint is confirmed not to write the ledger and is the correct
transport for out-of-band SQL.
The pattern (reuse p24-infra’s proven shape, generalised per repo)
Each repo gets three things, named for that repo:
1. A per-repo ledger table
CREATE TABLE IF NOT EXISTS public._<repo>_applied_migrations (
filename TEXT PRIMARY KEY,
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Lock it down (MANDATORY — this table lives in the PostgREST-exposed `public` schema).
-- Without this it comes up RLS-disabled with Supabase's default `authenticated=arwdDxtm`
-- grants, so ANY authenticated session (incl. a driver/technician PIN login) could DELETE
-- ledger rows — making applied migrations "pending" again so the next runner replays them,
-- including destructive DML — or INSERT a not-yet-created filename to permanently and
-- silently suppress that migration when it lands.
ALTER TABLE public._<repo>_applied_migrations ENABLE ROW LEVEL SECURITY;
REVOKE ALL ON TABLE public._<repo>_applied_migrations FROM PUBLIC;
REVOKE ALL ON TABLE public._<repo>_applied_migrations FROM anon, authenticated;No policies needed. RLS enabled + zero policies denies every non-bypassing role by default. The runner connects via the Management API (
postgres) orservice_role— both bypass RLS regardless — so it reads/writes the ledger normally. First shipped and independently reviewed in et-op (PR #1541, migration20260806100000_secure_et_op_migration_ledger.sql).
| Repo | Ledger table |
|---|---|
| p24-infra | public._p24_applied_migrations (pre-existing — the reference implementation) |
| et-operational-platform | public._et_op_applied_migrations |
| whatsup-android-chat-puller | public._whatsup_applied_migrations |
The shared supabase_migrations.schema_migrations is left untouched — its ~510 rows become dead
weight once no repo reads it for tracking. Do not try to clean it up (D6).
2. A small idempotent runner (duplicated per repo — no shared package, D3)
Read local supabase/migrations/*.sql in lexicographic order → diff against this repo’s ledger →
apply pending files → record each in the ledger. Fail fast on the first error; never apply past a
broken migration.
- p24-infra (self-hosted bms-4 runner, can reach the DB directly):
psql --single-transactionvia the socat pooler proxy —scripts/apply-supabase-migrations.sh. This is the reference. - et-op / whatsup (GitHub-hosted
ubuntu-latest, cannot reach the bms-4 socat proxy): apply via the Management API/database/queryendpoint (transport decision D2). Nopsqldependency.
Runner requirements (all repos):
- Idempotent — already-applied files are skipped; safe to re-run.
- Fail-fast — stop on first error, do not apply further migrations.
-- migrate:no-transactionopt-out — a migration containing that directive comment on any line is applied without a transaction wrapper (forCREATE INDEX CONCURRENTLY,VACUUM,ALTER TYPE ... ADD VALUE). Such a file must be self-idempotent (IF NOT EXISTS).- Concurrency safety (D5) — both layers:
- a GitHub Actions
concurrency:group on the migration workflow, and - a Postgres advisory lock inside the runner. For a Management-API runner (each
/database/queryis its own connection/transaction, so a session-levelpg_advisory_lockwould release immediately), take a transaction-level lock per migration: wrap each migration in a single/database/querycall of the formBEGIN; SELECT pg_advisory_xact_lock(<repo_key>); <re-check not already applied, else abort>; <migration body>; INSERT INTO public._<repo>_applied_migrations(filename) VALUES ('<f>'); COMMIT;. The xact lock is held for that transaction and released on COMMIT, so a second concurrent runner blocks, then its re-check sees the row and skips. (no-transactionmigrations can’t take an xact lock — rely on the workflowconcurrency:group +IF NOT EXISTSidempotency for those.)
- a GitHub Actions
3. A CI workflow that runs the runner on push
Replaces supabase-migrations.yml (supabase db push). Triggers on push to the repo’s default
branch when supabase/migrations/** changes, plus workflow_dispatch with a dry-run input. Includes
the concurrency: group above.
First-time adoption: verified backfill (D4 — MANDATORY, do not shortcut)
Before the runner goes live, the ledger must be seeded with the migrations that are already applied to the live DB. Verify each migration against the live schema catalog before marking it applied — the thorough option, deliberately chosen over the fast-path “seed all, verify only the broken-window set”.
Precedent for the thoroughness (D4): p24-infra incident #4450 — an unverified backfill silently marked a migration as applied that had never actually run, so the runner skipped it and a later deploy hit a missing object. A blanket “seed all 289 as applied” repeats exactly that risk.
Verification procedure (per migration file):
- Parse the file for the objects it creates/alters:
CREATE TABLE,ALTER TABLE ... ADD COLUMN,CREATE [OR REPLACE] FUNCTION,CREATE POLICY,CREATE INDEX,CREATE TYPE,CREATE VIEW, etc. - Query the live catalog (via Management API
/database/query, read-only) to confirm those objects exist:information_schema.tables/.columns,pg_proc,pg_policies,pg_indexes,pg_type,information_schema.views. - Classify:
- Verified present → seed into the ledger as applied (
applied_at= best-known date, or NOW()). - Effect absent → the migration is genuinely pending. Do not seed it; let the runner apply it on first run (it will apply cleanly if the objects really are missing).
- Data-only / not verifiable by catalog (e.g. a backfill UPDATE, a one-off data fix) → cross-check
the name against the existing shared
schema_migrationsledger; if present there, seed as applied; otherwise flag for manual review — do not guess.
- Verified present → seed into the ledger as applied (
- Produce a seed SQL file (
supabase/migrations/…_backfill_<repo>_ledger.sql— modelled on p24-infra’s20260630_applied_migrations_backfill.sql) thatINSERT ... ON CONFLICT DO NOTHINGs the verified-present set, and a short report of the pending + flagged sets.
Budget real time/tool calls for this — for et-op that means checking all 289 migrations, for whatsup all 28. It is a deliberate, one-time cost.
Transport reference (Management-API runner)
POST https://api.supabase.com/v1/projects/mwkqmgadqnkkihjdeqsi/database/query
Authorization: Bearer <sbp_… Management API PAT>
Content-Type: application/json
{ "query": "<SQL>" }
- Token: repo’s own
SUPABASE_ACCESS_TOKENGitHub secret (asbp_…PAT). Read-only catalog queries and DDL both go through the same endpoint. - This endpoint does not write
supabase_migrations.schema_migrations— that is exactly why it is the sanctioned transport here. - Full escaping/gotchas (PowerShell):
docs/playbooks/supabase-management-api-sql-windows.md.
Do / Don’t
- ✅ New migration → drop a timestamped
.sqlin the repo’ssupabase/migrations/, open a PR, let CI apply it. - ✅ Out-of-band apply for testing → Management API
/database/query. - ❌
supabase db push/supabase-migrations.ymlfor this project. - ❌
mcp__claude_ai_Supabase__apply_migration— banned project-wide. - ❌ Hand-applying a migration with
execute_sqland skipping the ledger. - ❌ Touching / “cleaning up” the shared
supabase_migrations.schema_migrationstable.
Related
- Reference implementation:
scripts/apply-supabase-migrations.sh,docs/playbooks/supabase-migrations-ci.md. - Design + decisions: #5645 (brainstorm), #5653 (this implementation),
et-operational-platform#1500(Return-To). - Implementation plan:
docs/plans/plan-5653-shared-supabase-migration-tracking.md.