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"