Skip to content

Commit 3fab9d4

Browse files
matt-aitkenTrigger.dev RepoOps
authored andcommitted
chore: add a Postgres migration safety guard
Add a CI guard that checks new Prisma migrations for idempotent, lock-safe DDL: creates must use IF NOT EXISTS, drops IF EXISTS, CREATE TYPE, ADD CONSTRAINT and RENAME must sit in a guarded DO block, INSERTs need ON CONFLICT, and indexes on existing tables must be built CONCURRENTLY in a single-statement migration. The enforced cutoff date is pinned in the `guard:migrations` script in `apps/webapp/package.json`; `-- --all` audits the whole history locally. A `-- migration-guard: allow <reason>` comment opts a single statement out. Mono-RevId: 7c937700bc4eae8b5b51fecdb60b620bf3c9807b
1 parent 463d155 commit 3fab9d4

10 files changed

Lines changed: 1980 additions & 23 deletions

File tree

.claude/rules/database-safety.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
---
22
paths:
33
- "internal-packages/database/**"
4+
- "internal-packages/run-ops-database/**"
45
---
56

67
# Database Migration Safety
78

89
- When adding indexes to **existing tables**, use `CREATE INDEX CONCURRENTLY IF NOT EXISTS` to avoid table locks. These must be in their own separate migration file (one index per file).
9-
- Indexes on **newly created tables** (same migration as `CREATE TABLE`) do not need CONCURRENTLY.
10+
- Indexes on **newly created tables** (same migration as `CREATE TABLE`) do not need CONCURRENTLY, but still need `IF NOT EXISTS`. On such a table, `ALTER TABLE ... DROP CONSTRAINT IF EXISTS "name", ADD CONSTRAINT "name" ...` in one statement is an accepted alternative to a DO block for the Prisma-generated constraints.
1011
- When indexing a **new column on an existing table**, split into two migrations: first `ADD COLUMN IF NOT EXISTS`, then `CREATE INDEX CONCURRENTLY IF NOT EXISTS` in a separate file.
1112
- After generating a migration with Prisma, remove extraneous lines for: `_BackgroundWorkerToBackgroundWorkerFile`, `_BackgroundWorkerToTaskQueue`, `_TaskRunToTaskRunTag`, `_WaitpointRunConnections`, `_completedWaitpoints`, `SecretStore_key_idx`, and unrelated TaskRun indexes.
13+
- Every statement must be idempotent so a migration can be re-run after a partial apply: `CREATE TABLE IF NOT EXISTS`, `ADD COLUMN IF NOT EXISTS`, `ADD VALUE IF NOT EXISTS`, `DROP ... IF EXISTS`, `DROP INDEX CONCURRENTLY IF EXISTS`. `CREATE TYPE`, `ADD CONSTRAINT` and `RENAME` have no `IF NOT EXISTS`, so wrap each one in a `DO $$ ... $$` block under an `IF NOT EXISTS (SELECT 1 FROM pg_type / pg_constraint / pg_attribute ...) THEN ... END IF` guard (or give the block an `EXCEPTION WHEN duplicate_object` handler). Data `INSERT`s need `ON CONFLICT`.
14+
- CI enforces this via `pnpm --filter webapp run guard:migrations`; the enforced cutoff date is the `--cutoff` pinned in that script in `apps/webapp/package.json` (rules in `apps/webapp/scripts/migrationSafetyGuard.core.ts`). A statement that genuinely cannot comply may carry `-- migration-guard: allow <reason>` on the line above it.
1215
- Never drop columns or tables without explicit approval.
1316
- New code should target `RunEngineVersion.V2` only.
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
name: "🛡️ Migration Safety Guard"
2+
3+
on:
4+
workflow_call:
5+
6+
permissions:
7+
contents: read
8+
9+
jobs:
10+
migration-guard:
11+
runs-on: warp-ubuntu-latest-x64-16x
12+
13+
steps:
14+
- name: ⬇️ Checkout repo
15+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
16+
with:
17+
persist-credentials: false
18+
19+
- name: ⎔ Setup pnpm
20+
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
21+
with:
22+
version: 10.33.2
23+
24+
- name: ⎔ Setup node
25+
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
26+
with:
27+
node-version: 24.18.0
28+
cache: "pnpm"
29+
30+
- name: 📥 Download deps
31+
run: pnpm install --frozen-lockfile
32+
33+
- name: 🛡️ Migration safety guard
34+
run: pnpm --filter webapp run guard:migrations

.github/workflows/pr_checks.yml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ jobs:
2121
code: ${{ steps.code_filter.outputs.code }}
2222
typecheck_self: ${{ steps.filter.outputs.typecheck_self }}
2323
webapp: ${{ steps.filter.outputs.webapp == 'true' || steps.shared_packages_filter.outputs.shared_packages == 'true' }}
24+
migrations: ${{ steps.filter.outputs.migrations }}
2425
packages: ${{ steps.filter.outputs.packages }}
2526
internal: ${{ steps.filter.outputs.internal == 'true' || steps.shared_packages_filter.outputs.shared_packages == 'true' }}
2627
obsmap: ${{ steps.filter.outputs.obsmap }}
@@ -73,6 +74,14 @@ jobs:
7374
- 'pnpm-lock.yaml'
7475
- 'pnpm-workspace.yaml'
7576
- 'turbo.json'
77+
migrations:
78+
- 'internal-packages/database/prisma/migrations/**'
79+
- 'internal-packages/run-ops-database/prisma/migrations/**'
80+
- 'apps/webapp/scripts/migrationSafetyGuard*.ts'
81+
- 'apps/webapp/scripts/lib/**'
82+
- 'apps/webapp/package.json'
83+
- '.github/workflows/pr_checks.yml'
84+
- '.github/workflows/migration-guard.yml'
7685
packages:
7786
- 'packages/**'
7887
- '.github/workflows/pr_checks.yml'
@@ -181,6 +190,11 @@ jobs:
181190
if: needs.changes.outputs.webapp == 'true'
182191
uses: ./.github/workflows/fk-cascade-guard.yml
183192

193+
migration-guard:
194+
needs: changes
195+
if: needs.changes.outputs.migrations == 'true'
196+
uses: ./.github/workflows/migration-guard.yml
197+
184198
webapp:
185199
needs: changes
186200
if: needs.changes.outputs.webapp == 'true'
@@ -238,6 +252,7 @@ jobs:
238252
- typecheck
239253
- runops-guard
240254
- fk-cascade-guard
255+
- migration-guard
241256
- webapp
242257
- e2e-webapp
243258
- packages

apps/webapp/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
"typecheck": "cross-env NODE_OPTIONS=\"--max-old-space-size=8192\" tsc --noEmit -p ./tsconfig.check.json",
1919
"guard:runops-legacy": "cross-env NODE_OPTIONS=\"--max-old-space-size=8192\" tsx ./scripts/runOpsLegacyGuard.ts",
2020
"guard:fk-cascade-index": "tsx ./scripts/fkCascadeIndexGuard.ts",
21+
"guard:migrations": "tsx ./scripts/migrationSafetyGuard.ts --cutoff 20260918",
2122
"env:check": "varlock load --agent",
2223
"env": "varlock run --inject vars --",
2324
"db:seed": "varlock run --inject vars -- tsx seed.ts",

apps/webapp/scripts/fkCascadeIndexGuard.ts

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -32,17 +32,7 @@
3232
*/
3333
import * as fs from "node:fs";
3434
import * as path from "node:path";
35-
36-
function findRepoRoot(start: string): string {
37-
let dir = path.resolve(start);
38-
for (;;) {
39-
if (fs.existsSync(path.join(dir, "pnpm-workspace.yaml"))) return dir;
40-
const parent = path.dirname(dir);
41-
if (parent === dir)
42-
throw new Error("Could not locate repo root (pnpm-workspace.yaml not found)");
43-
dir = parent;
44-
}
45-
}
35+
import { findRepoRoot } from "./lib/repoRoot";
4636

4737
const REPO_ROOT = findRepoRoot(process.cwd());
4838

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import * as fs from "node:fs";
2+
import * as path from "node:path";
3+
4+
/** Walks up from `start` to the pnpm workspace root the guard scripts resolve paths against. */
5+
export function findRepoRoot(start: string): string {
6+
let dir = path.resolve(start);
7+
for (;;) {
8+
if (fs.existsSync(path.join(dir, "pnpm-workspace.yaml"))) return dir;
9+
const parent = path.dirname(dir);
10+
if (parent === dir)
11+
throw new Error("Could not locate repo root (pnpm-workspace.yaml not found)");
12+
dir = parent;
13+
}
14+
}

0 commit comments

Comments
 (0)