skip to content

Two helm package runs over identical chart source produce .tgz files with different digests. Why?

level: seniorimportance: should knowfreq 36%

answer

  1. The archive records more than the content
  2. A tar entry carries metadata per file
  3. A fresh clone resets every timestamp
  4. One environment variable pins the clock
  5. SOURCE_DATE_EPOCH, Helm 4 only

basics

~20 s

A chart archive is a tar, and tar entries carry each file's modification time, so a fresh checkout changes the bytes even when the content is identical. Helm 4's helm package honours SOURCE_DATE_EPOCH: pin it and the archive becomes byte-reproducible.

solid answer

~50 s

The digest of a `.tgz` is not a digest of the chart's content — it is a digest of a tar stream that also records per-file metadata, above all modification times. A CI job that clones the repository stamps every file with the checkout time, so packaging the same commit an hour later produces a different archive and a different `sha256`. In Helm 4, `helm package` honours the `SOURCE_DATE_EPOCH` convention: export a fixed Unix timestamp — typically the commit's own timestamp — and the archive's entry timestamps are pinned, making repeated builds byte-identical. Helm 3 has no such support, so there you build the artifact once and store it rather than rebuilding. Timestamps are usually the whole story, but not always: `-u/--dependency-update` re-resolves dependencies and can pull a newer subchart into `charts/`, and `--version`/`--app-version` stamping changes the metadata. Pin those too if you want determinism.

code

bash · 4 lines
bash
export SOURCE_DATE_EPOCH=$(git log -1 --format=%ct)
helm dependency build ./pdfsign
helm package -d ./dist ./pdfsign
sha256sum ./dist/pdfsign-2.7.3.tgz

go deeper

for a junior

Know that a chart archive is a tar file and that tar records file timestamps, so two builds of the same source can differ in bytes even when nothing about the chart changed.

for a middle

Explain the mechanism and the fix: mtimes in tar entries, a fresh checkout resetting them, and Helm 4 honouring SOURCE_DATE_EPOCH to pin them, with Helm 3 having no equivalent.

for a senior

Demonstrate the triage: pin the clock, then extract and diff both archives to separate metadata drift from genuine content drift, and name floating dependency ranges and stamped metadata as the remaining variables.

for a principal

Own the standard: whether artifacts are rebuilt or built once and stored, how packaging jobs get their epoch, when a reproducibility claim is worth the pinning discipline it demands, and what the org does when the claim fails.

