Skip to content

Regression from Helm 3: annotateAndMerge corrupts manifests when rendered template content has --- followed by no newline #31867

Description

@matheuscscp

Hi team!

When a chart template has --- followed by {{- include ... }} (note the {{- which trims whitespace), the Go template {{- directive trims the newline after ---, producing ---apiVersion: v1\n... as the raw rendered content. This can happen at the start of a template file or between documents in a multi-document template.

In Helm v4, the new annotateAndMerge() function in pkg/action/action.go parses this content with kio.ParseAll() before sending it to post-renderers. kio.ParseAll() correctly (per the YAML spec) interprets ---apiVersion as a YAML mapping key — not a document separator followed by a mapping — and then re-serializes it as '---apiVersion': v1. This corrupts the manifest — the resource loses its apiVersion field entirely.

The bug only triggers when a PostRenderer is set (since annotateAndMerge is only called in the if pr != nil branch). The Helm CLI doesn't use a post-renderer by default, which is why helm install and helm template work fine. However, the Helm SDK is used by projects like Flux helm-controller, which always sets a PostRenderer to inject origin labels — causing all such charts to fail.

Related issue: fluxcd/helm-controller#1423

Regression

This is a regression from Helm v3. In v3, the post-renderer received the raw manifest bytes directly — no YAML round-trip:

// Helm v3 - pkg/action/action.go
if pr != nil {
    b, err = pr.Run(b)
}

In v4, commit e6362d7 ("Allow post-renderer to process hooks") introduced annotateAndMerge(), which parses all rendered templates through kio.ParseAll() and re-serializes them to inject postrenderer.helm.sh/postrender-filename annotations before passing to the post-renderer. This YAML round-trip is what corrupts the manifest.

Note: kio.ParseAll() is correct per the YAML spec — --- is only a document separator when it's on its own line. The bug is that Helm feeds raw template output (which can have --- glued to content due to Go template whitespace trimming) into a strict YAML parser without normalizing it first.

Root cause

pkg/action/action.go:

func annotateAndMerge(files map[string]string) (string, error) {
	// ...
	for _, fname := range fnames {
		content := files[fname]
		// ...
		manifests, err := kio.ParseAll(content)  // <-- parses raw rendered template
		if err != nil {
			return "", fmt.Errorf("parsing %s: %w", fname, err)
		}
		// ...
	}
	// ...
}

The rendered template content for a file like:

---
{{- include "mychart.service" . }}

After Go template execution is:

---apiVersion: v1
kind: Service
...

kio.ParseAll() does not recognize ---apiVersion as a YAML document separator followed by content. Instead, it parses it as a YAML mapping key ---apiVersion with value v1, and re-serializes it as '---apiVersion': v1.

This also affects multi-document templates where --- appears between documents:

{{ include "lib.deployment" (list . "mychart.deployment") }}
{{ define "mychart.deployment" }}
{{ end }}
---
{{- include "lib.service" (list . "mychart.service") }}
{{ define "mychart.service" }}
{{ end }}

Here the ---\n{{- in the middle of the file produces the same ---apiVersion corruption for the service document.

To reproduce

Minimal chart structure:

mychart/Chart.yaml

apiVersion: v2
name: mychart
version: 0.1.0

mychart/templates/_helpers.tpl

{{- define "mychart.service" -}}
apiVersion: v1
kind: Service
metadata:
  name: {{ .Release.Name }}-svc
spec:
  type: ClusterIP
  ports:
    - port: 80
      targetPort: http
      protocol: TCP
      name: http
{{- end -}}

mychart/templates/service.yaml

---
{{- include "mychart.service" . }}

helm template renders this correctly:

$ helm template myrelease ./mychart
---
# Source: mychart/templates/service.yaml
apiVersion: v1
kind: Service
metadata:
  name: myrelease-svc
spec:
  type: ClusterIP
  ports:
    - port: 80
      targetPort: http
      protocol: TCP
      name: http

helm install also works because no PostRenderer is set.

But when using the Helm SDK with a PostRenderer, the manifest is corrupted. This Go program demonstrates it:

package main

import (
	"bytes"
	"fmt"
	"os"

	helmaction "helm.sh/helm/v4/pkg/action"
	helmloader "helm.sh/helm/v4/pkg/chart/v2/loader"
	helmpostrender "helm.sh/helm/v4/pkg/postrenderer"
	"helm.sh/helm/v4/pkg/storage"
	"helm.sh/helm/v4/pkg/storage/driver"
)

type spyPostRenderer struct{}

func (s *spyPostRenderer) Run(renderedManifests *bytes.Buffer) (*bytes.Buffer, error) {
	fmt.Printf("Post-renderer received:\n%s\n", renderedManifests.String())
	return renderedManifests, nil
}

var _ helmpostrender.PostRenderer = &spyPostRenderer{}

func main() {
	store := storage.Init(driver.NewMemory())
	cfg := &helmaction.Configuration{Releases: store}

	install := helmaction.NewInstall(cfg)
	install.ReleaseName = "myrelease"
	install.Namespace = "default"
	install.DryRunStrategy = helmaction.DryRunClient
	install.PostRenderer = &spyPostRenderer{}

	chrt, err := helmloader.Load("./mychart")
	if err != nil {
		fmt.Fprintf(os.Stderr, "load error: %v\n", err)
		os.Exit(1)
	}

	_, err = install.RunWithContext(nil, chrt, map[string]interface{}{})
	if err != nil {
		fmt.Fprintf(os.Stderr, "install error: %v\n", err)
		os.Exit(1)
	}
}

Output:

Post-renderer received:
'---apiVersion': v1
kind: Service
metadata:
  name: myrelease-svc
  annotations:
    postrenderer.helm.sh/postrender-filename: 'mychart/templates/service.yaml'
spec:
  type: ClusterIP
  ports:
  - port: 80
    targetPort: http
    protocol: TCP
    name: http

Note '---apiVersion': v1 — the apiVersion field has been corrupted into a ---apiVersion mapping key.

Expected behavior

The post-renderer should receive a valid manifest with apiVersion: v1 as a proper field, not '---apiVersion': v1.

Workaround

Use {{ include instead of {{- include after ---:

---
{{ include "mychart.service" . }}

Or remove the --- entirely (Helm adds document separators automatically).

Version

Helm v4.1.1

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugCategorizes issue or PR as related to a bug.

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions