From dad0b59d4300d879c5d6f7e08fd71329848c6ab8 Mon Sep 17 00:00:00 2001 From: Maxime Grenu Date: Tue, 10 Mar 2026 15:02:14 +0100 Subject: [PATCH 1/3] feat(util): respect SOURCE_DATE_EPOCH for reproducible chart archives Add ParseSourceDateEpoch and ApplySourceDateEpoch helpers to the v2 and v3 chart util packages. The caller (helm package, dependency build) now reads the environment variable, stamps every zero-valued ModTime field on the chart tree, and lets the existing tar writer pick up those times. This keeps Save() free from environment side effects: the epoch is parsed and applied at the call site. Entries that already carry a non-zero ModTime are left untouched. Signed-off-by: Maxime Wojtczak Signed-off-by: Maxime Grenu --- internal/chart/v3/util/epoch.go | 84 ++++++++++++++ internal/chart/v3/util/epoch_test.go | 158 +++++++++++++++++++++++++++ pkg/action/package.go | 7 ++ pkg/chart/v2/util/epoch.go | 84 ++++++++++++++ pkg/chart/v2/util/epoch_test.go | 158 +++++++++++++++++++++++++++ pkg/downloader/manager.go | 7 ++ 6 files changed, 498 insertions(+) create mode 100644 internal/chart/v3/util/epoch.go create mode 100644 internal/chart/v3/util/epoch_test.go create mode 100644 pkg/chart/v2/util/epoch.go create mode 100644 pkg/chart/v2/util/epoch_test.go diff --git a/internal/chart/v3/util/epoch.go b/internal/chart/v3/util/epoch.go new file mode 100644 index 000000000..f043be6d1 --- /dev/null +++ b/internal/chart/v3/util/epoch.go @@ -0,0 +1,84 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package util + +import ( + "fmt" + "os" + "strconv" + "time" + + chart "helm.sh/helm/v4/internal/chart/v3" +) + +// ParseSourceDateEpoch reads the SOURCE_DATE_EPOCH environment variable and +// returns the corresponding time. It returns the zero time when the variable +// is not set or is set to the empty string. An error is returned when the +// value cannot be parsed or is negative. +// +// SOURCE_DATE_EPOCH is a standardised environment variable for reproducible +// builds; see https://reproducible-builds.org/docs/source-date-epoch/ +func ParseSourceDateEpoch() (time.Time, error) { + v, ok := os.LookupEnv("SOURCE_DATE_EPOCH") + if !ok || v == "" { + return time.Time{}, nil + } + epoch, err := strconv.ParseInt(v, 10, 64) + if err != nil { + return time.Time{}, fmt.Errorf("invalid SOURCE_DATE_EPOCH %q: %w", v, err) + } + if epoch < 0 { + return time.Time{}, fmt.Errorf("invalid SOURCE_DATE_EPOCH %q: negative value", v) + } + return time.Unix(epoch, 0), nil +} + +// ApplySourceDateEpoch sets the ModTime on the chart and all of its entries +// that currently have a zero ModTime to t. It recurses into dependencies. +// When t is the zero time this is a no-op. +func ApplySourceDateEpoch(c *chart.Chart, t time.Time) { + if t.IsZero() { + return + } + if c.ModTime.IsZero() { + c.ModTime = t + } + if c.Lock != nil && c.Lock.Generated.IsZero() { + c.Lock.Generated = t + } + if c.Schema != nil && c.SchemaModTime.IsZero() { + c.SchemaModTime = t + } + for _, f := range c.Raw { + if f.ModTime.IsZero() { + f.ModTime = t + } + } + for _, f := range c.Templates { + if f.ModTime.IsZero() { + f.ModTime = t + } + } + for _, f := range c.Files { + if f.ModTime.IsZero() { + f.ModTime = t + } + } + for _, dep := range c.Dependencies() { + ApplySourceDateEpoch(dep, t) + } +} diff --git a/internal/chart/v3/util/epoch_test.go b/internal/chart/v3/util/epoch_test.go new file mode 100644 index 000000000..0db37b3d9 --- /dev/null +++ b/internal/chart/v3/util/epoch_test.go @@ -0,0 +1,158 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package util + +import ( + "os" + "testing" + "time" + + "helm.sh/helm/v4/pkg/chart/common" + chart "helm.sh/helm/v4/internal/chart/v3" +) + +func TestParseSourceDateEpoch(t *testing.T) { + tests := []struct { + name string + value string + set bool + want time.Time + wantErr bool + }{ + { + name: "not set", + set: false, + want: time.Time{}, + }, + { + name: "valid epoch", + value: "1700000000", + set: true, + want: time.Unix(1700000000, 0), + }, + { + name: "invalid string", + value: "not-a-number", + set: true, + wantErr: true, + }, + { + name: "negative value", + value: "-1", + set: true, + wantErr: true, + }, + { + name: "zero", + value: "0", + set: true, + want: time.Unix(0, 0), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.set { + t.Setenv("SOURCE_DATE_EPOCH", tt.value) + } else { + prevVal, wasSet := os.LookupEnv("SOURCE_DATE_EPOCH") + os.Unsetenv("SOURCE_DATE_EPOCH") + t.Cleanup(func() { + if wasSet { + os.Setenv("SOURCE_DATE_EPOCH", prevVal) + } + }) + } + + got, err := ParseSourceDateEpoch() + if (err != nil) != tt.wantErr { + t.Errorf("ParseSourceDateEpoch() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !got.Equal(tt.want) { + t.Errorf("ParseSourceDateEpoch() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestApplySourceDateEpoch(t *testing.T) { + epoch := time.Unix(1700000000, 0) + + c := &chart.Chart{ + Metadata: &chart.Metadata{ + Name: "test", + Version: "0.1.0", + }, + Templates: []*common.File{ + {Name: "templates/test.yaml"}, + }, + Files: []*common.File{ + {Name: "README.md"}, + }, + } + + ApplySourceDateEpoch(c, epoch) + + if !c.ModTime.Equal(epoch) { + t.Errorf("Chart.ModTime = %v, want %v", c.ModTime, epoch) + } + for _, f := range c.Templates { + if !f.ModTime.Equal(epoch) { + t.Errorf("Template %s ModTime = %v, want %v", f.Name, f.ModTime, epoch) + } + } + for _, f := range c.Files { + if !f.ModTime.Equal(epoch) { + t.Errorf("File %s ModTime = %v, want %v", f.Name, f.ModTime, epoch) + } + } +} + +func TestApplySourceDateEpochPreservesExisting(t *testing.T) { + epoch := time.Unix(1700000000, 0) + existing := time.Unix(1600000000, 0) + + c := &chart.Chart{ + Metadata: &chart.Metadata{ + Name: "test", + Version: "0.1.0", + }, + ModTime: existing, + } + + ApplySourceDateEpoch(c, epoch) + + if !c.ModTime.Equal(existing) { + t.Errorf("Chart.ModTime = %v, want existing %v", c.ModTime, existing) + } +} + +func TestApplySourceDateEpochZeroNoop(t *testing.T) { + c := &chart.Chart{ + Metadata: &chart.Metadata{ + Name: "test", + Version: "0.1.0", + }, + } + + ApplySourceDateEpoch(c, time.Time{}) + + if !c.ModTime.IsZero() { + t.Errorf("Chart.ModTime = %v, want zero", c.ModTime) + } +} diff --git a/pkg/action/package.go b/pkg/action/package.go index 86426b412..07cfebff8 100644 --- a/pkg/action/package.go +++ b/pkg/action/package.go @@ -109,6 +109,13 @@ func (p *Package) Run(path string, _ map[string]any) (string, error) { } } + // Apply SOURCE_DATE_EPOCH for reproducible builds if set. + epoch, err := chartutil.ParseSourceDateEpoch() + if err != nil { + fmt.Fprintf(os.Stderr, "WARNING: %v\n", err) + } + chartutil.ApplySourceDateEpoch(ch, epoch) + var dest string if p.Destination == "." { // Save to the current working directory. diff --git a/pkg/chart/v2/util/epoch.go b/pkg/chart/v2/util/epoch.go new file mode 100644 index 000000000..f65e8c3ee --- /dev/null +++ b/pkg/chart/v2/util/epoch.go @@ -0,0 +1,84 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package util + +import ( + "fmt" + "os" + "strconv" + "time" + + chart "helm.sh/helm/v4/pkg/chart/v2" +) + +// ParseSourceDateEpoch reads the SOURCE_DATE_EPOCH environment variable and +// returns the corresponding time. It returns the zero time when the variable +// is not set or is set to the empty string. An error is returned when the +// value cannot be parsed or is negative. +// +// SOURCE_DATE_EPOCH is a standardised environment variable for reproducible +// builds; see https://reproducible-builds.org/docs/source-date-epoch/ +func ParseSourceDateEpoch() (time.Time, error) { + v, ok := os.LookupEnv("SOURCE_DATE_EPOCH") + if !ok || v == "" { + return time.Time{}, nil + } + epoch, err := strconv.ParseInt(v, 10, 64) + if err != nil { + return time.Time{}, fmt.Errorf("invalid SOURCE_DATE_EPOCH %q: %w", v, err) + } + if epoch < 0 { + return time.Time{}, fmt.Errorf("invalid SOURCE_DATE_EPOCH %q: negative value", v) + } + return time.Unix(epoch, 0), nil +} + +// ApplySourceDateEpoch sets the ModTime on the chart and all of its entries +// that currently have a zero ModTime to t. It recurses into dependencies. +// When t is the zero time this is a no-op. +func ApplySourceDateEpoch(c *chart.Chart, t time.Time) { + if t.IsZero() { + return + } + if c.ModTime.IsZero() { + c.ModTime = t + } + if c.Lock != nil && c.Lock.Generated.IsZero() { + c.Lock.Generated = t + } + if c.Schema != nil && c.SchemaModTime.IsZero() { + c.SchemaModTime = t + } + for _, f := range c.Raw { + if f.ModTime.IsZero() { + f.ModTime = t + } + } + for _, f := range c.Templates { + if f.ModTime.IsZero() { + f.ModTime = t + } + } + for _, f := range c.Files { + if f.ModTime.IsZero() { + f.ModTime = t + } + } + for _, dep := range c.Dependencies() { + ApplySourceDateEpoch(dep, t) + } +} diff --git a/pkg/chart/v2/util/epoch_test.go b/pkg/chart/v2/util/epoch_test.go new file mode 100644 index 000000000..3df89ba07 --- /dev/null +++ b/pkg/chart/v2/util/epoch_test.go @@ -0,0 +1,158 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package util + +import ( + "os" + "testing" + "time" + + "helm.sh/helm/v4/pkg/chart/common" + chart "helm.sh/helm/v4/pkg/chart/v2" +) + +func TestParseSourceDateEpoch(t *testing.T) { + tests := []struct { + name string + value string + set bool + want time.Time + wantErr bool + }{ + { + name: "not set", + set: false, + want: time.Time{}, + }, + { + name: "valid epoch", + value: "1700000000", + set: true, + want: time.Unix(1700000000, 0), + }, + { + name: "invalid string", + value: "not-a-number", + set: true, + wantErr: true, + }, + { + name: "negative value", + value: "-1", + set: true, + wantErr: true, + }, + { + name: "zero", + value: "0", + set: true, + want: time.Unix(0, 0), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.set { + t.Setenv("SOURCE_DATE_EPOCH", tt.value) + } else { + prevVal, wasSet := os.LookupEnv("SOURCE_DATE_EPOCH") + os.Unsetenv("SOURCE_DATE_EPOCH") + t.Cleanup(func() { + if wasSet { + os.Setenv("SOURCE_DATE_EPOCH", prevVal) + } + }) + } + + got, err := ParseSourceDateEpoch() + if (err != nil) != tt.wantErr { + t.Errorf("ParseSourceDateEpoch() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !got.Equal(tt.want) { + t.Errorf("ParseSourceDateEpoch() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestApplySourceDateEpoch(t *testing.T) { + epoch := time.Unix(1700000000, 0) + + c := &chart.Chart{ + Metadata: &chart.Metadata{ + Name: "test", + Version: "0.1.0", + }, + Templates: []*common.File{ + {Name: "templates/test.yaml"}, + }, + Files: []*common.File{ + {Name: "README.md"}, + }, + } + + ApplySourceDateEpoch(c, epoch) + + if !c.ModTime.Equal(epoch) { + t.Errorf("Chart.ModTime = %v, want %v", c.ModTime, epoch) + } + for _, f := range c.Templates { + if !f.ModTime.Equal(epoch) { + t.Errorf("Template %s ModTime = %v, want %v", f.Name, f.ModTime, epoch) + } + } + for _, f := range c.Files { + if !f.ModTime.Equal(epoch) { + t.Errorf("File %s ModTime = %v, want %v", f.Name, f.ModTime, epoch) + } + } +} + +func TestApplySourceDateEpochPreservesExisting(t *testing.T) { + epoch := time.Unix(1700000000, 0) + existing := time.Unix(1600000000, 0) + + c := &chart.Chart{ + Metadata: &chart.Metadata{ + Name: "test", + Version: "0.1.0", + }, + ModTime: existing, + } + + ApplySourceDateEpoch(c, epoch) + + if !c.ModTime.Equal(existing) { + t.Errorf("Chart.ModTime = %v, want existing %v", c.ModTime, existing) + } +} + +func TestApplySourceDateEpochZeroNoop(t *testing.T) { + c := &chart.Chart{ + Metadata: &chart.Metadata{ + Name: "test", + Version: "0.1.0", + }, + } + + ApplySourceDateEpoch(c, time.Time{}) + + if !c.ModTime.IsZero() { + t.Errorf("Chart.ModTime = %v, want zero", c.ModTime) + } +} diff --git a/pkg/downloader/manager.go b/pkg/downloader/manager.go index 6043fbaaa..5104efbad 100644 --- a/pkg/downloader/manager.go +++ b/pkg/downloader/manager.go @@ -899,6 +899,13 @@ func tarFromLocalDir(chartpath, name, repo, version, destPath string) (string, e } if constraint.Check(v) { + // Apply SOURCE_DATE_EPOCH for reproducible builds if set. + epoch, epochErr := chartutil.ParseSourceDateEpoch() + if epochErr != nil { + fmt.Fprintf(os.Stderr, "WARNING: %v\n", epochErr) + } + chartutil.ApplySourceDateEpoch(ch, epoch) + _, err = chartutil.Save(ch, destPath) return ch.Metadata.Version, err } From f2d339dfa46cd001c6613fd0732ee17f271e35a9 Mon Sep 17 00:00:00 2001 From: Maxime Grenu Date: Wed, 11 Mar 2026 11:34:13 +0100 Subject: [PATCH 2/3] test(util): add end-to-end and dependency tests for SOURCE_DATE_EPOCH Add TestSaveWithSourceDateEpoch to both v2 and v3 util packages to verify the full pipeline: parse the epoch, stamp the chart tree, save to a tar archive, then assert that every tar entry carries exactly the expected timestamp. This catches any regression where writeToTar might silently fall back to time.Now(). Also add TestApplySourceDateEpochDependencies to confirm that the recursive walk correctly stamps sub-chart entries while preserving non-zero ModTimes on the parent chart. Signed-off-by: Maxime Grenu --- internal/chart/v3/util/epoch_test.go | 93 +++++++++++++++++++++++++++ pkg/chart/v2/util/epoch_test.go | 95 ++++++++++++++++++++++++++++ 2 files changed, 188 insertions(+) diff --git a/internal/chart/v3/util/epoch_test.go b/internal/chart/v3/util/epoch_test.go index 0db37b3d9..7fd9f37ef 100644 --- a/internal/chart/v3/util/epoch_test.go +++ b/internal/chart/v3/util/epoch_test.go @@ -156,3 +156,96 @@ func TestApplySourceDateEpochZeroNoop(t *testing.T) { t.Errorf("Chart.ModTime = %v, want zero", c.ModTime) } } + +func TestApplySourceDateEpochDependencies(t *testing.T) { + epoch := time.Unix(1700000000, 0) + existing := time.Unix(1600000000, 0) + + dep := &chart.Chart{ + Metadata: &chart.Metadata{ + Name: "dep", + Version: "0.1.0", + }, + Templates: []*common.File{ + {Name: "templates/dep.yaml"}, + }, + } + + c := &chart.Chart{ + Metadata: &chart.Metadata{ + Name: "parent", + Version: "1.0.0", + }, + ModTime: existing, + Templates: []*common.File{ + {Name: "templates/main.yaml"}, + }, + } + c.AddDependency(dep) + + ApplySourceDateEpoch(c, epoch) + + // Parent chart already had a ModTime, so it should be preserved. + if !c.ModTime.Equal(existing) { + t.Errorf("parent Chart.ModTime = %v, want existing %v", c.ModTime, existing) + } + // Dependency had a zero ModTime, so it should be stamped. + if !dep.ModTime.Equal(epoch) { + t.Errorf("dep Chart.ModTime = %v, want %v", dep.ModTime, epoch) + } + for _, f := range dep.Templates { + if !f.ModTime.Equal(epoch) { + t.Errorf("dep Template %s ModTime = %v, want %v", f.Name, f.ModTime, epoch) + } + } +} + +func TestSaveWithSourceDateEpoch(t *testing.T) { + // End-to-end: parse SOURCE_DATE_EPOCH, apply to a chart with zero + // ModTimes, save as a tar archive, and verify every tar entry carries + // exactly the expected timestamp. + const epochStr = "1700000000" + want := time.Unix(1700000000, 0) + + t.Setenv("SOURCE_DATE_EPOCH", epochStr) + + epoch, err := ParseSourceDateEpoch() + if err != nil { + t.Fatalf("ParseSourceDateEpoch() error: %v", err) + } + + c := &chart.Chart{ + Metadata: &chart.Metadata{ + APIVersion: chart.APIVersionV3, + Name: "epoch-test", + Version: "0.1.0", + }, + Values: map[string]any{"key": "value"}, + Schema: []byte(`{"title": "Values"}`), + Files: []*common.File{{Name: "README.md", Data: []byte("# test")}}, + Templates: []*common.File{{Name: "templates/test.yaml", Data: []byte("apiVersion: v1")}}, + } + + ApplySourceDateEpoch(c, epoch) + + tmp := t.TempDir() + where, err := Save(c, tmp) + if err != nil { + t.Fatalf("Save() error: %v", err) + } + + headers, err := retrieveAllHeadersFromTar(where) + if err != nil { + t.Fatalf("failed to read tar: %v", err) + } + + if len(headers) == 0 { + t.Fatal("archive contains no entries") + } + + for _, h := range headers { + if !h.ModTime.Equal(want) { + t.Errorf("tar entry %q ModTime = %v, want %v", h.Name, h.ModTime, want) + } + } +} diff --git a/pkg/chart/v2/util/epoch_test.go b/pkg/chart/v2/util/epoch_test.go index 3df89ba07..5a47c2589 100644 --- a/pkg/chart/v2/util/epoch_test.go +++ b/pkg/chart/v2/util/epoch_test.go @@ -156,3 +156,98 @@ func TestApplySourceDateEpochZeroNoop(t *testing.T) { t.Errorf("Chart.ModTime = %v, want zero", c.ModTime) } } + +func TestApplySourceDateEpochDependencies(t *testing.T) { + epoch := time.Unix(1700000000, 0) + existing := time.Unix(1600000000, 0) + + dep := &chart.Chart{ + Metadata: &chart.Metadata{ + APIVersion: chart.APIVersionV2, + Name: "dep", + Version: "0.1.0", + }, + Templates: []*common.File{ + {Name: "templates/dep.yaml"}, + }, + } + + c := &chart.Chart{ + Metadata: &chart.Metadata{ + APIVersion: chart.APIVersionV2, + Name: "parent", + Version: "1.0.0", + }, + ModTime: existing, + Templates: []*common.File{ + {Name: "templates/main.yaml"}, + }, + } + c.AddDependency(dep) + + ApplySourceDateEpoch(c, epoch) + + // Parent chart already had a ModTime, so it should be preserved. + if !c.ModTime.Equal(existing) { + t.Errorf("parent Chart.ModTime = %v, want existing %v", c.ModTime, existing) + } + // Dependency had a zero ModTime, so it should be stamped. + if !dep.ModTime.Equal(epoch) { + t.Errorf("dep Chart.ModTime = %v, want %v", dep.ModTime, epoch) + } + for _, f := range dep.Templates { + if !f.ModTime.Equal(epoch) { + t.Errorf("dep Template %s ModTime = %v, want %v", f.Name, f.ModTime, epoch) + } + } +} + +func TestSaveWithSourceDateEpoch(t *testing.T) { + // End-to-end: parse SOURCE_DATE_EPOCH, apply to a chart with zero + // ModTimes, save as a tar archive, and verify every tar entry carries + // exactly the expected timestamp. + const epochStr = "1700000000" + want := time.Unix(1700000000, 0) + + t.Setenv("SOURCE_DATE_EPOCH", epochStr) + + epoch, err := ParseSourceDateEpoch() + if err != nil { + t.Fatalf("ParseSourceDateEpoch() error: %v", err) + } + + c := &chart.Chart{ + Metadata: &chart.Metadata{ + APIVersion: chart.APIVersionV2, + Name: "epoch-test", + Version: "0.1.0", + }, + Values: map[string]any{"key": "value"}, + Schema: []byte(`{"title": "Values"}`), + Files: []*common.File{{Name: "README.md", Data: []byte("# test")}}, + Templates: []*common.File{{Name: "templates/test.yaml", Data: []byte("apiVersion: v1")}}, + } + + ApplySourceDateEpoch(c, epoch) + + tmp := t.TempDir() + where, err := Save(c, tmp) + if err != nil { + t.Fatalf("Save() error: %v", err) + } + + headers, err := retrieveAllHeadersFromTar(where) + if err != nil { + t.Fatalf("failed to read tar: %v", err) + } + + if len(headers) == 0 { + t.Fatal("archive contains no entries") + } + + for _, h := range headers { + if !h.ModTime.Equal(want) { + t.Errorf("tar entry %q ModTime = %v, want %v", h.Name, h.ModTime, want) + } + } +} From 81c8a2d84463decc420252da4d4268c460c3e7eb Mon Sep 17 00:00:00 2001 From: Maxime Grenu Date: Wed, 11 Mar 2026 12:49:41 +0100 Subject: [PATCH 3/3] fix(epoch): override all timestamps unconditionally and route warnings through m.Out ApplySourceDateEpoch previously only stamped entries with a zero ModTime, but charts loaded from disk (via loader.LoadDir) already have non-zero ModTimes populated from the filesystem. This made the function a no-op in the common helm-package path, defeating reproducible builds. The function now unconditionally overrides every ModTime to the SOURCE_DATE_EPOCH value, which is the correct behaviour for producing bit-for-bit identical archives regardless of filesystem metadata. Additionally, tarFromLocalDir in pkg/downloader/manager.go was writing its SOURCE_DATE_EPOCH warning directly to os.Stderr, which is inconsistent with the rest of Manager that routes user-facing output through m.Out. The function now accepts an io.Writer parameter and the call site passes m.Out. Tests updated to verify that existing (non-zero) timestamps are overridden rather than preserved. Signed-off-by: Maxime Kawawa-Beaudan Signed-off-by: Maxime Grenu --- internal/chart/v3/util/epoch.go | 25 +++++++++---------------- internal/chart/v3/util/epoch_test.go | 28 ++++++++++++++++++++++------ pkg/chart/v2/util/epoch.go | 25 +++++++++---------------- pkg/chart/v2/util/epoch_test.go | 28 ++++++++++++++++++++++------ pkg/downloader/manager.go | 6 +++--- 5 files changed, 65 insertions(+), 47 deletions(-) diff --git a/internal/chart/v3/util/epoch.go b/internal/chart/v3/util/epoch.go index f043be6d1..1a9b293be 100644 --- a/internal/chart/v3/util/epoch.go +++ b/internal/chart/v3/util/epoch.go @@ -47,36 +47,29 @@ func ParseSourceDateEpoch() (time.Time, error) { return time.Unix(epoch, 0), nil } -// ApplySourceDateEpoch sets the ModTime on the chart and all of its entries -// that currently have a zero ModTime to t. It recurses into dependencies. +// ApplySourceDateEpoch overrides the ModTime on the chart and all of its +// entries to t, ensuring reproducible archives regardless of the original +// timestamps. It recurses into dependencies. // When t is the zero time this is a no-op. func ApplySourceDateEpoch(c *chart.Chart, t time.Time) { if t.IsZero() { return } - if c.ModTime.IsZero() { - c.ModTime = t - } - if c.Lock != nil && c.Lock.Generated.IsZero() { + c.ModTime = t + if c.Lock != nil { c.Lock.Generated = t } - if c.Schema != nil && c.SchemaModTime.IsZero() { + if c.Schema != nil { c.SchemaModTime = t } for _, f := range c.Raw { - if f.ModTime.IsZero() { - f.ModTime = t - } + f.ModTime = t } for _, f := range c.Templates { - if f.ModTime.IsZero() { - f.ModTime = t - } + f.ModTime = t } for _, f := range c.Files { - if f.ModTime.IsZero() { - f.ModTime = t - } + f.ModTime = t } for _, dep := range c.Dependencies() { ApplySourceDateEpoch(dep, t) diff --git a/internal/chart/v3/util/epoch_test.go b/internal/chart/v3/util/epoch_test.go index 7fd9f37ef..b2d58b0c5 100644 --- a/internal/chart/v3/util/epoch_test.go +++ b/internal/chart/v3/util/epoch_test.go @@ -123,7 +123,7 @@ func TestApplySourceDateEpoch(t *testing.T) { } } -func TestApplySourceDateEpochPreservesExisting(t *testing.T) { +func TestApplySourceDateEpochOverridesExisting(t *testing.T) { epoch := time.Unix(1700000000, 0) existing := time.Unix(1600000000, 0) @@ -133,12 +133,28 @@ func TestApplySourceDateEpochPreservesExisting(t *testing.T) { Version: "0.1.0", }, ModTime: existing, + Templates: []*common.File{ + {Name: "templates/test.yaml", ModTime: existing}, + }, + Files: []*common.File{ + {Name: "README.md", ModTime: existing}, + }, } ApplySourceDateEpoch(c, epoch) - if !c.ModTime.Equal(existing) { - t.Errorf("Chart.ModTime = %v, want existing %v", c.ModTime, existing) + if !c.ModTime.Equal(epoch) { + t.Errorf("Chart.ModTime = %v, want epoch %v", c.ModTime, epoch) + } + for _, f := range c.Templates { + if !f.ModTime.Equal(epoch) { + t.Errorf("Template %s ModTime = %v, want epoch %v", f.Name, f.ModTime, epoch) + } + } + for _, f := range c.Files { + if !f.ModTime.Equal(epoch) { + t.Errorf("File %s ModTime = %v, want epoch %v", f.Name, f.ModTime, epoch) + } } } @@ -185,9 +201,9 @@ func TestApplySourceDateEpochDependencies(t *testing.T) { ApplySourceDateEpoch(c, epoch) - // Parent chart already had a ModTime, so it should be preserved. - if !c.ModTime.Equal(existing) { - t.Errorf("parent Chart.ModTime = %v, want existing %v", c.ModTime, existing) + // Parent chart had an existing ModTime, but it should be overridden. + if !c.ModTime.Equal(epoch) { + t.Errorf("parent Chart.ModTime = %v, want epoch %v", c.ModTime, epoch) } // Dependency had a zero ModTime, so it should be stamped. if !dep.ModTime.Equal(epoch) { diff --git a/pkg/chart/v2/util/epoch.go b/pkg/chart/v2/util/epoch.go index f65e8c3ee..67367a0c6 100644 --- a/pkg/chart/v2/util/epoch.go +++ b/pkg/chart/v2/util/epoch.go @@ -47,36 +47,29 @@ func ParseSourceDateEpoch() (time.Time, error) { return time.Unix(epoch, 0), nil } -// ApplySourceDateEpoch sets the ModTime on the chart and all of its entries -// that currently have a zero ModTime to t. It recurses into dependencies. +// ApplySourceDateEpoch overrides the ModTime on the chart and all of its +// entries to t, ensuring reproducible archives regardless of the original +// timestamps. It recurses into dependencies. // When t is the zero time this is a no-op. func ApplySourceDateEpoch(c *chart.Chart, t time.Time) { if t.IsZero() { return } - if c.ModTime.IsZero() { - c.ModTime = t - } - if c.Lock != nil && c.Lock.Generated.IsZero() { + c.ModTime = t + if c.Lock != nil { c.Lock.Generated = t } - if c.Schema != nil && c.SchemaModTime.IsZero() { + if c.Schema != nil { c.SchemaModTime = t } for _, f := range c.Raw { - if f.ModTime.IsZero() { - f.ModTime = t - } + f.ModTime = t } for _, f := range c.Templates { - if f.ModTime.IsZero() { - f.ModTime = t - } + f.ModTime = t } for _, f := range c.Files { - if f.ModTime.IsZero() { - f.ModTime = t - } + f.ModTime = t } for _, dep := range c.Dependencies() { ApplySourceDateEpoch(dep, t) diff --git a/pkg/chart/v2/util/epoch_test.go b/pkg/chart/v2/util/epoch_test.go index 5a47c2589..89012d417 100644 --- a/pkg/chart/v2/util/epoch_test.go +++ b/pkg/chart/v2/util/epoch_test.go @@ -123,7 +123,7 @@ func TestApplySourceDateEpoch(t *testing.T) { } } -func TestApplySourceDateEpochPreservesExisting(t *testing.T) { +func TestApplySourceDateEpochOverridesExisting(t *testing.T) { epoch := time.Unix(1700000000, 0) existing := time.Unix(1600000000, 0) @@ -133,12 +133,28 @@ func TestApplySourceDateEpochPreservesExisting(t *testing.T) { Version: "0.1.0", }, ModTime: existing, + Templates: []*common.File{ + {Name: "templates/test.yaml", ModTime: existing}, + }, + Files: []*common.File{ + {Name: "README.md", ModTime: existing}, + }, } ApplySourceDateEpoch(c, epoch) - if !c.ModTime.Equal(existing) { - t.Errorf("Chart.ModTime = %v, want existing %v", c.ModTime, existing) + if !c.ModTime.Equal(epoch) { + t.Errorf("Chart.ModTime = %v, want epoch %v", c.ModTime, epoch) + } + for _, f := range c.Templates { + if !f.ModTime.Equal(epoch) { + t.Errorf("Template %s ModTime = %v, want epoch %v", f.Name, f.ModTime, epoch) + } + } + for _, f := range c.Files { + if !f.ModTime.Equal(epoch) { + t.Errorf("File %s ModTime = %v, want epoch %v", f.Name, f.ModTime, epoch) + } } } @@ -187,9 +203,9 @@ func TestApplySourceDateEpochDependencies(t *testing.T) { ApplySourceDateEpoch(c, epoch) - // Parent chart already had a ModTime, so it should be preserved. - if !c.ModTime.Equal(existing) { - t.Errorf("parent Chart.ModTime = %v, want existing %v", c.ModTime, existing) + // Parent chart had an existing ModTime, but it should be overridden. + if !c.ModTime.Equal(epoch) { + t.Errorf("parent Chart.ModTime = %v, want epoch %v", c.ModTime, epoch) } // Dependency had a zero ModTime, so it should be stamped. if !dep.ModTime.Equal(epoch) { diff --git a/pkg/downloader/manager.go b/pkg/downloader/manager.go index 5104efbad..f5fed6b11 100644 --- a/pkg/downloader/manager.go +++ b/pkg/downloader/manager.go @@ -304,7 +304,7 @@ func (m *Manager) downloadAll(deps []*chart.Dependency) error { if m.Debug { fmt.Fprintf(m.Out, "Archiving %s from repo %s\n", dep.Name, dep.Repository) } - ver, err := tarFromLocalDir(m.ChartPath, dep.Name, dep.Repository, dep.Version, tmpPath) + ver, err := tarFromLocalDir(m.ChartPath, dep.Name, dep.Repository, dep.Version, tmpPath, m.Out) if err != nil { saveError = err break @@ -873,7 +873,7 @@ func writeLock(chartpath string, lock *chart.Lock, legacyLockfile bool) error { } // archive a dep chart from local directory and save it into destPath -func tarFromLocalDir(chartpath, name, repo, version, destPath string) (string, error) { +func tarFromLocalDir(chartpath, name, repo, version, destPath string, out io.Writer) (string, error) { if !strings.HasPrefix(repo, "file://") { return "", fmt.Errorf("wrong format: chart %s repository %s", name, repo) } @@ -902,7 +902,7 @@ func tarFromLocalDir(chartpath, name, repo, version, destPath string) (string, e // Apply SOURCE_DATE_EPOCH for reproducible builds if set. epoch, epochErr := chartutil.ParseSourceDateEpoch() if epochErr != nil { - fmt.Fprintf(os.Stderr, "WARNING: %v\n", epochErr) + fmt.Fprintf(out, "WARNING: %v\n", epochErr) } chartutil.ApplySourceDateEpoch(ch, epoch)