Skip to content

Make published container images reproducible - #55689

Open
jetersen wants to merge 3 commits into
dotnet:mainfrom
jetersen:feat/reproducible-container-timestamps
Open

Make published container images reproducible#55689
jetersen wants to merge 3 commits into
dotnet:mainfrom
jetersen:feat/reproducible-container-timestamps

Conversation

@jetersen

@jetersen jetersen commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Publishing the same application twice produces two different image digests. Nothing about the build changed, but the registry cannot deduplicate the blobs and anything watching it treats the rebuild as a new artifact. This came up in a GitOps pipeline, where retrying a failed publish of one commit created a second, spurious release of an unchanged application.

Image creation is a function of the current time in four places:

  • every layer tar entry is stamped with the moment it happened to be written. PaxTarEntry defaults its modification time to DateTime.UtcNow, and the file's own timestamp is never read, so each entry samples the clock separately;
  • the image config samples DateTime.UtcNow twice, once for created and once for the generated history entries;
  • the generated org.opencontainers.image.created label uses UtcNow in the targets file;
  • every layer tar embeds the process id, because TarWriter names each pax extended header ./PaxHeaders.<process id>/..

The first and last dominate: either alone changes the layer digest even when the content is byte-identical.

What this changes

The three timestamp sites use SOURCE_DATE_EPOCH when it is set, which is the cross-ecosystem convention for exactly this problem. When it is unset the behavior is unchanged, so this part is opt-in. Following the specification, a value that cannot be interpreted is ignored rather than failing the build.

The pax header name is replaced with a constant. The name is not meaningful to an extractor: the path an extended header applies to is carried in its path record, not in its entry name, and the header always applies to the entry that immediately follows it. POSIX suggests the process id so that two concurrent extractions cannot collide over a temporary name, which does not apply to an archive being written to a stream. This is done with a small write-through stream between the tar writer and the stream that hashes the layer, so the digest is computed over the normalized bytes. It reassembles 512 byte blocks itself, since a caller can write across block boundaries, and it tracks each header's declared size so that file content which happens to look like a header is never rewritten.

Directory enumeration order is filesystem-defined, so entries are also sorted by their path in the container to keep the tar stream stable across machines.

Note that the pax change affects every publish, not only opted-in builds. Digests will shift once for everyone. That seemed right given it is fixing a defect rather than adding a behavior, but I am happy to gate it if you would prefer.

Verification

Published the same project twice to a registry and compared the manifest digests.

Before, the two publishes differed. Investigating showed only 13 bytes of the layer differed, and all of them followed from the pax entry name, the rest being the header checksums it shifts.

After:

scenario digest
republish, no changes sha256:2e726658…sha256:2e726658… identical
one line of source changed sha256:6354b493… differs
base image 10.0-noble sha256:006bdce… differs
base image 10.0-alpine sha256:b0c7746… differs

So the digest is still a function of the inputs; it is reproducible, not frozen. Also confirmed that with SOURCE_DATE_EPOCH unset the created label is still the current time.

Tests

Added coverage for the timestamp helper (including malformed values, culture independence and the unset case), for the normalizing stream (chunked writes, archive still reads back correctly, file content that looks like a header is untouched), and for layer reproducibility, including that layers built from different content still differ.

I checked these fail without the fix: reverting the entry timestamp change fails two of the three layer tests, while the "different content still differs" test keeps passing.

Addresses dotnet/sdk-container-builds#34 (determinism for layers) and dotnet/sdk-container-builds#585 (SOURCE_DATE_EPOCH support), which are called out as prerequisites in #54038.

Also covers most of #52256, which asks for control over the creation timestamps for the same reproducibility reason. Of the options suggested there, this implements the SOURCE_DATE_EPOCH convention, which subsumes "set it to the commit timestamp" (SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct)) without the SDK needing to shell out to git.

One thing that issue reports is worth separating out. Setting the label directly does get overwritten:

<ContainerLabel Include="org.opencontainers.image.created" Value="2025-12-20T00:33:31.4004695Z" />

I confirmed this on a current SDK; the value is silently replaced with the publish time. But that is because the generated label is added to the same item group and wins, and there is already a supported way to yield to the user:

