name: CI
on:
workflow_call:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
jobs:
test:
name: Build and Test
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with:
persist-credentials: false
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 with:
components: clippy, rustfmt
- name: Cache dependencies
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6
- name: Check formatting
run: cargo fmt -- --check
- name: Verify installer contract
run: sh scripts/test-install-dry-run
- name: Verify Action shell contracts
run: sh scripts/test-action-contract
- name: Build (locked)
run: cargo build --locked --verbose
- name: Verify frozen-cache fixtures
working-directory: live_tests
run: ./run.sh
- name: Run tests (locked)
run: cargo test --locked --verbose
- name: Verify crate package
run: cargo package --locked
- name: Run generated migration fuzz corpus
run: scripts/fuzz
- name: Run Clippy (Linter)
run: cargo clippy --all-targets --locked -- -D warnings
- name: Install and run cargo-audit
run: |
cargo install cargo-audit --version 0.22.2 --locked
test "$(cargo-audit --version)" = "cargo-audit 0.22.2"
cargo audit
msrv:
name: Rust 1.94 MSRV
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with:
persist-credentials: false
- name: Install Rust 1.94
uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 with:
toolchain: 1.94.0
- name: Check all targets with the MSRV
run: cargo check --all-targets --locked
platform-smoke:
name: Runtime smoke (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with:
persist-credentials: false
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87
- name: Build runtime
run: cargo build --locked
- name: Exercise CLI runtime
run: cargo test --locked --test cli_tests test_cli_help
live-differential:
name: PostgreSQL ${{ matrix.postgres }} differential harness
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- postgres: "14"
image: postgres:14@sha256:156f0b253fd61366d5fc2107ad45955027d5612f695a8436ce20167f3fa79bff
- postgres: "15"
image: postgres:15@sha256:9b1d34adbce1dd07ee6e94b4a2cf698884b89bd44a6c9c12f5da8f3acbfe4957
- postgres: "16"
image: postgres:16@sha256:f1c3376c26f2609ab9f29f71f824103fe2fcd8ee0346485cb6122a4f93df6f94
- postgres: "17"
image: postgres:17@sha256:67f41722b7a8cbdb868a44a4995c846eddfdc2973bccb291ce937dce88ad5675
- postgres: "18"
image: postgres:18@sha256:4ef4dbc939d61acea57712655ddb4b4ab27419c913f94cca0cd57cb3ea3c2280
services:
postgres:
image: ${{ matrix.image }}
env:
POSTGRES_DB: safe_migrate
POSTGRES_USER: safe_migrate
POSTGRES_PASSWORD: safe_migrate
options: >-
--health-cmd "pg_isready -U safe_migrate -d safe_migrate"
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
env:
DATABASE_URL: postgres://safe_migrate:safe_migrate@localhost:5432/safe_migrate
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with:
persist-credentials: false
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87
- name: Cache dependencies
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6
- name: Reject an unreachable live database
shell: bash
env:
DATABASE_URL: postgres://127.0.0.1:1/postgres?connect_timeout=1
run: |
set +e
output="$(scripts/live-differential --rule rule_02_drop-database 2>&1)"
status=$?
set -e
printf '%s\n' "$output"
test "$status" -ne 0
grep -q 'requires reachable PostgreSQL' <<< "$output"
- name: Verify successful automatic synchronization
run: scripts/live-auto-sync
- name: Verify encrypted cache CLI contract
run: scripts/live-cache-encryption
- name: Verify routine and replication catalog synchronization
run: scripts/live-catalog-sync
- name: Compare routine and replication state with PostgreSQL
run: scripts/live-catalog-differential
- name: Compare simulator state with PostgreSQL
shell: bash
run: |
set -o pipefail
scripts/live-differential -vv 2>&1 | tee "live-differential-postgres-${{ matrix.postgres }}.log"
- name: Upload differential log
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with:
name: live-differential-postgres-${{ matrix.postgres }}
path: live-differential-postgres-${{ matrix.postgres }}.log
if-no-files-found: error
action-smoke:
name: Reusable Action smoke test
runs-on: ubuntu-latest
services:
postgres:
image: postgres:18@sha256:4ef4dbc939d61acea57712655ddb4b4ab27419c913f94cca0cd57cb3ea3c2280
env:
POSTGRES_DB: safe_migrate
POSTGRES_USER: safe_migrate
POSTGRES_PASSWORD: safe_migrate
options: >-
--health-cmd "pg_isready -U safe_migrate -d safe_migrate"
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with:
persist-credentials: false
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87
- name: Create Action smoke fixtures
id: sync_fixture
shell: bash
run: |
migration_path="$RUNNER_TEMP/action-smoke.sql"
auto_sync_config="$RUNNER_TEMP/action-auto-sync.toml"
auto_sync_encrypted_config="$RUNNER_TEMP/action-auto-sync-encrypted.toml"
printf '%s\n' 'CREATE SCHEMA action_smoke;' > "$migration_path"
printf '%s\n' 'auto_sync = true' > "$auto_sync_config"
printf '%s\n' \
'auto_sync = true' \
'cache_encryption = true' \
> "$auto_sync_encrypted_config"
printf '%s\n' "migration=$migration_path" >> "$GITHUB_OUTPUT"
printf '%s\n' "auto-sync-config=$auto_sync_config" >> "$GITHUB_OUTPUT"
printf '%s\n' "auto-sync-encrypted-config=$auto_sync_encrypted_config" \
>> "$GITHUB_OUTPUT"
- name: Synchronize through the Action
id: synchronized
uses: ./
env:
DATABASE_URL: "host=localhost port=5432 user=safe_migrate password=safe_migrate dbname=safe_migrate options='-c lock_timeout=5s -c statement_timeout=15min'"
SAFE_MIGRATE_CACHE_KEY: "1111111111111111111111111111111111111111111111111111111111111111"
with:
config: ${{ steps.sync_fixture.outputs.auto-sync-encrypted-config }}
sync: "true"
schemas: public
baseline: action-smoke
encrypted-cache: "true"
output-dir: action-synchronized-artifacts
- name: Remove the synchronized local file
shell: bash
env:
CACHE_PATH: ${{ steps.synchronized.outputs.cache-path }}
SYNC_STATUS: ${{ steps.synchronized.outputs.sync-status }}
BASELINE_SOURCE: ${{ steps.synchronized.outputs.baseline-source }}
JSON_REPORT: ${{ steps.synchronized.outputs.json-report }}
run: |
test "$SYNC_STATUS" = "refreshed"
test "$BASELINE_SOURCE" = "synced"
test "$CACHE_PATH" = "$HOME/.cache/safe-migrate-action/baselines/action-smoke/baseline-v7.cache"
test -z "$JSON_REPORT"
rm -f -- "$CACHE_PATH"
- name: Restore and analyze without database access
id: restored
uses: ./
env:
SAFE_MIGRATE_CACHE_KEY: "1111111111111111111111111111111111111111111111111111111111111111"
with:
mode: lint
path: ${{ steps.sync_fixture.outputs.migration }}
config: ${{ steps.sync_fixture.outputs.auto-sync-encrypted-config }}
baseline: action-smoke
encrypted-cache: "true"
output-dir: action-restored-artifacts
- name: Verify synchronized timeout evidence
shell: bash
env:
JSON_REPORT: ${{ steps.restored.outputs.json-report }}
EXIT_CODE: ${{ steps.restored.outputs.exit-code }}
SYNC_STATUS: ${{ steps.restored.outputs.sync-status }}
BASELINE_SOURCE: ${{ steps.restored.outputs.baseline-source }}
DIAGNOSTIC_LOG: ${{ steps.restored.outputs.diagnostic-log }}
run: |
test "$EXIT_CODE" = "0"
test "$SYNC_STATUS" = "not-requested"
test "$BASELINE_SOURCE" = "github-cache"
jq -e \
'.baseline.status == "available"
and .baseline.observed_settings.lock_timeout_ms == 5000
and .baseline.observed_settings.statement_timeout_ms == 900000
and ([.violations[].rule_id] | index("require-lock-timeout") | not)
and ([.violations[].rule_id] | index("require-statement-timeout") | not)' \
"$JSON_REPORT"
jq -e '.baseline.auto_sync == "bypassed"' "$JSON_REPORT"
grep -q -- '--no-auto-sync bypasses configured automatic cache sync' \
"$DIAGNOSTIC_LOG"
! grep -q 'Automatic cache sync enabled' "$DIAGNOSTIC_LOG"
- name: Reject an encrypted baseline without its key
id: encrypted_key_missing
continue-on-error: true
uses: ./
with:
mode: lint
path: ${{ steps.sync_fixture.outputs.migration }}
baseline: action-smoke
encrypted-cache: "true"
output-dir: action-encrypted-key-missing-artifacts
- name: Assert encrypted baseline key is required
shell: bash
env:
OUTCOME: ${{ steps.encrypted_key_missing.outcome }}
run: |
test "$OUTCOME" = "failure"
- name: Reject a missing synchronized baseline
id: missing_baseline
continue-on-error: true
uses: ./
env:
SAFE_MIGRATE_CACHE_KEY: "1111111111111111111111111111111111111111111111111111111111111111"
with:
mode: lint
path: ${{ steps.sync_fixture.outputs.migration }}
config: ${{ steps.sync_fixture.outputs.auto-sync-encrypted-config }}
baseline: action-missing-${{ github.run_id }}-${{ github.run_attempt }}
output-dir: action-missing-baseline-artifacts
- name: Assert missing-baseline failure contract
shell: bash
env:
DIAGNOSTIC_LOG: ${{ steps.missing_baseline.outputs.diagnostic-log }}
EXIT_CODE: ${{ steps.missing_baseline.outputs.exit-code }}
BASELINE_SOURCE: ${{ steps.missing_baseline.outputs.baseline-source }}
OUTCOME: ${{ steps.missing_baseline.outcome }}
run: |
test "$OUTCOME" = "failure"
test "$EXIT_CODE" = "1"
test "$BASELINE_SOURCE" = "unavailable"
grep -q 'No synchronized baseline was restored' "$DIAGNOSTIC_LOG"
- name: Generate review artifacts with the local Action
id: safe_migrate
uses: ./
with:
mode: lint
path: live_tests/rule_01_irreversible-migration/safe_002_add_col.sql
no-cache: "true"
output-dir: action-smoke-artifacts
- name: Reject a missing explicit cache
id: missing_explicit_cache
continue-on-error: true
uses: ./
env:
SAFE_MIGRATE_CACHE_KEY: "1111111111111111111111111111111111111111111111111111111111111111"
with:
mode: lint
path: ${{ steps.sync_fixture.outputs.migration }}
cache: ${{ runner.temp }}/missing-explicit.cache
output-dir: action-missing-explicit-cache-artifacts
- name: Assert missing explicit cache is operational
shell: bash
env:
OUTCOME: ${{ steps.missing_explicit_cache.outcome }}
EXIT_CODE: ${{ steps.missing_explicit_cache.outputs.exit-code }}
DIAGNOSTIC_LOG: ${{ steps.missing_explicit_cache.outputs.diagnostic-log }}
run: |
test "$OUTCOME" = "failure"
test "$EXIT_CODE" = "1"
grep -q 'Explicit cache does not exist or is not a file' \
"$DIAGNOSTIC_LOG"
- name: Verify generated artifacts
shell: bash
env:
JSON_REPORT: ${{ steps.safe_migrate.outputs.json-report }}
MARKDOWN_REPORT: ${{ steps.safe_migrate.outputs.markdown-report }}
EXIT_CODE: ${{ steps.safe_migrate.outputs.exit-code }}
run: |
test -s "$JSON_REPORT"
test -s "$MARKDOWN_REPORT"
test "$EXIT_CODE" = "0"
- name: Plant untrusted workspace configuration
shell: bash
run: |
printf '%s\n' '[rules.drop-database]' 'disabled = true' > safe-migrate.toml
- name: Verify Action defaults ignore workspace configuration
id: untrusted_config
continue-on-error: true
uses: ./
with:
mode: lint
path: live_tests/rule_02_drop-database/001_drop_db.sql
no-cache: "true"
output-dir: action-untrusted-config-artifacts
- name: Assert security rule remained enabled
shell: bash
env:
OUTCOME: ${{ steps.untrusted_config.outcome }}
EXIT_CODE: ${{ steps.untrusted_config.outputs.exit-code }}
JSON_REPORT: ${{ steps.untrusted_config.outputs.json-report }}
run: |
test "$OUTCOME" = "failure"
test "$EXIT_CODE" = "2"
grep -q '"rule_id": "drop-database"' \
"$JSON_REPORT"
- name: Reject encryption mode that disagrees with explicit config
id: encryption_config_mismatch
continue-on-error: true
uses: ./
env:
SAFE_MIGRATE_CACHE_KEY: "1111111111111111111111111111111111111111111111111111111111111111"
with:
mode: lint
path: live_tests/rule_01_irreversible-migration/safe_002_add_col.sql
config: safe-migrate.toml
baseline: action-smoke
encrypted-cache: "true"
output-dir: action-encryption-config-mismatch-artifacts
- name: Assert encryption mismatch is operational
shell: bash
env:
OUTCOME: ${{ steps.encryption_config_mismatch.outcome }}
EXIT_CODE: ${{ steps.encryption_config_mismatch.outputs.exit-code }}
DIAGNOSTIC_LOG: ${{ steps.encryption_config_mismatch.outputs.diagnostic-log }}
run: |
test "$OUTCOME" = "failure"
test "$EXIT_CODE" = "1"
grep -q 'encrypted-cache requires cache_encryption = true' \
"$DIAGNOSTIC_LOG"
- name: Verify advisory mode preserves the analyzer status
id: advisory
uses: ./
with:
mode: lint
path: live_tests/rule_02_drop-database/001_drop_db.sql
no-cache: "true"
advisory: "true"
output-dir: action-advisory-artifacts
- name: Assert advisory mode succeeded with blocking findings
shell: bash
env:
EXIT_CODE: ${{ steps.advisory.outputs.exit-code }}
JSON_REPORT: ${{ steps.advisory.outputs.json-report }}
run: |
test "$EXIT_CODE" = "2"
test -s "$JSON_REPORT"
grep -q '"tier": "Tier1"' "$JSON_REPORT"
- name: Verify a missing explicit config produces diagnostic artifacts
id: operational_error
continue-on-error: true
uses: ./
with:
mode: lint
path: live_tests/rule_01_irreversible-migration/safe_002_add_col.sql
config: missing-safe-migrate.toml
no-cache: "true"
output-dir: action-operational-error-artifacts
- name: Assert operational error artifact contract
shell: bash
env:
OUTCOME: ${{ steps.operational_error.outcome }}
EXIT_CODE: ${{ steps.operational_error.outputs.exit-code }}
JSON_REPORT: ${{ steps.operational_error.outputs.json-report }}
MARKDOWN_REPORT: ${{ steps.operational_error.outputs.markdown-report }}
DIAGNOSTIC_LOG: ${{ steps.operational_error.outputs.diagnostic-log }}
run: |
test "$OUTCOME" = "failure"
test "$EXIT_CODE" = "1"
test -s "$JSON_REPORT"
test -s "$MARKDOWN_REPORT"
test -s "$DIAGNOSTIC_LOG"
jq -e \
'.status == "operational_error" and .exit_code == 1' \
"$JSON_REPORT"
grep -q 'explicit config does not exist or is not a file' \
"$DIAGNOSTIC_LOG"