JavaScript developers commit package-lock.json without thinking about it - yet most .NET teams run dotnet restore with no lock file at all. That means the exact packages your build uses can change between restores without any code change: floating versions (8.*) resolve to something new, or a transitive dependency publishes a new version that shifts your whole graph. The build that passed yesterday and the one deployed today may not be running the same bytes.
NuGet has lock files too - they're just off by default.
Turn them on for every project in one place - your Directory.Build.props:
<PropertyGroup><RestorePackagesWithLockFile>true</RestorePackagesWithLockFile></PropertyGroup>
The next dotnet restore generates a packages.lock.json per project recording the exact resolved version and content hash of every direct and transitive package. Commit these files.
A lock file only guarantees repeatability if CI refuses to deviate from it:
dotnet restore
❌ Bad example - CI re-resolves the dependency graph, so it can build something no developer has tested
dotnet restore --locked-mode
✅ Good example - Restore fails (NU1004) if the graph no longer matches the committed lock file
You can also enforce this via MSBuild so it applies automatically on CI:
<PropertyGroup><RestoreLockedMode Condition="'$(ContinuousIntegrationBuild)' == 'true'">true</RestoreLockedMode></PropertyGroup>
dotnet restore --force-evaluate