-p:ContainerGenerateLabelsImageCreated=false

With that set, I confirmed the user-specified value survives to the image config. So that half is arguably a discoverability problem rather than a missing feature, and I have left it alone here rather than changing item precedence in this PR.

Publishing the same application twice produces two different image digests,
because image creation is a function of the current time in three places:

- every layer tar entry is stamped with the moment it happened to be written,
  as `PaxTarEntry` defaults its modification time to `DateTime.UtcNow` and the
  file's own timestamp is never read;
- the image config's `created` field and the generated history entries each
  sample `DateTime.UtcNow` independently;
- the generated `org.opencontainers.image.created` label uses `UtcNow`.

The layer entries dominate: because each entry samples the clock separately,
two publishes of byte-identical content yield different layer digests, so the
registry cannot deduplicate the blobs and downstream tooling treats a rebuild
of an unchanged commit as a brand new artifact. That is what motivated this
change: retrying a publish of one commit created a second, spurious artifact
in a GitOps promotion pipeline.

These sites now use SOURCE_DATE_EPOCH when it is set, the cross-ecosystem
convention for this exact problem (https://reproducible-builds.org/docs/source-date-epoch/).
When it is unset the behavior is unchanged, so this is opt-in. Following the
specification, a value that cannot be interpreted is ignored rather than
failing the build.

Directory enumeration order is also filesystem-defined, so entries are now
sorted by their path in the container to keep the tar stream stable across
machines.

The digest remains a function of the content: layers built from different
content still differ, which is covered by a test.
With the timestamps pinned, publishing the same content twice still produced
different layer digests. Only 13 bytes of the layer differed, and they all
followed from one field: `TarWriter` names the pax extended header entry that
precedes each entry `./PaxHeaders.<process id>/.`, so the archive depends on
the process that produced it. The remaining bytes were the header checksums
that the name change shifts.

POSIX suggests the process id so that concurrent extractions cannot collide
over a temporary name, but the name is not meaningful to an extractor: the
path an extended header applies to is carried in its `path` record, and the
header always applies to the entry that immediately follows it. It is replaced
here with a constant while writing the layer.

The rewrite is done with a small write-through stream placed between the tar
writer and the stream that hashes the layer, so the digest is computed over the
normalized bytes. It reassembles the 512 byte blocks itself, since a caller can
write across block boundaries, and it tracks each header's size so that file
content which happens to look like a header is never rewritten.

Verified end to end by publishing the same project twice to a registry:
previously the two digests differed, now they are identical, while changing the
source or the base image still changes the digest as expected.
Copilot AI lite review requested due to automatic review settings August 8, 2026 23:18
@jetersen
jetersen requested a review from a team as a code owner August 8, 2026 23:18
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 1 pipeline(s).
2 pipeline(s) were filtered out due to trigger conditions.
There may be pipelines that require an authorized user to comment /azp run to run.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR makes PublishContainer outputs reproducible across repeated publishes of identical inputs by removing time/process-id nondeterminism from layer tar generation, image config creation timestamps, and the org.opencontainers.image.created label. This supports registry-side deduplication and enables downstream workflows (e.g., GitOps retry/promotion) to treat identical rebuilds as the same artifact.

Changes:

  • Honor SOURCE_DATE_EPOCH (when set) for layer entry mtimes, image config created/history timestamps, and the generated OCI created label.
  • Normalize pax extended header entry names to remove process-id variance and sort directory enumeration by container path to stabilize tar entry order.
  • Add unit tests covering SOURCE_DATE_EPOCH parsing, pax header name normalization correctness, and layer digest reproducibility.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
test/Microsoft.NET.Build.Containers.UnitTests/SourceDateEpochTests.cs Adds unit coverage for SOURCE_DATE_EPOCH parsing behavior (valid/invalid/unset/culture).
test/Microsoft.NET.Build.Containers.UnitTests/PaxHeaderNameNormalizingStreamTests.cs Adds tests ensuring pax header renaming is deterministic and does not corrupt tar archives.
test/Microsoft.NET.Build.Containers.UnitTests/LayerReproducibilityTests.cs Adds tests asserting identical inputs yield identical layer digests and entries use the pinned timestamp.
src/Containers/packaging/build/Microsoft.NET.Build.Containers.targets Updates generated org.opencontainers.image.created label to use SOURCE_DATE_EPOCH when available.
src/Containers/Microsoft.NET.Build.Containers/SourceDateEpoch.cs Introduces a helper to compute a stable UTC timestamp from SOURCE_DATE_EPOCH with fallback behavior.
src/Containers/Microsoft.NET.Build.Containers/PaxHeaderNameNormalizingStream.cs Introduces a stream filter to rewrite pax header names deterministically while preserving tar validity.
src/Containers/Microsoft.NET.Build.Containers/Layer.cs Makes layer tar generation deterministic (pax name normalization, stable mtimes, sorted entries).
src/Containers/Microsoft.NET.Build.Containers/ImageConfig.cs Makes config created and history timestamps deterministic and consistent within the blob.
Suppressed comments (2)

test/Microsoft.NET.Build.Containers.UnitTests/LayerReproducibilityTests.cs:79

  • This test sets SOURCE_DATE_EPOCH and then clears it to null in the finally block, which can clobber a pre-existing value in the test process. Capture and restore the original environment variable value instead.
        finally
        {
            Environment.SetEnvironmentVariable("SOURCE_DATE_EPOCH", null);
        }

test/Microsoft.NET.Build.Containers.UnitTests/LayerReproducibilityTests.cs:97

  • This test sets SOURCE_DATE_EPOCH and then clears it to null in the finally block, which can clobber a pre-existing value in the test process. Capture and restore the original environment variable value instead.
        finally
        {
            Environment.SetEnvironmentVariable("SOURCE_DATE_EPOCH", null);
        }

Bound the digit count accepted by the targets file. DateTimeOffset.FromUnixTimeSeconds
throws outside its supported range, so a value such as 99999999999999999999 failed
evaluation with MSB4186 instead of being ignored, contradicting the documented behavior
of ignoring values that cannot be interpreted. The C# helper already guarded this.

Also restore any pre-existing SOURCE_DATE_EPOCH in the layer tests rather than clearing it.
@jetersen

jetersen commented Aug 8, 2026

Copy link
Copy Markdown
Contributor Author

Thanks, one of these was a real bug.

Out-of-range SOURCE_DATE_EPOCH in the targets file: correct, now fixed. I ran each input through MSBuild to check:

SOURCE_DATE_EPOCH before after
1636374896 2021-11-08T12:34:56Z 2021-11-08T12:34:56Z
1,636,374,896 ignored, falls back ignored, falls back
1636374896 ignored, falls back ignored, falls back
abc / -5 / empty ignored, falls back ignored, falls back
99999999999999999999 error MSB4186 ignored, falls back

So the comma and whitespace cases were already safe: the regex rejects them and evaluation falls back to UtcNow, because the property is only expanded into FromUnixTimeSeconds after that guard clears it. But the out-of-range case did break the build exactly as you describe, since DateTimeOffset.FromUnixTimeSeconds throws. The C# helper already caught this; the targets file did not.

Fixed by bounding the digit count, which keeps every value inside the supported range:

'^[0-9]{1,11}$'

I also quoted the property in the IsMatch call as you suggested. It was not reachable as a break given the guard ordering, but quoting is correct and costs nothing.

Test environment variable restore: applied. Agreed, and it is cheap to be correct. The three sites in LayerReproducibilityTests now capture the previous value and restore it instead of clearing to null. SourceDateEpochTests already did this.

Full suite after both changes: 320 total, 314 passed, 6 skipped, 0 failed.

using (TarWriter writer = new(gz, TarEntryFormat.Pax, leaveOpen: true))
// The extended header names the runtime writes contain the current process id, which
// would otherwise make the layer differ between two builds of identical content.
using (PaxHeaderNameNormalizingStream normalized = new(gz, leaveOpen: true))

@jetersen jetersen Aug 9, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure if this deserves a fix in TarWriter implementation, but the quickest path for Build Containers was this stream fix. Opened dotnet/runtime#132049

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants