| name | helm-chart-development |
| description | Trigger when a user is building, writing, or debugging Helm chart source files — not deploying or installing charts. Key scenarios: packaging an application as a Helm chart for the first time, writing or fixing Go templates (deployment, service, ServiceMonitor, ingress), authoring _helpers.tpl helper functions, configuring values.schema.json validation, diagnosing ct lint or ah lint schema errors, writing helm-unittest test suites, or setting up helm-docs with README.md.gotmpl. The core signal: the user is acting as a chart developer (creating or modifying chart files), not as a chart consumer operating a deployed release. |
Helm Chart Development
Quick Reference
| Task | Command |
|---|
| Lint (chart-testing) | ct lint --debug --config ./.github/configs/ct-lint.yaml --lint-conf ./.github/configs/lintconf.yaml |
| Lint (ArtifactHub) | ah lint -p charts/<chart-name> |
| Unit tests | helm unittest --strict --file 'unittests/**/*.yaml' charts/<chart-name> |
| Unit tests (all charts) | for chart in charts/*/; do helm unittest --strict --file 'unittests/**/*.yaml' "${chart%/}"; done |
| Update dependencies | helm dependency update charts/<chart-name> |
| Template dry-run | helm template myrelease charts/<chart-name> -f values.yaml |
| Install debug | helm install myrelease charts/<chart-name> --debug --dry-run |
Chart Structure
charts/<name>/
├── Chart.yaml # Chart metadata, version, appVersion, ArtifactHub annotations
├── Chart.lock # Locked dependency versions
├── values.yaml # Default values
├── values.schema.json # JSON Schema validation for values (additionalProperties: false)
├── values-kind.yaml # Minimal values for kind cluster CI testing (no external deps, no PVCs)
├── README.md # Auto-generated by helm-docs from README.md.gotmpl — never edit directly
├── README.md.gotmpl # Template source for README generation
├── LICENSE
├── .skip-kind-test # Optional: if present, ct install step is skipped in CI
├── files/ # Static files mounted into containers (e.g., scripts, configs)
├── templates/
│ ├── _helpers.tpl
│ ├── deployment.yaml
│ ├── service.yaml
│ ├── ingress.yaml
│ ├── serviceaccount.yaml
│ ├── hpa.yaml
│ ├── servicemonitor.yaml
│ ├── NOTES.txt
│ └── *_test.yaml # Helm template test files (flat — no tests/ subdirectory)
└── unittests/
└── *_test.yaml # helm-unittest test suites (separate from templates/)
Chart.yaml
apiVersion: v2
name: myapp
description: A Helm chart for MyApp
type: application
version: 1.0.0
appVersion: "2.3.1"
home: https://github.com/org/myapp
icon: https://example.com/icon.png
sources:
- https://github.com/org/myapp
maintainers:
- name: Platform Team
email: platform@company.com
keywords:
- app
- web
annotations:
artifacthub.io/category: integration-delivery
artifacthub.io/license: Apache-2.0
artifacthub.io/prerelease: "false"
artifacthub.io/containsSecurityUpdates: "false"
artifacthub.io/changes: |
- kind: added
description: Initial release
artifacthub.io/images: |
- name: myapp
image: myorg/myapp:2.3.1
artifacthub.io/maintainers: |
- name: Platform Team
email: platform@company.com
artifacthub.io/links: |
- name: support
url: https://github.com/org/myapp/issues
dependencies:
- name: postgresql
version: "12.x.x"
repository: https://charts.bitnami.com/bitnami
condition: postgresql.enabled
For the full list of ArtifactHub annotations with format details, read references/artifacthub-annotations.md.
Versioning Rules
- Any change (including docs) requires a
version bump in Chart.yaml following semver.
appVersion tracks the upstream application version.
- Breaking changes bump MAJOR version and must add an "Upgrading" section to
README.md.gotmpl.
artifacthub.io/changes must be populated on every release — it drives auto-generated release notes.
README Generation
README.md is auto-generated from README.md.gotmpl by helm-docs. Never edit README.md directly.
helm-docs --chart-search-root=charts --template-files=README.md.gotmpl
The pre-commit hook runs helm-docs automatically. If it modifies README.md, the commit fails — re-stage and retry:
git add charts/<chart-name>/README.md
git commit -s
Values Design
replicaCount: 1
image:
repository: myapp
pullPolicy: IfNotPresent
tag: ""
imagePullSecrets: []
nameOverride: ""
fullnameOverride: ""
serviceAccount:
create: true
annotations: {}
name: ""
automount: true
podAnnotations: {}
podLabels: {}
podSecurityContext:
fsGroup: 1000
runAsNonRoot: true
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
runAsNonRoot: true
runAsUser: 1000
capabilities:
drop:
- ALL
service:
type: ClusterIP
port: 80
targetPort: 8080
ingress:
enabled: false
className: ""
annotations: {}
hosts:
- host: chart-example.local
paths:
- path: /
pathType: Prefix
tls: []
resources:
limits:
cpu: 500m
memory: 512Mi
requests:
cpu: 100m
memory: 128Mi
autoscaling:
enabled: false
minReplicas: 1
maxReplicas: 10
targetCPUUtilizationPercentage: 80
nodeSelector: {}
tolerations: []
affinity: {}
extraEnv: []
extraVolumeMounts: []
extraVolumes: []
livenessProbe:
httpGet:
path: /healthz
port: http
initialDelaySeconds: 10
periodSeconds: 10
readinessProbe:
httpGet:
path: /ready
port: http
initialDelaySeconds: 5
periodSeconds: 5
postgresql:
enabled: false
auth:
database: myapp
username: myapp
Values Schema (values.schema.json)
Uses JSON Schema draft-07. The "additionalProperties": false at root is required — any value in values.yaml without a schema entry causes ct lint to fail.
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"additionalProperties": false,
"required": ["image"],
"properties": {
"replicaCount": {
"type": "integer",
"minimum": 1
},
"image": {
"type": "object",
"additionalProperties": false,
"required": ["repository"],
"properties": {
"repository": { "type": "string" },
"tag": { "type": "string" },
"pullPolicy": {
"type": "string",
"enum": ["Always", "IfNotPresent", "Never"]
}
}
},
"resources": {
"type": "object",
"additionalProperties": false,
"properties": {
"limits": {
"type": "object",
"properties": {
"cpu": { "type": "string" },
"memory": { "type": "string" }
}
},
"requests": {
"type": "object",
"properties": {
"cpu": { "type": "string" },
"memory": { "type": "string" }
}
}
}
}
}
}
Template Helpers (_helpers.tpl)
{{- define "myapp.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- define "myapp.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- $name := default .Chart.Name .Values.nameOverride }}
{{- if contains $name .Release.Name }}
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{- end }}
{{- define "myapp.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- define "myapp.labels" -}}
helm.sh/chart: {{ include "myapp.chart" . }}
{{ include "myapp.selectorLabels" . }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}
{{- define "myapp.selectorLabels" -}}
app.kubernetes.io/name: {{ include "myapp.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
{{- define "myapp.serviceAccountName" -}}
{{- if .Values.serviceAccount.create }}
{{- default (include "myapp.fullname" .) .Values.serviceAccount.name }}
{{- else }}
{{- default "default" .Values.serviceAccount.name }}
{{- end }}
{{- end }}
{{- define "myapp.image" -}}
{{- $tag := .Values.image.tag | default .Chart.AppVersion -}}
{{- printf "%s:%s" .Values.image.repository $tag }}
{{- end }}
For string, math, list, dict, encoding, and date functions, read references/template-functions.md.
Template Patterns
Deployment
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "myapp.fullname" . }}
labels:
{{- include "myapp.labels" . | nindent 4 }}
spec:
{{- if not .Values.autoscaling.enabled }}
replicas: {{ .Values.replicaCount }}
{{- end }}
selector:
matchLabels:
{{- include "myapp.selectorLabels" . | nindent 6 }}
template:
metadata:
annotations:
checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
{{- with .Values.podAnnotations }}
{{- toYaml . | nindent 8 }}
{{- end }}
labels:
{{- include "myapp.labels" . | nindent 8 }}
{{- with .Values.podLabels }}
{{- toYaml . | nindent 8 }}
{{- end }}
spec:
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
serviceAccountName: {{ include "myapp.serviceAccountName" . }}
securityContext:
{{- toYaml .Values.podSecurityContext | nindent 8 }}
containers:
- name: {{ .Chart.Name }}
securityContext:
{{- toYaml .Values.securityContext | nindent 12 }}
image: {{ include "myapp.image" . }}
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: http
containerPort: {{ .Values.service.targetPort }}
protocol: TCP
{{- with .Values.livenessProbe }}
livenessProbe:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.readinessProbe }}
readinessProbe:
{{- toYaml . | nindent 12 }}
{{- end }}
resources:
{{- toYaml .Values.resources | nindent 12 }}
{{- with .Values.extraEnv }}
env:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.extraVolumeMounts }}
volumeMounts:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.extraVolumes }}
volumes:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
Conditional resource (Ingress)
{{- if .Values.ingress.enabled -}}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "myapp.fullname" . }}
labels:
{{- include "myapp.labels" . | nindent 4 }}
{{- with .Values.ingress.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- if .Values.ingress.className }}
ingressClassName: {{ .Values.ingress.className }}
{{- end }}
rules:
{{- range .Values.ingress.hosts }}
- host: {{ .host | quote }}
http:
paths:
{{- range .paths }}
- path: {{ .path }}
pathType: {{ .pathType }}
backend:
service:
name: {{ include "myapp.fullname" $ }}
port:
number: {{ $.Values.service.port }}
{{- end }}
{{- end }}
{{- end }}
Unit Tests (helm-unittest)
Tests live in unittests/ and use the helm-unittest syntax. Each file asserts on rendered template output.
suite: deployment
templates:
- deployment.yaml
tests:
- it: should render with default values
asserts:
- isKind:
of: Deployment
- equal:
path: spec.template.spec.containers[0].image
value: myapp:1.0.0
- it: should set replica count
set:
replicaCount: 3
asserts:
- equal:
path: spec.replicas
value: 3
- it: should not set replicas when autoscaling is enabled
set:
autoscaling.enabled: true
asserts:
- notExists:
path: spec.replicas
- it: should disable deployment when enabled is false
set:
enabled: false
asserts:
- hasDocuments:
count: 0
Common assertion types: isKind, equal, notEqual, contains, notContains, exists, notExists, hasDocuments, matchSnapshot.
Use set: to override values per test. Snapshot tests store expected output in unittests/__snapshot__/.
NOTES.txt Template
{{- $fullName := include "myapp.fullname" . -}}
1. Get the application URL by running these commands:
{{- if .Values.ingress.enabled }}
{{- range $host := .Values.ingress.hosts }}
{{- range .paths }}
http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ .path }}
{{- end }}
{{- end }}
{{- else if contains "NodePort" .Values.service.type }}
export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ $fullName }})
export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}")
echo http://$NODE_IP:$NODE_PORT
{{- else if contains "ClusterIP" .Values.service.type }}
kubectl --namespace {{ .Release.Namespace }} port-forward svc/{{ $fullName }} 8080:{{ .Values.service.port }}
echo "Visit http://127.0.0.1:8080"
{{- end }}
Templating Tips
{{- if .Values.enabled }}
key: value
{{- end }}
labels:
{{- include "myapp.labels" . | nindent 2 }}
image: {{ required "image.repository is required" .Values.image.repository }}
port: {{ .Values.port | default 8080 }}
name: {{ coalesce .Values.name .Values.nameOverride .Chart.Name }}
{{- range $key, $value := .Values.env }}
- name: {{ $key }}
value: {{ $value | quote }}
{{- end }}
Contributing Requirements
- Each PR must only change one chart at a time.
- Commits require a
Signed-off-by trailer: git commit -s
- Never add a
Co-Authored-By trailer.
- YAML files: 2-space indent, no trailing spaces, LF line endings, newline at EOF.