name: safe-migrate
description: Lint PostgreSQL migrations offline against a synchronized database baseline.
author: safe-migrate contributors
inputs:
mode:
description: Analyze one migration (`lint`) or an ordered migration directory (`lint-chain`).
required: false
default: lint-chain
path:
description: Migration file for `lint` or directory for `lint-chain`.
required: true
cache:
description: Explicit trusted Cache V6 path; disables managed GitHub cache restore and save.
required: false
default: ""
config:
description: Explicitly trusted safe-migrate TOML path. Empty uses built-in defaults and never reads workspace configuration.
required: false
default: ""
sync:
description: Refresh from DATABASE_URL in this trusted job, then remove database access before linting.
required: false
default: "false"
schemas:
description: Optional comma-separated schema scope passed to sync. Requires sync to be true.
required: false
default: ""
baseline:
description: Logical managed-cache name; use different names for different target databases.
required: false
default: default
encrypted-cache:
description: Require authenticated cache encryption; missing keys fall back to Tainted linting and cannot sync.
required: false
default: "false"
no-cache:
description: Bypass every baseline for a Tainted preview; incompatible with cache, sync, schemas, and encryption.
required: false
default: "false"
output-dir:
description: Directory for `safe-migrate-report.json` and `safe-migrate-report.md`.
required: false
default: safe-migrate-artifacts
advisory:
description: Report blocking findings without failing the job. Operational errors still fail.
required: false
default: "false"
outputs:
json-report:
description: Path to the generated JSON report.
value: ${{ steps.analysis.outputs.json-report }}
markdown-report:
description: Path to the generated Markdown report.
value: ${{ steps.analysis.outputs.markdown-report }}
exit-code:
description: "Analyzer exit status: 0 completed, 1 operational failure, 2 blocking findings."
value: ${{ steps.analysis.outputs.exit-code }}
diagnostic-log:
description: Path to analyzer diagnostics, including the reason for operational failures.
value: ${{ steps.analysis.outputs.diagnostic-log }}
cache-path:
description: Resolved cache path used by the Action.
value: ${{ steps.baseline.outputs.cache-path }}
sync-status:
description: "Synchronization outcome: not-requested, refreshed, or failed."
value: ${{ steps.analysis.outputs.sync-status }}
baseline-source:
description: "Baseline source: synced, github-cache, explicit-file, or unavailable."
value: ${{ steps.analysis.outputs.baseline-source }}
runs:
using: composite
steps:
- name: Resolve baseline inputs
id: baseline
shell: bash
env:
DATABASE_URL: ""
INPUT_MODE: ${{ inputs.mode }}
INPUT_PATH: ${{ inputs.path }}
INPUT_CACHE: ${{ inputs.cache }}
INPUT_CONFIG: ${{ inputs.config }}
INPUT_SYNC: ${{ inputs.sync }}
INPUT_SCHEMAS: ${{ inputs.schemas }}
INPUT_BASELINE: ${{ inputs.baseline }}
INPUT_ENCRYPTED_CACHE: ${{ inputs.encrypted-cache }}
INPUT_NO_CACHE: ${{ inputs.no-cache }}
INPUT_OUTPUT_DIR: ${{ inputs.output-dir }}
INPUT_ADVISORY: ${{ inputs.advisory }}
run: |
set -euo pipefail
if [ "$INPUT_ENCRYPTED_CACHE" = true ] && \
[ -n "${SAFE_MIGRATE_CACHE_KEY:-}" ] && \
[[ ! "$SAFE_MIGRATE_CACHE_KEY" =~ ^[0-9A-Fa-f]{64}$ ]]; then
echo "SAFE_MIGRATE_CACHE_KEY must contain exactly 64 hexadecimal characters" >&2
exit 1
fi
if [ -n "${SAFE_MIGRATE_CACHE_KEY:-}" ]; then
key_available=true
else
key_available=false
fi
cd "$GITHUB_WORKSPACE"
/bin/sh "$GITHUB_ACTION_PATH/scripts/action-baseline" validate \
"$INPUT_SYNC" "$INPUT_NO_CACHE" "$INPUT_ENCRYPTED_CACHE" \
"$key_available" "$INPUT_CACHE" "$INPUT_SCHEMAS" \
"$INPUT_BASELINE" "$INPUT_MODE" "$INPUT_ADVISORY" \
"$INPUT_PATH" "$INPUT_CONFIG" "$INPUT_OUTPUT_DIR"
cache_transport_path=""
if [ -n "$INPUT_CACHE" ]; then
cache_path="$INPUT_CACHE"
elif [ "$INPUT_NO_CACHE" != true ]; then
# The literal ~ path keeps the cache version stable across runners;
# the analyzer receives the corresponding absolute path.
managed_root="${HOME}/.cache/safe-migrate-action"
if [ -L "$managed_root" ] || \
{ [ -e "$managed_root" ] && [ ! -d "$managed_root" ]; }; then
echo "Managed cache root must be a directory, not a symlink: $managed_root" >&2
exit 1
fi
mkdir -p -- "$managed_root"
baseline_root="${managed_root}/baselines"
if [ -L "$baseline_root" ] || \
{ [ -e "$baseline_root" ] && [ ! -d "$baseline_root" ]; }; then
echo "Managed baseline root must be a directory, not a symlink: $baseline_root" >&2
exit 1
fi
mkdir -p -- "$baseline_root"
cache_dir="${baseline_root}/${INPUT_BASELINE}"
rm -rf -- "$cache_dir"
mkdir -p -- "$cache_dir"
cache_path="${cache_dir}/baseline-v6.cache"
cache_transport_path="~/.cache/safe-migrate-action/baselines/${INPUT_BASELINE}/baseline-v6.cache"
else
cache_root="${RUNNER_TEMP}/safe-migrate-action"
mkdir -p "$cache_root"
cache_dir="$(mktemp -d "${cache_root}/invocation.XXXXXX")"
cache_path="${cache_dir}/baseline-v6.cache"
fi
printf '%s\n' "cache-path=${cache_path}" >> "$GITHUB_OUTPUT"
printf '%s\n' "cache-transport-path=${cache_transport_path}" >> "$GITHUB_OUTPUT"
if [ "$INPUT_ENCRYPTED_CACHE" != true ] || [ "$key_available" = true ]; then
printf '%s\n' 'baseline-readable=true' >> "$GITHUB_OUTPUT"
else
printf '%s\n' 'baseline-readable=false' >> "$GITHUB_OUTPUT"
fi
if [ -z "$INPUT_CACHE" ] && [ "$INPUT_NO_CACHE" != true ]; then
if [ "$INPUT_ENCRYPTED_CACHE" = true ]; then
cache_mode=encrypted
else
cache_mode=plaintext
fi
cache_prefix="safe-migrate-v6-${RUNNER_OS}-${cache_mode}-${INPUT_BASELINE}-"
printf '%s\n' "cache-prefix=${cache_prefix}" >> "$GITHUB_OUTPUT"
printf '%s\n' \
"cache-primary-key=${cache_prefix}${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" \
>> "$GITHUB_OUTPUT"
fi
- name: Resolve Action installation
id: action-install
shell: bash
env:
ACTION_REF: ${{ github.action_ref }}
DATABASE_URL: ""
SAFE_MIGRATE_CACHE_KEY: ""
run: |
set -euo pipefail
if [ "${RUNNER_OS}:${RUNNER_ARCH}" = "Windows:ARM64" ]; then
echo "Windows ARM64 runners are not supported because no release artifact is published" >&2
exit 1
fi
if [ -z "$ACTION_REF" ]; then
resolved=source
else
resolved="$(/bin/sh "$GITHUB_ACTION_PATH/scripts/action-resolve-version" \
"$ACTION_REF" "$GITHUB_ACTION_PATH/Cargo.toml")"
fi
if [ "$resolved" = source ]; then
printf '%s\n' 'install-mode=source' >> "$GITHUB_OUTPUT"
printf '%s\n' 'release-version=' >> "$GITHUB_OUTPUT"
else
printf '%s\n' 'install-mode=release' >> "$GITHUB_OUTPUT"
printf '%s\n' "release-version=${resolved}" >> "$GITHUB_OUTPUT"
fi
- name: Restore synchronized baseline
id: restore-baseline
if: ${{ inputs.cache == '' && inputs.no-cache != 'true' && steps.baseline.outputs.baseline-readable == 'true' }}
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 env:
DATABASE_URL: ""
SAFE_MIGRATE_CACHE_KEY: ""
with:
path: ${{ steps.baseline.outputs.cache-transport-path }}
key: ${{ steps.baseline.outputs.cache-primary-key }}
restore-keys: ${{ steps.baseline.outputs.cache-prefix }}
- name: Install Rust
if: ${{ steps.action-install.outputs.install-mode == 'source' }}
uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 env:
DATABASE_URL: ""
SAFE_MIGRATE_CACHE_KEY: ""
- name: Install safe-migrate
id: install
shell: bash
env:
INSTALL_MODE: ${{ steps.action-install.outputs.install-mode }}
RELEASE_VERSION: ${{ steps.action-install.outputs.release-version }}
DATABASE_URL: ""
SAFE_MIGRATE_CACHE_KEY: ""
run: |
set -euo pipefail
install_root="${RUNNER_TEMP}/safe-migrate-action"
mkdir -p "$install_root"
case "$INSTALL_MODE" in
source)
cargo install --path "$GITHUB_ACTION_PATH" --locked --root "$install_root/source"
binary="${install_root}/source/bin/safe-migrate"
;;
release)
[ -n "$RELEASE_VERSION" ] || {
echo "Resolved release version is empty" >&2
exit 1
}
case "${RUNNER_OS}:${RUNNER_ARCH}" in
Linux:X64) target=x86_64-unknown-linux-gnu ;;
Linux:ARM64) target=aarch64-unknown-linux-gnu ;;
macOS:X64) target=x86_64-apple-darwin ;;
macOS:ARM64) target=aarch64-apple-darwin ;;
Windows:X64) target=x86_64-pc-windows-msvc ;;
*) echo "Unsupported GitHub runner: ${RUNNER_OS}/${RUNNER_ARCH}" >&2; exit 1 ;;
esac
/bin/sh "$GITHUB_ACTION_PATH/install.sh" \
--version "$RELEASE_VERSION" \
--target "$target" \
--install-dir "$install_root" \
--force
binary="${install_root}/safe-migrate"
;;
*)
echo "Unsupported Action installation mode: $INSTALL_MODE" >&2
exit 1
;;
esac
if [ "$RUNNER_OS" = "Windows" ]; then
binary="${binary}.exe"
fi
[ -x "$binary" ] || { echo "Installed binary is not executable: $binary" >&2; exit 1; }
printf '%s\n' "binary=${binary}" >> "$GITHUB_OUTPUT"
- name: Analyze migrations
id: analysis
continue-on-error: true
shell: bash
env:
INPUT_MODE: ${{ inputs.mode }}
INPUT_PATH: ${{ inputs.path }}
INPUT_CACHE: ${{ inputs.cache }}
INPUT_CONFIG: ${{ inputs.config }}
INPUT_SYNC: ${{ inputs.sync }}
INPUT_SCHEMAS: ${{ inputs.schemas }}
INPUT_ENCRYPTED_CACHE: ${{ inputs.encrypted-cache }}
INPUT_NO_CACHE: ${{ inputs.no-cache }}
INPUT_OUTPUT_DIR: ${{ inputs.output-dir }}
RESOLVED_CACHE: ${{ steps.baseline.outputs.cache-path }}
RESTORED_CACHE_KEY: ${{ steps.restore-baseline.outputs.cache-matched-key }}
BASELINE_READABLE: ${{ steps.baseline.outputs.baseline-readable }}
SAFE_MIGRATE_BINARY: ${{ steps.install.outputs.binary }}
run: |
set -euo pipefail
cd "$GITHUB_WORKSPACE"
mkdir -p -- "$INPUT_OUTPUT_DIR"
binary="$SAFE_MIGRATE_BINARY"
json_report="${INPUT_OUTPUT_DIR}/safe-migrate-report.json"
markdown_report="${INPUT_OUTPUT_DIR}/safe-migrate-report.md"
diagnostic_log="${INPUT_OUTPUT_DIR}/safe-migrate-diagnostics.log"
: > "$diagnostic_log"
preflight_failed=false
if [ -n "$INPUT_CONFIG" ]; then
config_path="$INPUT_CONFIG"
if ! /bin/sh "$GITHUB_ACTION_PATH/scripts/action-baseline" \
validate-config "$config_path" "$INPUT_ENCRYPTED_CACHE" \
2> >(tee -a "$diagnostic_log" >&2); then
preflight_failed=true
fi
else
config_path="$(mktemp "${RUNNER_TEMP}/safe-migrate-default.XXXXXX")"
if [ "$INPUT_ENCRYPTED_CACHE" = true ]; then
printf '%s\n' 'cache_encryption = true' > "$config_path"
fi
fi
sync_status=not-requested
if [ -n "$RESTORED_CACHE_KEY" ] && [ -f "$RESOLVED_CACHE" ]; then
baseline_source=github-cache
elif [ -n "$RESTORED_CACHE_KEY" ]; then
baseline_source=unavailable
echo "GitHub reported a cache match, but the baseline file was not restored." \
| tee -a "$diagnostic_log" >&2
elif [ -n "$INPUT_CACHE" ] && [ -f "$RESOLVED_CACHE" ]; then
baseline_source=explicit-file
else
baseline_source=unavailable
fi
if [ "$BASELINE_READABLE" != true ]; then
baseline_source=unavailable
echo "Encrypted baseline key unavailable; running Tainted analysis without the baseline." \
| tee -a "$diagnostic_log" >&2
fi
if [ -n "$INPUT_CACHE" ] && [ ! -f "$RESOLVED_CACHE" ] && \
[ "$INPUT_SYNC" != true ]; then
echo "Explicit cache does not exist or is not a file: $RESOLVED_CACHE" \
| tee -a "$diagnostic_log" >&2
preflight_failed=true
fi
operational_failed=false
if [ "$preflight_failed" = true ]; then
if [ "$INPUT_SYNC" = true ]; then
sync_status=failed
fi
operational_failed=true
elif [ "$INPUT_SYNC" = true ]; then
mkdir -p "$(dirname -- "$RESOLVED_CACHE")"
set +e
/bin/sh "$GITHUB_ACTION_PATH/scripts/action-baseline" sync \
"$binary" "$RESOLVED_CACHE" "$config_path" "$INPUT_SCHEMAS" \
> >(tee -a "$diagnostic_log") \
2> >(tee -a "$diagnostic_log" >&2)
sync_exit=$?
set -e
if [ "$sync_exit" -eq 0 ]; then
sync_status=refreshed
baseline_source=synced
else
sync_status=failed
operational_failed=true
echo "safe-migrate sync failed with status ${sync_exit}; no baseline will be published" \
| tee -a "$diagnostic_log" >&2
fi
fi
command=()
if [ "$preflight_failed" != true ]; then
case "$INPUT_MODE" in
lint)
command=(lint --file "$INPUT_PATH")
;;
lint-chain)
command=(lint-chain --dir "$INPUT_PATH")
;;
*)
echo "mode must be lint or lint-chain, got: $INPUT_MODE" \
| tee -a "$diagnostic_log" >&2
;;
esac
fi
if [ "${#command[@]}" -eq 0 ]; then
final_status=1
else
command+=(--cache "$RESOLVED_CACHE" --config "$config_path" --no-auto-sync)
if [ "$INPUT_NO_CACHE" = "true" ] || [ "$BASELINE_READABLE" != true ] || \
{ [ -z "$INPUT_CACHE" ] && [ ! -f "$RESOLVED_CACHE" ]; }; then
command+=(--no-cache)
fi
set +e
env -u DATABASE_URL "$binary" "${command[@]}" --json \
> "$json_report" 2> >(tee -a "$diagnostic_log" >&2)
json_status=$?
env -u DATABASE_URL "$binary" "${command[@]}" --markdown \
> "$markdown_report" 2> >(tee -a "$diagnostic_log" >&2)
markdown_status=$?
set -e
final_status="$json_status"
if [ "$json_status" -ne "$markdown_status" ]; then
final_status=1
echo "JSON and Markdown runs returned different statuses: ${json_status} and ${markdown_status}" \
| tee -a "$diagnostic_log" >&2
fi
if [ "$operational_failed" = true ]; then
final_status=1
fi
fi
case "$final_status" in
0|2) ;;
*) final_status=1 ;;
esac
if [ "$final_status" -eq 1 ]; then
if [ ! -s "$diagnostic_log" ]; then
echo "safe-migrate failed without emitting a diagnostic" \
| tee -a "$diagnostic_log" >&2
fi
printf '%s\n' \
'{' \
' "artifact_schema_version": 1,' \
' "status": "operational_error",' \
' "exit_code": 1,' \
' "message": "safe-migrate did not produce an analysis report; see safe-migrate-diagnostics.log"' \
'}' > "$json_report"
printf '%s\n' \
'# safe-migrate operational error' \
'' \
'Analysis did not complete. See `safe-migrate-diagnostics.log` for the underlying error.' \
> "$markdown_report"
fi
printf '%s\n' "json-report=${json_report}" >> "$GITHUB_OUTPUT"
printf '%s\n' "markdown-report=${markdown_report}" >> "$GITHUB_OUTPUT"
printf '%s\n' "diagnostic-log=${diagnostic_log}" >> "$GITHUB_OUTPUT"
printf '%s\n' "exit-code=${final_status}" >> "$GITHUB_OUTPUT"
printf '%s\n' "sync-status=${sync_status}" >> "$GITHUB_OUTPUT"
printf '%s\n' "baseline-source=${baseline_source}" >> "$GITHUB_OUTPUT"
exit "$final_status"
- name: Save synchronized baseline
if: ${{ always() && inputs.cache == '' && steps.analysis.outputs.sync-status == 'refreshed' }}
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 env:
DATABASE_URL: ""
SAFE_MIGRATE_CACHE_KEY: ""
with:
path: ${{ steps.baseline.outputs.cache-transport-path }}
key: ${{ steps.baseline.outputs.cache-primary-key }}
- name: Publish summary and annotations
if: ${{ always() }}
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea env:
DATABASE_URL: ""
JSON_REPORT: ${{ steps.analysis.outputs.json-report }}
MARKDOWN_REPORT: ${{ steps.analysis.outputs.markdown-report }}
SAFE_MIGRATE_CACHE_KEY: ""
with:
script: |
const fs = require('fs');
if (process.env.MARKDOWN_REPORT && fs.existsSync(process.env.MARKDOWN_REPORT)) {
const markdown = fs.readFileSync(process.env.MARKDOWN_REPORT, 'utf8');
await core.summary.addRaw(markdown).write();
}
if (!process.env.JSON_REPORT || !fs.existsSync(process.env.JSON_REPORT)) return;
let report;
try {
report = JSON.parse(fs.readFileSync(process.env.JSON_REPORT, 'utf8'));
} catch (error) {
core.error(`Could not parse safe-migrate JSON report: ${error.message}`);
return;
}
for (const finding of report.violations || []) {
if (finding.tier !== 'Tier1' && finding.tier !== 'Tier2') continue;
const location = finding.location || {};
const properties = {
title: `safe-migrate: ${finding.rule_title || finding.rule_id || 'finding'}${finding.rule_title && finding.rule_id ? ` (${finding.rule_id})` : ''}`,
file: location.file,
startLine: location.line,
startColumn: location.column,
};
const message = `${finding.rule_summary ? `${finding.rule_summary} ` : ''}${finding.reason || 'Migration finding'}${finding.recipe ? ` — ${finding.recipe}` : ''}`;
if (finding.tier === 'Tier1') core.error(message, properties);
else core.warning(message, properties);
}
- name: Apply final gate
if: ${{ always() }}
shell: bash
env:
ANALYZER_STATUS: ${{ steps.analysis.outputs.exit-code }}
ADVISORY: ${{ inputs.advisory }}
DATABASE_URL: ""
SAFE_MIGRATE_CACHE_KEY: ""
run: |
/bin/sh "$GITHUB_ACTION_PATH/scripts/action-final-gate" \
"${ANALYZER_STATUS:-1}" "$ADVISORY"