What's Helm's plan?
June 2026
One of the things Helm is missing compared to Terraform is a plan step. With Terraform you get terraform plan: you see exactly what's going to change before anything touches the cluster, look it over, and then decide. With Helm you run helm upgrade and find out what changed by watching it happen.
There's helm-diff, which is excellent. But the output is a wall of YAML in a pipeline log. You have to click into the job to read it. What would be better is something that surfaces in the merge request itself: a small widget that says "2 resources to add, 1 to change, 0 to destroy" before anyone has to click anything.
Turns out GitLab has exactly this, for Terraform. It's called the Terraform plan artifact and it shows up right in the MR view. You point it at a JSON file and GitLab renders a summary of what would change. No actual Terraform required, just the JSON file in the right format.
So: generate that JSON from Helm.
The approach
helm get manifest gives you the currently deployed manifests, helm template renders what the incoming version would produce. Strip secrets from both, key every resource by Kind/name, and compare the two sets:
- only in the new manifests →
create - only in the current manifests →
delete - in both but content differs →
update - identical → skip
Terraform publishes a JSON plan format spec. There's a format_version field, a resource_changes array, and each entry has before and after objects showing the resource on each side of the change. Implement it properly (right provider names, right action arrays, right after_unknown field) and it looks like real Terraform output.
Then run it.
The job helm:preview failed to generate a report. Generating the report caused an error.
Maybe format_version: "1.1" is too new. Try "0.1". Same error.
Maybe the before and after objects are too large; Kubernetes specs can get deeply nested. Slim everything down to just kind and metadata.name. Same error, and now updates where only spec changed have identical before and after, because the changed field got stripped. The schema is both broken and wrong.
Maybe after_unknown needs actual boolean values instead of an empty object. Same error.
At that point the obvious move is to strip everything back. Separate job, alpine image, one line of script writing a hardcoded JSON, nothing else that could be causing the problem:
terraform:report:test:
image: alpine
stage: test
script:
- printf '%s' '{"format_version":"0.1","resource_changes":[]}' > plan.json
artifacts:
reports:
terraform: plan.jsonSame error. Which rules out the Helm script, the manifest parsing, the jq pipeline: everything specific to the implementation. The problem is the format itself.
Reading the source
At some point guessing stops being a useful strategy. GitLab is open source. The Terraform plan parser lives at lib/gitlab/ci/parsers/terraform/tfplan.rb:
REQUIRED_KEYS = %w[create update delete].freeze
def has_required_keys?
REQUIRED_KEYS.all? { |key| @plan.key?(key) }
endThree keys. create, update, delete. That's the entire validation. The parser doesn't touch resource_changes, doesn't read format_version, doesn't look at before or after. The widget shows a count of resources to add, change, and destroy, and those numbers come from three integers at the root of the JSON.
The format GitLab actually wants:
{"create": 2, "update": 1, "delete": 0}The confusion is understandable once you find the right references. There's a GitLab forum thread about this exact issue, and the MR that introduced the feature has a jq alias buried in it:
alias convert_report="jq -r '
([.resource_changes[]?.change.actions?] | flatten) |
{
"create": (map(select(. == "create")) | length),
"update": (map(select(. == "update")) | length),
"delete": (map(select(. == "delete")) | length)
}'"This takes the full terraform show -json output and converts it into the three-key format before saving the artifact. Every example of people using this feature runs the full plan through that filter first. The documentation describes the format Terraform produces; the widget reads what that format gets converted into.
The fix is three lines of jq:
jq '{create: (.create|length), update: (.update|length), delete: (.delete|length)}' \
/tmp/changes.json > helm-plan.jsonWhere /tmp/changes.json is an intermediate file with resource names sorted into arrays; that part is still useful for printing a colored summary in the job log:
+ Deployment/my-app
~ ConfigMap/my-app-config
- CronJob/cleanup
Plan: 1 to add, 1 to change, 1 to destroy.The widget shows up on the MR a few minutes into the pipeline, before the deploy has run. Reviewers can see a count of what would change without clicking anything. Small thing, but it changes the feel of merging.
Notes
Read the source before spending time on the schema. The documentation describes what goes in, not what the parser reads.
The full resource_changes format with before and after is still worth generating as a separate artifact, just not for the widget. Tools like conftest and OPA can use it for policy enforcement in CI: gate on resource limits being set, flag anything modifying a production namespace. The widget only shows counts, but a richer artifact is available if you need it.
helm diff is great, but it lives in the job log. Three lines of jq put a count of Kubernetes resource changes on the MR before anyone clicks anything.