From f7591abe2a77a46013b5905877db8a38d4835aaf Mon Sep 17 00:00:00 2001 From: Kunal Jain Date: Mon, 20 Apr 2026 14:02:03 -0700 Subject: [PATCH] fix: Add support for SOURCE_DATE_EPOCH in reproducible builds Signed-off-by: Kunal Jain --- internal/chart/v3/util/save.go | 19 +++++++++- internal/chart/v3/util/save_test.go | 55 +++++++++++++++++++++++++++++ pkg/chart/v2/util/save.go | 19 +++++++++- pkg/chart/v2/util/save_test.go | 55 +++++++++++++++++++++++++++++ 4 files changed, 146 insertions(+), 2 deletions(-) diff --git a/internal/chart/v3/util/save.go b/internal/chart/v3/util/save.go index f886c6175..b802ab421 100644 --- a/internal/chart/v3/util/save.go +++ b/internal/chart/v3/util/save.go @@ -25,6 +25,7 @@ import ( "io/fs" "os" "path/filepath" + "strconv" "time" "sigs.k8s.io/yaml" @@ -234,7 +235,9 @@ func writeToTar(out *tar.Writer, name string, body []byte, modTime time.Time) er Size: int64(len(body)), ModTime: modTime, } - if h.ModTime.IsZero() { + if epoch, ok := sourceDateEpoch(); ok { + h.ModTime = epoch + } else if h.ModTime.IsZero() { h.ModTime = time.Now() } if err := out.WriteHeader(h); err != nil { @@ -244,6 +247,20 @@ func writeToTar(out *tar.Writer, name string, body []byte, modTime time.Time) er return err } +// sourceDateEpoch returns the time specified by SOURCE_DATE_EPOCH env var, if set. +// SOURCE_DATE_EPOCH is a Unix timestamp used to ensure reproducible builds. +func sourceDateEpoch() (time.Time, bool) { + s, ok := os.LookupEnv("SOURCE_DATE_EPOCH") + if !ok { + return time.Time{}, false + } + secs, err := strconv.ParseInt(s, 10, 64) + if err != nil { + return time.Time{}, false + } + return time.Unix(secs, 0), true +} + // If the name has directory name has characters which would change the location // they need to be removed. func validateName(name string) error { diff --git a/internal/chart/v3/util/save_test.go b/internal/chart/v3/util/save_test.go index 34e7d898e..3baaa52b0 100644 --- a/internal/chart/v3/util/save_test.go +++ b/internal/chart/v3/util/save_test.go @@ -23,6 +23,7 @@ import ( "crypto/sha256" "encoding/hex" "errors" + "fmt" "io" "os" "path" @@ -341,6 +342,60 @@ func TestRepeatableSave(t *testing.T) { } } +func TestSourceDateEpoch(t *testing.T) { + epoch := int64(1630000000) + t.Setenv("SOURCE_DATE_EPOCH", fmt.Sprintf("%d", epoch)) + + mkChart := func(modTime time.Time) *chart.Chart { + return &chart.Chart{ + Metadata: &chart.Metadata{ + APIVersion: chart.APIVersionV3, + Name: "ahab", + Version: "1.2.3", + }, + ModTime: modTime, + Files: []*common.File{ + {Name: "scheherazade/shahryar.txt", ModTime: modTime, Data: []byte("1,001 Nights")}, + }, + } + } + + tmp := t.TempDir() + + where1, err := Save(mkChart(time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)), filepath.Join(tmp, "build1")) + if err != nil { + t.Fatal(err) + } + where2, err := Save(mkChart(time.Date(2023, 6, 15, 12, 0, 0, 0, time.UTC)), filepath.Join(tmp, "build2")) + if err != nil { + t.Fatal(err) + } + + sum1, err := sha256Sum(where1) + if err != nil { + t.Fatal(err) + } + sum2, err := sha256Sum(where2) + if err != nil { + t.Fatal(err) + } + + if sum1 != sum2 { + t.Errorf("builds with same SOURCE_DATE_EPOCH should be byte-identical: %s != %s", sum1, sum2) + } + + allHeaders, err := retrieveAllHeadersFromTar(where1) + if err != nil { + t.Fatal(err) + } + expectedTime := time.Unix(epoch, 0) + for _, header := range allHeaders { + if !header.ModTime.Equal(expectedTime) { + t.Errorf("expected timestamp %v, got %v for %s", expectedTime, header.ModTime, header.Name) + } + } +} + func sha256Sum(filePath string) (string, error) { f, err := os.Open(filePath) if err != nil { diff --git a/pkg/chart/v2/util/save.go b/pkg/chart/v2/util/save.go index e66d86991..6e258df0a 100644 --- a/pkg/chart/v2/util/save.go +++ b/pkg/chart/v2/util/save.go @@ -25,6 +25,7 @@ import ( "io/fs" "os" "path/filepath" + "strconv" "time" "sigs.k8s.io/yaml" @@ -246,7 +247,9 @@ func writeToTar(out *tar.Writer, name string, body []byte, modTime time.Time) er Size: int64(len(body)), ModTime: modTime, } - if h.ModTime.IsZero() { + if epoch, ok := sourceDateEpoch(); ok { + h.ModTime = epoch + } else if h.ModTime.IsZero() { h.ModTime = time.Now() } if err := out.WriteHeader(h); err != nil { @@ -256,6 +259,20 @@ func writeToTar(out *tar.Writer, name string, body []byte, modTime time.Time) er return err } +// sourceDateEpoch returns the time specified by SOURCE_DATE_EPOCH env var, if set. +// SOURCE_DATE_EPOCH is a Unix timestamp used to ensure reproducible builds. +func sourceDateEpoch() (time.Time, bool) { + s, ok := os.LookupEnv("SOURCE_DATE_EPOCH") + if !ok { + return time.Time{}, false + } + secs, err := strconv.ParseInt(s, 10, 64) + if err != nil { + return time.Time{}, false + } + return time.Unix(secs, 0), true +} + // If the name has directory name has characters which would change the location // they need to be removed. func validateName(name string) error { diff --git a/pkg/chart/v2/util/save_test.go b/pkg/chart/v2/util/save_test.go index 2f2b73efd..8dca6f28a 100644 --- a/pkg/chart/v2/util/save_test.go +++ b/pkg/chart/v2/util/save_test.go @@ -23,6 +23,7 @@ import ( "crypto/sha256" "encoding/hex" "errors" + "fmt" "io" "os" "path" @@ -345,6 +346,60 @@ func TestRepeatableSave(t *testing.T) { } } +func TestSourceDateEpoch(t *testing.T) { + epoch := int64(1630000000) + t.Setenv("SOURCE_DATE_EPOCH", fmt.Sprintf("%d", epoch)) + + mkChart := func(modTime time.Time) *chart.Chart { + return &chart.Chart{ + Metadata: &chart.Metadata{ + APIVersion: chart.APIVersionV2, + Name: "ahab", + Version: "1.2.3", + }, + ModTime: modTime, + Files: []*common.File{ + {Name: "scheherazade/shahryar.txt", ModTime: modTime, Data: []byte("1,001 Nights")}, + }, + } + } + + tmp := t.TempDir() + + where1, err := Save(mkChart(time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)), filepath.Join(tmp, "build1")) + if err != nil { + t.Fatal(err) + } + where2, err := Save(mkChart(time.Date(2023, 6, 15, 12, 0, 0, 0, time.UTC)), filepath.Join(tmp, "build2")) + if err != nil { + t.Fatal(err) + } + + sum1, err := sha256Sum(where1) + if err != nil { + t.Fatal(err) + } + sum2, err := sha256Sum(where2) + if err != nil { + t.Fatal(err) + } + + if sum1 != sum2 { + t.Errorf("builds with same SOURCE_DATE_EPOCH should be byte-identical: %s != %s", sum1, sum2) + } + + allHeaders, err := retrieveAllHeadersFromTar(where1) + if err != nil { + t.Fatal(err) + } + expectedTime := time.Unix(epoch, 0) + for _, header := range allHeaders { + if !header.ModTime.Equal(expectedTime) { + t.Errorf("expected timestamp %v, got %v for %s", expectedTime, header.ModTime, header.Name) + } + } +} + func sha256Sum(filePath string) (string, error) { f, err := os.Open(filePath) if err != nil {