From d883b8dd21b209e9f60e7da2322df6ce5f3ba463 Mon Sep 17 00:00:00 2001 From: Shane Starcher Date: Wed, 13 May 2026 11:24:12 -0400 Subject: [PATCH] Prefer `helm dependency build` over `dependency up` when Chart.lock exists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When chartify is engaged (e.g. helmfile invokes it for releases that use jsonPatches, strategic-merge patches, or kustomize), it copies the chart to a temp directory and runs `helm dependency up` to flatten dependencies before rendering. `dep up` re-resolves from Chart.yaml's version constraints and rewrites Chart.lock, which silently pulls newer dependency versions whenever constraints permit (e.g. `version: "*"`). This diverges from helmfile's non-chartify code path (pkg/helmexec/exec.go `BuildDeps`), which calls `helm dependency build` and honors the lock. The asymmetry means an unrelated change like adding a `jsonPatches` block to a release can flip a chart from locked-version rendering to "latest matching constraint" rendering, producing surprising image/version drift. Behavior change: - If `Chart.lock` (or legacy `requirements.lock`) exists and no adhoc chart dependencies were injected, run `helm dependency build`. This resolves dependencies from the lock and matches the rest of the helmfile pipeline. - If `dependency build` fails (typically because the lock is out of sync with Chart.yaml), fall back to `dependency up` and log the fallback. This preserves the previous escape hatch for charts whose lock has drifted. - If no lock file exists, or adhoc dependencies are present, behavior is unchanged — still `dependency up`. Adds a unit test for the lock-file detection helper. Signed-off-by: Shane Starcher --- chartify.go | 38 ++++++++++++++++++++++++++++++++++++-- util_test.go | 28 ++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/chartify.go b/chartify.go index ec13432..a9c5b17 100644 --- a/chartify.go +++ b/chartify.go @@ -382,13 +382,35 @@ func (r *Runner) Chartify(release, dirOrChart string, opts ...ChartifyOption) (s "This may result in outdated chart dependencies.", release) } else { // Flatten the chart by fetching dependent chart archives and merging their K8s manifests into the temporary local chart - // So that we can uniformly patch them with JSON patch, Strategic-Merge patch, or with injectors - depArgs := []string{"dependency", "up", tempDir} + // So that we can uniformly patch them with JSON patch, Strategic-Merge patch, or with injectors. + // + // Prefer `helm dependency build` when a Chart.lock exists and no adhoc dependencies were + // injected. `build` resolves dependencies from the lock file, matching the behavior of + // helmfile's non-chartify code path (pkg/helmexec/exec.go BuildDeps) and respecting the + // reproducibility users expect when they commit a Chart.lock. `up` re-resolves against + // Chart.yaml constraints and rewrites the lock, which silently picks up newer versions + // when constraints permit (e.g. `version: "*"`). + // + // Adhoc dependencies are appended to Chart.yaml after the lock was generated, so the lock + // is by definition out of sync — `build` would fail. Fall back to `up` in that case. + useBuild := len(u.AdhocChartDependencies) == 0 && hasChartLock(tempDir) + depCmd := "up" + if useBuild { + depCmd = "build" + } + depArgs := []string{"dependency", depCmd, tempDir} // Helm 4 requires --plain-http for HTTP-only OCI registries if u.OCIPlainHTTP && r.IsHelm4() { depArgs = append(depArgs, "--plain-http") } _, err := r.run(nil, r.helmBin(), depArgs...) + if err != nil && useBuild { + // `helm dependency build` errors when Chart.lock is out of sync with Chart.yaml. + // Fall back to `up` so chartify keeps working on charts whose lock has drifted. + r.Logf("`helm dependency build` failed for release %s, falling back to `helm dependency up`: %v", release, err) + depArgs[1] = "up" + _, err = r.run(nil, r.helmBin(), depArgs...) + } if err != nil { return "", err } @@ -680,6 +702,18 @@ func (r *Runner) RewriteChartToPreventDoubleRendering(tempDir, filesDir string) return nil } +// hasChartLock reports whether a Chart.lock or requirements.lock file exists in the +// given chart directory. Helm uses Chart.lock for apiVersion v2 charts and the legacy +// requirements.lock for v1 charts; either is sufficient for `helm dependency build`. +func hasChartLock(chartDir string) bool { + for _, name := range []string{"Chart.lock", "requirements.lock"} { + if _, err := os.Stat(filepath.Join(chartDir, name)); err == nil { + return true + } + } + return false +} + func createDirForFile(f string) error { dstFileDir := filepath.Dir(f) if _, err := os.Lstat(dstFileDir); err == nil { diff --git a/util_test.go b/util_test.go index 6a944c4..9316c32 100644 --- a/util_test.go +++ b/util_test.go @@ -1,11 +1,39 @@ package chartify import ( + "os" + "path/filepath" "testing" "github.com/google/go-cmp/cmp" ) +func TestHasChartLock(t *testing.T) { + tests := []struct { + name string + lockName string // empty means no lock file is created + want bool + }{ + {name: "no lock file", lockName: "", want: false}, + {name: "Chart.lock present", lockName: "Chart.lock", want: true}, + {name: "requirements.lock present", lockName: "requirements.lock", want: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + if tt.lockName != "" { + path := filepath.Join(dir, tt.lockName) + if err := os.WriteFile(path, []byte("dependencies: []\n"), 0644); err != nil { + t.Fatalf("writing fixture: %v", err) + } + } + if got := hasChartLock(dir); got != tt.want { + t.Errorf("hasChartLock() = %v, want %v", got, tt.want) + } + }) + } +} + func TestCreateFlagChain(t *testing.T) { testcases := []struct { flag string