MigrationGuard.Testing 0.2.0

dotnet add package MigrationGuard.Testing --version 0.2.0
                    
NuGet\Install-Package MigrationGuard.Testing -Version 0.2.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="MigrationGuard.Testing" Version="0.2.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="MigrationGuard.Testing" Version="0.2.0" />
                    
Directory.Packages.props
<PackageReference Include="MigrationGuard.Testing" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add MigrationGuard.Testing --version 0.2.0
                    
#r "nuget: MigrationGuard.Testing, 0.2.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package MigrationGuard.Testing@0.2.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=MigrationGuard.Testing&version=0.2.0
                    
Install as a Cake Addin
#tool nuget:?package=MigrationGuard.Testing&version=0.2.0
                    
Install as a Cake Tool

MigrationGuard

Catch dangerous EF Core migrations before they reach production. MigrationGuard reads your migrations and fails the build when one contains an operation that is risky on a live database.

MC0001 error: 20260716160227_DropCustomerBio: Drops column "Bio" from "Customers". During a rolling deploy, old app instances still read/write this column.
    safe: Expand → migrate → contract: stop using the column and deploy first, then drop it in a later migration.
    docs: https://github.com/Mefju08/Migration-Analyzer/blob/master/docs/rules/MC0001.md

It catches four families of problem:

  • Rolling-deploy breakage — dropping or renaming a column while the previous app version is still serving traffic.
  • Long locks — a plain CREATE INDEX, SET NOT NULL, or a new constraint blocking reads/writes while it scans.
  • Table rewrites — changing a column type, or adding a stored generated / identity column.
  • Irreversible data loss — a Down that throws away what Up backfilled.

It is the .NET equivalent of Ruby's strong_migrations, which had no counterpart in the .NET ecosystem.


Quick start

1. Install into your existing test project:

dotnet add package MigrationGuard.Testing

2. Add one test:

using MigrationGuard.Core;
using MigrationGuard.Testing;

public class MigrationSafetyTests
{
    [Fact]
    public void Migrations_are_production_safe() =>
        MigrationCheck.Assert<AppDbContext>(o => o.Provider = DbProvider.PostgreSql(version: 16));
}

3. Run it. The test fails with the full report as its message:

MigrationGuard found 5 issues in 4 of 19 migrations:

20260716160205_CreateCustomerEmailIndex
  MC0003 error: Creates index "IX_Customers_Email" on "Customers". On PostgreSQL a plain index build holds table locks for the duration of the build.
      safe: Build the index without holding a write lock: migrationBuilder.Sql("CREATE INDEX CONCURRENTLY ...", suppressTransaction: true). CONCURRENTLY cannot run inside a transaction.
      docs: https://github.com/Mefju08/Migration-Analyzer/blob/v0.2.0/docs/rules/MC0003.md

20260716160227_DropCustomerBio
  MC0001 error: Drops column "Bio" from "Customers". During a rolling deploy, old app instances still read/write this column.
      safe: Expand → migrate → contract: stop using the column and deploy first, then drop it in a later migration.
      docs: https://github.com/Mefju08/Migration-Analyzer/blob/v0.2.0/docs/rules/MC0001.md

…

Summary: 5 error(s), 0 warning(s), 0 info.
note: a reviewed-and-accepted operation can be acknowledged with [SafeToRun("MC0003", "why it is safe here")] on the migration class.

(abridged — that is real output from this repo's fixture migrations)

No database is involved. MigrationGuard reads EF Core's own operation model, so the test is as fast as any unit test and runs anywhere.

Why MigrationCheck and not MigrationGuard? A type named MigrationGuard cannot coexist with the library's MigrationGuard.* namespaces — the compiler rejects it with CS0434. See ADR-003.


A check fired. Now what?

You have four options, roughly in order of preference.

1. Fix it — follow the safe: line

Every diagnostic carries the safe alternative for that exact operation, and the docs: link explains what the database does and why. For MC0003 on PostgreSQL:

// instead of migrationBuilder.CreateIndex(...)
migrationBuilder.Sql(
    "CREATE INDEX CONCURRENTLY IX_Customers_Email ON \"Customers\" (\"Email\")",
    suppressTransaction: true);

2. Suppress one rule on one migration — [SafeToRun]

When you have checked and it genuinely is safe. The reason is required, so the decision stays auditable in review and in git blame:

using MigrationGuard.Annotations;

[SafeToRun("MC0003", "table has 200 rows, verified 2026-07-12")]
public partial class AddUserIndex : Migration { }

This attribute goes on the migration, so the project that owns your migrations needs dotnet add package MigrationGuard.Annotations. It is netstandard2.0 with zero dependencies — that is the whole reason it ships separately from the engine.

3. Ignore everything that already shipped — baseline

See Adopting on an existing project below.

4. Tune the rule globally — severity

Downgrade a rule so it reports without failing the build, or turn it off completely:

MigrationCheck.Assert<AppDbContext>(o =>
{
    o.Provider = DbProvider.PostgreSql(16);
    o.Severity("MC0009", Severity.Warning);  // report, don't fail
    o.Disable("MC0017");                     // off entirely
});

Only Error fails the build. Warning and Info are advisory — to see them, use the CLI or the non-throwing MigrationCheck.Analyze<AppDbContext>(), which returns the AnalysisResult instead.


Adopting on an existing project

A repo with existing migrations will light up on the first run — those migrations already shipped, and rewriting history helps nobody. Baseline them: list their ids in a JSON file and MigrationGuard skips them entirely, so the tool only judges what you write from now on.

["20240101120000_InitialCreate", "20240115090000_AddOrders"]

A migration id is simply its file name without .cs. Generate the file with either built-in generator — no scripting required:

// one-off (e.g. in a temporary test) — writes every current migration id, returns the count
MigrationCheck.WriteBaseline<AppDbContext>("migrationguard.baseline.json");
dotnet migration-check --assembly bin/Debug/net8.0/MyApp.dll --context AppDbContext \
    --write-baseline migrationguard.baseline.json

Baselined migrations stay visible: the report's summary counts them (… 12 baselined.), so a run that skipped half the history cannot read as a full clean bill of health.

Then point at it, and make sure the file reaches the test's output directory:

MigrationCheck.Assert<AppDbContext>(o =>
{
    o.Provider = DbProvider.PostgreSql(16);
    o.Baseline = "migrationguard.baseline.json";
});
<None Update="migrationguard.baseline.json" CopyToOutputDirectory="PreserveNewest" />

A relative path is resolved against the working directory first, then against the context assembly's directory. If it resolves to nothing you get a FileNotFoundException — a configuration error, not a finding.


Command-line tool

For pipelines that analyze a compiled assembly rather than running your test suite:

dotnet tool install --global MigrationGuard.Tool

dotnet migration-check \
    --assembly src/MyApp/bin/Release/net8.0/MyApp.dll \
    --context AppDbContext \
    --config migrationguard.json
Option Required Meaning
--assembly yes Path to the compiled assembly containing the migrations
--context yes DbContext name — simple (AppDbContext) or full
--config no Path to migrationguard.json; without it the provider is unknown and only provider-agnostic rules run
--format no text (default) or sarif
--write-baseline no Write every discovered migration id to this file and exit — adoption in an existing project

Real output — findings grouped per migration, [SafeToRun] acknowledgements kept visible for audit:

MigrationGuard found 3 issues in 2 of 19 migrations:

20260716160227_DropCustomerBio
  MC0001 error: Drops column "Bio" from "Customers". …
      safe: Expand → migrate → contract: stop using the column and deploy first, then drop it in a later migration.
      docs: https://github.com/Mefju08/Migration-Analyzer/blob/v0.2.0/docs/rules/MC0001.md

20260717063332_AddOrderSummaryWithBackfill
  MC0007 warning: Mixes a data change (UPDATE/DELETE) with schema changes …
  MC0010 info: Raw SQL that MigrationGuard cannot analyze — review it manually …

acknowledged by [SafeToRun] (1):
  MC0003 20260716160326_CreateOrderTotalIndexSafe — Orders is small here; a blocking index build is acceptable.

Summary: 1 error(s), 1 warning(s), 1 info, 0 not analyzed, 1 acknowledged.
note: a reviewed-and-accepted operation can be acknowledged with [SafeToRun("MC0001", "why it is safe here")] on the migration class.

Docs links point at this build's own version tag, so they stay valid even after files move on master. When the analysis runs without a configured provider, the report says so in a note: — provider-specific rules are inactive in that mode, and silence would read as a clean bill of health.

Exit codes: 0 clean · 1 violations at Error severity · 2 tool failure — bad arguments, the assembly or context could not be loaded, or a migration could not be analyzed. Exit 2 is deliberate: an unanalyzable migration must never be reported as clean (ADR-002).

Point --assembly at the application's build output (the startup project's bin/, or dotnet publish output) — the tool resolves your EF provider and other dependencies from that directory. The most common mistake is pointing at the migrations class library's own bin/: a class library's build output has no NuGet dependencies in it, so the run fails with Could not load file or assembly '<some dependency>'. Either aim at the app's output (the migrations dll is copied there too), or make the class library self-contained by adding <CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies> to its .csproj. The tool detects this case and prints the same guidance as a hint: line.


Continuous integration

