From 4cca4ed8d60a8877f15318496db9d037beecc8e5 Mon Sep 17 00:00:00 2001 From: Lohit Kolluri Date: Wed, 27 May 2026 18:18:24 +0530 Subject: [PATCH 1/2] feat: honor SOURCE_DATE_EPOCH for chart archives Add StampModTimes() on Chart struct to recursively set ModTime on all chart file entries. CLI reads SOURCE_DATE_EPOCH env var and wires through all 5 commands: package, install, upgrade, dep build, dep update. Removes unused v3 duplicate source_date_epoch.go (YAGNI). Inlines env var parsing to CLI layer from chart util. Signed-off-by: Lohit Kolluri --- pkg/action/package.go | 7 +++ pkg/chart/v2/chart.go | 31 ++++++++++++ pkg/chart/v2/util/save_test.go | 34 +++++++++++++ pkg/cmd/dependency_build.go | 5 ++ pkg/cmd/dependency_update.go | 5 ++ pkg/cmd/install.go | 5 ++ pkg/cmd/package.go | 6 +++ pkg/cmd/source_date_epoch.go | 42 ++++++++++++++++ pkg/cmd/source_date_epoch_test.go | 82 +++++++++++++++++++++++++++++++ pkg/cmd/upgrade.go | 5 ++ pkg/downloader/manager.go | 11 ++++- 11 files changed, 231 insertions(+), 2 deletions(-) create mode 100644 pkg/cmd/source_date_epoch.go create mode 100644 pkg/cmd/source_date_epoch_test.go diff --git a/pkg/action/package.go b/pkg/action/package.go index 86426b412..e08416e10 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" @@ -58,6 +59,8 @@ type Package struct { KeyFile string CaFile string InsecureSkipTLSVerify bool + // SourceDateEpoch, when set, normalizes chart timestamps for reproducible archives. + SourceDateEpoch *time.Time } const ( @@ -103,6 +106,10 @@ func (p *Package) Run(path string, _ map[string]any) (string, error) { ch.Metadata.AppVersion = p.AppVersion } + if p.SourceDateEpoch != nil { + ch.StampModTimes(*p.SourceDateEpoch) + } + if reqs := ac.MetaDependencies(); len(reqs) > 0 { if err := CheckDependencies(ch, reqs); err != nil { return "", err diff --git a/pkg/chart/v2/chart.go b/pkg/chart/v2/chart.go index 9d798b4de..9772754ce 100644 --- a/pkg/chart/v2/chart.go +++ b/pkg/chart/v2/chart.go @@ -177,6 +177,37 @@ func (ch *Chart) CRDObjects() []CRD { return crds } +// 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 + if len(ch.Schema) > 0 { + ch.SchemaModTime = epoch + } + if ch.Lock != nil { + ch.Lock.Generated = epoch + } + + for _, f := range ch.Raw { + if f != nil { + f.ModTime = epoch + } + } + for _, f := range ch.Templates { + if f != nil { + f.ModTime = epoch + } + } + for _, f := range ch.Files { + if f != nil { + f.ModTime = epoch + } + } + for _, dep := range ch.Dependencies() { + dep.StampModTimes(epoch) + } +} + func hasManifestExtension(fname string) bool { ext := filepath.Ext(fname) return strings.EqualFold(ext, ".yaml") || strings.EqualFold(ext, ".yml") || strings.EqualFold(ext, ".json") diff --git a/pkg/chart/v2/util/save_test.go b/pkg/chart/v2/util/save_test.go index 2f2b73efd..5c9c3910e 100644 --- a/pkg/chart/v2/util/save_test.go +++ b/pkg/chart/v2/util/save_test.go @@ -186,6 +186,40 @@ func TestSavePreservesTimestamps(t *testing.T) { } } +func TestSaveWithSourceDateEpoch(t *testing.T) { + epoch := time.Unix(1609459200, 0).UTC() + tmp := t.TempDir() + c := &chart.Chart{ + Metadata: &chart.Metadata{ + APIVersion: chart.APIVersionV2, + Name: "ahab", + Version: "1.2.3", + }, + Files: []*common.File{ + {Name: "scheherazade/shahryar.txt", Data: []byte("1,001 Nights")}, + }, + Schema: []byte("{\n \"title\": \"Values\"\n}"), + } + + c.StampModTimes(epoch) + where, err := Save(c, tmp) + if err != nil { + t.Fatalf("Failed to save: %s", err) + } + + allHeaders, err := retrieveAllHeadersFromTar(where) + if err != nil { + t.Fatalf("Failed to parse tar: %v", err) + } + + expected := epoch.Round(time.Second) + for _, header := range allHeaders { + if !header.ModTime.Equal(expected) { + t.Fatalf("Expected SOURCE_DATE_EPOCH timestamp %v, got %v for %q", expected, header.ModTime, header.Name) + } + } +} + // We could refactor `load.go` to use this `retrieveAllHeadersFromTar` function // as well, so we are not duplicating components of the code which iterate // through the tar. diff --git a/pkg/cmd/dependency_build.go b/pkg/cmd/dependency_build.go index f572fbae1..85c385670 100644 --- a/pkg/cmd/dependency_build.go +++ b/pkg/cmd/dependency_build.go @@ -55,6 +55,10 @@ func newDependencyBuildCmd(out io.Writer) *cobra.Command { if len(args) > 0 { chartpath = filepath.Clean(args[0]) } + sourceDateEpoch, err := sourceDateEpochFromEnv() + if err != nil { + return err + } registryClient, err := newRegistryClient(out, client.CertFile, client.KeyFile, client.CaFile, client.InsecureSkipTLSVerify, client.PlainHTTP, client.Username, client.Password) if err != nil { @@ -72,6 +76,7 @@ func newDependencyBuildCmd(out io.Writer) *cobra.Command { RepositoryCache: settings.RepositoryCache, ContentCache: settings.ContentCache, Debug: settings.Debug, + SourceDateEpoch: sourceDateEpoch, } if client.Verify { man.Verify = downloader.VerifyIfPossible diff --git a/pkg/cmd/dependency_update.go b/pkg/cmd/dependency_update.go index a7131e587..0db36553c 100644 --- a/pkg/cmd/dependency_update.go +++ b/pkg/cmd/dependency_update.go @@ -58,6 +58,10 @@ func newDependencyUpdateCmd(_ *action.Configuration, out io.Writer) *cobra.Comma if len(args) > 0 { chartpath = filepath.Clean(args[0]) } + sourceDateEpoch, err := sourceDateEpochFromEnv() + if err != nil { + return err + } registryClient, err := newRegistryClient(out, client.CertFile, client.KeyFile, client.CaFile, client.InsecureSkipTLSVerify, client.PlainHTTP, client.Username, client.Password) if err != nil { @@ -75,6 +79,7 @@ func newDependencyUpdateCmd(_ *action.Configuration, out io.Writer) *cobra.Comma RepositoryCache: settings.RepositoryCache, ContentCache: settings.ContentCache, Debug: settings.Debug, + SourceDateEpoch: sourceDateEpoch, } if client.Verify { man.Verify = downloader.VerifyAlways diff --git a/pkg/cmd/install.go b/pkg/cmd/install.go index 832968ff0..b0f8c64b7 100644 --- a/pkg/cmd/install.go +++ b/pkg/cmd/install.go @@ -297,6 +297,10 @@ func runInstall(args []string, client *action.Install, valueOpts *values.Options } if req := ac.MetaDependencies(); len(req) > 0 { + sourceDateEpoch, err := sourceDateEpochFromEnv() + if err != nil { + return nil, err + } // If CheckDependencies returns an error, we have unfulfilled dependencies. // As of Helm 2.4.0, this is treated as a stopping condition: // https://github.com/helm/helm/issues/2209 @@ -313,6 +317,7 @@ func runInstall(args []string, client *action.Install, valueOpts *values.Options ContentCache: settings.ContentCache, Debug: settings.Debug, RegistryClient: client.GetRegistryClient(), + SourceDateEpoch: sourceDateEpoch, } if err := man.Update(); err != nil { return nil, err diff --git a/pkg/cmd/package.go b/pkg/cmd/package.go index 46133d971..7e240c8d0 100644 --- a/pkg/cmd/package.go +++ b/pkg/cmd/package.go @@ -59,6 +59,11 @@ func newPackageCmd(out io.Writer) *cobra.Command { if len(args) == 0 { return errors.New("need at least one argument, the path to the chart") } + sourceDateEpoch, err := sourceDateEpochFromEnv() + if err != nil { + return err + } + client.SourceDateEpoch = sourceDateEpoch if client.Sign { if client.Key == "" { return errors.New("--key is required for signing a package") @@ -101,6 +106,7 @@ func newPackageCmd(out io.Writer) *cobra.Command { RepositoryConfig: settings.RepositoryConfig, RepositoryCache: settings.RepositoryCache, ContentCache: settings.ContentCache, + SourceDateEpoch: sourceDateEpoch, } if err := downloadManager.Update(); err != nil { diff --git a/pkg/cmd/source_date_epoch.go b/pkg/cmd/source_date_epoch.go new file mode 100644 index 000000000..6f73e3b30 --- /dev/null +++ b/pkg/cmd/source_date_epoch.go @@ -0,0 +1,42 @@ +/* +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 cmd + +import ( + "errors" + "fmt" + "os" + "strconv" + "time" +) + +// sourceDateEpochFromEnv returns SOURCE_DATE_EPOCH when set, or nil when unset. +func sourceDateEpochFromEnv() (*time.Time, error) { + epochStr, ok := os.LookupEnv("SOURCE_DATE_EPOCH") + if !ok || epochStr == "" { + return nil, nil + } + epoch, err := strconv.ParseInt(epochStr, 10, 64) + if err != nil { + return nil, fmt.Errorf("invalid SOURCE_DATE_EPOCH: %w", err) + } + if epoch < 0 { + return nil, errors.New("invalid SOURCE_DATE_EPOCH: must not be negative") + } + t := time.Unix(epoch, 0).UTC() + return &t, nil +} diff --git a/pkg/cmd/source_date_epoch_test.go b/pkg/cmd/source_date_epoch_test.go new file mode 100644 index 000000000..719de7c90 --- /dev/null +++ b/pkg/cmd/source_date_epoch_test.go @@ -0,0 +1,82 @@ +/* +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 cmd + +import ( + "testing" + "time" +) + +func TestSourceDateEpochFromEnv(t *testing.T) { + t.Setenv("SOURCE_DATE_EPOCH", "1609459200") + + got, err := sourceDateEpochFromEnv() + if err != nil { + t.Fatalf("sourceDateEpochFromEnv() error: %v", err) + } + if got == nil { + t.Fatal("expected non-nil epoch") + } + want := time.Unix(1609459200, 0).UTC() + if !got.Equal(want) { + t.Fatalf("expected %v, got %v", want, *got) + } +} + +func TestSourceDateEpochFromEnvUnset(t *testing.T) { + t.Setenv("SOURCE_DATE_EPOCH", "") + + got, err := sourceDateEpochFromEnv() + if err != nil { + t.Fatalf("sourceDateEpochFromEnv() error: %v", err) + } + if got != nil { + t.Fatalf("expected nil epoch, got %v", *got) + } +} + +func TestSourceDateEpochFromEnvInvalid(t *testing.T) { + t.Setenv("SOURCE_DATE_EPOCH", "not-a-number") + + if _, err := sourceDateEpochFromEnv(); err == nil { + t.Fatal("expected error for invalid SOURCE_DATE_EPOCH") + } +} + +func TestSourceDateEpochFromEnvNegative(t *testing.T) { + t.Setenv("SOURCE_DATE_EPOCH", "-1") + + if _, err := sourceDateEpochFromEnv(); err == nil { + t.Fatal("expected error for negative SOURCE_DATE_EPOCH") + } +} + +func TestSourceDateEpochFromEnvZero(t *testing.T) { + t.Setenv("SOURCE_DATE_EPOCH", "0") + + got, err := sourceDateEpochFromEnv() + if err != nil { + t.Fatalf("sourceDateEpochFromEnv() error: %v", err) + } + if got == nil { + t.Fatal("expected non-nil epoch") + } + want := time.Unix(0, 0).UTC() + if !got.Equal(want) { + t.Fatalf("expected %v, got %v", want, *got) + } +} diff --git a/pkg/cmd/upgrade.go b/pkg/cmd/upgrade.go index 9ec4f4ef2..0a0490956 100644 --- a/pkg/cmd/upgrade.go +++ b/pkg/cmd/upgrade.go @@ -204,6 +204,10 @@ func newUpgradeCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { return err } if req := ac.MetaDependencies(); len(req) > 0 { + sourceDateEpoch, err := sourceDateEpochFromEnv() + if err != nil { + return err + } if err := action.CheckDependencies(ch, req); err != nil { err = fmt.Errorf("an error occurred while checking for chart dependencies. You may need to run 'helm dependency build' to fetch missing dependencies: %w", err) if client.DependencyUpdate { @@ -217,6 +221,7 @@ func newUpgradeCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { RepositoryCache: settings.RepositoryCache, ContentCache: settings.ContentCache, Debug: settings.Debug, + SourceDateEpoch: sourceDateEpoch, } if err := man.Update(); err != nil { return err diff --git a/pkg/downloader/manager.go b/pkg/downloader/manager.go index ac4f2207e..c426eef7e 100644 --- a/pkg/downloader/manager.go +++ b/pkg/downloader/manager.go @@ -29,6 +29,7 @@ import ( "regexp" "strings" "sync" + "time" "github.com/Masterminds/semver/v3" "sigs.k8s.io/yaml" @@ -78,6 +79,8 @@ type Manager struct { // ContentCache is a location where a cache of charts can be stored ContentCache string + // SourceDateEpoch, when set, normalizes chart timestamps for reproducible archives. + SourceDateEpoch *time.Time } // Build rebuilds a local charts directory from a lockfile. @@ -304,7 +307,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.SourceDateEpoch) if err != nil { saveError = err break @@ -870,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, sourceDateEpoch *time.Time) (string, error) { if !strings.HasPrefix(repo, "file://") { return "", fmt.Errorf("wrong format: chart %s repository %s", name, repo) } @@ -885,6 +888,10 @@ func tarFromLocalDir(chartpath, name, repo, version, destPath string) (string, e return "", err } + if sourceDateEpoch != nil { + ch.StampModTimes(*sourceDateEpoch) + } + constraint, err := semver.NewConstraint(version) if err != nil { return "", fmt.Errorf("dependency %s has an invalid version/constraint format: %w", name, err) From d8979b1ae0e5cde998a332545dc031b8fb371fd4 Mon Sep 17 00:00:00 2001 From: Lohit Kolluri Date: Sun, 28 Jun 2026 20:19:43 +0530 Subject: [PATCH 2/2] docs: document SOURCE_DATE_EPOCH in environment variables help Add a short entry for $SOURCE_DATE_EPOCH to the env var table shown in 'helm help'. The variable is honored by 'helm package', 'install', 'upgrade', 'dep build', and 'dep update' to produce reproducible chart archives. Full documentation belongs in helm-www. Signed-off-by: Lohit Kolluri --- pkg/cmd/root.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/cmd/root.go b/pkg/cmd/root.go index 3203ced25..f241483a9 100644 --- a/pkg/cmd/root.go +++ b/pkg/cmd/root.go @@ -84,6 +84,7 @@ Environment variables: | $HELM_QPS | set the Queries Per Second in cases where a high number of calls exceed the option for higher burst values | | $HELM_COLOR | set color output mode. Allowed values: never, always, auto (default: never) | | $NO_COLOR | set to any non-empty value to disable all colored output (overrides $HELM_COLOR) | +| $SOURCE_DATE_EPOCH | set a Unix timestamp for reproducible chart archives | Helm stores cache, configuration, and data based on the following configuration order: