fix: ensure post-renderer is applied when --output-dir-template is used

When helmfile template is run with both --output-dir (from --output-dir-template)
and --post-renderer, Helm writes template files to the output directory before
invoking the post-renderer, bypassing it entirely.

Fix this by stripping --output-dir from the flags passed to Helm when a
post-renderer is also specified. Helm then sends all rendered manifests through
the post-renderer to stdout. writeRenderedManifests() captures that output and
writes it to the output directory, splitting documents by their '# Source:'
comments to recreate the per-template file structure. Documents without a
'# Source:' comment (e.g. resources injected by the post-renderer) are
collected into a single fallback 'manifest.yaml' file.

Agent-Logs-Url: https://github.com/helmfile/helmfile/sessions/38cec14e-e49f-4e90-918e-d400de52bd8e

Co-authored-by: yxxhero <11087727+yxxhero@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-04-09 13:01:12 +00:00
committed by GitHub
co-authored by yxxhero
parent f6652485d1
commit c6aa46234c
2 changed files with 196 additions and 12 deletions
+126 -12
View File
@@ -646,28 +646,61 @@ func (helm *execer) TemplateRelease(name string, chart string, flags ...string)
helm.logger.Infof("Templating release=%v, chart=%v", name, redactedURL(chart))
args := []string{"template", name, chart}
out, err := helm.exec(append(args, flags...), map[string]string{}, nil)
var outputToFile bool
for _, f := range flags {
if strings.HasPrefix("--output-dir", f) {
outputToFile = true
break
// Scan flags to detect --output-dir and --post-renderer.
var outputDir string
var hasPostRenderer bool
for i, f := range flags {
if f == "--output-dir" && i+1 < len(flags) {
outputDir = flags[i+1]
}
if f == "--post-renderer" {
hasPostRenderer = true
}
}
if outputToFile {
// With --output-dir is passed to helm-template,
outputToFile := outputDir != ""
// When both --output-dir and --post-renderer are used, Helm writes template
// files to the output directory before invoking the post-renderer, which
// means the post-renderer never sees the rendered manifests and the files
// contain unprocessed (pre-post-renderer) content.
//
// To work around this, we strip --output-dir from the flags so that Helm
// sends all rendered manifests through the post-renderer to stdout, and
// then we write the post-rendered output to the output directory ourselves,
// recreating the file-per-template structure using the "# Source:" comment
// that Helm embeds in each rendered document.
if outputToFile && hasPostRenderer {
var filteredFlags []string
for i := 0; i < len(flags); i++ {
if flags[i] == "--output-dir" {
i++ // skip the path value as well
continue
}
filteredFlags = append(filteredFlags, flags[i])
}
flags = filteredFlags
}
out, err := helm.exec(append(args, flags...), map[string]string{}, nil)
switch {
case outputToFile && hasPostRenderer:
// Write the post-rendered manifests to the output directory.
if writeErr := writeRenderedManifests(outputDir, out); writeErr != nil {
return fmt.Errorf("failed to write post-rendered templates to output dir %s: %w", outputDir, writeErr)
}
case outputToFile:
// With --output-dir passed to helm-template,
// we can safely direct all the logs from it to our logger.
//
// It's safe because anything written to stdout by helm-template with output-dir is logs,
// like excessive `wrote path/to/output/dir/chart/template/file.yaml` messages,
// but manifets.
// but not manifests.
//
// See https://github.com/roboll/helmfile/pull/1691#issuecomment-805636021 for more information.
helm.info(out)
} else {
default:
// Always write to stdout for use with e.g. `helmfile template | kubectl apply -f -`
helm.write(nil, out)
}
@@ -675,6 +708,87 @@ func (helm *execer) TemplateRelease(name string, chart string, flags ...string)
return err
}
// writeRenderedManifests writes YAML manifests to the output directory.
// It splits the rendered output by "# Source:" comments to recreate the
// per-template file structure that Helm's --output-dir normally provides.
// Documents that do not carry a "# Source:" comment (e.g. resources added
// by a post-renderer) are collected and written to a single fallback file
// named "manifest.yaml" at the root of the output directory.
func writeRenderedManifests(outputDir string, content []byte) error {
if len(content) == 0 {
return nil
}
// Split on the YAML document separator. Helm emits "---\n" between
// documents; trim leading/trailing whitespace from each chunk.
rawDocs := bytes.Split(content, []byte("\n---"))
// fileContents maps relative path → accumulated YAML content.
fileContents := make(map[string][]byte)
// fileOrder preserves insertion order so output is deterministic.
var fileOrder []string
var unsourced [][]byte
for _, raw := range rawDocs {
doc := bytes.TrimSpace(raw)
if len(doc) == 0 {
continue
}
// Look for a "# Source: <path>" comment on any line of the document.
var sourcePath string
for _, line := range bytes.Split(doc, []byte("\n")) {
if bytes.HasPrefix(line, []byte("# Source: ")) {
sourcePath = string(bytes.TrimSpace(bytes.TrimPrefix(line, []byte("# Source: "))))
break
}
}
if sourcePath != "" {
if _, seen := fileContents[sourcePath]; !seen {
fileOrder = append(fileOrder, sourcePath)
fileContents[sourcePath] = append([]byte("---\n"), doc...)
} else {
fileContents[sourcePath] = append(fileContents[sourcePath], []byte("\n---\n")...)
fileContents[sourcePath] = append(fileContents[sourcePath], doc...)
}
} else {
unsourced = append(unsourced, doc)
}
}
// Write per-source files.
for _, relPath := range fileOrder {
fullPath := filepath.Join(outputDir, relPath)
if err := os.MkdirAll(filepath.Dir(fullPath), 0755); err != nil {
return fmt.Errorf("failed to create directory for %s: %w", fullPath, err)
}
if err := os.WriteFile(fullPath, fileContents[relPath], 0644); err != nil {
return fmt.Errorf("failed to write %s: %w", fullPath, err)
}
}
// Write unsourced documents (e.g. injected by a post-renderer) to a
// single fallback file.
if len(unsourced) > 0 {
var combined []byte
for _, doc := range unsourced {
combined = append(combined, []byte("---\n")...)
combined = append(combined, doc...)
combined = append(combined, '\n')
}
fallback := filepath.Join(outputDir, "manifest.yaml")
if err := os.MkdirAll(outputDir, 0755); err != nil {
return fmt.Errorf("failed to create output directory %s: %w", outputDir, err)
}
if err := os.WriteFile(fallback, combined, 0644); err != nil {
return fmt.Errorf("failed to write %s: %w", fallback, err)
}
}
return nil
}
func (helm *execer) DiffRelease(context HelmContext, name, chart, namespace string, suppressDiff bool, flags ...string) error {
diffMsg := fmt.Sprintf("Comparing release=%v, chart=%v, namespace=%v\n", name, redactedURL(chart), namespace)
if context.Writer != nil && !suppressDiff {
+70
View File
@@ -1255,6 +1255,76 @@ exec: helm --kubeconfig config --kube-context dev template release https://examp
}
}
func Test_WriteRenderedManifests(t *testing.T) {
t.Run("sourced documents are written to per-file paths", func(t *testing.T) {
dir := t.TempDir()
content := []byte("---\n# Source: chart/templates/deployment.yaml\napiVersion: apps/v1\nkind: Deployment\n\n---\n# Source: chart/templates/service.yaml\napiVersion: v1\nkind: Service\n")
require.NoError(t, writeRenderedManifests(dir, content))
deployment, err := os.ReadFile(filepath.Join(dir, "chart/templates/deployment.yaml"))
require.NoError(t, err)
require.Contains(t, string(deployment), "kind: Deployment")
service, err := os.ReadFile(filepath.Join(dir, "chart/templates/service.yaml"))
require.NoError(t, err)
require.Contains(t, string(service), "kind: Service")
})
t.Run("documents without Source comment are written to manifest.yaml", func(t *testing.T) {
dir := t.TempDir()
content := []byte("---\napiVersion: v1\nkind: Namespace\nmetadata:\n name: prometheus\n")
require.NoError(t, writeRenderedManifests(dir, content))
manifest, err := os.ReadFile(filepath.Join(dir, "manifest.yaml"))
require.NoError(t, err)
require.Contains(t, string(manifest), "kind: Namespace")
})
t.Run("empty content is a no-op", func(t *testing.T) {
dir := t.TempDir()
require.NoError(t, writeRenderedManifests(dir, nil))
require.NoError(t, writeRenderedManifests(dir, []byte{}))
})
t.Run("mixed sourced and unsourced documents", func(t *testing.T) {
dir := t.TempDir()
content := []byte("---\n# Source: chart/templates/deployment.yaml\napiVersion: apps/v1\nkind: Deployment\n\n---\napiVersion: v1\nkind: Namespace\nmetadata:\n name: added-by-post-renderer\n")
require.NoError(t, writeRenderedManifests(dir, content))
deployment, err := os.ReadFile(filepath.Join(dir, "chart/templates/deployment.yaml"))
require.NoError(t, err)
require.Contains(t, string(deployment), "kind: Deployment")
manifest, err := os.ReadFile(filepath.Join(dir, "manifest.yaml"))
require.NoError(t, err)
require.Contains(t, string(manifest), "kind: Namespace")
})
}
func Test_TemplateRelease_OutputDirWithPostRenderer(t *testing.T) {
// When both --output-dir and --post-renderer are specified, TemplateRelease
// should strip --output-dir from the flags forwarded to helm so that the
// post-renderer actually receives the rendered manifests.
outputDir := t.TempDir()
manifests := "---\n# Source: chart/templates/configmap.yaml\napiVersion: v1\nkind: ConfigMap\nmetadata:\n name: test\n"
runner := &mockRunner{output: []byte(manifests)}
logger := NewLogger(io.Discard, "error")
helm, err := New("helm", HelmExecOptions{}, logger, "", "", runner)
require.NoError(t, err)
err = helm.TemplateRelease("release", "chart",
"--output-dir", outputDir,
"--post-renderer", "./my-post-renderer.sh",
)
require.NoError(t, err)
// The file should exist and contain the (mock) post-rendered content.
content, err := os.ReadFile(filepath.Join(outputDir, "chart/templates/configmap.yaml"))
require.NoError(t, err)
require.Contains(t, string(content), "kind: ConfigMap")
}
func Test_IsHelm3(t *testing.T) {
helm3Runner := mockRunner{output: []byte("v3.0.0+ge29ce2a\n")}
helm, err := New("helm", HelmExecOptions{}, NewLogger(os.Stdout, "info"), "", "dev", &helm3Runner)