Fail the pull request:

- run: dotnet test              # if you use the Testing package, you are already done

Or run the CLI and get inline PR annotations via SARIF:

jobs:
  migration-safety:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      security-events: write        # required to upload SARIF
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-dotnet@v4
        with:
          dotnet-version: 8.0.x

      - run: dotnet build src/MyApp/MyApp.csproj -c Release
      - run: dotnet tool install --global MigrationGuard.Tool

      - name: Analyze migrations
        id: check
        continue-on-error: true     # let the upload run, then fail explicitly below
        run: |
          dotnet migration-check \
            --assembly src/MyApp/bin/Release/net8.0/MyApp.dll \
            --context AppDbContext \
            --config migrationguard.json \
            --format sarif > migrationguard.sarif

      - uses: github/codeql-action/upload-sarif@v3
        with:
          sarif_file: migrationguard.sarif

      - name: Fail on violations
        if: steps.check.outcome == 'failure'
        run: exit 1

SARIF maps Error → error, Warning → warning, Info → note, and each result links back to that rule's documentation page. Note that a tool failure (exit 2) writes the reason to stderr and produces no SARIF, so check the step log when the upload has nothing to read.


Configuration

Everything is available both in code and in migrationguard.json:

{
  "provider": "postgresql",
  "providerVersion": 16,
  "largeTables": ["users", "events"],
  "severity": { "MC0009": "warning", "MC0017": "none" },
  "baseline": "migrationguard.baseline.json"
}
Key Code equivalent Notes
provider o.Provider = DbProvider.PostgreSql(16) postgresql | sqlserver; anything else = unknown provider
providerVersion argument to DbProvider.* Drives version-dependent rules; defaults to 16 / 2022
largeTables o.LargeTables = ["users"] Enables MC0008 and MC0011, which only matter at size
severity o.Severity(id, Severity.Warning) error | warning | info | none
baseline o.Baseline = "..." JSON array of migration ids to skip
o.MigrationsAssembly = typeof(SomeMigration).Assembly When migrations live outside the context's assembly (MigrationsAssembly(...))
o.AllowNoMigrations = true Permit a run that finds zero migrations (default: that throws — see below)

