Reproducible and Deterministic Builds
|
This section documents MSBuild 18.9, the .NET SDK 10.0.402 (with its This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The C# Reference bibliography lists the reference material consulted while preparing these pages. |
A build is deterministic when the same inputs produce byte-identical outputs, and reproducible when anyone can recreate those inputs — the same source revision, SDK, packages and settings — and verify the result. Reproducibility is what lets a consumer check that a package on nuget.org really came from the tagged source, lets a cache reuse outputs safely, and makes "works on my machine" differences visible as a diff. .NET gets most of the way by default: for modern .NET projects the C# compiler is deterministic by default. The rest is configuration, and this page shows each piece working on Ledgerline with .NET SDK 10.0.402 (NuGet 7.9.0): two clones of the same commit, in different folders, built and packed until their hashes match.
| Input that varies | Setting that removes the variation | Default |
|---|---|---|
Compiler output (timestamps, GUIDs, order) |
|
on |
Absolute source paths in PDBs and assemblies |
|
off — set it on CI |
Source revision and repository URL |
Source Link ( |
on since .NET 8 |
Package versions |
|
off (Lock Files) |
SDK version |
|
no pin (Installing and Selecting SDKs) |
Timestamps inside |
|
off (on by default in .NET 11, a preview) |
The compiler: Deterministic
With Deterministic set to true (the default for modern .NET projects), csc produces the same assembly for the
same inputs: the timestamp and MVID fields are derived from a hash of the compilation inputs instead of the clock and a
random GUID. The documented inputs include the command line, the .rsp file, the exact compiler version and its
referenced assemblies. "Same inputs" includes the full paths
of the source files, which end up in the PDB and in the assembly’s debug directory. Building the library in two folders
shows the problem:
git clone ~/ledgerline a
git clone ~/ledgerline agent-2/work/ledgerline
(cd a && dotnet build src/Ledgerline -c Release -f net10.0)
(cd agent-2/work/ledgerline && dotnet build src/Ledgerline -c Release -f net10.0)
sha256sum a/src/Ledgerline/bin/Release/net10.0/Ledgerline.dll agent-2/work/ledgerline/src/Ledgerline/bin/Release/net10.0/Ledgerline.dll
strings -n 8 a/src/Ledgerline/bin/Release/net10.0/Ledgerline.dll | grep '\.pdb'
9a3432f31ea37b9dddfa1fe48964c0c39f78833310af0b6947dac80eb1a19d3c a/src/Ledgerline/bin/Release/net10.0/Ledgerline.dll
ee941427736451458fcbd3d4ecdefc1f4eca03c5d93614ad49989a370d82779f agent-2/work/ledgerline/src/Ledgerline/bin/Release/net10.0/Ledgerline.dll
~/repro/a/src/Ledgerline/obj/Release/net10.0/Ledgerline.pdb
The assembly records where its PDB was written, so two checkouts in different folders differ.
Source: C# compiler options: Deterministic.
ContinuousIntegrationBuild
ContinuousIntegrationBuild=true tells the SDK the build is an official one: source paths are normalised (the source
root is mapped to /_/), and Source Link requires the source control information to be present. It should be on in CI
and off locally, because the debugger cannot find local sources through /_/ paths. The documented pattern, which
Ledgerline's Directory.Build.props uses, conditions it on the variables the CI systems set:
<PropertyGroup Condition="'$(GITHUB_ACTIONS)' == 'true' or '$(TF_BUILD)' == 'true'">
<ContinuousIntegrationBuild>true</ContinuousIntegrationBuild>
</PropertyGroup>
GITHUB_ACTIONS (GitHub Actions) and TF_BUILD (Azure Pipelines) are environment variables, which MSBuild turns into
properties. Simulating CI with the variable gives identical assemblies in both folders, and the PDB path is now mapped:
export GITHUB_ACTIONS=true
(cd a && dotnet build src/Ledgerline -c Release -f net10.0 --no-incremental)
(cd agent-2/work/ledgerline && dotnet build src/Ledgerline -c Release -f net10.0 --no-incremental)
sha256sum a/src/Ledgerline/bin/Release/net10.0/Ledgerline.dll agent-2/work/ledgerline/src/Ledgerline/bin/Release/net10.0/Ledgerline.dll
strings -n 8 a/src/Ledgerline/bin/Release/net10.0/Ledgerline.dll | grep '\.pdb'
a748f7ce767c2a0cb0a6c8efa71e498d15ca4e0597a57645c7577c3018f6557b a/src/Ledgerline/bin/Release/net10.0/Ledgerline.dll
a748f7ce767c2a0cb0a6c8efa71e498d15ca4e0597a57645c7577c3018f6557b agent-2/work/ledgerline/src/Ledgerline/bin/Release/net10.0/Ledgerline.dll
/_/src/Ledgerline/obj/Release/net10.0/Ledgerline.pdb
Other CI systems need their own variable in the condition (for example CI on many hosted runners) or
-p:ContinuousIntegrationBuild=true on the command line.
Source Link and the source revision
Since .NET 8, Source Link is part of the SDK: no Microsoft.SourceLink.* package is needed for GitHub, GitLab,
Azure Repos or Bitbucket repositories. During the build it reads the Git repository (without needing git on the
PATH), sets SourceRevisionId to the commit and writes a Source Link JSON map into the PDB, so debuggers can download
the exact source of each file. As a side effect — a documented breaking change of .NET 8 — the commit is appended to
InformationalVersion as SemVer build metadata:
cd src/Ledgerline
dotnet msbuild -p:TargetFramework=net10.0 -t:GetAssemblyAttributes -getProperty:InformationalVersion
dotnet msbuild -p:TargetFramework=net10.0 -p:IncludeSourceRevisionInInformationalVersion=false -t:GetAssemblyAttributes -getProperty:InformationalVersion
git log --oneline -1
1.0.0+bfbb6aa4c9ab954bc44426dee0d15d4af458eab2
1.0.0
bfbb6aa local tool manifest
Keep the revision in official builds — it identifies the source of a binary — and turn it off only where the
+<sha> suffix breaks something that parses the version. A build from a source archive without .git has no
revision unless SourceRevisionId is passed explicitly. Packaging symbols and Source Link for nuget.org is covered on
Symbols, Source Link and Deterministic Packages.
Some library-guidance pages still tell you to add the Microsoft.SourceLink.GitHub package; with SDK 8.0.100 and later
that is unnecessary.
Deterministic packs
A .nupkg is a ZIP file, and ZIP entries carry modification times. Even with identical assemblies, two packs differ.
Packing the same commit in the two clones (with GITHUB_ACTIONS=true, so the DLLs are identical) and comparing the
packages entry by entry with a small Python script (nupkgdiff.py: it hashes each entry and compares its ZIP timestamp):
(cd a && dotnet pack src/Ledgerline -c Release -o ../q1)
(cd agent-2/work/ledgerline && dotnet pack src/Ledgerline -c Release -o ../../../q2)
python3 nupkgdiff.py q1/Ledgerline.1.0.0.nupkg q2/Ledgerline.1.0.0.nupkg
DIFF time Ledgerline.nuspec
SAME README.md
DIFF time [Content_Types].xml
DIFF time _rels/.rels
SAME icon.png
DIFF time lib/net10.0/Ledgerline.dll
DIFF time lib/net10.0/Ledgerline.xml
DIFF time lib/net8.0/Ledgerline.dll
DIFF time lib/net8.0/Ledgerline.xml
DIFF time lib/netstandard2.0/Ledgerline.dll
DIFF time lib/netstandard2.0/Ledgerline.xml
DIFF time package/services/metadata/core-properties/nuget.psmdcp
Every content is the same; only the times differ. The pack target has two inputs for this:
| Property | Meaning |
|---|---|
|
Deterministic mode for the package: one timestamp for every entry. The documentation gives the default as |
|
The timestamp to use: an RFC 3339 date/time, a Unix time in seconds, |
|
Documented as used automatically in place of |
With both properties set, the two clones produce the same package:
(cd a && dotnet pack src/Ledgerline -c Release -o ../p1 -p:Deterministic=true -p:DeterministicTimestamp=2026-10-23T00:00:00Z)
(cd agent-2/work/ledgerline && dotnet pack src/Ledgerline -c Release -o ../../../p2 -p:Deterministic=true -p:DeterministicTimestamp=2026-10-23T00:00:00Z)
sha256sum p1/*.nupkg p2/*.nupkg
unzip -l p1/Ledgerline.1.0.0.nupkg
684b9056df05624150774b7410eb4a8422c809fd837e062485fbde4d73630d89 p1/Ledgerline.1.0.0.nupkg
684b9056df05624150774b7410eb4a8422c809fd837e062485fbde4d73630d89 p2/Ledgerline.1.0.0.nupkg
Archive: p1/Ledgerline.1.0.0.nupkg
Length Date Time Name
--------- ---------- ----- ----
473 2026-10-23 00:00 _rels/.rels
1260 2026-10-23 00:00 Ledgerline.nuspec
297 2026-10-23 00:00 README.md
680 2026-10-23 00:00 icon.png
13312 2026-10-23 00:00 lib/net10.0/Ledgerline.dll
…
A practical choice for the timestamp is the commit time, so it changes only when the source does. In SDK 10.0.402 the
SOURCE_DATE_EPOCH variable alone did not have that effect: with SOURCE_DATE_EPOCH exported and only
-p:Deterministic=true, the entries still carried the current time and the two packages differed, because the pack
targets set DeterministicTimestamp to false before NuGet looks at the variable. Passing the value explicitly works:
export SOURCE_DATE_EPOCH=$(git log -1 --format=%ct)
(cd a && dotnet pack src/Ledgerline -c Release -o ../p5 -p:Deterministic=true -p:DeterministicTimestamp=$SOURCE_DATE_EPOCH)
(cd agent-2/work/ledgerline && dotnet pack src/Ledgerline -c Release -o ../../../p6 -p:Deterministic=true -p:DeterministicTimestamp=$SOURCE_DATE_EPOCH)
sha256sum p5/*.nupkg p6/*.nupkg
unzip -l p5/Ledgerline.1.0.0.nupkg
d240eb5f782e3a617a3c0817c2c31d014efda80e705e748fdef9755a9dc10b56 p5/Ledgerline.1.0.0.nupkg
d240eb5f782e3a617a3c0817c2c31d014efda80e705e748fdef9755a9dc10b56 p6/Ledgerline.1.0.0.nupkg
473 2026-10-23 17:53 _rels/.rels
1260 2026-10-23 17:53 Ledgerline.nuspec
(17:53:05 UTC is the commit time; the unzip listing is abridged.) The documentation is also inconsistent on
when deterministic pack arrived: the dotnet pack examples say ".NET 10.0.400 and later", the pack-target table gives
the default as ".NET 11+". Both are true — available from 10.0.400, on by default in 11 — and .NET 11 is still a
preview.
Locked restores and pinned SDKs
Determinism of the compiler and the packer is worthless if the inputs move. Two more files close the gap, each with its own page:
-
packages.lock.jsonwithRestorePackagesWithLockFile=true, anddotnet restore --locked-modeon CI, so a floating or newly published transitive version cannot slip in (Lock Files and Locked Mode); -
global.jsonwith a pinned SDK, because the SDK version decides the compiler, the analyzers, the default language version and the build logic.
Reproducible container images (.NET 11 preview)
The SDK’s built-in container publishing (dotnet publish -t:PublishContainer) produced different image digests for the
same inputs, because of timestamps, archive headers and file enumeration order. The .NET 11 preview SDK fixes this:
with SOURCE_DATE_EPOCH set to a stable value, independent publishes of the same inputs produce the same digest. It is
not available on SDK 10.0.402 and is not run here; see
Container Images.
Verifying a package
To check that a published package matches its source: check out the commit named in its InformationalVersion or
nuspec repository element, use the SDK version from the repository’s global.json, build with the same switches
(ContinuousIntegrationBuild, Deterministic, the same DeterministicTimestamp), and compare — whole-file hashes
first, then entry by entry as above. A package signed by nuget.org (repository signature) gains a signature file,
so compare the entries other than .signature.p7s. The dotnet organisation’s
dotnet/reproducible-builds repository (a dotnet-org GitHub project)
collects MSBuild settings for reproducible builds.
Common mistakes
-
ContinuousIntegrationBuildon for local builds — the debugger cannot map/_/paths back to local files. -
A CI system whose variable is not in the condition — the CI build silently embeds absolute paths.
-
Relying on
SOURCE_DATE_EPOCHalone with SDK 10.0.402 — passDeterministicTimestampexplicitly. -
Comparing packages after nuget.org signed one — the repository signature changes the file.
-
Floating versions or no lock file — reproducible compiler, non-reproducible inputs.
-
Adding the
Microsoft.SourceLink.GitHubpackage on .NET 8+ — redundant; the SDK includes it.