From 15fd16f9d27e7f8e69c5fafe7dd1250e98263a59 Mon Sep 17 00:00:00 2001 From: Amariah Kamau <110414493+AmariahAK@users.noreply.github.com> Date: Mon, 20 Jul 2026 13:42:02 +0300 Subject: [PATCH 1/2] fix(package): normalize StampModTimes timestamp and add Chart.lock reproducibility test StampModTimes now normalizes the input time.Time to UTC and truncates to whole seconds before stamping, ensuring Chart.lock generated field is deterministic regardless of how the caller constructs the time.Time. Add chart-with-lock test fixture and TestRunWithSourceDateEpochAndLock to verify that a chart with a Chart.lock gets its generated field stamped with the SourceDateEpoch, and that two builds with the same epoch produce byte-identical output. Closes #32396 Co-authored-by: atlarix-agent Signed-off-by: Amariah Kamau <110414493+AmariahAK@users.noreply.github.com> --- pkg/action/package_test.go | 74 +++++++++++++++++++ .../charts/chart-with-lock/Chart.lock | 6 ++ .../charts/chart-with-lock/Chart.yaml | 4 + .../chart-with-lock/templates/empty.yaml | 1 + pkg/chart/v2/chart.go | 17 +++-- 5 files changed, 94 insertions(+), 8 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 create mode 100644 pkg/action/testdata/charts/chart-with-lock/templates/empty.yaml diff --git a/pkg/action/package_test.go b/pkg/action/package_test.go index 9984b508a..aac2eebd4 100644 --- a/pkg/action/package_test.go +++ b/pkg/action/package_test.go @@ -17,10 +17,18 @@ limitations under the License. package action import ( + "archive/tar" + "bytes" + "compress/gzip" "errors" + "io" "os" "path" + "strings" "testing" + "time" + + "sigs.k8s.io/yaml" "github.com/Masterminds/semver/v3" "github.com/stretchr/testify/assert" @@ -170,3 +178,69 @@ func TestRun(t *testing.T) { require.Equal(t, "empty-0.1.0.tgz", filename) require.NoError(t, os.Remove(filename)) } + +func TestRunWithSourceDateEpochAndLock(t *testing.T) { + tmp := t.TempDir() + epoch := time.Unix(1609459200, 0).UTC() + + // Build twice with the same SourceDateEpoch and assert byte-identical output. + var first []byte + for i := range 2 { + client := NewPackage() + client.Destination = tmp + client.SourceDateEpoch = &epoch + + dest, err := client.Run("testdata/charts/chart-with-lock", nil) + require.NoError(t, err) + + data, err := os.ReadFile(dest) + require.NoError(t, err) + + if i == 0 { + first = data + } else { + require.Equal(t, first, data, "two builds with the same SourceDateEpoch must be byte-identical") + } + require.NoError(t, os.Remove(dest)) + } + + // Open the archive and inspect Chart.lock content. + gz, err := gzip.NewReader(bytes.NewReader(first)) + require.NoError(t, err) + defer gz.Close() + + tr := tar.NewReader(gz) + var lockData []byte + var lockHdr *tar.Header + for { + hdr, err := tr.Next() + if errors.Is(err, io.EOF) { + break + } + require.NoError(t, err) + if strings.HasSuffix(hdr.Name, "Chart.lock") { + lockHdr = hdr + var buf bytes.Buffer + _, err := io.Copy(&buf, tr) + require.NoError(t, err) + lockData = buf.Bytes() + break + } + } + require.NotNil(t, lockHdr, "Chart.lock not found in archive") + require.NotNil(t, lockData) + + // Validate the generated field in the YAML is the epoch (not the fixture's original timestamp). + var lock struct { + Generated string `yaml:"generated"` + } + err = yaml.Unmarshal(lockData, &lock) + require.NoError(t, err) + require.Equal(t, "2021-01-01T00:00:00Z", lock.Generated, + "Chart.lock generated field must match SourceDateEpoch") + + // Tar header ModTime must also match. + require.True(t, lockHdr.ModTime.Equal(epoch), + "Chart.lock tar header ModTime must match SourceDateEpoch: got %v, want %v", + lockHdr.ModTime, epoch) +} 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..308e579a6 --- /dev/null +++ b/pkg/action/testdata/charts/chart-with-lock/Chart.lock @@ -0,0 +1,6 @@ +dependencies: +- name: nginx + repository: https://charts.bitnami.com/bitnami + version: 1.0.0 +digest: sha256:abc123def456 +generated: "2024-01-01T12: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..0f8129514 --- /dev/null +++ b/pkg/action/testdata/charts/chart-with-lock/Chart.yaml @@ -0,0 +1,4 @@ +apiVersion: v2 +name: chart-with-lock +description: Test chart with Chart.lock for reproducibility testing +version: 0.1.0 diff --git a/pkg/action/testdata/charts/chart-with-lock/templates/empty.yaml b/pkg/action/testdata/charts/chart-with-lock/templates/empty.yaml new file mode 100644 index 000000000..c80812f6e --- /dev/null +++ b/pkg/action/testdata/charts/chart-with-lock/templates/empty.yaml @@ -0,0 +1 @@ +# This file is intentionally blank diff --git a/pkg/chart/v2/chart.go b/pkg/chart/v2/chart.go index 9772754ce..65c594603 100644 --- a/pkg/chart/v2/chart.go +++ b/pkg/chart/v2/chart.go @@ -179,32 +179,33 @@ func (ch *Chart) CRDObjects() []CRD { // StampModTimes sets timestamps on the chart (and dependencies) to epoch. // This is used for reproducible builds via SOURCE_DATE_EPOCH. -func (ch *Chart) StampModTimes(epoch time.Time) { - ch.ModTime = epoch +func (ch *Chart) StampModTimes(t time.Time) { + t = t.UTC().Truncate(time.Second) + ch.ModTime = t if len(ch.Schema) > 0 { - ch.SchemaModTime = epoch + ch.SchemaModTime = t } if ch.Lock != nil { - ch.Lock.Generated = epoch + ch.Lock.Generated = t } for _, f := range ch.Raw { if f != nil { - f.ModTime = epoch + f.ModTime = t } } for _, f := range ch.Templates { if f != nil { - f.ModTime = epoch + f.ModTime = t } } for _, f := range ch.Files { if f != nil { - f.ModTime = epoch + f.ModTime = t } } for _, dep := range ch.Dependencies() { - dep.StampModTimes(epoch) + dep.StampModTimes(t) } } From 90d57846f937fd7b85219741a012abdd93d6936c Mon Sep 17 00:00:00 2001 From: Amariah Kamau <110414493+AmariahAK@users.noreply.github.com> Date: Mon, 20 Jul 2026 17:42:24 +0300 Subject: [PATCH 2/2] fix(package): rename t back to epoch in StampModTimes for self-documentation Parameter `t` renamed to `epoch` in Chart.StampModTimes() as requested by reviewer: the `epoch` name is self-documenting. Co-authored-by: atlarix-agent Signed-off-by: Amariah Kamau <110414493+AmariahAK@users.noreply.github.com> --- pkg/chart/v2/chart.go | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/pkg/chart/v2/chart.go b/pkg/chart/v2/chart.go index 65c594603..653ecbf22 100644 --- a/pkg/chart/v2/chart.go +++ b/pkg/chart/v2/chart.go @@ -179,33 +179,33 @@ func (ch *Chart) CRDObjects() []CRD { // StampModTimes sets timestamps on the chart (and dependencies) to epoch. // This is used for reproducible builds via SOURCE_DATE_EPOCH. -func (ch *Chart) StampModTimes(t time.Time) { - t = t.UTC().Truncate(time.Second) - ch.ModTime = t +func (ch *Chart) StampModTimes(epoch time.Time) { + epoch = epoch.UTC().Truncate(time.Second) + ch.ModTime = epoch if len(ch.Schema) > 0 { - ch.SchemaModTime = t + ch.SchemaModTime = epoch } if ch.Lock != nil { - ch.Lock.Generated = t + ch.Lock.Generated = epoch } for _, f := range ch.Raw { if f != nil { - f.ModTime = t + f.ModTime = epoch } } for _, f := range ch.Templates { if f != nil { - f.ModTime = t + f.ModTime = epoch } } for _, f := range ch.Files { if f != nil { - f.ModTime = t + f.ModTime = epoch } } for _, dep := range ch.Dependencies() { - dep.StampModTimes(t) + dep.StampModTimes(epoch) } }