Zero migrations found = error, not "clean". With migrations generated into a separate project, the default lookup (the context's own assembly) finds nothing — and a green test that analyzed nothing would be a false sense of safety. Both the Testing package and the CLI fail loudly in that case; set o.MigrationsAssembly to point at the right assembly, or o.AllowNoMigrations = true for a project that genuinely has no migrations yet.

none disables a rule completely — the same convention .editorconfig uses for diagnostics; in code that is o.Disable("MC0017").

largeTables matters. Two rules stay silent until you declare which tables are big, because on a small table their operations are harmless: MC0011 (adding a NOT NULL column with a default) and MC0008 (no lock_timeout). List the tables where a full rewrite or a lock queue would actually hurt.


Rule catalog

Rule Default severity What it catches
MC0001 Error Dropping a column/table (breaks old instances during rolling deploy)
MC0002 Error Renaming a column/table (drop+add for the running old version)
MC0003 Error (PG) / Warning (SQL Server) Creating an index (blocking build)
MC0004 Error (PG) / Warning (SQL Server) Making a column NOT NULL (blocking scan / Sch-M lock)
MC0005 Error (PG) Changing a column type (table rewrite)
MC0006 Warning Adding a foreign key (validation under lock)
MC0007 Warning Data change (UPDATE/DELETE) inside a migration
MC0008 Info No lock_timeout on a large table
MC0009 Warning Down would destroy data that Up backfilled
MC0010 Info Raw SQL that cannot be analyzed
MC0011 Warning Adding a NOT NULL column with a default to a large table
MC0012 Error Adding a check constraint (validation under lock)
MC0013 Error (PG) Adding a stored generated column (table rewrite)
MC0014 Error (PG) Adding an identity/auto-incrementing column (table rewrite)
MC0015 Error (PG) Adding a unique constraint (ACCESS EXCLUSIVE)
MC0016 Warning (PG) Adding a json column (docs recommend jsonb)
MC0017 Info Non-unique index over more than three columns
MC0018 Error (PG) Dropping an index (ACCESS EXCLUSIVE; also fires on the implicit drop when EF replaces an index)

Each rule's page states what EF generates, why it hurts, the safe alternative with code, and links to the official documentation the behavior came from.

Providers

  • PostgreSQL — fully modeled (all 18 rules).
  • SQL Server — modeled from verified Microsoft documentation: the Sch-M lock taken by ALTER TABLE, WITH CHECK validation of new constraints (safe path: WITH NOCHECK + re-check), ONLINE = ON (2016+), and the 2012+/Enterprise metadata-only fast path for constant defaults. MC0003/MC0004 are Warning rather than Error where the docs leave nuance; MC0005 and MC0013–MC0016 stay silent because their facts are not verified for SQL Server.
  • Unknown / other — only provider-agnostic rules (MC0001, MC0002, MC0007, MC0009, MC0010) run.

MigrationGuard never claims something is safe on an unverified fact. Where a database behavior could not be confirmed from official documentation it is marked ⚠️ TODO in the rule page and the rule stays at Warning rather than Error. Both providers are covered end-to-end by real dotnet ef-generated fixture migrations.


Writing your own rule

IMigrationRule is public, and so is the engine — a rule is a pure function from a migration's operations to diagnostics:

using Microsoft.EntityFrameworkCore.Migrations.Operations;
using MigrationGuard.Core;

public sealed class NoTruncateRule : IMigrationRule
{
    public string Id => "APP0001";

    public IEnumerable<Diagnostic> Analyze(MigrationInfo migration, AnalysisContext ctx)
    {
        foreach (MigrationOperation op in migration.UpOperations)
        {
            if (op is SqlOperation sql && sql.Sql.Contains("TRUNCATE", StringComparison.OrdinalIgnoreCase))
                yield return new Diagnostic(
                    Id, Severity.Error, migration.Id,
                    "TRUNCATE in a migration empties the table with no way back.",
                    "Delete in batches from a maintenance job instead.");
        }
    }
}

MigrationCheck.Assert always uses the built-in rules, so to add your own, compose the engine directly — every piece of it is public API:

ProviderProfile provider = DbProvider.PostgreSql(16);

IReadOnlyList<MigrationInfo> migrations = MigrationLoader.Load(
    typeof(AppDbContext).Assembly, typeof(AppDbContext), provider.ActiveProvider);

var analyzer = new MigrationAnalyzer([.. MigrationAnalyzer.DefaultRules(), new NoTruncateRule()]);
AnalysisResult result = analyzer.Analyze(migrations, new AnalysisContext(provider));

Assert.False(result.HasErrors, DiagnosticFormatter.FormatReport(result.Diagnostics));

[SafeToRun("APP0001", "…")] and severity overrides work on your rules exactly as on the built-in ones — the engine keys both off the rule id.


How it works

MigrationGuard analyzes the EF Core semantic modelMigration.UpOperations, the same operation list EF itself turns into SQL. Not source code, not SQL text:

var migration = (Migration)Activator.CreateInstance(migrationType)!;
migration.ActiveProvider = "Npgsql.EntityFrameworkCore.PostgreSQL";
IReadOnlyList<MigrationOperation> ops = migration.UpOperations;   // ← rules pattern-match on these

No Roslyn, no SQL parsing, no database connection. The rationale, and what that buys and costs, is in ADR-001; the CLI's isolated assembly loading is in ADR-002.

The one thing it cannot read is raw migrationBuilder.Sql(...) — that is reported as MC0010 so you know the migration was not fully checked, rather than being silently passed.

Parity with strong_migrations

Benchmarked against strong_migrations' 24 checks. Six cannot apply to EF Core (force: true is an ActiveRecord concept; exclusion constraints and enum renames have no EF operation — they surface as raw SQL via MC0010; and 3 are MySQL/MariaDB-only). Of the 18 that do apply, 17 are fully covered and one partially: renaming a schema is caught only when it arrives as a RenameTable with a new schema, because a standalone schema rename has no EF Core operation.

Feature parity: safety_assured[SafeToRun] · start_after → baseline · target_versionproviderVersion · custom checks → IMigrationRule · disabling a check → o.Disable("MC0017").

Deliberately out of scope: strong_migrations' "safe by default" auto-rewriting and its lock/statement timeout and retry settings — those belong to a migration runner. MigrationGuard is an analyzer: MC0008 advises on lock_timeout, it does not execute anything.

Packages

Package Install it in Purpose
MigrationGuard.Testing your test project MigrationCheck.Assert<TContext>()
MigrationGuard.Annotations the project owning your migrations [SafeToRun] (netstandard2.0, zero deps)
MigrationGuard.Tool global tool dotnet migration-check
MigrationGuard.Core Engine, rules, provider profiles; comes with the others

Building

dotnet build MigrationGuard.sln
dotnet test  MigrationGuard.sln

Requires the .NET 8 SDK. CI (build + test + pack) runs in GitHub Actions. Design decisions live in docs/adr, and a running decision log in docs/NOTES.md.

License

MIT.

Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 was computed.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 was computed.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.2.0 34 7/17/2026
0.1.1 39 7/17/2026
0.1.0 36 7/17/2026