A build that works on a developer's machine but fails in CI is more than an inconvenience. It is often a sign that the build environment is resolving dependencies differently.
.NET applications can depend on hundreds of direct and transitive NuGet packages. If those dependencies are allowed to change during restore, the same source code can potentially produce a different dependency graph at a later point in time.
That creates problems for:
CI/CD pipelines
Security investigations
Production deployments
Rollbacks
Release audits
Developer onboarding
Reproducing build failures
A reproducible build starts with a simple principle:
The source code, dependency graph, SDK, and build configuration should be explicit enough that the same inputs produce the same dependency resolution.
For .NET projects, NuGet lock files can be an important part of that strategy.
This article explains how locked dependency graphs work, how to enable them, how to enforce them in CI, and where teams commonly get into trouble.
Why Dependency Reproducibility Matters
Consider a project with this dependency:
<ItemGroup>
<PackageReference Include="Example.Data.Client"
Version="5.2.0" />
</ItemGroup>
That direct dependency may itself depend on:
Example.Data.Client
|
+-- Example.Core
|
+-- Example.Serialization
|
+-- Example.Logging
Those transitive dependencies become part of the actual application dependency graph.
If a transitive dependency is resolved differently later, the application may effectively be built with different inputs even though the Git commit has not changed.
The important distinction is:
Source code unchanged
≠
Dependency graph unchanged
A reproducible build needs both.
What Is a Locked Dependency Graph?
A NuGet lock file records information about the resolved dependency graph.
For a project, the lock file is commonly named:
packages.lock.json
A simplified example looks like:
{
"version": 1,
"dependencies": {
"net10.0": {
"Example.Data.Client": {
"type": "Direct",
"requested": "[5.2.0, )",
"resolved": "5.2.0",
"contentHash": "..."
}
}
}
}
The actual file contains considerably more information.
The important idea is that restore has a recorded dependency resolution that can be checked during future restores.
Instead of asking:
"What dependency versions can I resolve today?"
the build can ask:
"Can I restore exactly the dependency graph that was previously recorded?"
That distinction is valuable in CI and production release pipelines.
Enable NuGet Lock Files
Lock-file behavior can be enabled through MSBuild properties.
For example:
<PropertyGroup>
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
</PropertyGroup>
After restoring the project, NuGet can generate:
packages.lock.json
The lock file should normally be committed to source control when the repository uses locked dependency resolution as a build policy.
A typical project structure becomes:
MyApplication/
├── MyApplication.csproj
├── Program.cs
├── NuGet.config
├── packages.lock.json
└── ...
The lock file is not a replacement for the project file.
The project file defines the application's package requirements.
The lock file records the resolved dependency graph.
Restore in Locked Mode
Generating a lock file is only one part of the process.
CI must also enforce it.
Use:
dotnet restore --locked-mode
Locked mode tells NuGet that the existing lock file must be honored.
If the dependency graph would need to change, restore fails instead of silently updating the lock file.
Conceptually:
Project files
|
v
Existing lock file
|
v
Can current restore match it?
|
+---+---+
| |
Yes No
| |
Build Fail
This is exactly the behavior desired in a reproducible CI build.
Why Normal Restore Is Different
A normal restore can update dependency resolution when the project's dependency requirements change or when the resolved graph no longer matches the current project state.
That behavior is useful during development.
It is less desirable for a release pipeline that expects dependency resolution to be controlled.
A useful development workflow is:
Developer changes dependency
|
v
Normal restore
|
v
Lock file updated
|
v
Review dependency changes
|
v
Commit project + lock file
The CI workflow then becomes:
Git commit
|
v
dotnet restore --locked-mode
|
+-- Match --> Continue
|
+-- Mismatch --> Fail
This separates dependency modification from dependency consumption.
Use a Deterministic SDK Version
A lock file does not solve every reproducibility problem.
The .NET SDK itself can affect the build.
For example, a repository can use global.json to specify the SDK version.
{
"sdk": {
"version": "10.0.100",
"rollForward": "latestPatch"
}
}
The exact SDK version should be selected according to the application's supported toolchain.
The important principle is to avoid accidentally building the same commit with materially different SDK versions.
Think of reproducibility as multiple layers:
Source Code
+
.NET SDK
+
NuGet Configuration
+
Package Sources
+
Dependency Graph
+
Build Configuration
=
Build Inputs
Locking only the NuGet graph leaves other variables uncontrolled.
Check the Lock File Into Source Control
If a repository uses locked dependency resolution, the lock file should generally be version-controlled.
For example:
git add packages.lock.json
git commit -m "Lock NuGet dependency graph"
Do not add it to .gitignore simply because it is generated.
Generated does not always mean disposable.
In this case, the generated file represents an important build input.
Review Lock File Changes Like Code Changes
A pull request that changes:
packages.lock.json
should receive dependency-focused review.
For example:
Example.Core
5.1.0 -> 5.2.0
Example.Serialization
3.4.1 -> 3.5.0
New transitive dependency
Example.Security.Extensions
A reviewer should ask:
Why did this dependency change?
Was the change intentional?
Is the source expected?
Did a direct dependency introduce a new transitive package?
Did a major version change occur?
Does the new dependency have known security issues?
The lock file therefore becomes useful not only for reproducibility but also for dependency governance.
Central Package Management and Lock Files
Large repositories may centralize package versions.
For example:
<ItemGroup>
<PackageVersion Include="Example.Core"
Version="5.2.0" />
<PackageVersion Include="Example.Data"
Version="4.1.0" />
</ItemGroup>
This can simplify version management across many projects.
However, centralized package versions and lock files solve different problems.
Central package management answers:
"What version should this package request?"
The lock file answers:
"What dependency graph was actually resolved?"
Using both can provide stronger control.
Multiple Target Frameworks Need Attention
A project may target multiple frameworks:
<TargetFrameworks>
net10.0;net8.0
</TargetFrameworks>
Dependency resolution can differ between target frameworks.
For example:
net10.0
|
+-- Package A 5.x
net8.0
|
+-- Package A 4.x
Do not assume that one target framework's dependency graph represents the entire project.
The lock file can contain framework-specific resolution information.
When reviewing changes, consider every target framework used by the application.
Package Sources Are Part of Reproducibility
A lock file is not a complete solution if package sources are uncontrolled.
For example:
<packageSources>
<clear />
<add key="CompanyFeed"
value="https://packages.company.local/nuget/v3/index.json" />
<add key="nuget.org"
value="https://api.nuget.org/v3/index.json" />
</packageSources>
The repository should explicitly define approved package sources where reproducibility and supply-chain security are important.
Otherwise, two environments could use different NuGet configuration and potentially resolve dependencies differently.
A stronger build model is:
Known SDK
+
Known NuGet configuration
+
Known package sources
+
Locked dependency graph
Combine Locking With Package Source Mapping
For enterprise repositories, package source mapping can add another layer of control.
For example:
<packageSourceMapping>
<packageSource key="CompanyFeed">
<package pattern="Company.*" />
</packageSource>
<packageSource key="nuget.org">
<package pattern="*" />
</packageSource>
</packageSourceMapping>
This creates an explicit relationship between package identities and package sources.
The combination is useful:
Lock file
|
v
Expected dependency graph
Source mapping
|
v
Expected package provenance
Reproducibility and supply-chain security therefore reinforce each other.
A CI Pipeline Example
A basic CI pipeline can perform restore in locked mode before building.
For example:
steps:
- name: Restore
run: dotnet restore --locked-mode
- name: Build
run: dotnet build --no-restore --configuration Release
- name: Test
run: dotnet test --no-restore --configuration Release
The important detail is:
restore
|
v
build --no-restore
|
v
test --no-restore
The build and test stages do not unexpectedly perform another dependency restore.
That makes the pipeline's dependency boundary easier to understand.
Fail Fast on Lock File Drift
Suppose a developer changes:
<PackageReference Include="Example.Core"
Version="6.0.0" />
but forgets to regenerate the lock file.
CI runs:
dotnet restore --locked-mode
and detects that the existing lock file does not represent the current project dependency requirements.
The build should fail.
That is a good failure.
It forces the dependency change to be explicit.
The developer can then regenerate the lock file, inspect the resulting changes, and commit both files.
Updating Dependencies Safely
Dependency updates should be intentional.
A practical workflow is:
1. Change PackageReference
2. Restore normally
3. Update lock file
4. Review dependency changes
5. Run tests
6. Run security checks
7. Commit project + lock file
8. CI restores with --locked-mode
This gives dependency changes a clear audit trail.
Detecting Unexpected Dependency Changes
A locked graph also makes unexpected changes easier to detect.
Suppose a pull request modifies only:
OrderService.cs
but also changes:
packages.lock.json
That deserves attention.
It may be legitimate, but the dependency change should have an explanation.
A CI policy can flag dependency changes when:
No package-related project file changed
|
v
Lock file changed
|
v
Review required
This is particularly useful in large repositories.
Reproducibility Does Not Mean Bit-for-Bit Reproducible
There is an important distinction between:
Dependency reproducibility
and
Complete binary reproducibility.
A lock file primarily helps control dependency resolution.
It does not automatically guarantee that every build produces byte-for-byte identical binaries.
Other variables can affect the final artifact, including:
SDK version
Build properties
Source-generated content
Native toolchains
Environment variables
Timestamps
Platform-specific tooling
Build scripts
Therefore, describe the lock file accurately.
It helps make dependency resolution reproducible.
It is one component of a broader reproducible-build strategy.
Common Mistakes
Generating a Lock File but Not Enforcing It
This is probably the most common mistake.
If CI simply runs:
dotnet restore
the lock file is not necessarily being treated as a strict dependency contract.
Use locked mode where enforcement is required.
Ignoring the Lock File
If the lock file is continuously regenerated and committed without review, teams lose one of its major benefits.
Treat dependency changes as meaningful changes.
Forgetting Transitive Dependencies
A direct package update can introduce many new packages.
Review the complete graph.
Ignoring Multiple Target Frameworks
A package can resolve differently for different target frameworks.
Check all supported frameworks.
Assuming Lock Files Prevent Malicious Packages
They do not.
A lock file can make dependency changes visible and controlled, but it does not independently establish package trust.
Combine it with source controls and security scanning.
Using Different NuGet Configurations in CI
If developers and CI restore against different feeds or mapping rules, reproducibility becomes difficult.
Keep the restore environment explicit.
Troubleshooting Locked Restore Failures
A locked restore failure generally means the current project state does not match the recorded dependency graph.
Start with:
dotnet restore --locked-mode
If it fails, check:
Was a
PackageReferencechanged?Was a package version changed centrally?
Was a target framework added or removed?
Did
NuGet.configchange?Did package source mapping change?
Was the lock file generated using a different dependency configuration?
Is the correct .NET SDK being used?
If the dependency change is intentional, regenerate the lock file using the normal restore process and review the resulting diff.
Do not simply delete the lock file to make CI pass.
That defeats the purpose of the control.
A Practical Reproducibility Checklist
For a production .NET repository, verify:
[ ] SDK version is controlled
[ ] NuGet sources are explicit
[ ] Internal package sources are controlled
[ ] Package source mapping is defined where appropriate
[ ] Lock files are generated
[ ] Lock files are committed
[ ] CI uses --locked-mode
[ ] Build uses --no-restore after restore
[ ] Test uses the same restored graph
[ ] Dependency changes are reviewed
[ ] Vulnerability scanning is enabled
[ ] Multiple target frameworks are considered
This checklist provides a strong baseline without requiring an overly complicated build system.
Frequently Asked Questions
Does packages.lock.json lock direct package versions?
It records resolved dependency information, including direct and transitive dependencies. The exact contents depend on the project's target frameworks and dependency graph.
Should every .NET project use a lock file?
Not necessarily. The right approach depends on the repository, application lifecycle, and dependency-management strategy. Lock files become particularly valuable when deterministic dependency resolution is important.
Should the lock file be committed to Git?
If the repository uses NuGet locked mode as part of its build policy, yes. The lock file needs to be available to CI and should be reviewed as part of dependency changes.
Can I still update packages?
Yes. The normal workflow is to intentionally update the package requirement, regenerate the lock file, review the dependency graph, and then commit the resulting changes.
Does a lock file improve security?
It can improve dependency governance and make unexpected dependency changes easier to detect, but it is not a complete security control. Package-source restrictions, vulnerability scanning, package provenance, and CI hardening are still important.
Does locked restore guarantee identical application binaries?
No. Locked restore primarily controls dependency resolution. Complete reproducibility involves additional build inputs such as the SDK, toolchain, build configuration, and environment.
Conclusion
Reproducible .NET builds begin by making dependency resolution an explicit build input rather than an implicit result of whatever NuGet happens to resolve at build time. A committed packages.lock.json combined with dotnet restore --locked-mode gives CI a clear contract: if the dependency graph changes, the build should know about it.
For enterprise applications, the strongest approach goes beyond the lock file. Control the .NET SDK, package sources, source mapping, dependency versions, and CI restore behavior together. Review lock-file changes as carefully as application code changes, and fail the build when an unexpected dependency change appears. That turns dependency management from a hidden source of build drift into a controlled and auditable part of the .NET delivery process.

Jasen FiciPosted Aug 24, 2026, 12:10 PM
We highlighted this in DotNetNews here: https://dotnetnews.co/archive/the-net-news-daily-issue-525/