Debian Is Mandating Reproducible Builds. What Does That Mean for .NET?

Debian just made reproducible builds mandatory for its packages. It's the strongest supply-chain signal in years — and the .NET toolchain is more ready for it than most people realise. Here's how to make your builds byte-for-byte reproducible and prove it in CI.

Jean-Pierre Broeders

Freelance .NET Developer

June 8, 202610 min. read
Debian Is Mandating Reproducible Builds. What Does That Mean for .NET?

Debian Is Mandating Reproducible Builds. What Does That Mean for .NET?

A short message from the Debian release team climbed the Hacker News front page this week (the announcement itself), and it is a bigger deal than its plain-text formatting suggests. As of May 9, 2026, Debian's migration tooling blocks any new package that cannot be reproduced, and blocks any package already in testing that regresses in reproducibility. Debian 14 "Forky", expected in 2027, will be the first major general-purpose Linux distribution to make reproducible builds mandatory across the board. At the time of the announcement, 98.29% of architecture-independent packages already reproduced — 23,731 passing, 414 flagged as bad.

That number matters because Debian is the upstream for more than 120 derivative distributions, including Ubuntu, Mint, Kali, Raspberry Pi OS and Tails. When Debian's testing branch enforces reproducibility, everything downstream inherits the guarantee. This is the largest single push for verifiable builds the open-source world has seen.

I want to take this out of the Linux-packaging context and ask the question that actually affects me as a .NET freelancer: if reproducible builds are becoming table stakes for distributing software, how ready is the .NET toolchain, and what do I have to do to get there? The pleasant surprise is that .NET is further along than most people assume. The unpleasant surprise is that "deterministic by default" and "actually reproducible" are not the same thing, and the gap is exactly where supply-chain bugs hide.

What reproducible actually means

A build is reproducible when compiling the same source, in the same defined environment, always produces the exact same bytes — every time, on any machine. That is a stronger property than "deterministic". Determinism means the compiler is a pure function of its inputs. Reproducibility means the whole build is, including every environmental variable that sneaks into the output: absolute file paths, embedded timestamps, the order the filesystem hands you files, locale, and the compiler version itself.

Why bother? Because reproducibility is what lets a third party verify that a published binary actually came from the published source. Without it, you are trusting that whoever ran the build did not slip something in between git clone and dotnet publish — and the xz backdoor showed precisely how that trust gets abused. A reproducible build turns "trust me" into "verify it yourself": anyone rebuilds, compares hashes, and either they match or someone has questions to answer.

.NET is deterministic by default — and that is not enough

