Skip to content
Use this GitHub action with your project
Add this Action to an existing workflow or create a new one
View on Marketplace

Repository files navigation

safe-migrate

safe-migrate checks PostgreSQL migrations against a synchronized database baseline. Run safe-migrate sync, then use lint or lint-chain offline to simulate migrations against the captured state.

safe-migrate is a review aid, not a substitute for testing migrations on a representative database or planning application rollouts and backfills.

Install

With Rust

If Rust is installed:

cargo install safe-migrate --locked
safe-migrate --version

Prebuilt binary

The installer selects a supported Linux, macOS, Windows/MSYS, or Termux target and verifies the release checksum:

curl -fsSL https://raw.githubusercontent.com/dsecurity49/safe-migrate/main/install.sh | bash
safe-migrate --version

To pin the installer and binary to one release:

VERSION='<release-tag>'
BASE_URL='https://raw.githubusercontent.com/dsecurity49/safe-migrate'
curl -fsSL "${BASE_URL}/${VERSION}/install.sh" |
  bash -s -- --version "${VERSION}"

Run bash install.sh --help after downloading the installer for pinning and destination options. Archives, checksums, and manual downloads are available on the GitHub Releases page.

Quick start

Create a database baseline, then lint one migration or a directory of ordered migrations:

export DATABASE_URL='postgres://readonly_user:password@localhost:5432/app'

safe-migrate sync
safe-migrate cache inspect
safe-migrate lint --file migrations/001_add_status.sql
safe-migrate lint-chain --dir migrations/

sync writes .safe-migrate.cache by default. Use a least-privilege database role that can read the required PostgreSQL catalogs, and keep DATABASE_URL out of source control.

Direct remote connections are rejected. Connect through localhost, a Unix socket, or an SSH tunnel:

ssh -N -L 5433:db.internal:5432 bastion
export DATABASE_URL='postgres://readonly_user@localhost:5433/app'
safe-migrate sync

lint and lint-chain do not connect to PostgreSQL unless auto_sync = true is configured.

--no-cache runs the parser and state machine without a verified database baseline. Findings use Tainted confidence, meaning some database evidence is missing, and settings such as migration timeouts remain unknown.

What sync provides

SQL alone cannot show the existing schema, table statistics, dependencies, role and search-path context, or inherited timeout settings. sync captures that baseline once, so later lint runs are offline and review the same state.

The cache records schema, dependency, privilege, and statistics metadata, but never connection credentials or password hashes. Treat it as sensitive and do not publish it.

Sync with the database, role, and defaults used by the migration runner; it only reads them. Refresh the cache when that baseline changes. If the runner does not already enforce timeouts, put them explicitly in the migration:

SET lock_timeout = '5s';
SET statement_timeout = '15min';

lock_timeout should be positive and shorter than a positive statement_timeout; otherwise PostgreSQL can reach the statement timeout first.

Commands

Command Use it for Important options
lint --file migration.sql Check one migration. --cache, --config, --no-cache, --json, --markdown
lint-chain --dir migrations/ Check an ordered migration directory while carrying state forward. --cache, --config, --no-cache, --json, --markdown
sync Refresh the database baseline. --out, --schemas, --config
cache inspect Show cache provenance and redacted object counts. --cache, --json
rules Discover rules, remediation, and effective settings. --rule, --json, --config

--no-auto-sync suppresses configured automatic refresh for one lint or lint-chain run. --no-color works with every command.

Use the CLI for less common options and subcommands:

safe-migrate --help
safe-migrate <command> --help

Machine-readable output, confidence values, and exit codes are defined in the CLI and report contract.

Rule discovery

safe-migrate rules lists rule IDs, tiers, remediation, supported configuration fields, and effective settings. Rule discovery JSON uses schema version 2; lint JSON uses schema version 1.

safe-migrate rules
safe-migrate rules --rule require-concurrent-index
safe-migrate rules --rule require-concurrent-index --json

Unknown IDs are errors. Pass --config to include settings from a TOML file.

Chain analysis

lint-chain analyzes files in filename order and carries modeled schema, transaction, search-path, and role state across statements and files. This can catch failures caused by interactions between otherwise valid migrations.

Migration timeouts

The Tier 2 require-lock-timeout and require-statement-timeout rules apply to statements that Squawk classifies as potentially disruptive to normal database queries. They use the synchronized values and follow ordered SQL changes from SET, SET LOCAL, SET ... DEFAULT, RESET, and RESET ALL, including commit, rollback, and savepoint scope. A missing baseline is reported as unknown evidence rather than silently treated as a configured timeout.

Findings and exit status

Findings use three tiers:

Tier Meaning
Tier 1 — HALT Fix before deployment.
Tier 2 — WARN Review required.
Tier 3 — SAFE Informational or lower-risk.

Reports also include confidence:

Confidence Meaning
Exact Analysis stayed consistent with the supplied SQL and baseline.
Tainted Baseline evidence or modeled state is incomplete or uncertain.

