From 1df209e084f61524f875f8030c0c98303b4338c7 Mon Sep 17 00:00:00 2001 From: Ilya Kiselev Date: Fri, 29 May 2026 23:52:31 +0300 Subject: [PATCH 1/7] feat(package): support SOURCE_DATE_EPOCH for reproducible chart archives Read SOURCE_DATE_EPOCH in the CLI layer (pkg/cmd/package.go) and pass it to the action layer as Package.SourceDateEpoch *time.Time. Before calling Save(), stampModTimes() overwrites every tar-entry ModTime in the chart (including templates, files, raw values, schema and sub-charts) with the requested epoch. writeToTar and Save are not modified: the caller now supplies the epoch as the modTime argument, which is how mattfarina's review (#32060) asked for this to be structured. Closes #31439 Signed-off-by: Ilya Kiselev --- pkg/action/package.go | 30 ++++++++++++++++++++ pkg/action/package_test.go | 57 ++++++++++++++++++++++++++++++++++++++ pkg/cmd/package.go | 10 +++++++ 3 files changed, 97 insertions(+) diff --git a/pkg/action/package.go b/pkg/action/package.go index 86426b412..ebacd4da0 100644 --- a/pkg/action/package.go +++ b/pkg/action/package.go @@ -23,6 +23,7 @@ import ( "os" "path/filepath" "syscall" + "time" "github.com/Masterminds/semver/v3" "golang.org/x/term" @@ -49,6 +50,12 @@ type Package struct { Destination string DependencyUpdate bool + // SourceDateEpoch, when non-nil, overrides all tar entry modification times + // in the produced chart archive. Set by callers that want reproducible builds. + // The environment variable SOURCE_DATE_EPOCH is the conventional way to supply + // this value from the CLI; reading that variable is the CLI's responsibility. + SourceDateEpoch *time.Time + RepositoryConfig string RepositoryCache string PlainHTTP bool @@ -121,6 +128,10 @@ func (p *Package) Run(path string, _ map[string]any) (string, error) { dest = p.Destination } + if p.SourceDateEpoch != nil { + stampModTimes(ch, *p.SourceDateEpoch) + } + name, err := chartutil.Save(ch, dest) if err != nil { return "", fmt.Errorf("failed to save: %w", err) @@ -254,3 +265,22 @@ func openPassphraseFile(passphraseFile string, stdin *os.File) (*os.File, error) } return os.Open(passphraseFile) } + +// stampModTimes recursively sets all file modification times in a chart to t. +// This is used to produce reproducible archives when SourceDateEpoch is set. +func stampModTimes(c *chart.Chart, t time.Time) { + c.ModTime = t + c.SchemaModTime = t + for _, f := range c.Raw { + f.ModTime = t + } + for _, f := range c.Templates { + f.ModTime = t + } + for _, f := range c.Files { + f.ModTime = t + } + for _, dep := range c.Dependencies() { + stampModTimes(dep, t) + } +} diff --git a/pkg/action/package_test.go b/pkg/action/package_test.go index 2e1d4ff07..2c8749a2c 100644 --- a/pkg/action/package_test.go +++ b/pkg/action/package_test.go @@ -17,10 +17,14 @@ limitations under the License. package action import ( + "archive/tar" + "compress/gzip" "errors" + "io" "os" "path" "testing" + "time" "github.com/Masterminds/semver/v3" "github.com/stretchr/testify/require" @@ -170,3 +174,56 @@ func TestRun(t *testing.T) { require.Equal(t, "empty-0.1.0.tgz", filename) require.NoError(t, os.Remove(filename)) } + +func TestRunWithSourceDateEpoch(t *testing.T) { + chartPath := "testdata/charts/chart-with-schema" + epoch := time.Unix(1700000000, 0) + + client := NewPackage() + client.SourceDateEpoch = &epoch + + filename, err := client.Run(chartPath, nil) + require.NoError(t, err) + t.Cleanup(func() { os.Remove(filename) }) + + // All tar entry ModTimes must equal the epoch. + f, err := os.Open(filename) + require.NoError(t, err) + defer f.Close() + + gr, err := gzip.NewReader(f) + require.NoError(t, err) + defer gr.Close() + + tr := tar.NewReader(gr) + for { + hdr, err := tr.Next() + if err == io.EOF { + break + } + require.NoError(t, err) + require.Equal(t, epoch, hdr.ModTime, "expected epoch ModTime for entry %s", hdr.Name) + } +} + +func TestRunWithSourceDateEpochReproducible(t *testing.T) { + chartPath := "testdata/charts/chart-with-schema" + epoch := time.Unix(1700000000, 0) + + build := func() []byte { + t.Helper() + dir := t.TempDir() + client := NewPackage() + client.SourceDateEpoch = &epoch + client.Destination = dir + filename, err := client.Run(chartPath, nil) + require.NoError(t, err) + data, err := os.ReadFile(filename) + require.NoError(t, err) + return data + } + + first := build() + second := build() + require.Equal(t, first, second, "two builds with the same SOURCE_DATE_EPOCH must be byte-identical") +} diff --git a/pkg/cmd/package.go b/pkg/cmd/package.go index 14f9c8425..616da3ca1 100644 --- a/pkg/cmd/package.go +++ b/pkg/cmd/package.go @@ -22,6 +22,8 @@ import ( "io" "os" "path/filepath" + "strconv" + "time" "github.com/spf13/cobra" @@ -59,6 +61,14 @@ func newPackageCmd(out io.Writer) *cobra.Command { if len(args) == 0 { return errors.New("need at least one argument, the path to the chart") } + if epochStr, ok := os.LookupEnv("SOURCE_DATE_EPOCH"); ok { + sec, err := strconv.ParseInt(epochStr, 10, 64) + if err != nil || sec < 0 { + return fmt.Errorf("invalid SOURCE_DATE_EPOCH %q: must be a non-negative Unix timestamp", epochStr) + } + t := time.Unix(sec, 0) + client.SourceDateEpoch = &t + } if client.Sign { if client.Key == "" { return errors.New("--key is required for signing a package") From 9b457ea0321515bca04336f974fa25eee53a3dde Mon Sep 17 00:00:00 2001 From: Ilya Kiselev Date: Fri, 29 May 2026 23:57:56 +0300 Subject: [PATCH 2/7] fix: normalize epoch to UTC/truncate before stamping tar modtimes Tar headers have second-level granularity and store timezone-independent Unix timestamps. Normalize SourceDateEpoch with .UTC().Truncate(time.Second) so callers passing sub-second or non-UTC values still get deterministic output. Also verify in the test that gzip header ModTime is zero (helm design) and compare tar entry ModTimes with time.Equal() since tar.Reader returns local time. Signed-off-by: Ilya Kiselev --- pkg/action/package.go | 3 +++ pkg/action/package_test.go | 10 +++++++--- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/pkg/action/package.go b/pkg/action/package.go index ebacd4da0..d3e8d1a52 100644 --- a/pkg/action/package.go +++ b/pkg/action/package.go @@ -267,8 +267,11 @@ func openPassphraseFile(passphraseFile string, stdin *os.File) (*os.File, error) } // stampModTimes recursively sets all file modification times in a chart to t. +// t is normalized to UTC and truncated to whole seconds before use because tar +// headers have second-level granularity and timezone-independent storage. // This is used to produce reproducible archives when SourceDateEpoch is set. func stampModTimes(c *chart.Chart, t time.Time) { + t = t.UTC().Truncate(time.Second) c.ModTime = t c.SchemaModTime = t for _, f := range c.Raw { diff --git a/pkg/action/package_test.go b/pkg/action/package_test.go index 2c8749a2c..89f6385b3 100644 --- a/pkg/action/package_test.go +++ b/pkg/action/package_test.go @@ -177,7 +177,8 @@ func TestRun(t *testing.T) { func TestRunWithSourceDateEpoch(t *testing.T) { chartPath := "testdata/charts/chart-with-schema" - epoch := time.Unix(1700000000, 0) + // Use UTC so the comparison with tar header ModTime (always UTC) is straightforward. + epoch := time.Unix(1700000000, 0).UTC() client := NewPackage() client.SourceDateEpoch = &epoch @@ -186,15 +187,18 @@ func TestRunWithSourceDateEpoch(t *testing.T) { require.NoError(t, err) t.Cleanup(func() { os.Remove(filename) }) - // All tar entry ModTimes must equal the epoch. f, err := os.Open(filename) require.NoError(t, err) defer f.Close() + // Check gzip header ModTime: helm leaves it at zero (deterministic by design). gr, err := gzip.NewReader(f) require.NoError(t, err) + require.True(t, gr.Header.ModTime.IsZero(), "gzip header ModTime should be zero") defer gr.Close() + // All tar entry ModTimes must represent the same instant as epoch. + // tar.Reader returns ModTime in local timezone, so use Equal() not require.Equal. tr := tar.NewReader(gr) for { hdr, err := tr.Next() @@ -202,7 +206,7 @@ func TestRunWithSourceDateEpoch(t *testing.T) { break } require.NoError(t, err) - require.Equal(t, epoch, hdr.ModTime, "expected epoch ModTime for entry %s", hdr.Name) + require.True(t, epoch.Equal(hdr.ModTime), "entry %s: got ModTime %v, want %v", hdr.Name, hdr.ModTime, epoch) } } From bfba53e264364386cb2e4aa7759a9cc2c1b50f7c Mon Sep 17 00:00:00 2001 From: Ilya Kiselev Date: Sat, 30 May 2026 00:07:51 +0300 Subject: [PATCH 3/7] fix(package): remove embedded field from gzip selector per QF1008 Replace gr.Header.ModTime with gr.ModTime in test to satisfy staticcheck QF1008: embedded field "Header" can be elided from selector. Signed-off-by: Ilya Kiselev --- pkg/action/package_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/action/package_test.go b/pkg/action/package_test.go index 89f6385b3..fea13d441 100644 --- a/pkg/action/package_test.go +++ b/pkg/action/package_test.go @@ -194,7 +194,7 @@ func TestRunWithSourceDateEpoch(t *testing.T) { // Check gzip header ModTime: helm leaves it at zero (deterministic by design). gr, err := gzip.NewReader(f) require.NoError(t, err) - require.True(t, gr.Header.ModTime.IsZero(), "gzip header ModTime should be zero") + require.True(t, gr.ModTime.IsZero(), "gzip header ModTime should be zero") defer gr.Close() // All tar entry ModTimes must represent the same instant as epoch. From b0c3f438f349aabce828a63d39040a45617b5a8f Mon Sep 17 00:00:00 2001 From: Ilya Kiselev Date: Sat, 30 May 2026 00:12:57 +0300 Subject: [PATCH 4/7] test(package): clarify comment on Time.Equal usage in tar modtime check Signed-off-by: Ilya Kiselev --- pkg/action/package_test.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pkg/action/package_test.go b/pkg/action/package_test.go index fea13d441..ccc73ab91 100644 --- a/pkg/action/package_test.go +++ b/pkg/action/package_test.go @@ -197,8 +197,9 @@ func TestRunWithSourceDateEpoch(t *testing.T) { require.True(t, gr.ModTime.IsZero(), "gzip header ModTime should be zero") defer gr.Close() - // All tar entry ModTimes must represent the same instant as epoch. - // tar.Reader returns ModTime in local timezone, so use Equal() not require.Equal. + // Use Equal() rather than require.Equal so that timezone differences between + // the stored epoch (UTC) and the value tar.Reader reconstructs do not cause + // a false failure on non-UTC machines. tr := tar.NewReader(gr) for { hdr, err := tr.Next() From cd6bf78a206626ffb7a387bd5c42f32891d3a46e Mon Sep 17 00:00:00 2001 From: Ilya Kiselev Date: Wed, 24 Jun 2026 01:56:29 +0300 Subject: [PATCH 5/7] fix(package): make mod-time stamping a Chart method and stamp Lock.Generated stampModTimes lived in pkg/action as a free function operating on *chart.Chart from outside the package. Moved it to chart.Chart.StampModTimes in pkg/chart/v2, which is also where it can reach the private dependencies field directly instead of going through the exported Dependencies() accessor. While moving it, also stamp Lock.Generated when Lock is set: chartutil.Save uses Lock.Generated as the tar entry modtime for Chart.lock, so without this the lock file entry kept its original timestamp and broke reproducibility for any chart with a Chart.lock. Signed-off-by: Ilya Kiselev --- pkg/action/package.go | 24 +----------- pkg/action/package_test.go | 37 +++++++++++++++++++ .../charts/chart-with-lock/Chart.lock | 3 ++ .../charts/chart-with-lock/Chart.yaml | 4 ++ pkg/chart/v2/chart.go | 26 +++++++++++++ 5 files changed, 71 insertions(+), 23 deletions(-) create mode 100644 pkg/action/testdata/charts/chart-with-lock/Chart.lock create mode 100644 pkg/action/testdata/charts/chart-with-lock/Chart.yaml diff --git a/pkg/action/package.go b/pkg/action/package.go index d3e8d1a52..b8ee21c68 100644 --- a/pkg/action/package.go +++ b/pkg/action/package.go @@ -129,7 +129,7 @@ func (p *Package) Run(path string, _ map[string]any) (string, error) { } if p.SourceDateEpoch != nil { - stampModTimes(ch, *p.SourceDateEpoch) + ch.StampModTimes(*p.SourceDateEpoch) } name, err := chartutil.Save(ch, dest) @@ -265,25 +265,3 @@ func openPassphraseFile(passphraseFile string, stdin *os.File) (*os.File, error) } return os.Open(passphraseFile) } - -// stampModTimes recursively sets all file modification times in a chart to t. -// t is normalized to UTC and truncated to whole seconds before use because tar -// headers have second-level granularity and timezone-independent storage. -// This is used to produce reproducible archives when SourceDateEpoch is set. -func stampModTimes(c *chart.Chart, t time.Time) { - t = t.UTC().Truncate(time.Second) - c.ModTime = t - c.SchemaModTime = t - for _, f := range c.Raw { - f.ModTime = t - } - for _, f := range c.Templates { - f.ModTime = t - } - for _, f := range c.Files { - f.ModTime = t - } - for _, dep := range c.Dependencies() { - stampModTimes(dep, t) - } -} diff --git a/pkg/action/package_test.go b/pkg/action/package_test.go index ccc73ab91..2f6f50330 100644 --- a/pkg/action/package_test.go +++ b/pkg/action/package_test.go @@ -232,3 +232,40 @@ func TestRunWithSourceDateEpochReproducible(t *testing.T) { second := build() require.Equal(t, first, second, "two builds with the same SOURCE_DATE_EPOCH must be byte-identical") } + +func TestRunWithSourceDateEpochStampsLockGenerated(t *testing.T) { + chartPath := "testdata/charts/chart-with-lock" + epoch := time.Unix(1700000000, 0).UTC() + + client := NewPackage() + client.SourceDateEpoch = &epoch + + filename, err := client.Run(chartPath, nil) + require.NoError(t, err) + t.Cleanup(func() { os.Remove(filename) }) + + f, err := os.Open(filename) + require.NoError(t, err) + defer f.Close() + + gr, err := gzip.NewReader(f) + require.NoError(t, err) + defer gr.Close() + + found := false + tr := tar.NewReader(gr) + for { + hdr, err := tr.Next() + if err == io.EOF { + break + } + require.NoError(t, err) + if path.Base(hdr.Name) == "Chart.lock" { + found = true + // Without stamping Lock.Generated, this entry would keep the + // fixture's original 2016 timestamp instead of the epoch. + require.True(t, epoch.Equal(hdr.ModTime), "Chart.lock entry: got ModTime %v, want %v", hdr.ModTime, epoch) + } + } + require.True(t, found, "expected archive to contain a Chart.lock entry") +} diff --git a/pkg/action/testdata/charts/chart-with-lock/Chart.lock b/pkg/action/testdata/charts/chart-with-lock/Chart.lock new file mode 100644 index 000000000..824df0a40 --- /dev/null +++ b/pkg/action/testdata/charts/chart-with-lock/Chart.lock @@ -0,0 +1,3 @@ +dependencies: [] +digest: sha256:0000000000000000000000000000000000000000000000000000000000000 +generated: "2016-01-01T00:00:00Z" diff --git a/pkg/action/testdata/charts/chart-with-lock/Chart.yaml b/pkg/action/testdata/charts/chart-with-lock/Chart.yaml new file mode 100644 index 000000000..2cb1b3557 --- /dev/null +++ b/pkg/action/testdata/charts/chart-with-lock/Chart.yaml @@ -0,0 +1,4 @@ +apiVersion: v2 +name: chart-with-lock +version: 0.1.0 +description: Test chart with a Chart.lock, used to verify SourceDateEpoch stamping diff --git a/pkg/chart/v2/chart.go b/pkg/chart/v2/chart.go index 4cfc2b890..adfaab15c 100644 --- a/pkg/chart/v2/chart.go +++ b/pkg/chart/v2/chart.go @@ -105,6 +105,32 @@ func (ch *Chart) Root() *Chart { // Dependencies are the charts that this chart depends on. func (ch *Chart) Dependencies() []*Chart { return ch.dependencies } +// StampModTimes recursively sets all file modification times in the chart and +// its dependencies to t. t is normalized to UTC and truncated to whole seconds +// because tar headers have second-level granularity and timezone-independent +// storage. This is used to produce reproducible archives when a build process +// supplies a fixed timestamp (e.g. via SOURCE_DATE_EPOCH). +func (ch *Chart) StampModTimes(t time.Time) { + t = t.UTC().Truncate(time.Second) + ch.ModTime = t + ch.SchemaModTime = t + if ch.Lock != nil { + ch.Lock.Generated = t + } + for _, f := range ch.Raw { + f.ModTime = t + } + for _, f := range ch.Templates { + f.ModTime = t + } + for _, f := range ch.Files { + f.ModTime = t + } + for _, dep := range ch.dependencies { + dep.StampModTimes(t) + } +} + // IsRoot determines if the chart is the root chart. func (ch *Chart) IsRoot() bool { return ch.parent == nil } From 43dcad45e1a2c2850cdb8d728e66db7ace3fd6f0 Mon Sep 17 00:00:00 2001 From: Ilya Kiselev Date: Wed, 24 Jun 2026 01:56:37 +0300 Subject: [PATCH 6/7] test(cmd): cover SOURCE_DATE_EPOCH parsing in helm package CLI Adds CLI-level coverage for the SOURCE_DATE_EPOCH path in pkg/cmd/package.go: invalid (non-numeric, negative) values surface the expected error, and a valid value produces an archive whose tar entries are all stamped with the given epoch. Signed-off-by: Ilya Kiselev --- pkg/cmd/package_test.go | 66 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/pkg/cmd/package_test.go b/pkg/cmd/package_test.go index db4a2523a..2a96feabf 100644 --- a/pkg/cmd/package_test.go +++ b/pkg/cmd/package_test.go @@ -16,12 +16,16 @@ limitations under the License. package cmd import ( + "archive/tar" + "compress/gzip" "fmt" + "io" "os" "path/filepath" "regexp" "strings" "testing" + "time" "helm.sh/helm/v4/internal/test/ensure" chart "helm.sh/helm/v4/pkg/chart/v2" @@ -192,6 +196,68 @@ func TestSetAppVersion(t *testing.T) { } } +func TestPackageSourceDateEpoch(t *testing.T) { + chartToPackage := "testdata/testcharts/alpine" + + for _, tt := range []struct { + name string + envVal string + err string + }{ + {name: "non-numeric value", envVal: "not-a-number", err: "invalid SOURCE_DATE_EPOCH"}, + {name: "negative value", envVal: "-1", err: "invalid SOURCE_DATE_EPOCH"}, + } { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + t.Setenv("SOURCE_DATE_EPOCH", tt.envVal) + cmd := fmt.Sprintf("package %s --destination=%s", chartToPackage, dir) + _, _, err := executeActionCommand(cmd) + if err == nil || !strings.Contains(err.Error(), tt.err) { + t.Fatalf("expected error containing %q, got %v", tt.err, err) + } + }) + } + + t.Run("valid value stamps archive entries", func(t *testing.T) { + dir := t.TempDir() + t.Setenv("SOURCE_DATE_EPOCH", "1700000000") + cmd := fmt.Sprintf("package %s --destination=%s", chartToPackage, dir) + _, output, err := executeActionCommand(cmd) + if err != nil { + t.Logf("Output: %s", output) + t.Fatal(err) + } + + chartPath := filepath.Join(dir, "alpine-0.1.0.tgz") + f, err := os.Open(chartPath) + if err != nil { + t.Fatal(err) + } + defer f.Close() + + gr, err := gzip.NewReader(f) + if err != nil { + t.Fatal(err) + } + defer gr.Close() + + epoch := time.Unix(1700000000, 0).UTC() + tr := tar.NewReader(gr) + for { + hdr, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + t.Fatal(err) + } + if !epoch.Equal(hdr.ModTime) { + t.Errorf("entry %s: got ModTime %v, want %v", hdr.Name, hdr.ModTime, epoch) + } + } + }) +} + func TestPackageFileCompletion(t *testing.T) { checkFileCompletion(t, "package", true) checkFileCompletion(t, "package mypath", true) // Multiple paths can be given From 0ef7c25e15b05b44530e45e07350bafccdb629fb Mon Sep 17 00:00:00 2001 From: Ilya Kiselev Date: Wed, 24 Jun 2026 02:04:39 +0300 Subject: [PATCH 7/7] docs(package): document that SourceDateEpoch also rewrites Chart.lock's generated field Lock.Generated isn't just the tar entry modtime for Chart.lock, it's also marshaled into the lock file's own YAML content. Made that explicit on both the SourceDateEpoch field and chart.Chart.StampModTimes so it isn't a surprise to SDK consumers or future maintainers. Signed-off-by: Ilya Kiselev --- pkg/action/package.go | 9 ++++++--- pkg/chart/v2/chart.go | 5 +++++ 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/pkg/action/package.go b/pkg/action/package.go index b8ee21c68..9da63553a 100644 --- a/pkg/action/package.go +++ b/pkg/action/package.go @@ -51,9 +51,12 @@ type Package struct { DependencyUpdate bool // SourceDateEpoch, when non-nil, overrides all tar entry modification times - // in the produced chart archive. Set by callers that want reproducible builds. - // The environment variable SOURCE_DATE_EPOCH is the conventional way to supply - // this value from the CLI; reading that variable is the CLI's responsibility. + // in the produced chart archive, and also overwrites the "generated" + // timestamp in Chart.lock (if present) since that value is both the lock + // file's own tar entry modtime and part of its marshaled YAML content. + // Set by callers that want reproducible builds. The environment variable + // SOURCE_DATE_EPOCH is the conventional way to supply this value from the + // CLI; reading that variable is the CLI's responsibility. SourceDateEpoch *time.Time RepositoryConfig string diff --git a/pkg/chart/v2/chart.go b/pkg/chart/v2/chart.go index adfaab15c..d24b3bded 100644 --- a/pkg/chart/v2/chart.go +++ b/pkg/chart/v2/chart.go @@ -110,6 +110,11 @@ func (ch *Chart) Dependencies() []*Chart { return ch.dependencies } // because tar headers have second-level granularity and timezone-independent // storage. This is used to produce reproducible archives when a build process // supplies a fixed timestamp (e.g. via SOURCE_DATE_EPOCH). +// +// If Lock is set, its Generated field is also overwritten with t. This is not +// just a tar header change: Generated is marshaled into Chart.lock's own YAML +// content, so this stamping is visible to anyone who reads the lock file out +// of the produced archive. func (ch *Chart) StampModTimes(t time.Time) { t = t.UTC().Truncate(time.Second) ch.ModTime = t