Here is the part people get wrong. Modern .NET sets <Deterministic>true</Deterministic> by default. The Roslyn compiler, given identical inputs, produces byte-for-byte identical output: it replaces the timestamp and the MVID (the module's ModuleVersionId, normally a random GUID) with values derived from a hash of all compilation inputs. So far so good.

But "identical inputs" is doing a lot of work in that sentence. The compiler's notion of inputs includes the current directory path. And by default, your assembly and its PDB embed the absolute source file paths. Build the same commit at C:\DEV\myapp and at /home/runner/work/myapp, and you get different bytes — not because the code differs, but because the paths baked into the debug information differ. Your local build and your CI build will not match, and neither will two developers' machines.

That is the gap ContinuousIntegrationBuild closes. When set to true, it normalises stored file paths so the absolute build location no longer leaks into the output:

<Project>
  <PropertyGroup>
    <Deterministic>true</Deterministic>
    <!-- Turn on path normalisation only on CI, where you don't need
         the debugger to resolve local source paths. -->
    <ContinuousIntegrationBuild Condition="'$(GITHUB_ACTIONS)' == 'true'">true</ContinuousIntegrationBuild>
  </PropertyGroup>
</Project>

Note the condition. You deliberately do not enable it for local debug builds, because path normalisation breaks the debugger's ability to find your source on disk. On the CI server, where nobody is stepping through code, you want it on. For Azure Pipelines the variable is TF_BUILD; for GitHub Actions it is GITHUB_ACTIONS. Put this in a Directory.Build.props at the root of the repo so every project inherits it without you remembering to.

SourceLink: traceability for free, with one caveat

Since .NET 8, SourceLink is bundled into the SDK. Every build now embeds the source control commit into the InformationalVersion, so a binary carries the exact SHA it was built from — 1.4.0+a1b2c3d. That is genuinely useful: given a mystery DLL in production, you can read the commit straight off it.

It interacts with reproducibility in a way worth understanding, though. SourceLink data — the commit SHA, the repo URL — is itself a compilation input. That is fine and even desirable: two builds of the same commit embed the same SHA, so they stay reproducible. The trap is if you inject anything non-deterministic into versioning, the most common culprit being a build number or a timestamp-derived version:

<!-- DON'T: a timestamp makes every build unique on purpose -->
<Version>1.4.0-ci$([System.DateTime]::Now.ToString("yyyyMMddHHmmss"))</Version>

If your version string changes every build, your output changes every build, and reproducibility is gone by construction. Drive the version from the commit or the tag, not the clock. If you need build metadata, put it in a place that does not feed the compiler — a release note, an OCI image label, a provenance attestation — not the assembly version.

A reproducible publish in practice

Pulling it together, here is the recipe I now apply to anything I distribute. The project properties:

<!-- Directory.Build.props -->
<Project>
  <PropertyGroup>
    <Deterministic>true</Deterministic>
    <ContinuousIntegrationBuild Condition="'$(GITHUB_ACTIONS)' == 'true'">true</ContinuousIntegrationBuild>
    <!-- Embed sources the compiler can't otherwise resolve, so the PDB is self-contained -->
    <EmbedUntrackedSources>true</EmbedUntrackedSources>
    <DebugType>portable</DebugType>
    <!-- Pin the SDK so the compiler version is itself an input you control -->
  </PropertyGroup>
</Project>

And a global.json pinning the SDK, because the precise compiler version is part of the input set — a different SDK is a different build:

{
  "sdk": {
    "version": "9.0.301",
    "rollForward": "disable"
  }
}

Then the proof step. Reproducibility you do not verify is just a hopeful comment. The cheapest possible check is to build the artifact twice and compare hashes:

#!/usr/bin/env bash
set -euo pipefail

dotnet publish -c Release -o ./out-1 /p:ContinuousIntegrationBuild=true
dotnet publish -c Release -o ./out-2 /p:ContinuousIntegrationBuild=true

# Compare every produced file by content hash.
diff <(cd out-1 && find . -type f -exec sha256sum {} \; | sort) \
     <(cd out-2 && find . -type f -exec sha256sum {} \; | sort) \
  && echo "Reproducible ✅" \
  || { echo "NON-reproducible ❌"; exit 1; }

Two builds on the same runner catches the obvious non-determinism — embedded timestamps, random GUIDs, the version-string mistake above. To catch path leakage you need the stronger test: build on two genuinely different paths and compare. That is where the real bugs surface, and it is worth a dedicated CI job:

# .github/workflows/reproducible.yml
name: reproducible-build
on: [push]
jobs:
  verify:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-dotnet@v4
      - name: Build at path A
        run: dotnet publish src/MyApp -c Release -o /tmp/a /p:ContinuousIntegrationBuild=true
      - name: Build at path B
        run: |
          cp -r . /tmp/repo-b && cd /tmp/repo-b
          dotnet publish src/MyApp -c Release -o /tmp/b /p:ContinuousIntegrationBuild=true
      - name: Compare
        run: |
          diff <(cd /tmp/a && find . -type f -exec sha256sum {} \; | sort) \
               <(cd /tmp/b && find . -type f -exec sha256sum {} \; | sort)

If that job is green, you have something genuinely worth distributing: a binary anyone can independently rebuild and check.

The container layer is where it usually breaks

Most .NET teams do not ship a bare DLL; they ship a container image. And the moment you wrap a reproducible assembly in a Dockerfile, you can re-introduce every non-determinism you just eliminated. apt-get pulls whatever version is current today. The image creation timestamp changes every build. File ordering in a layer depends on the build context. So the assembly hashes match but the image digests never do.

Some of this is fixable with discipline. Pin base images by digest, not by tag:

# Pin by digest so "8.0" doesn't silently change underneath you
FROM mcr.microsoft.com/dotnet/aspnet@sha256:abc123...

The timestamp problem has a standard answer that the reproducible-builds community converged on years ago: the SOURCE_DATE_EPOCH environment variable, which build tools honour as the canonical "now". BuildKit respects it for layer timestamps via --build-arg SOURCE_DATE_EPOCH=... combined with rewrite-timestamp. Set it from the commit date so it is stable per commit:

export SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct)
docker buildx build \
  --build-arg SOURCE_DATE_EPOCH=$SOURCE_DATE_EPOCH \
  --output type=image,name=myapp,rewrite-timestamp=true .

Be honest with yourself about how far you need to take this. End-to-end reproducible container images are real work, and for an internal line-of-business service the payoff may not justify it. For anything you publish to others — a base image, an open-source tool, a package other teams depend on — it increasingly will.

SLSA: from "reproducible" to "provably built here"

Reproducibility answers "does this binary match the source?". The complementary question is "where and how was it built?", and that is what build provenance answers. The SLSA framework formalises this, and GitHub Actions makes the basic level almost free with the build-provenance attestation action:

permissions:
  id-token: write
  attestations: write
  contents: read
steps:
  - uses: actions/attest-build-provenance@v1
    with:
      subject-path: '/tmp/a/MyApp.dll'

This produces a signed, verifiable statement that this artifact was built by this workflow from this commit. Pair it with a reproducible build and you have both halves of the trust story: provenance says where it came from, reproducibility lets anyone confirm the provenance is not lying.

The takeaway

Debian's mandate is a signal, not an isolated event. Reproducible builds are moving from a security-researcher curiosity to a baseline expectation for anyone distributing software, and the regulatory pressure around software supply chains is pushing the same direction. The good news for .NET developers is that the toolchain has quietly done most of the hard work: deterministic compilation is on by default, SourceLink ships in the box, and ContinuousIntegrationBuild closes the one gap that bites everyone. The work that remains is mostly discipline — pin your SDK, drive versions from commits not clocks, and add a CI job that actually rebuilds and compares.

Start with the two-path comparison job. It takes an afternoon, it will probably fail the first time, and the reason it fails will teach you exactly which part of your build is lying about being reproducible. Read the Debian announcement and the HN discussion for the broader context — then go make your own builds something a stranger could verify.

Want to stay updated?

Subscribe to my newsletter or get in touch for freelance projects.

Get in Touch