### Why the digests differ at all People reason about a chart archive as if it were content-addressed: same files in, same hash out. It is not. `helm package` writes a tar archive and compresses it, and a tar archive stores, for every entry, a header containing the path plus file metadata — modification time among it. Two builds of the same commit on two machines, or on the same machine after a fresh clone, produce files whose mtimes are the moment the checkout happened. The chart content is identical; the tar bytes are not; the `sha256` differs. This surfaces the moment anyone starts comparing artifacts. A pipeline builds `pdfsign-2.7.3.tgz` in the build job and again in a verification job, and gets `sha256:9c41…` and `sha256:2be7…` for what is unmistakably the same chart. Someone concludes the source is not what they think it is, or that something in the pipeline is tampering. Nothing is wrong; the format simply records more than the content. ### The fix Helm 4 provides Helm 4's `helm package` honours `SOURCE_DATE_EPOCH`, the cross-ecosystem convention for reproducible builds: an environment variable holding a Unix timestamp in seconds, which build tools use in place of "now" wherever they would otherwise embed a time. Set it — conventionally to the timestamp of the commit being packaged — and the archive's entry timestamps are pinned to that value, so packaging the same source produces the same bytes on any machine at any time. ```bash export SOURCE_DATE_EPOCH=$(git log -1 --format=%ct) helm package -d ./dist ./pdfsign sha256sum ./dist/pdfsign-2.7.3.tgz ``` Run that twice from two clean clones of the same commit and the digests match. Helm 3.21 does not implement this; on Helm 3 the practical answer is that the artifact is built once, stored, and referenced by digest thereafter — you compare stored copies, not rebuilds. ### What still moves after you pin the clock Timestamps are usually the whole explanation, but a reproducibility claim is only as strong as its weakest input, and packaging has three more: - **Dependency resolution.** `-u/--dependency-update` re-resolves what `Chart.yaml` declares and downloads into `charts/`. If a dependency is declared with a range rather than an exact version, a build a week later legitimately vendors a different subchart and the archive genuinely differs. For a chart with an optional bundled database subchart this is the common case. Build from a committed lock or from vendored `charts/` when you want byte-equality, and treat a floating dependency range as an explicit decision to be non-reproducible. - **Metadata stamping.** `--version` and `--app-version` change `Chart.yaml` inside the archive. A pipeline that stamps a build counter into the version is producing a different artifact every run by design; that is fine, but it is not reproducibility. - **The file set.** Anything `.helmignore` lets through that varies between checkouts — a generated file, a local override someone's environment writes — changes the archive. Reproducibility failures that survive a pinned `SOURCE_DATE_EPOCH` are almost always this: something in the tree is not in version control. ### Why anyone cares Three reasons, in increasing order of seriousness. First, caching and comparison. If a rebuild yields the same bytes, a pipeline can skip republishing, and a reviewer can confirm that the artifact on the shelf corresponds to the commit it claims. Second, incident work. When you are asked whether the chart running in production is the chart in the tag, a reproducible build turns a debate into a command. Without it, you can only compare the extracted contents, which is doable but not something you want to be inventing at 2 a.m. Third, verification generally. Digests are how artifacts are referenced once they leave your machine — a repository index records one, and any verification step downstream is checking one. An artifact whose digest changes every build is one that no downstream check can pin, and it pushes teams toward the weaker habit of trusting a version *string* instead. Verifying an archive's authorship is a separate mechanism from making its bytes stable, but the second makes the first far more useful. ### The pragmatic position Set `SOURCE_DATE_EPOCH` from the commit in every chart-packaging job — it is one line and it removes a whole category of confusing pipeline noise. Pin dependency versions rather than ranges. Then, when two builds still disagree, you have a real finding rather than a shrug, and the diff of the extracted trees will tell you exactly which file is not coming from the repository.

  • You set SOURCE_DATE_EPOCH and two builds still differ. Where do you look next?
    Extract both archives and diff the trees; the digests differ because a file's content or the file set differs. The usual causes are a dependency resolved from a version range pulling a different subchart, a generated file that is not in version control, or metadata stamped by `--version`/`--app-version`. Pin the dependency, commit or ignore the generated file, and re-run before concluding anything about the pipeline.
  • Why not just compare the extracted contents instead of chasing byte-identical archives?
    You can, and it is the right diagnostic when a comparison already fails. But downstream systems reference artifacts by digest, not by an extracted tree, so a digest that changes on every build cannot be pinned by anything outside your pipeline. Reproducible bytes let a single hash comparison answer the question that would otherwise need a manual extraction and diff.
  • How does this interact with pinning chart dependencies?
    Directly. Packaging with a dependency update re-resolves declared ranges, so a chart with a floating subchart constraint is non-reproducible by construction no matter what the clock says. Building from a committed lock or from vendored contents under `charts/` makes the input fixed; a range is a deliberate trade of reproducibility for automatic pickup of subchart updates.

Two photocopies of the same page are not identical if the machine stamps each with the time it was made. SOURCE_DATE_EPOCH tells the machine which time to stamp.

saying these in an interview costs you the question

  • Thinks a .tgz digest is a digest of the chart's content
  • Blames the compression level or a random build id
  • Assumes rebuilding a published archive is always safe
  • Never heard of SOURCE_DATE_EPOCH and guesses a Helm flag
  • Claims Helm 3 packaging is reproducible by default
  • Ignores floating dependency ranges as a source of drift

context