.NET 11 reproducible containers solve a specific release-pipeline problem: retrying the same SDK container publish can now produce the same manifest digest when you give the build a stable SOURCE_DATE_EPOCH. Use the source commit time, keep every other input fixed, and verify the result with two temporary tags before depending on digest-based GitOps, signing, or promotion.

This is an SDK container-publishing feature in .NET 11 RC1. It applies to /t:PublishContainer; it does not make Dockerfile builds reproducible automatically, and it does not make a moving base-image tag stable. If you are still moving production applications from older releases, finish the broader SDK and container checks in the .NET 10 migration checklist before treating digest stability as the only upgrade concern.

The CI Contract in One Command

Set SOURCE_DATE_EPOCH to a Unix timestamp derived from the source revision that produced the image:

epoch="$(git show -s --format=%ct HEAD)"

dotnet publish src/Orders.Api/Orders.Api.csproj \
  --configuration Release \
  --os linux \
  --arch x64 \
  /t:PublishContainer \
  -p:ContainerRegistry=ghcr.io \
  -p:ContainerRepository=acme/orders-api \
  -p:ContainerImageTag="${GIT_SHA}" \
  -p:SOURCE_DATE_EPOCH="${epoch}"

Pin the .NET 11 SDK in global.json as well. Reproducibility cannot be tested meaningfully when two runners may select different SDK builds:

{
  "sdk": {
    "version": "11.0.100-rc.1.26425.128",
    "rollForward": "disable"
  }
}

The timestamp is only one input. The application source, restored packages, runtime identifier, publish properties, generated files, and resolved base image must also be identical. For a release pipeline, prefer a base-image digest or an otherwise controlled base-image update process instead of assuming that a mutable tag still points to yesterday’s bytes.

Why the Digest Changed Before .NET 11

An OCI image digest covers the manifest, which refers to the image configuration and layers by digest. A timestamp or archive-header change can therefore create a different layer or configuration digest even when the application source is unchanged.

The .NET SDK implementation previously sampled the current time for layer entries, image configuration, history, and generated OCI creation labels. PAX extended-header names also contained a process ID, and filesystem enumeration order could vary. The .NET 11 work normalizes those values when SOURCE_DATE_EPOCH is supplied: it uses one parsed creation time, a stable PAX header name, and sorted container paths.

That produces reproducible output, not frozen output. Microsoft verified that an unchanged republish retained its digest while a source edit or base-image change produced a different digest. That distinction matters: a digest should stay stable for the same inputs and change when a deployment input changes.

If SOURCE_DATE_EPOCH is missing, malformed, negative, or outside the supported date range, the SDK falls back to the current UTC time. The build does not fail. That compatibility behavior is convenient, but it means a typo can silently remove reproducibility. Validate the value before calling dotnet publish:

epoch="$(git show -s --format=%ct HEAD)"

case "$epoch" in
  ''|*[!0-9]*)
    echo "SOURCE_DATE_EPOCH must be a non-negative Unix timestamp" >&2
    exit 64
    ;;
esac

export SOURCE_DATE_EPOCH="$epoch"

The explicit validation turns an opt-in hint into a pipeline contract.

Verify .NET 11 Reproducible Containers in CI

Do not verify reproducibility by comparing two mutable tags after one of them has been overwritten. Publish the same commit twice under two temporary tags in a disposable registry repository. Authenticate to the registry first, then run:

set -euo pipefail

project="src/Orders.Api/Orders.Api.csproj"
registry="ghcr.io"
repository="acme/orders-api-repro-check"
epoch="$(git show -s --format=%ct HEAD)"

publish_tag() {
  local tag="$1"

  dotnet publish "$project" \
    --configuration Release \
    --os linux \
    --arch x64 \
    /t:PublishContainer \
    -p:ContainerRegistry="$registry" \
    -p:ContainerRepository="$repository" \
    -p:ContainerImageTag="$tag" \
    -p:SOURCE_DATE_EPOCH="$epoch"
}

publish_tag repro-a
publish_tag repro-b

image="$registry/$repository"
digest_a="$(docker buildx imagetools inspect "$image:repro-a" \
  --format '{{json .Manifest}}' | jq -r '.digest')"
digest_b="$(docker buildx imagetools inspect "$image:repro-b" \
  --format '{{json .Manifest}}' | jq -r '.digest')"

printf 'repro-a: %s\nrepro-b: %s\n' "$digest_a" "$digest_b"
test -n "$digest_a"
test "$digest_a" = "$digest_b"

The expected evidence is two non-empty, identical sha256: manifest digests. The Docker imagetools inspect command reads the registry manifest, so the comparison proves what the registry stored rather than what a local build intended to push.

Run this adoption check on two clean runners if your pipeline supports it. Reusing one workspace can hide generated-file or restore differences. Once the check passes, normal CI does not need to publish every image twice; a retry of the same commit should be enough to exercise the invariant naturally.

Verify the Timestamp Instead of Trusting the Flag

Inspect the image configuration for one temporary tag:

docker buildx imagetools inspect \
  ghcr.io/acme/orders-api-repro-check:repro-a \
  --format '{{json .Image}}' \
  | jq -r '.created'

date --utc --date="@$SOURCE_DATE_EPOCH" --iso-8601=seconds

Both values should represent the same instant. Use this as diagnostic evidence, not as the only gate: matching creation times do not prove that every layer and manifest byte is reproducible. The digest comparison remains authoritative.

Understand the New Registry Reuse Path

After the SDK computes the image locally, .NET 11 RC1 checks whether that manifest already exists in the destination repository. When it exists, the SDK skips processing the layers and configuration for upload but still applies every requested tag. The local image must still be built because the SDK needs its computed manifest digest before it can ask the registry whether that exact manifest is present.

This means the second publish in the verification script may be much quieter and faster. It is not a skipped deployment. Confirm both tags resolve to the same digest; do not treat the absence of repeated blob uploads as proof that the second tag was applied.

If a registry proxy behaves incorrectly during the manifest-existence check, bypass only that optimization:

dotnet publish src/Orders.Api/Orders.Api.csproj \
  /t:PublishContainer \
  -p:ContainerRegistry=registry.example.com \
  -p:ContainerRepository=platform/orders-api \
  -p:ContainerImageTag="$GIT_SHA" \
  -p:SOURCE_DATE_EPOCH="$SOURCE_DATE_EPOCH" \
  -p:ContainerPushNoCache=true

ContainerPushNoCache=true forces the manifest-level push path. It does not disable registry blob deduplication, and it does not change the reproducibility inputs. Keep it as a bounded troubleshooting or validation switch rather than a default workaround.

For multi-platform publishing, verify both the top-level image index and each platform manifest. The reuse optimization applies to the individual platform images; an index can still change when its platform set, ordering, annotations, or referenced manifests change.

Common Reasons the Digest Still Changes

SOURCE_DATE_EPOCH removes SDK-controlled time and ordering noise. It cannot normalize arbitrary nondeterminism produced earlier in the build. Investigate these inputs in order:

  1. The SDK differs. Compare dotnet --version and keep global.json committed.
  2. The base image moved. Record the resolved base-image digest, not only its tag.
  3. Restore resolved different packages. Commit the lock file where appropriate and use locked restore in release CI.
  4. Generated files embed current time, paths, or runner-specific data. Compare the publish directories before blaming the container writer.
  5. Publish properties or RID differ. Log the exact command and relevant MSBuild properties.
  6. The source checkout differs. Include submodules, generated sources, and untracked build inputs in the comparison.
  7. Only the top-level multi-platform index differs. Compare each platform manifest separately.

A useful diagnostic split is:

Different publish-directory bytes?
  yes -> investigate compilation, restore, or generators
  no  -> compare base-image digest and container properties
          then compare config, manifest, and layer descriptors

Avoid “fixing” the mismatch by reusing yesterday’s tag or suppressing the comparison. The purpose of the gate is to expose an untracked input.

Production Boundaries

A stable digest improves artifact identity, but it is not a security verdict. Continue to scan the image, generate provenance, and sign or attest the digest according to your release policy. Deploy by digest when immutability matters; a mutable tag can be repointed even when the underlying image is reproducible.

Choose the epoch from source control, not from a human-entered release date. A commit timestamp gives every runner a shared value and changes when the source revision changes. It is not a claim about when the image was actually built, so keep real build time in external provenance or CI metadata if operators need it.

Treat a base-image update as a legitimate new input. If mcr.microsoft.com/dotnet/aspnet:11.0 resolves to a patched base, the resulting application image should receive a new digest even when your source commit is unchanged. Record both the application revision and base digest so incident response can explain why.

Finally, keep the rollback simple. If the RC1 SDK causes a registry-specific problem, pin the previous known-good SDK or set ContainerPushNoCache=true to isolate the reuse check. Do not remove digest verification permanently; restore it after the compatibility issue is understood.

Adoption Checklist

  • Pin the exact .NET 11 SDK used by developer and CI builds.
  • Derive and validate SOURCE_DATE_EPOCH from the source revision.
  • Keep the runtime identifier, publish properties, and restore graph stable.
  • Control or record the resolved base-image digest.
  • Publish two temporary tags from clean inputs and compare registry digests.
  • Confirm the image created value matches the chosen epoch.
  • Verify every requested tag exists even when the SDK reuses a manifest.
  • Test ContainerPushNoCache=true only when the registry path needs diagnosis.
  • For multi-platform images, compare the index and each platform manifest.
  • Keep scanning, signing, provenance, and deployment-by-digest as separate controls.

With those checks in place, a CI retry stops looking like a new release when nothing changed, while real input changes still produce a new artifact identity.

References

Found this useful? Support more practical developer content.

Author

Practical .NET, Angular, Azure, Blazor, and AI engineering for real-world development.

Write A Comment

Ads Blocker Image Powered by Code Help Pro

Ads Blocker Detected!!!

We have detected that you are using extensions to block ads. Please support us by disabling these ads blocker.

Powered By
Best Wordpress Adblock Detecting Plugin | CHP Adblock