From 1df209e084f61524f875f8030c0c98303b4338c7 Mon Sep 17 00:00:00 2001 From: Ilya Kiselev Date: Fri, 29 May 2026 23:52:31 +0300 Subject: [PATCH] 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")