From 7926059e29e414ab7ae4af3838f018ff94cfa151 Mon Sep 17 00:00:00 2001
From: harystyleseze <141352725+harystyleseze@users.noreply.github.com>
Date: Sat, 17 Oct 2026 04:08:33 -0700
Subject: [PATCH] ci: lint new migrations for lock safety with allowlist
---
.github/workflows/ci.yml | 16 ++++++++
.squawk.toml | 4 ++
CONTRIBUTING.md | 20 ++++++++++
package.json | 1 +
prisma/migration-allowlist.json | 1 +
scripts/lint-migrations.sh | 71 +++++++++++++++++++++++++++++++++
6 files changed, 113 insertions(+)
create mode 100644 .squawk.toml
create mode 100644 prisma/migration-allowlist.json
create mode 100755 scripts/lint-migrations.sh
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 717a127..14487f3 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -68,3 +68,19 @@ jobs:
env:
TEST_DATABASE_URL: postgresql://oracledesk:oracledesk@localhost:5432/postgres
TEST_REDIS_URL: redis://localhost:6379
+
+ migrations:
+ name: Migration lint
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version-file: .nvmrc
+
+ - name: Lint new migrations
+ run: npm run lint:migrations
diff --git a/.squawk.toml b/.squawk.toml
new file mode 100644
index 0000000..f5d5e41
--- /dev/null
+++ b/.squawk.toml
@@ -0,0 +1,4 @@
+# Squawk configuration for scripts/lint-migrations.sh (#104).
+# Rules are suppressed per migration through prisma/migration-allowlist.json,
+# never here. Keep this list empty.
+excluded_rules = []
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index e463ae5..f3ca209 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -53,6 +53,26 @@ When adding `CHECK` constraints or foreign keys to existing tables, follow the
2. Run `scripts/audit-constraints.ts [--fix]` to detect and repair or quarantine any invalid rows.
3. Validate the constraint in a subsequent migration using `ALTER TABLE ... VALIDATE CONSTRAINT ...` (takes `SHARE UPDATE EXCLUSIVE`, keeping tables available for reads and writes).
+### Migration lint
+
+CI (`migrations` job) runs `npm run lint:migrations` against the migrations a
+PR adds. Locally, run it after committing so it can diff against the base
+(`MIGRATIONS_BASE_REF=upstream/develop npm run lint:migrations`).
+
+- Editing, deleting or renaming a merged migration fails the lint.
+- Every new migration must start with `SET lock_timeout = '5s';` so a
+ migration blocked by a busy table fails fast instead of queuing queries.
+- New migrations are checked with a pinned [squawk](https://squawkhq.com/docs/)
+ (lock-heavy changes such as non-concurrent indexes, column type changes,
+ drops and renames are rejected).
+- To suppress a rule, add an entry to `prisma/migration-allowlist.json` scoped
+ to one migration, listing the rules, a `reason` and an `approvedBy` reviewer:
+ `{ "migration": "
", "rules": ["ban-drop-column"], "reason": "...", "approvedBy": "@reviewer" }`.
+- Enum values: add with `ALTER TYPE ... ADD VALUE` (the new value cannot be used
+ in the same transaction). Removing or renaming values needs expand/contract
+ (stop writing the value, migrate rows, recreate the type in a later release)
+ and an allowlist entry.
+
## Chain writes
Anything that signs must go through `src/services/chain.service.ts`, which
diff --git a/package.json b/package.json
index 328d718..da01b33 100644
--- a/package.json
+++ b/package.json
@@ -16,6 +16,7 @@
"prisma:studio": "prisma studio",
"sync:contracts": "node scripts/sync-contracts.mjs",
"check:contracts": "node scripts/sync-contracts.mjs --check",
+ "lint:migrations": "bash scripts/lint-migrations.sh",
"db:seed": "tsx prisma/seed.ts",
"db:reset": "prisma migrate reset --force --skip-seed && npm run db:seed",
"snapshot:export": "tsx scripts/snapshot-export.ts",
diff --git a/prisma/migration-allowlist.json b/prisma/migration-allowlist.json
new file mode 100644
index 0000000..fe51488
--- /dev/null
+++ b/prisma/migration-allowlist.json
@@ -0,0 +1 @@
+[]
diff --git a/scripts/lint-migrations.sh b/scripts/lint-migrations.sh
new file mode 100755
index 0000000..a5b8585
--- /dev/null
+++ b/scripts/lint-migrations.sh
@@ -0,0 +1,71 @@
+#!/usr/bin/env bash
+# Lints new Prisma migrations (#104).
+#
+# - Historical migrations are forward-only: modifying one fails.
+# - Every new migration must set a lock_timeout.
+# - New migrations are linted with a pinned squawk; exceptions come from
+# prisma/migration-allowlist.json (one migration, specific rules, a reason).
+#
+# Base ref: origin/$GITHUB_BASE_REF on pull requests, HEAD~1 otherwise.
+set -euo pipefail
+
+SQUAWK_VERSION="2.21.0"
+ALLOWLIST="prisma/migration-allowlist.json"
+
+if [ -n "${GITHUB_BASE_REF:-}" ]; then
+ base="origin/${GITHUB_BASE_REF}"
+else
+ base="${MIGRATIONS_BASE_REF:-HEAD~1}"
+fi
+if ! git rev-parse --verify --quiet "$base" >/dev/null; then
+ echo "Base ref $base not found; fetch full history (fetch-depth: 0)." >&2
+ exit 2
+fi
+merge_base="$(git merge-base "$base" HEAD)"
+pattern='prisma/migrations/*/migration.sql'
+
+modified="$(git diff --name-only --diff-filter=MDR "$merge_base" HEAD -- "$pattern")"
+if [ -n "$modified" ]; then
+ echo "Merged migrations are forward-only; these were modified, deleted or renamed:" >&2
+ echo "$modified" >&2
+ exit 1
+fi
+
+added="$(git diff --name-only --diff-filter=A "$merge_base" HEAD -- "$pattern")"
+if [ -z "$added" ]; then
+ echo "No new migrations; nothing to lint."
+ exit 0
+fi
+
+failed=0
+while IFS= read -r file; do
+ name="$(basename "$(dirname "$file")")"
+
+ if ! grep -Eiq "^[[:space:]]*SET[[:space:]]+(LOCAL[[:space:]]+)?lock_timeout[[:space:]]*(=|TO)" "$file"; then
+ echo "$file: missing \"SET lock_timeout = '5s';\" (a blocked migration must fail fast)." >&2
+ failed=1
+ fi
+
+ # Allowlist entries must name a reason and a reviewer.
+ excludes="$(MIGRATION="$name" ALLOWLIST="$ALLOWLIST" node -e '
+ const entries = JSON.parse(require("fs").readFileSync(process.env.ALLOWLIST, "utf8"));
+ const rules = [];
+ for (const e of entries) {
+ if (!e.reason || !e.approvedBy || !Array.isArray(e.rules)) {
+ console.error("Invalid allowlist entry for " + e.migration + ": rules, reason and approvedBy are required.");
+ process.exit(1);
+ }
+ if (e.migration === process.env.MIGRATION) rules.push(...e.rules);
+ }
+ process.stdout.write(rules.join(","));
+ ')"
+
+ args=(--assume-in-transaction)
+ if [ -n "$excludes" ]; then args+=(--exclude="$excludes"); fi
+ echo "Linting $file${excludes:+ (allowlisted: $excludes)}"
+ if ! npx --yes "squawk-cli@${SQUAWK_VERSION}" "${args[@]}" "$file"; then
+ failed=1
+ fi
+done <<< "$added"
+
+exit "$failed"