Fix migrate.yml auto-applies production DB migrations on push with no concurrency guard or deploy ordering - #2703
Open
polylane[bot] wants to merge 1 commit into
Open
Fix migrate.yml auto-applies production DB migrations on push with no concurrency guard or deploy ordering#2703polylane[bot] wants to merge 1 commit into
polylane[bot] wants to merge 1 commit into
Conversation
…ith a 15-minute timeout
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes: migrate.yml auto-applies production DB migrations on push with no concurrency guard or deploy ordering
Every push to main that touches a database schema file triggers an automatic migration against the production Turso database — the single shared data store behind the API, background jobs, and every customer status page. Nothing stopped two of those migrations from running at the same time, which can corrupt or deadlock a database that all tenants share, and nothing bounded a stalled migration run. This adds the same concurrency guard and 15-minute job timeout the repository already uses on its other release workflows, so production migrations queue one at a time instead of racing.
What caused this
Affected:
int_01b0344f3001ufkixqp40d4t· severity mediumBefore / After
❌ Before: Two pushes to main touching schema files within a few minutes ran two production migrations at the same time against the one shared Turso database, with nothing queuing them and nothing bounding a hung run. A migration that stalled held the runner open indefinitely.
✅ After: A second push that touches
packages/db/drizzle/**waits in a queue until the in-flight migration finishes, then applies the newest migration set; a manually dispatched run waits behind a push-triggered one the same way. A migration that stalls fails after 15 minutes instead of running forever.What changed
.github/workflows/migrate.yml: added a workflow-levelconcurrencygroup (migrate-db,cancel-in-progress: false) so overlapping drizzle-triggered pushes, or a push plus a manual dispatch, queue instead of running two migrators against the production database at once..github/workflows/migrate.yml: addedtimeout-minutes: 15to the migrate job, matching deploy.yml, deploy-checker.yml, deploy-private-location.yml and restart-workflows.yml, so a hung migration fails fast and cannot hold a runner or block queued runs indefinitely.Why it's safe
cancel-in-progress: falsemeans every queued run still executes, only later; nothing is dropped.Validation
pnpm verify(oxfmt + oxlint + doc refs + deno check, all 40 workspace packages): 40/40 tasks successful, run in this thread afterpnpm install --frozen-lockfile.actionlint .github/workflows/migrate.yml(v1.7.7): clean, no findings.concurrency: {group: migrate-db, cancel-in-progress: false}andtimeout-minutes: 15verified present,on:triggers unchanged.pnpm verify:test: not run — the change touches only a CI workflow file, no package code or tests; the repo's test suites need a live libSQL database, which this change does not affect.Root cause and scoping notes
Root cause
deno run -A src/migrate.mts(drizzle's libsql migrator) against the production Turso DATABASE_URL on every push to main touchingpackages/db/drizzle/**, plus manual dispatch — with noconcurrency:block, so GitHub never serialized runs.__drizzle_migrationsand applies pending SQL with no database-level lock; two concurrent runs on back-to-back schema pushes can both attempt the same migration set and interleave, leaving the DB in a partial state. The window is real: 2026-09-10 saw pushes at 12:27Z, 12:39Z, 12:40Z.cancel-in-progress: falsemakes GitHub queue every run, so the race cannot open. The static name is deliberate: every run targets the same prod DB via the same secrets. The 15-minute timeout matches the sibling workflows and only affects a hung run.Out of scope / follow-ups
Causal chain
deno run -A src/migrate.mts— drizzle's libsql migrator against production DATABASE_URL — concurrently. The migrator reads __drizzle_migrations and applies pending SQL files with no database-level lock, so concurrent runs can both attempt the same pending set and interleave mid-set. Established from reading packages/db/src/migrate.mts and .github/workflows/migrate.yml this run.on: push: branches: [main] paths: [packages/db/drizzle/**]plus workflow_dispatch, single job, no concurrency block, no timeout — verified live this runDetection outcome
The fingerprinted signal is the confirmed issue "migrate.yml auto-applies production DB migrations on push with no concurrency guard or deploy ordering" (iss_08ecdd893001h972tpc1fo8x). Once this deploys, the unguarded-migration aspect it names stops being true: every Migrate DB run — push-triggered or dispatched — joins one
migrate-dbconcurrency group, so a second run queues instead of racing the first against the production Turso DB, and a stalled run fails at 15 minutes instead of holding open indefinitely. The signal's remaining aspects (migrate-vs-deploy interleaving and unvalidated migrations reaching main) are unchanged by design and are recorded as follow-ups on the issue timeline, not silenced here.Fix chosen
Chosen: cause (removes the mechanism that produces the failure): Add a workflow-level concurrency group (migrate-db, cancel-in-progress: false) and a 15-minute job timeout to migrate.yml, serializing all production migration runs against each other.
Considered and not chosen:
Outcome after fix
Replay the 09-10 burst: pushes land on main at 12:27Z, 12:39Z and 12:40Z, the first two touching
packages/db/drizzle/**. Today, the 12:27Z push starts a migrate run immediately and the 12:39Z push starts a second one while the first may still hold the lock window — two drizzle migrators reading the same__drizzle_migrationstable and racing to apply the same pending SQL against the production Turso DB. With this change: the 12:27Z run joinsmigrate-dband runs; the 12:39Z run enters the same group, is marked queued, and GitHub starts it only after the first completes, at which point it applies the newer migration set against a DB already at the older state. If instead a migration hangs (e.g. a network partition to Turso), the job now fails at 15 minutes and the queued run proceeds, where before it held the runner indefinitely and nothing behind it could advance. The deployment itself is untouched: deploy.yml still rolls the API on the same push, as before — that interleaving is explicitly left open on the issue timeline.1 file changed (+4/-0)
.github/workflows/migrate.yml: modified, +4/-0Repository conventions present in the repository: AGENTS.md, CLAUDE.md, apps/checker/AGENTS.md, apps/dashboard/AGENTS.md, apps/server/AGENTS.md, apps/status-page/AGENTS.md, apps/web/AGENTS.md, apps/workflows/AGENTS.md, packages/services/AGENTS.md, packages/ui/AGENTS.md.
Repository lint:
pnpm run lint(declared in package.json) passed over the whole repository (the declared target takes no file arguments).Generated by Polylane.