Prove a CPM migration changed nothing that ships
A migration pull request is sixty changed .csproj files, and git diff cannot answer the one question a reviewer actually has: does this change what we build against? cpmigrate --verify answers it.
Why the question is not rhetorical
Moving a version out of a project file and into Directory.Packages.props is a no-op. Two projects disagreeing about a version is not.
Central package management allows one version per package, so a migration has to pick one. The default, --conflict-strategy Highest, silently upgrades every project on the losing side — and then the package that was upgraded pulls its own dependencies forward with it. That is a real change to the binaries you ship, arriving inside a change that reads as tidy-up.
Nothing in the file diff shows it. The project on the losing side loses a Version attribute, exactly like every other project in the PR.
What --verify does
It restores the solution, records the fully-resolved package graph for every project and every target framework, performs the migration, restores again, and diffs the two.
cpmigrate -s ./MySolution.sln --verify
Resolved-graph verification
─────────────────────────────────────────────
Projects restored 6 / 6
Resolved versions 221
Unchanged 216
Changed 5 ← all explained
Serilog.PerformanceTests.csproj [net10.0]
PolySharp 1.14.1 → 1.15.0 — conflict unified to 1.15.0 over 1.14.1 (Highest)
VERDICT 5 resolved version(s) moved, all accounted for by 1 deliberate decision.
It reads project.assets.json rather than the project files, so what it compares is the version restore actually settled on — not the version a file declares.
Explained, or an alarm
Every version that moved is matched against the decisions the migration made. A direct reference landing on the version a conflict was unified to is explained. A transitive package is explained when it is genuinely reachable from an explained one in the resolved graph — read from the graph's own edges, not guessed from proximity.
Anything left over is unexplained: nothing the tool did accounts for it. That exits 9 and rolls the migration back, so a failed run leaves the tree as it found it.
Demanding a literal no-op
--verify-strict fails on any graph change, including ones the report can explain. "Every change is accounted for" and "nothing changed" are different claims, and a team is entitled to require the second.
cpmigrate -s ./MySolution.sln --verify --verify-strict
A strict failure deliberately does not roll back — the drift is accounted for, and the person who asked for a no-op wants to read what stopped it being one.
In a pull request
--output Markdown renders the receipt for a PR body or a job summary, including which version won for each conflicted package, out of what, and at whose direction.
cpmigrate -s ./MySolution.sln --verify --force --output Markdown --quiet > receipt.md
cat receipt.md >> "$GITHUB_STEP_SUMMARY"
The full workflow, including the gate, is in the CI/CD guide. Set "verify": true in .cpmigrate.json to apply it to every migration in the repository.
What it costs, and what it refuses to guess
Two full solution restores — one for the baseline, one for the result. That is the whole cost, and it is why verification is opt-in rather than default.
A run that cannot reach a verdict is never reported as clean. A failed restore is a failure, not an empty graph. A target framework restore did not describe is a failure, not a framework with no packages — including when both captures lack it, where the two agree and nothing looks missing. And if the two captures do not cover the same projects, the comparison is abandoned rather than narrowed: quietly diffing only the projects they share would report "0 changed" over a project that stopped building, which looks exactly like a migration that was safe.
Three project layouts are refused outright rather than measured badly. Two projects in one directory share a single obj/project.assets.json, so neither can be read independently. Two projects that would be reported under the same name cannot be told apart in the diff. And a project that redirects its intermediate output — MSBuildProjectExtensionsPath, BaseIntermediateOutputPath, ProjectAssetsFile — puts its graph somewhere only full MSBuild evaluation could find, which this pass deliberately does not perform. Each is named in the report and exits 9.