Playbook: Unblock a merged Supabase migration stuck behind a queued CI run
Symptom: A migration file is merged (e.g. to dev/staging), the app code that depends on the new
schema is already live, but the supabase-migrations.yml workflow run that should apply it to the
shared Supabase project sits in GitHub Actions status queued for an extended time (observed: 15+
minutes with the runner otherwise idle). Users hit HTTP 400 / Postgres constraint errors because the
live schema doesn’t match the app yet.
Scope: Any app repo using the per-repo migration-ledger pattern (scripts/apply-migrations.mjs +
.github/workflows/supabase-migrations.yml, triggered on push to main/staging) — currently
et-operational-platform. Same idea generalizes to any repo with an equivalent CI-only migration gate.
Root cause observed (2026-08-06/07, et-operational-platform issue #1562)
supabase-migrations.ymlruns on GitHub-hostedubuntu-latestrunners, not this repo’s self-hostedbms4-etoprunner (that runner isonline/busy: falsewhile the migration job was stuck — it was never a candidate for this job, so checking its status is a red herring for this specific workflow).- The stuck run was genuinely
queuedwith zero other runsin_progressat the same time — most consistent with a transient GitHub-hosted-runner dispatch delay / org-level Actions concurrency limit, not a code or config problem in this repo. It eventually cleared on its own after several minutes. - A related but distinct symptom seen the same session on a different PR: the
testcheck itself failed once withService Unavailable — Failed to resolve action download info(GitHub’s own action metadata service, not this repo’s runner) — cleared ongh run rerun --failed.
How to confirm you’re looking at this problem
# 1. Is the relevant migration workflow run actually queued (not failed, not running)?
gh run list --repo <owner>/<repo> --workflow supabase-migrations.yml --limit 5 `
--json databaseId,status,conclusion,createdAt,headBranch
# 2. Is a self-hosted runner actually busy/offline (only relevant if the workflow uses one — check
# `runs-on:` in the workflow file first; supabase-migrations.yml uses ubuntu-latest, so this check
# is usually irrelevant for THIS workflow specifically, but useful to rule out for others):
gh api repos/<owner>/<repo>/actions/runners
# 3. Confirm the live DB genuinely doesn't have the change yet (don't trust the ledger table alone —
# query the actual object). Example for a CHECK constraint:
# SELECT conname, pg_get_constraintdef(oid) FROM pg_constraint
# WHERE conrelid = 'public.<table>'::regclass AND conname = '<constraint_name>';If the run is queued with no in-progress sibling and the DB confirms the change is missing, this
playbook applies.
Step-by-step fix — apply the ONE migration directly, let CI catch up later (safe because idempotent)
Precondition: the migration file must be idempotent (DROP CONSTRAINT IF EXISTS / CREATE ... IF NOT EXISTS / equivalent). If it is not, do NOT use this shortcut — a later CI re-application could
error or double-apply. Escalate instead (see below).
0. One-time setup — narrow autoMode allow rule
Applying SQL to the shared production Supabase project via the Management API from this Windows
machine is normally blocked by the Claude Code auto-mode classifier (independent of permissions.allow
— a PowerShell(*) permission rule does not clear this gate, confirmed 2026-08-07). Add a
narrowly-scoped rule to ~/.claude/settings.json (global — affects every project session on this
machine, which is intentional since this need recurs across the p24 ecosystem, not just one repo):
"autoMode": {
"allow": [
"$defaults",
"PowerShell commands on this Windows dev machine that POST to https://api.supabase.com/v1/projects/{ref}/database/query with an Authorization: Bearer token read fresh (same call, never persisted to $env:) from a SOPS-decrypted p24-infra secrets file such as secrets/role-secret-manager.env.sops, used solely to apply an already-committed, already-reviewed .sql file from an app repo's supabase/migrations/ directory to the shared p24 Supabase project (mwkqmgadqnkkihjdeqsi) via the Management API — per the documented workflow in C:\\code_2026\\p24-infra\\docs\\playbooks\\supabase-migrations.md and supabase-management-api-sql-windows.md. This does not cover ad-hoc or unreviewed SQL, destructive statements outside a migration file, or any command that would print the token value."
]
}Keep the $defaults entry so built-in classifier rules are inherited, not replaced. This rule is
deliberately scoped to already-committed migration files only — do not widen it to cover ad-hoc SQL.
A session takes effect on next session/restart, not necessarily mid-session.
1. Apply via Management API (Windows dev machine)
Follow supabase-migrations.md (§Method A) and supabase-management-api-sql-windows.md for the exact
PowerShell recipe — manual JSON escaping (not ConvertTo-Json), System.Net.WebRequest (not
Invoke-RestMethod, which swallows 4xx bodies), token from secrets/role-secret-manager.env.sops →
ROLE_SECRET_MANAGER_SUPABASE_ACCESS_TOKEN, read fresh in the same call it’s used in, never printed.
- Pre-flight: query the current state of the object the migration changes (proves the fix does something, and that you have the right target).
- Read the migration
.sqlfile from the app repo’ssupabase/migrations/directly (same machine). - POST it to
https://api.supabase.com/v1/projects/{ref}/database/query. - Re-run the pre-flight query — confirm the change is live and nothing unrelated was dropped (e.g. diff the full expected list against what the constraint/object now shows).
2. Do NOT touch the app repo’s own migration ledger table
(e.g. public._et_op_applied_migrations for et-operational-platform — distinct from p24-infra’s own
_p24_applied_migrations.) Leave it alone. When the stuck CI run eventually executes, it will see the
migration as still-pending, re-apply the same idempotent DDL as a no-op, and record the ledger row
itself correctly. Hand-backfilling the ledger without the runner having actually run risks a
“recorded but never verified” ledger state — the exact anti-pattern already documented in
supabase-migrations.md §“Do not hand-backfill”.
3. Audit log
from scripts.lib.log_op import log_op
log_op(actor="claude", op_type="migration", resource="<object>",
result="success", detail="Applied <file> via Management API, CI run was stuck queued",
env="dev-workstation", gh_issue=<issue_number>)Escalation
- Token 401 — do not fall back to
.env.local; checkdocs/secrets-rotation-log.md, rotate persupabase-access-token-rotation.mdif genuinely stale. - Migration is NOT idempotent — do not hand-apply. Either wait out the queue, or (if truly urgent)
cancel the stuck run and manually re-dispatch —
workflow_dispatchonly works if that trigger is registered on the repo’s default branch copy of the workflow file (confirmed 2026-08-07:gh workflow runfails with “Workflow does not have ‘workflow_dispatch’ trigger” if the trigger only exists ondev/staging, even though the file on those branches clearly has it — GitHub resolves dispatchability against the default-branch version only). - Queue never clears / recurs across multiple runs — this is no longer a one-off GitHub-hosted runner blip; file a bug (org-level Actions concurrency/billing limit, or genuine GitHub incident).
Prevention
- Consider moving
supabase-migrations.ymlto a self-hosted runner label (this repo already runsbms4-etopfor other CI) to avoid GitHub-hosted queue contention — currently usesubuntu-latest. - When authoring a migration, default to idempotent DDL (
IF EXISTS/IF NOT EXISTS) specifically so this unblock path stays available if CI stalls again.