--- 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/` | | Unit tests | `helm unittest --strict --file 'unittests/**/*.yaml' charts/` | | Unit tests (all charts) | `for chart in charts/*/; do helm unittest --strict --file 'unittests/**/*.yaml' "${chart%/}"; done` | | Update dependencies | `helm dependency update charts/` | | Template dry-run | `helm template myrelease charts/ -f values.yaml` | | Install debug | `helm install myrelease charts/ --debug --dry-run` | ## Chart Structure ``` charts// ├── 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 ```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. ```bash 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: ```bash git add charts//README.md git commit -s ``` ## Values Design ```yaml # -- Number of replicas replicaCount: 1 image: # -- Image repository repository: myapp # -- Image pull policy pullPolicy: IfNotPresent # -- Image tag (defaults to appVersion) tag: "" # -- Image pull secrets imagePullSecrets: [] # -- Override chart name nameOverride: "" # -- Override full name 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. ```json { "$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) ```yaml {{- 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 ```yaml 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) ```yaml {{- 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](https://github.com/helm-unittest/helm-unittest) syntax. Each file asserts on rendered template output. ```yaml # unittests/deployment_test.yaml 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 ```yaml # Trim whitespace with {{- and -}} {{- if .Values.enabled }} key: value {{- end }} # Proper indentation labels: {{- include "myapp.labels" . | nindent 2 }} # Fail if required value is missing image: {{ required "image.repository is required" .Values.image.repository }} # Default value port: {{ .Values.port | default 8080 }} # Coalesce (first non-empty wins) name: {{ coalesce .Values.name .Values.nameOverride .Chart.Name }} # Range over map (env vars pattern) {{- 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.