- 1. Plugin System
- 2. Chart Testing
- 3. Linting
- 4. Schema Validation
- 5. OCI Registry Advanced Usage
- 6. Chart Signing and Verification
- 7. The plugin.yaml Contract: Required Fields and Deprecated Ones
- 8. The Environment Variables a Plugin Is Given
- 9. Taking One Chart Through the Whole Gate, in Order
- 10. Operating values.schema.json
- 11. Failure Modes and the Order to Diagnose Them
- 12. When Not to Use These
- 13. References
- 14. Summary
1. Plugin System
1.1 Plugin Structure
# plugin.yaml
name: 'my-plugin'
version: '1.0.0'
usage: 'A custom Helm plugin'
description: 'This plugin does something useful'
command: '$HELM_PLUGIN_DIR/bin/my-plugin'
hooks:
install: '$HELM_PLUGIN_DIR/scripts/install.sh'
update: '$HELM_PLUGIN_DIR/scripts/update.sh'
delete: '$HELM_PLUGIN_DIR/scripts/cleanup.sh'
1.2 Plugin Management
helm plugin install https://github.com/example/helm-my-plugin
helm plugin list
helm plugin update my-plugin
helm plugin uninstall my-plugin
1.3 Key Plugins
helm-diff: Preview changes before upgrade
helm plugin install https://github.com/databus23/helm-diff
helm diff upgrade my-release ./my-chart -f values.yaml
helm diff revision my-release 2 3
helm-secrets: Secure secret management
helm plugin install https://github.com/jkroepke/helm-secrets
helm secrets install my-release ./my-chart -f secrets.yaml
helm secrets enc secrets.yaml
helm-unittest: Chart unit testing
helm plugin install https://github.com/helm-unittest/helm-unittest
helm unittest ./my-chart
2. Chart Testing
2.1 helm test
Built-in test mechanism that runs in-cluster after release:
# templates/tests/test-connection.yaml
apiVersion: v1
kind: Pod
metadata:
name: {{ include "my-chart.fullname" . }}-test-connection
annotations:
"helm.sh/hook": test
spec:
restartPolicy: Never
containers:
- name: wget
image: busybox
command: ['wget']
args: ['{{ include "my-chart.fullname" . }}:{{ .Values.service.port }}']
helm test my-release --timeout 5m --logs
2.2 helm-unittest
Local unit tests without a cluster:
# tests/deployment_test.yaml
suite: test deployment
templates:
- deployment.yaml
tests:
- it: should create deployment with correct replicas
set:
replicaCount: 3
asserts:
- isKind:
of: Deployment
- equal:
path: spec.replicas
value: 3
- it: should set correct image
set:
image:
repository: nginx
tag: '1.25'
asserts:
- equal:
path: spec.template.spec.containers[0].image
value: 'nginx:1.25'
- it: should not create ingress when disabled
template: ingress.yaml
set:
ingress:
enabled: false
asserts:
- hasDocuments:
count: 0
2.3 ct (chart-testing) Tool
Automated chart change detection and testing in CI/CD:
ct list-changed --target-branch main
ct lint --target-branch main
ct install --target-branch main
ct lint-and-install --target-branch main
ct configuration file:
# ct.yaml
remote: origin
target-branch: main
chart-dirs:
- charts
chart-repos:
- bitnami=https://charts.bitnami.com/bitnami
helm-extra-args: --timeout 600s
validate-maintainers: false
3. Linting
3.1 helm lint
helm lint ./my-chart
helm lint ./my-chart --strict
helm lint ./my-chart -f production-values.yaml
helm lint ./my-chart --set replicaCount=3
What helm lint checks:
- Required fields in Chart.yaml
- Template rendering errors
- values.yaml validity
- Chart name and version conventions
- Label and annotation recommendations
3.2 yamllint and kubeval/kubeconform
# YAML syntax check
helm template my-release ./my-chart | yamllint -
# Kubernetes schema validation (kubeconform)
helm template my-release ./my-chart | kubeconform \
-strict \
-kubernetes-version 1.29.0 \
-summary
4. Schema Validation
{
"$schema": "https://json-schema.org/draft-07/schema#",
"type": "object",
"required": ["replicaCount", "image"],
"properties": {
"replicaCount": {
"type": "integer",
"minimum": 1,
"maximum": 100
},
"image": {
"type": "object",
"required": ["repository"],
"properties": {
"repository": { "type": "string" },
"tag": { "type": "string", "default": "latest" },
"pullPolicy": { "type": "string", "enum": ["Always", "IfNotPresent", "Never"] }
}
},
"service": {
"type": "object",
"properties": {
"type": { "type": "string", "enum": ["ClusterIP", "NodePort", "LoadBalancer"] },
"port": { "type": "integer", "minimum": 1, "maximum": 65535 }
}
}
}
}
Schema is automatically validated during helm install, helm upgrade, helm lint, and helm template.
5. OCI Registry Advanced Usage
helm package ./my-chart
helm push my-chart-1.0.0.tgz oci://ghcr.io/myorg/charts
helm install my-release oci://ghcr.io/myorg/charts/my-chart --version 1.0.0
helm pull oci://ghcr.io/myorg/charts/my-chart --version 1.0.0
Using OCI in CI/CD:
# GitHub Actions example
# .github/workflows/helm-publish.yaml
name: Publish Helm Chart
on:
push:
tags: ['v*']
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Login to GHCR
run: echo "$GITHUB_TOKEN" | helm registry login ghcr.io -u $ --password-stdin
- name: Package and Push
run: |
helm package ./charts/my-app
helm push my-app-*.tgz oci://ghcr.io/$GITHUB_REPOSITORY_OWNER/charts
6. Chart Signing and Verification
helm package --sign --key 'my-key' --keyring ~/.gnupg/secring.gpg ./my-chart
helm verify my-chart-1.0.0.tgz
helm install my-release my-chart-1.0.0.tgz --verify
7. The plugin.yaml Contract: Required Fields and Deprecated Ones
The example in 1.1 is not the shape the docs recommend today. Per the documentation, only name and version are required in plugin.yaml, and version must follow SemVer 2. Everything else is optional. usage is the single-line usage text Helm prints; description is the long description that shows up in helm help. ignoreFlags is a boolean that, when set, stops Helm from passing user-supplied flags to the plugin. If your plugin parses its own arguments, turning this on removes any chance of colliding with Helm's own flag parsing. downloaders configures the downloader capability for custom protocol support, which is what you reach for when charts live behind a scheme Helm does not know about.
The part that actually matters is that command and hooks — both used in the example above — are marked deprecated in the docs, in favour of platformCommand and platformHooks. The difference is structural, not cosmetic. command is a single string, so a plugin supporting several platforms had to branch on uname inside that string or add another wrapper shell script. That branch was code written by the plugin author, and when a new architecture appeared it silently ran the wrong binary. platformCommand is a list keyed by os and arch, which moves the responsibility for picking a binary out of shell script and into Helm. platformHooks does the same for the install, update and delete lifecycle hooks. Plugins using the old fields do not break today, but a new plugin should start on the platform variants.
One thing deserves an honest caveat. The helm.sh plugins page carries a banner saying it has not yet been updated for Helm 4, and the site version at the time of writing was 4.2.4. So the field list above is the latest documentation you can actually read, but it is not verified against Helm 4. In particular the docs name platformHooks and describe its purpose without showing an example, so this post will not invent the YAML shape. Check the exact field in the docs for the version you run.
# plugin.yaml — the shape the docs recommend
name: 'my-plugin'
version: '1.0.0'
usage: 'my-plugin [flags] CHART'
description: 'Renders a chart the way our CI renders it'
ignoreFlags: false
platformCommand:
- os: linux
arch: amd64
command: '$HELM_PLUGIN_DIR/bin/my-plugin-linux-amd64'
- os: darwin
arch: arm64
command: '$HELM_PLUGIN_DIR/bin/my-plugin-darwin-arm64'
8. The Environment Variables a Plugin Is Given
A plugin is a standalone executable, but it does not run in a vacuum. Helm populates the process environment when it launches the plugin, and the docs guarantee the following variables.
HELM_PLUGINS # plugins directory
HELM_PLUGIN_NAME # name as invoked by helm
HELM_PLUGIN_DIR # directory containing this plugin
HELM_BIN # path to the helm command
HELM_DEBUG
HELM_NAMESPACE
HELM_KUBECONTEXT
HELM_REGISTRY_CONFIG
HELM_REPOSITORY_CACHE
HELM_REPOSITORY_CONFIG
The two that decide whether a plugin behaves correctly are HELM_BIN and HELM_KUBECONTEXT. When a plugin needs to ask the cluster something it has two options: read kubeconfig itself and build a client, or shell out to the helm that HELM_BIN points at. Take the second. If the user invoked helm against a particular context, Helm hands that decision down in HELM_KUBECONTEXT. A plugin that parses kubeconfig on its own ignores that value and reads current-context instead, so the user targets staging while the plugin alone looks at production. For the same reason the namespace should come from HELM_NAMESPACE, repository lookups should use HELM_REPOSITORY_CACHE and HELM_REPOSITORY_CONFIG, and OCI registry credentials should come from HELM_REGISTRY_CONFIG. Hardcode any of those paths and the plugin breaks immediately on a CI runner that juggles several Helm configurations.
#!/usr/bin/env bash
set -euo pipefail
# Diagnostics first: print what helm actually handed us
echo "plugin=$HELM_PLUGIN_NAME dir=$HELM_PLUGIN_DIR" >&2
echo "namespace=$HELM_NAMESPACE context=$HELM_KUBECONTEXT" >&2
# Do not read kubeconfig; call back through the helm the caller gave us
exec "$HELM_BIN" template "$@"
HELM_DEBUG is useful too. It signals that the user ran helm in debug mode, so matching your plugin's log verbosity to it removes the need for a separate flag. Invent your own debug flag and ignore HELM_DEBUG, and the user turns on Helm's debug output only to find the one component that is failing stays quiet.
9. Taking One Chart Through the Whole Gate, in Order
Each tool above sounds good on its own, but the ordering is what creates the value. Later stages are slower and more expensive than earlier ones, so anything you could have caught early and defer instead only lengthens the feedback loop. This sequence is the baseline.
# 1) Static checks — no cluster
helm lint ./my-chart --strict --with-subcharts
# 2) Validate rendered output against the Kubernetes schema — no cluster
helm template my-release ./my-chart \
| kubeconform -strict -kubernetes-version 1.29.0 -summary
# 3) Per-branch unit checks — no cluster
helm unittest ./my-chart
# 4) Simulation
helm install my-release ./my-chart --dry-run
# 5) Real install, then verify from inside the cluster
helm install my-release ./my-chart --wait
helm test my-release --logs
Each stage catches something the previous one cannot. helm lint checks whether the chart is well-formed: required Chart.yaml fields, whether the templates render at all. --strict promotes warnings to failures and --with-subcharts lints dependent charts. Passing here means a string was produced, not that the string is a manifest Kubernetes will accept. Stage 2 closes that gap: kubeconform validates rendered YAML against the OpenAPI schema for a named Kubernetes version, so a typo like spec.replica or a resource that lost its apiVersion is caught here. Lint will never find those.
Stage 3 does what neither of the first two can do structurally. Lint and kubeconform only ever see one render with one set of defaults, but real incidents come from specific values combinations. helm unittest renders with different values and asserts on specific paths in the result, so it pins down per-branch contracts: turning ingress off must yield zero documents, setting replicaCount to 3 must actually produce 3. This is still local rendering, though.
Stage 4 is where simulation first appears. helm install --dry-run is documented as taking one of none (the default), client, or server. That is the contrast with lint, which judges capabilities and deprecations against a version a human supplied via --kube-version — but how far each dry-run value round-trips to the API server has shifted between versions. Check the exact field in the docs for the version you run. The practical takeaway is one sentence: even after passing here, nobody has confirmed the image actually pulls or the Service actually answers.
Stage 5 is that confirmation. helm test runs the test hooks as Pods inside the cluster where the release is installed, and treats a successful Pod as evidence that the release works. Successful output includes a block like the following, and the line the docs point at is Phase: Succeeded.
NAME: demo
LAST DEPLOYED: Mon Feb 14 20:03:16 2022
NAMESPACE: default
STATUS: deployed
REVISION: 1
TEST SUITE: demo-test-connection
Last Started: Mon Feb 14 20:35:19 2022
Last Completed: Mon Feb 14 20:35:23 2022
Phase: Succeeded
The hook annotation has three values with different standing. The current standard is "helm.sh/hook": test. test-success is what was used through Helm v3 and is still accepted as a backwards-compatible alternative; test-failure is deprecated. If you inherited an old chart these may be mixed, and normalizing on test is the safe move. The general hook annotations helm.sh/hook-weight and helm.sh/hook-delete-policy apply to test resources as well. Hook weights are numbers that must be written as strings, and hooks of the same Kind are sorted in ascending order, so you can run a seeding Pod before a verifying Pod. hook-delete-policy takes before-hook-creation (the default), hook-succeeded, or hook-failed.
# templates/tests/test-connection.yaml — the annotations
metadata:
annotations:
'helm.sh/hook': test
'helm.sh/hook-weight': '10'
'helm.sh/hook-delete-policy': hook-succeeded
10. Operating values.schema.json
A schema file is a gate, not documentation. The docs state that validation occurs when any of helm install, helm upgrade, helm lint or helm template is invoked. What matters in that list is that lint and template are on it: bad values are caught before they reach a cluster, in fact on a CI runner with no cluster at all. Stage 1 of section 9 doubles as the schema gate.
The second rule involves subcharts. The docs say the final .Values object is checked against all subchart schemas. A parent chart cannot circumvent a subchart's restrictions and must satisfy them itself. In practice this shows up as follows: the parent's values.yaml overrides a subchart key, the subchart schema puts an enum or a minimum on that key, and someone reading only the parent sees a validation failure appear from nowhere. If the path in the error message is not in the parent schema, open the subchart schema next.
The third is the escape hatch. If a schema contains remote references, validation itself fails in an air-gapped environment. That is what --skip-schema-validation is for. Both helm install and helm lint document it, with the same description: it disables JSON schema validation. It is an air-gap workaround, not a switch for when validation is inconvenient. If that flag is permanently attached in your team's CI, the schema is already a dead file.
# Checking only the schema: stop at lint instead of rendering
helm lint ./my-chart -f production-values.yaml
# Only when an air-gapped environment blocks on remote schema references
helm install my-release ./my-chart --skip-schema-validation
11. Failure Modes and the Order to Diagnose Them
Lint passes but install fails — this is the most common one, and it is design rather than a bug. Lint never asks the API server; it is even told the cluster version by a human via --kube-version. So missing RBAC, a name already in use, an admission webhook rejection, or a CRD that does not exist are all outside its field of view. The diagnosis order is: read the rendered output, run it through kubeconform, then try --dry-run. If the first two turn up nothing, the problem is the cluster, not the chart.
Drifting unit-test snapshots are the next most frequent. The symptom is CI going red with no code change. The cause is usually a snapshot that captured the entire render including something that changes per release, such as a chart version or an image tag. Running -u (--update-snapshot) makes the red go away, but that is silence, not diagnosis. Read the diff first, confirm the changed lines were intended, and if a volatile value is the cause, replace that part of the snapshot with path-level assertions.
A plugin that suddenly touches the wrong cluster is the failure described in section 8. The symptom is distinctive: helm itself works against the right context while the plugin alone returns results from somewhere else. Look for a line in the plugin that reads kubeconfig or KUBECONFIG directly, and replace it with a call back through HELM_BIN.
Schema validation that fails only after a subchart bump is section 10's rule showing itself. The parent chart is untouched, so the cause is invisible. Check whether the new subchart version added a values.schema.json or tightened an existing constraint.
Finally, helm test leaving Pods behind. Without a hook-delete-policy the default before-hook-creation applies, so the test Pod lives until just before the next run. Completed Pods piling up in the namespace is the documented behaviour, not a leak. Unless you need the logs to stay, attach hook-succeeded.
# The diagnosis order, literally
helm template my-release ./my-chart | less # read the render first
helm template my-release ./my-chart | kubeconform -strict -summary
helm install my-release ./my-chart --dry-run # first simulation
kubectl get pods -l 'app.kubernetes.io/instance=my-release'
12. When Not to Use These
Skip unit tests that merely re-assert the template verbatim. A test asserting that spec.replicas equals .Values.replicaCount is the template transcribed a second time, so it must change whenever the template changes. Such a test catches no regressions and doubles the cost of every edit. The assertions worth having are about branching: whether a resource appears at all for a given values combination, whether a conditional block switches on.
The same goes for plugins. Building a plugin for something a values file or a few lines of Makefile already handles buys you distribution, versioning and per-platform binaries as new obligations. A plugin earns its keep when it must look like a helm subcommand and inherit helm's context and namespace. Without that requirement, a shell script is better.
Be wary of treating helm test as a substitute for real smoke tests. Test hooks are Pods running inside the cluster right after install, so TLS outside the ingress, external DNS, the auth gateway and the real traffic path are all out of scope. If your pipeline has a separate end-to-end check, keep helm test as the cheap check in front of it — and do not declare it sufficient.
13. References
- Helm — Plugins — checked 2026-08-16 (carries a "not yet updated for Helm 4" banner; site version 4.2.4)
- Helm — Chart Tests — checked 2026-08-16
- Helm — Chart Hooks — checked 2026-08-16
- Helm — Charts / Schema Files — checked 2026-08-16
- helm lint — checked 2026-08-16
- helm install — checked 2026-08-16
- helm-unittest — checked 2026-08-16
14. Summary
Helm extensibility and quality assurance:
- Plugin system: Extend functionality with helm-diff, helm-secrets, helm-unittest
- Multi-layer testing: Combine helm test (integration), unittest (unit), ct (CI/CD)
- Linting: Multi-angle validation with helm lint, yamllint, kubeconform
- Schema validation: Ensure input validity with values.schema.json
- OCI registry: Deploy charts using the same workflow as container images
- Signing/verification: Prove chart integrity and provenance