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
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 inpkg/action/action.goparses this content withkio.ParseAll()before sending it to post-renderers.kio.ParseAll()correctly (per the YAML spec) interprets---apiVersionas 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 itsapiVersionfield entirely.The bug only triggers when a
PostRendereris set (sinceannotateAndMergeis only called in theif pr != nilbranch). The Helm CLI doesn't use a post-renderer by default, which is whyhelm installandhelm templatework 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:
In v4, commit e6362d7 ("Allow post-renderer to process hooks") introduced
annotateAndMerge(), which parses all rendered templates throughkio.ParseAll()and re-serializes them to injectpostrenderer.helm.sh/postrender-filenameannotations 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:The rendered template content for a file like:
--- {{- include "mychart.service" . }}After Go template execution is:
kio.ParseAll()does not recognize---apiVersionas a YAML document separator followed by content. Instead, it parses it as a YAML mapping key---apiVersionwith valuev1, and re-serializes it as'---apiVersion': v1.This also affects multi-document templates where
---appears between documents:Here the
---\n{{-in the middle of the file produces the same---apiVersioncorruption for the service document.To reproduce
Minimal chart structure:
mychart/Chart.yamlmychart/templates/_helpers.tplmychart/templates/service.yamlhelm templaterenders this correctly:helm installalso works because no PostRenderer is set.But when using the Helm SDK with a PostRenderer, the manifest is corrupted. This Go program demonstrates it:
Output:
Note
'---apiVersion': v1— theapiVersionfield has been corrupted into a---apiVersionmapping key.Expected behavior
The post-renderer should receive a valid manifest with
apiVersion: v1as a proper field, not'---apiVersion': v1.Workaround
Use
{{ includeinstead of{{- includeafter---:Or remove the
---entirely (Helm adds document separators automatically).Version
Helm v4.1.1