Exact means exact relative to the modeled evidence; it is not a production deployment guarantee.

  • Exit 0: analysis completed without a Tier 1 finding.
  • Exit 1: invocation, configuration, parser, cache, I/O, or internal failure.
  • Exit 2: analysis completed with at least one Tier 1 finding.

Diagnostics go to standard error. JSON and Markdown reports go to standard output.

Configuration

Without --config, the CLI reads safe-migrate.toml from the current directory when it exists and otherwise uses built-in defaults. A path passed with --config must exist and pass validation.

# Lock-sensitive size thresholds.
tier1_threshold_rows = 100000
tier2_threshold_rows = 10000
default_rows = 10000
toast_width_threshold_bytes = 2048

# Cache policy.
stale_stats_days = 7
schemas = ["public", "auth"]
auto_sync = false
cache_encryption = false

# Used only when no cache supplies a PostgreSQL version.
assume_pg_version = 100000

# Primary rule IDs disabled globally.
disabled_rules = ["missing-idempotency"]

[rules.blocking-constraint]
tier1_threshold_rows = 5000
tier2_threshold_rows = 1000

[rules.missing-idempotency]
disabled = true

Every primary rule supports disabled; only row-sensitive rules support one or both threshold fields. safe-migrate rules --json lists the supported fields for each rule. Unknown settings, unsupported fields, and unknown primary rule IDs are errors.

Suppressions

Use a primary rule ID in a SQL comment to suppress that rule for one statement or the whole file:

-- safe-migrate: ignore(require-concurrent-index)
CREATE INDEX users_email_idx ON users (email);

/* safe-migrate: ignore-file(missing-idempotency) */

Keep suppressions narrow and explain the operational reason in the migration or its review.

Automatic sync

auto_sync = true refreshes the cache before lint and lint-chain. There is no command-line flag to enable it. Use --no-auto-sync to suppress it for one lint run. If refresh fails, safe-migrate prints the cause and continues with the previous readable cache; the old cache is replaced only after a new cache has been written successfully. --no-cache also bypasses automatic sync. The existing cache must use a supported format; otherwise rerun sync once database access is available.

Cache encryption

Set cache_encryption = true and provide a 32-byte key as 64 hexadecimal characters:

export SAFE_MIGRATE_CACHE_KEY='64 hexadecimal characters'
safe-migrate sync

The key is accepted only through the environment. Encrypted mode rejects plaintext caches, and plaintext mode rejects encrypted caches. Changing modes requires a fresh sync.

Cache compatibility

When safe-migrate encounters an unsupported cache format, rebuild it from the database:

safe-migrate sync

Cache formats are checked before use. If a format is unsupported, rebuild the cache with safe-migrate sync; no migration SQL is changed.

Use safe-migrate cache inspect to view cache provenance and redacted object and role counts without connecting to PostgreSQL. It never lists role names or membership edges.

GitHub Actions

The Action uses a baseline: one cache file containing a snapshot of your database metadata. The Action manages that file and its GitHub cache entry for you.

Trusted default-branch job
PostgreSQL -> sync -> runner baseline file -> GitHub Actions cache

Pull-request job
GitHub Actions cache -> runner baseline file -> lint-chain -> reports

The trusted workflow refreshes the baseline. Pull-request workflows restore it and run lint-chain without connecting to PostgreSQL.

1. Refresh the baseline

Run this after checkout in a trusted default-branch workflow. PostgreSQL must be reachable through localhost or a Unix socket; keep its URL in a secret. We recommend encrypting the saved baseline: it contains schema and role metadata, and GitHub cache contents are not signed. Store a 64-character hexadecimal key as SAFE_MIGRATE_CACHE_KEY and pass it to both workflows.

- uses: dsecurity49/safe-migrate@v0.7.0
  env:
    DATABASE_URL: ${{ secrets.SAFE_MIGRATE_DATABASE_URL }}
    SAFE_MIGRATE_CACHE_KEY: ${{ secrets.SAFE_MIGRATE_CACHE_KEY }}
  with:
    path: migrations
    sync: "true"
    schemas: public
    encrypted-cache: "true"

Replace public with the schemas that contain your migrations, or omit schemas to synchronize all non-system schemas.

2. Lint pull requests without syncing

Add this after checkout in the pull-request workflow:

- uses: dsecurity49/safe-migrate@v0.7.0
  env:
    SAFE_MIGRATE_CACHE_KEY: ${{ secrets.SAFE_MIGRATE_CACHE_KEY }}
  with:
    path: migrations
    encrypted-cache: "true"

Do not set sync: "true" here, and do not add actions/cache. The Action restores the baseline itself, passes it to lint-chain, and publishes the report. Fork pull requests do not receive the encryption key, so they lint without the baseline and report Tainted confidence.

For TOML configuration, encrypted caches, named baselines, and complete workflows, see the GitHub Action guide.

License

Dual-licensed under MIT or Apache-2.0.

About

A CI/CD infrastructure tool that prevents PostgreSQL database migrations from causing catastrophic locks in production.

Topics

Resources

Contributing

Stars

26 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages