diff --git a/pkg/action/package.go b/pkg/action/package.go index 1b7901f17..6fd968a56 100644 --- a/pkg/action/package.go +++ b/pkg/action/package.go @@ -50,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 @@ -59,8 +65,6 @@ type Package struct { KeyFile string CaFile string InsecureSkipTLSVerify bool - // SourceDateEpoch, when set, normalizes chart timestamps for reproducible archives. - SourceDateEpoch *time.Time } const ( @@ -128,6 +132,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) @@ -261,3 +269,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 9984b508a..8c6e720b3 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/assert" @@ -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") +}