Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/ISSUE_TEMPLATE/database-feedback.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ body:
attributes:
label: safe-migrate version
description: Paste the output of `safe-migrate --version`.
placeholder: safe-migrate 0.8.0
placeholder: safe-migrate 0.8.1
validations:
required: true

Expand Down
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,18 @@ commits and pull requests. Published binaries, checksums, and generated release
notes are available on the
[GitHub Releases page](https://github.com/dsecurity49/safe-migrate/releases).

## v0.8.1 — Unreleased

- Upgraded Squawk's parser, lexer, syntax tree, and linter to 2.64.0, including
its stricter routine validation and more precise type and transaction nodes.
- Preserved ordered-set aggregate and function signatures across the new AST,
and stopped prepared-transaction completion from ending the analyzed
migration transaction.
- Warn before GitHub secret setup when the baseline environment is missing,
unverifiable, or lacks reviewer or deployment-branch protection.
- Prevent `cargo test` from opening the interactive viewer when run from a
terminal.

## v0.8.0 — 2026-09-04

- Rule evaluation now uses one immutable context with explicit capability
Expand Down
18 changes: 9 additions & 9 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 5 additions & 5 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "safe-migrate"
version = "0.8.0"
version = "0.8.1"
edition = "2024"
rust-version = "1.94"
description = "Check PostgreSQL migrations against a synchronized database baseline"
Expand All @@ -14,10 +14,10 @@ keywords = ["postgres", "migration", "linter", "ast", "database"]
categories = ["command-line-utilities", "database"]

[dependencies]
squawk-syntax = "=2.63.0"
squawk-lexer = "=2.63.0"
squawk-parser = "=2.63.0"
squawk-linter = "=2.63.0"
squawk-syntax = "=2.64.0"
squawk-lexer = "=2.64.0"
squawk-parser = "=2.64.0"
squawk-linter = "=2.64.0"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
toml = "0.8"
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ Prebuilt binaries are available from
installer verifies release checksums:

```bash
VERSION='v0.8.0'
VERSION='v0.8.1'
curl -fsSL "https://raw.githubusercontent.com/dsecurity49/safe-migrate/${VERSION}/install.sh" |
bash -s -- --version "${VERSION}"
```
Expand Down
2 changes: 1 addition & 1 deletion docs/BENCHMARKS.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Local benchmark baseline

This document records reproducible, non-CI performance scenarios. The values
are comparison points for the current `0.8.0` hardening work, not performance
are comparison points for the recorded `0.8.0` hardening work, not performance
guarantees.
Run them with:

Expand Down
4 changes: 2 additions & 2 deletions docs/CONTRACT.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# CLI and Report Contract

This document defines safe-migrate v0.8.0's CLI, report, cache, and GitHub
This document defines safe-migrate v0.8.1's CLI, report, cache, and GitHub
Action behavior.

If you are learning safe-migrate, start with the [README](../README.md). This
Expand All @@ -20,7 +20,7 @@ database.
| `sync` | Read PostgreSQL metadata and settings into a local cache. Requires `DATABASE_URL` and a local, Unix-socket, or tunneled connection. |
| `cache inspect` | Show cache provenance and redacted counts without a database connection. Supports `--json`. |
| `rules` | List primary rules; `--rule <id>` selects one and `--json` emits the discovery schema. |
| `init github-actions --path <dir>` | Generate separate PR-analysis and trusted-refresh workflows. |
| `init github-actions --path <dir>` | Generate separate PR-analysis and trusted-refresh workflows. With `--configure-secrets`, warn before secret setup when the baseline environment is missing, unverifiable, or lacks an access-protection rule. |
| `init cache-key` | Generate a random 32-byte key as 64 lowercase hexadecimal characters. |

`init github-actions --configure-secrets` sends the database URL and generated
Expand Down
7 changes: 5 additions & 2 deletions docs/GITHUB_ACTIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,10 @@ The command creates two files:

It asks GitHub CLI for the database URL, generates a random 32-byte cache key,
and sends both directly to GitHub. Neither value is printed or written to a
workflow file.
workflow file. Before setting the database URL, it checks the environment and
warns if GitHub reports no required reviewers or deployment-branch restriction,
or if the environment cannot be found or verified. The warning is non-blocking;
stop and protect the environment before answering GitHub CLI's secret prompt.

The default branch is detected from `origin/HEAD`, with `main` as the fallback.
Use `--branch <name>` when detection is wrong. This must be the default or PR
Expand Down Expand Up @@ -273,7 +276,7 @@ base commit:
sparse-checkout: safe-migrate.toml
persist-credentials: false

- uses: dsecurity49/safe-migrate@v0.8.0
- uses: dsecurity49/safe-migrate@v0.8.1
env:
SAFE_MIGRATE_CACHE_KEY: ${{ secrets.SAFE_MIGRATE_CACHE_KEY }}
with:
Expand Down
2 changes: 1 addition & 1 deletion scripts/test-action-contract
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ baseline="$repo_root/scripts/action-baseline"
manifest="$repo_root/action.yml"
workflow="$repo_root/.github/workflows/ci.yml"

test "$(/bin/sh "$resolver" v0.8.0 "$repo_root/Cargo.toml")" = v0.8.0
test "$(/bin/sh "$resolver" v0.8.1 "$repo_root/Cargo.toml")" = v0.8.1
test "$(/bin/sh "$resolver" 0123456789abcdef0123456789abcdef01234567 "$repo_root/Cargo.toml")" = source

if /bin/sh "$resolver" main "$repo_root/Cargo.toml" >/dev/null 2>&1; then
Expand Down
95 changes: 63 additions & 32 deletions src/_internal/ast/visitor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -139,10 +139,14 @@ impl AstVisitor {
Stmt::CreateUser(node) => return Self::extract_create_user(node),
Stmt::Begin(_) => return Some(StatementFact::BeginTransaction),
Stmt::Commit(node) => {
return Some(if Self::is_and_chain(node.chain_clause()) {
StatementFact::CommitAndChain
} else {
StatementFact::CommitTransaction
return Some(match node {
ast::Commit::CommitPrepared(_) => StatementFact::OpaqueBlock,
ast::Commit::CommitTransaction(transaction)
if Self::is_and_chain(transaction.chain_clause()) =>
{
StatementFact::CommitAndChain
}
ast::Commit::CommitTransaction(_) => StatementFact::CommitTransaction,
});
}
Stmt::Rollback(node) => return Self::extract_rollback(node),
Expand Down Expand Up @@ -529,7 +533,9 @@ impl AstVisitor {
continue;
}
if let Some(ac) = AlterConstraint::cast(action.syntax().clone()) {
let deferrable = ac.deferrable_constraint_option().is_some();
let deferrable = ac.constraint_options().any(|option| {
matches!(option, ast::ConstraintOption::DeferrableConstraintOption(_))
});
let name = ac
.constraint_name_ref()
.and_then(|name| name.path_ref())
Expand Down Expand Up @@ -959,6 +965,13 @@ impl AstVisitor {
(columns, foreign_keys, table_constraints)
}

fn column_constraints(col: &Column) -> impl Iterator<Item = ColumnConstraint> + '_ {
col.clauses().filter_map(|clause| match clause {
ast::ColumnClause::ColumnConstraint(constraint) => Some(constraint),
_ => None,
})
}

fn extract_column_fact(col: &Column) -> Option<ColumnFact> {
let name_token = col.name().and_then(|n| n.ident_token()).or_else(|| {
col.syntax()
Expand All @@ -968,15 +981,14 @@ impl AstVisitor {
})?;
let name = Self::resolve_identifier_token(name_token.text());
let ty = col.ty().map(|t| t.syntax().text().to_string());
let is_identity = col.constraints().any(|constraint| {
let is_identity = Self::column_constraints(col).any(|constraint| {
matches!(constraint, ColumnConstraint::GeneratedConstraint(generated)
if matches!(generated.generated_as(), Some(ast::GeneratedAs::GeneratedIdentity(_))))
});
let not_null = is_identity
|| col
.constraints()
|| Self::column_constraints(col)
.any(|c| matches!(c, ColumnConstraint::NotNullConstraint(_)));
let primary_key_constraint_name = col.constraints().find_map(|constraint| {
let primary_key_constraint_name = Self::column_constraints(col).find_map(|constraint| {
let ColumnConstraint::PrimaryKeyConstraint(primary_key) = constraint else {
return None;
};
Expand All @@ -990,7 +1002,7 @@ impl AstVisitor {
});
let is_primary_key = primary_key_constraint_name.is_some();
let primary_key_constraint_name = primary_key_constraint_name.flatten();
let unique_constraint_name = col.constraints().find_map(|constraint| {
let unique_constraint_name = Self::column_constraints(col).find_map(|constraint| {
let ColumnConstraint::UniqueConstraint(unique) = constraint else {
return None;
};
Expand All @@ -1004,7 +1016,7 @@ impl AstVisitor {
});
let is_unique = unique_constraint_name.is_some();
let unique_constraint_name = unique_constraint_name.flatten();
let default = col.constraints().find_map(|c| {
let default = Self::column_constraints(col).find_map(|c| {
if let ColumnConstraint::DefaultConstraint(dc) = c {
Some(crate::_internal::analysis::expr_visitor::ExprVisitor::convert(dc.expr()?))
} else {
Expand Down Expand Up @@ -1057,7 +1069,7 @@ impl AstVisitor {
Some(AlterTableActionFact::DropNotNull { column: col_name })
}
AlterColumnOption::SetType(st) => {
let has_using = st.using_token().is_some();
let has_using = st.using_expr().is_some();
Some(AlterTableActionFact::SetType {
column: col_name,
ty: st.ty()?.syntax().text().to_string(),
Expand Down Expand Up @@ -1378,7 +1390,7 @@ impl AstVisitor {
})
})
.map(|t| Self::resolve_identifier_token(t.text()));
col.constraints()
Self::column_constraints(col)
.filter_map(|c| {
if let ColumnConstraint::ReferencesConstraint(rc) = c {
let ref_path = rc
Expand Down Expand Up @@ -2225,7 +2237,7 @@ impl AstVisitor {
.and_then(|n| n.ident_token())
.map(|t| Self::resolve_identifier_token(t.text())),
ty: param
.ty()
.func_type()
.map(|t| t.syntax().text().to_string())
.unwrap_or_else(|| "unknown".into()),
default: param.param_default().and_then(|pd| {
Expand All @@ -2238,18 +2250,27 @@ impl AstVisitor {
fn extract_ret_type(
ret: &squawk_syntax::ast::RetType,
) -> crate::_internal::analysis::facts::RetTypeFact {
if let Some(tal) = ret.table_arg_list() {
if let Some(tal) = ret.return_table_arg_list() {
let cols = tal
.args()
.filter_map(|arg| match arg {
TableArg::Column(col) => Self::extract_column_fact(&col),
_ => None,
.filter_map(|column| {
Some(ColumnFact {
name: Self::resolve_ast_identifier(&column.name()?),
ty: column.func_type().map(|ty| ty.syntax().text().to_string()),
not_null: false,
is_primary_key: false,
primary_key_constraint_name: None,
is_unique: false,
unique_constraint_name: None,
default: None,
generation: crate::_internal::analysis::facts::ColumnGeneration::Ordinary,
})
})
.collect();
crate::_internal::analysis::facts::RetTypeFact::Table(cols)
} else {
let ty = ret
.ty()
.func_type()
.map(|t| t.syntax().text().to_string())
.unwrap_or_else(|| "unknown".into());
crate::_internal::analysis::facts::RetTypeFact::Scalar(ty)
Expand Down Expand Up @@ -2596,13 +2617,19 @@ impl AstVisitor {
.map(|params| {
params
.params()
.chain(
params
.aggregate_order_by()
.into_iter()
.flat_map(|order_by| order_by.params()),
)
.filter_map(|param| {
if matches!(param.mode(), Some(ast::ParamMode::ParamOut(_))) {
return None;
}
Some(
param
.ty()
.func_type()
.map(|ty| ty.syntax().text().to_string())
.unwrap_or_else(|| "unknown".to_string()),
)
Expand Down Expand Up @@ -3733,6 +3760,7 @@ impl AstVisitor {
ast::ConfigValue::Literal(literal) => {
Self::resolve_string_literal(&literal)
}
ast::ConfigValue::PrefixExpr(_) => None,
})
.collect();
(!schemas.is_empty()).then_some(StatementFact::SetSearchPath {
Expand Down Expand Up @@ -3771,6 +3799,9 @@ impl AstVisitor {
Self::resolve_string_literal(&literal)
.or_else(|| Some(literal.syntax().text().to_string()))
}
ast::ConfigValue::PrefixExpr(expr) => {
Some(expr.syntax().text().to_string())
}
})
.collect();
if values.len() != 1 {
Expand Down Expand Up @@ -3894,20 +3925,20 @@ impl AstVisitor {
}

fn extract_rollback(node: &Rollback) -> Option<StatementFact> {
if node.prepared_token().is_some() {
return Some(StatementFact::OpaqueBlock);
}

match node
.savepoint_ref()
.and_then(|s| s.ident_token())
.map(|t| Self::resolve_identifier_token(t.text()))
{
Some(name) => Some(StatementFact::RollbackToSavepoint { name }),
None if Self::is_and_chain(node.chain_clause()) => {
match node {
Rollback::RollbackPrepared(_) => Some(StatementFact::OpaqueBlock),
Rollback::RollbackToSavepoint(rollback) => rollback
.savepoint_ref()
.and_then(|savepoint| savepoint.ident_token())
.map(|token| StatementFact::RollbackToSavepoint {
name: Self::resolve_identifier_token(token.text()),
}),
Rollback::RollbackTransaction(rollback)
if Self::is_and_chain(rollback.chain_clause()) =>
{
Some(StatementFact::RollbackAndChain)
}
None => Some(StatementFact::RollbackTransaction),
Rollback::RollbackTransaction(_) => Some(StatementFact::RollbackTransaction),
}
}

Expand Down
Loading
Loading