---
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

```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/<chart-name>/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.
