Skip to main content

Lint Postgres migration SQL for the dangerous operations that break production — as a GitHub Action, CLI, or pre-commit hook.

Project description

safemigrate-lint

CI PyPI License: MIT

A GitHub Action that lints Postgres migration SQL on every PR. Catches the operations that actually break production — written for the real shape of production migrations, not the textbook one.

  • 33 safety rules + 6 opt-in style rules across CRITICAL / WARNING / STYLE tiers
  • Real Postgres parser via pglast (libpg_query) — handles extension SQL (TimescaleDB, PostGIS) that other linters trip on
  • Cross-statement context — suppresses FK-to-new-table and similar false positives that pile up in single-statement linters
  • Posts a find-or-create PR comment with per-finding detail; creates a Check Run with severity-mapped conclusion

Demo

On every pull request, safemigrate-lint posts a comment that groups findings by severity — each with the lock it takes and the safe rewrite — and sets a Check Run conclusion you can require in branch protection.

safemigrate-lint comment demo

Example PR comment (click to expand)
## 🛡️ SafeMigrate Lint

**2 findings** — 1 critical, 1 warning.

### 🔴 CRITICAL — drop-column-restricted
migrations/0042_cleanup.sql:2
DROP COLUMN deleteat on threads is irreversible data loss.

### 🟡 WARNING — constraint-not-valid-required
migrations/0042_cleanup.sql:8
ADD CONSTRAINT orders_user_fk FOREIGN KEY without NOT VALID requires a full
table scan, holding AccessExclusiveLock for the duration.

Suggested fix:
  ALTER TABLE orders ADD CONSTRAINT orders_user_fk FOREIGN KEY (...) NOT VALID;
  -- then, in a separate migration:
  ALTER TABLE orders VALIDATE CONSTRAINT orders_user_fk;

Why

We scanned ~700 production migrations from Cal.com, Mattermost, Supabase, Hasura, and TimescaleDB. Zero of them ran the textbook DANGEROUS operations the popular linters warn loudest on (raw DROP TABLE in app code, etc.). The real risks live one layer deeper: ADD COLUMN GENERATED triggering a table rewrite, ADD CONSTRAINT FK without NOT VALID, dynamic SQL the analyzer can't see, constraint drops that silently break invariants. safemigrate-lint is built around those.

Atlas Pro charges $9/dev + $59/CI + $39/db per month for many of these checks. This action ships them free, MIT.

Quickstart

Drop this into .github/workflows/lint-migrations.yml:

name: Lint migrations
on:
  pull_request:
    paths:
      - 'migrations/**/*.sql'

permissions:
  contents: read
  pull-requests: write
  checks: write

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: Harshith029/safemigrate-lint@v1
        continue-on-error: true
        with:
          paths: 'migrations/**/*.sql'
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

For maximum reproducibility, pin to a commit SHA (@<full-sha>) instead of @v1.

Why continue-on-error: true?

The action's step exits non-zero whenever the lint finds anything (so workflows that don't set this turn red on every PR with findings). Use the Check Run as the semantic signal instead — it maps severity to conclusion:

findings check conclusion meaning
none success safe to merge
warnings / style only neutral review, but doesn't block
any critical action_required look at this before merging

In branch protection, require safemigrate-lint (the Check Run name) as a status check. The PR will be blocked on critical findings while warnings stay non-blocking.

Linting only the migrations a PR changed

By default the action lints every file matching paths. On a repo with a lot of existing migrations, that re-reports findings on old, already-shipped ones on every PR. To judge a PR only on the migrations it actually introduces, compute the diff and pass it to paths — pure git, no third-party action:

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0                          # so the diff can see the base branch
      - id: changed
        run: |
          base="${{ github.base_ref }}"
          files=$(git diff --name-only --diff-filter=ACMR "origin/$base...HEAD" \
                  | grep -E '^migrations/.*\.sql$' | tr '\n' ' ' || true)
          echo "files=$files" >> "$GITHUB_OUTPUT"
      - if: steps.changed.outputs.files != ''
        uses: Harshith029/safemigrate-lint@v1
        continue-on-error: true
        with:
          paths: ${{ steps.changed.outputs.files }}
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

This is the recommended setup for existing projects: new PRs are judged only on the migrations they add, not your whole history.

Other ways to run it

The same engine ships three ways — use whichever fits your workflow.

CLI

# from PyPI
pipx install safemigrate-lint           # or: uv tool install safemigrate-lint
# …or straight from source
pipx install git+https://github.com/Harshith029/safemigrate-lint

safemigrate-lint migrations/*.sql       # exit 0 clean · 1 findings · 2 input error
safemigrate-lint migrations/*.sql --severity=critical,warning,style --format=markdown

pre-commit

Catch dangerous migrations before they're even committed:

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/Harshith029/safemigrate-lint
    rev: v1.1.2
    hooks:
      - id: safemigrate-lint

Runs on staged *.sql files and blocks the commit on any finding.

Reference

Inputs

name default description
paths (required) Glob or newline-separated list of SQL files to lint
severity critical,warning Comma-separated severity levels to include: critical,warning,style
format json Output format for the action log: json or markdown

Outputs

name type description
findings-count integer Total findings emitted after severity filter
has-critical "true" / "false" Whether any critical-severity finding was emitted

Required permissions

scope needed for
contents: read checking out migration files
pull-requests: write posting / editing the PR comment
checks: write creating the Check Run

Inline suppression

For a one-off justified exception, prefix the statement with an ignore comment:

-- safemigrate:ignore=drop-column-restricted reason="column archived to data warehouse before drop"
ALTER TABLE users DROP COLUMN legacy_referrer;

Configuration via .safemigrate.toml

Optional repo-level config. Walks upward from the first linted file to find it.

[rules]
disabled = ["timestamptz-over-timestamp-preferred"]    # hard-disable, never fires

[rules.style]
enabled = ["bigint-over-int-preferred"]                # promote STYLE -> WARNING in default mode

How it compares to squawk

squawk is the closest other free OSS option. Both lint Postgres migrations, both are MIT.

safemigrate-lint squawk
Parser pglast (libpg_query — actual Postgres parser) Rust reimplementation
Extension SQL (TimescaleDB / PostGIS) parses cleanly known parser gaps on newer SQL
Cross-statement context yes — suppresses FK / index / constraint rules on same-migration tables per-statement only
Out-of-the-box GitHub Action yes (this repo) shipped binary + DIY workflow
PR comments + Check Run built-in DIY
Rule count 33 safety + 6 opt-in style 37 rules
Default-mode signal on a 23-fixture corpus 39 findings, all actionable 205 findings

Measured with squawk 2.56.0, both tools in their default configuration, on this repo's fixtures/migrations/. Most of squawk's extra findings are its style/opinion rules (prefer-robust-stmts, prefer-bigint-over-int, prefer-identity, …), which safemigrate-lint ships as opt-in STYLE rules rather than firing by default.

If you want the broadest rule catalog and you're comfortable wiring the action yourself, squawk is mature and well-maintained. If you want a one-paste install plus FK-to-new-table suppression by default, this is the trade.

Contributing

Contributions welcome — especially new rules and false-positive reports. See CONTRIBUTING.md for the dev setup and rule philosophy, and docs/writing-a-rule.md for a step-by-step rule walkthrough.

License

MIT — see LICENSE.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

safemigrate_lint-1.1.3.tar.gz (219.2 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

safemigrate_lint-1.1.3-py3-none-any.whl (82.4 kB view details)

Uploaded Python 3

File details

Details for the file safemigrate_lint-1.1.3.tar.gz.

File metadata

  • Download URL: safemigrate_lint-1.1.3.tar.gz
  • Upload date:
  • Size: 219.2 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.11.16 {"installer":{"name":"uv","version":"0.11.16","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

File hashes

Hashes for safemigrate_lint-1.1.3.tar.gz
Algorithm Hash digest
SHA256 d92e2a6ce1fe36834a2d0a2f9c8a86a65d82930584a3fb771df750d5ace34f37
MD5 6091db1bdd20beb92d2778487e8df80d
BLAKE2b-256 7eced12710dfc6d3628ecd91438f43f238109c0351d6f9e8fd05a0cedeab8e99

See more details on using hashes here.

File details

Details for the file safemigrate_lint-1.1.3-py3-none-any.whl.

File metadata

  • Download URL: safemigrate_lint-1.1.3-py3-none-any.whl
  • Upload date:
  • Size: 82.4 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.11.16 {"installer":{"name":"uv","version":"0.11.16","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

File hashes

Hashes for safemigrate_lint-1.1.3-py3-none-any.whl
Algorithm Hash digest
SHA256 3f96a1fe1e0457879d1a8a66d7cb6a7f5638e80624c18d379243245f50dec3f8
MD5 99212fa8925e9699a984f447cc37dc7e
BLAKE2b-256 45091f183d5251b4a4c279dab2fe7ee341f1dafb6819ecaa7c937c1a5d196642

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page