Aller au contenu
Conteneurs & Orchestration medium

CI/CD Helm : automatiser le packaging, lint et promotion des charts

60 min de lecture

logo helm

Automatiser le cycle de vie des charts Helm en CI/CD garantit la qualité (lint, tests), la reproductibilité (versioning automatique), et la traçabilité (qui a publié quoi, quand). Ce module présente une pipeline complète avec GitLab CI et GitHub Actions, ainsi que les patterns de promotion dev → staging → prod.


Un chart Helm est un artefact versionné, au même titre qu'une image de conteneur : il se valide, se construit, se publie, puis se consomme par sa version. La pipeline reprend donc les étapes classiques d'une chaîne de livraison, appliquées à un paquet YAML. Le déploiement, lui, est volontairement séparé : rien n'oblige à ce que la pipeline qui publie le chart soit celle qui l'installe.

Les quatre premières étapes s'enchaînent sans intervention et produisent un chart publié. La cinquième, en pointillés sur le schéma, dépend de votre modèle de déploiement.

Workflow CI/CD Helm : Lint, Test, Package, Publish, puis Deploy optionnel vers dev, staging et prod

Chaque étape ne valide qu'une chose, et échoue vite si cette chose ne va pas. Ce découpage a une raison pratique : quand la pipeline casse, le nom du job suffit à savoir ce qui est en cause.

ÉtapeOutilsObjectif
Linthelm lint, ct lintSyntaxe YAML, bonnes pratiques
Testhelm template, kubeval, kubeconformManifests K8s valides
Packagehelm packageCréer le .tgz versionné
Publishhelm pushPousser sur registry OCI
Deployhelm upgrade --install ou GitOpsDéployer sur cluster

Le lint contrôle le chart en tant que paquet : présence et cohérence de Chart.yaml, structure des répertoires, syntaxe des templates, conformité des values au schéma déclaré. Il ne dit rien de la validité Kubernetes des manifestes produits, c'est le rôle de l'étape suivante. C'est le job le plus rapide de la pipeline, à placer en premier pour couper court dès la première erreur.

Sans --strict, helm lint sort en succès malgré des avertissements ; en CI, c'est ce qui laisse passer les problèmes.

Fenêtre de terminal
# Lint un chart
helm lint ./charts/my-app
# Lint strict (warnings = erreurs)
helm lint ./charts/my-app --strict

Chart Testing (ct) est l'outil officiel pour tester des charts en CI :

Fenêtre de terminal
# Installer ct
pip install chart-testing
# Lint tous les charts modifiés
ct lint --config .ct.yaml
# Lint tous les charts
ct lint --all --config .ct.yaml

Configuration .ct.yaml :

.ct.yaml
chart-dirs:
- charts
validate-maintainers: false
validate-chart-schema: true
lint-conf:
- --strict

Un fichier values.schema.json placé à la racine du chart décrit les valeurs attendues, leurs types et celles qui sont obligatoires. Helm l'applique automatiquement, sans option supplémentaire, et refuse un values non conforme.

Fenêtre de terminal
# Si values.schema.json existe
helm lint ./charts/my-app # Valide automatiquement les values

Un chart peut passer le lint et produire des manifestes que Kubernetes refusera. Cette étape rend les templates hors cluster, puis confronte le YAML obtenu aux schémas de l'API Kubernetes. Elle ne demande aucun accès à un cluster, ce qui la rend exécutable sur n'importe quel runner.

La commande écrit les manifestes rendus sur la sortie standard et renvoie un code de retour non nul en cas d'erreur de template, ce qui suffit à faire échouer le job :

Fenêtre de terminal
# Générer les manifests sans cluster
helm template my-app ./charts/my-app -f values-test.yaml > rendered.yaml
# Vérifier que ça génère du YAML valide
helm template my-app ./charts/my-app > /dev/null
echo $? # 0 = OK

Deux validateurs consomment le YAML rendu et le comparent aux schémas OpenAPI de Kubernetes. kubeconform est le choix par défaut aujourd'hui : il est maintenu, rapide et gère les ressources personnalisées via des schémas additionnels. kubeval rend le même service mais son dépôt n'est plus actif.

Fenêtre de terminal
# Installer kubeconform (rapide, maintenu)
# https://github.com/yannh/kubeconform
helm template my-app ./charts/my-app | kubeconform -strict -summary

Dernier niveau, le seul qui exige un vrai cluster : Helm installe la release, puis exécute les Pods marqués de l'annotation helm.sh/hook: test et considère le test réussi si leur conteneur sort en code 0. Si votre chart définit des tests dans templates/tests/ :

Fenêtre de terminal
# Déployer puis tester
helm install my-app ./charts/my-app -n test --wait
helm test my-app -n test
helm uninstall my-app -n test

Le champ version de Chart.yaml est ce qui identifie le chart sur le registry, et un registry OCI refuse d'écraser une version déjà publiée. Toute la difficulté du packaging tient donc à une seule question : qui décide du numéro. Le champ appVersion, lui, désigne la version de l'application embarquée et évolue indépendamment.

Trois approches, du plus simple au plus automatisé. La première repose sur la discipline humaine et finit toujours par produire un oubli ; les deux autres dérivent le numéro de l'historique Git, ce qui le rend reproductible.

Option 1 : Version depuis Chart.yaml (manuelle)

Chart.yaml
version: 1.2.3 # Incrémenter manuellement
appVersion: "2.0.0"

Option 2 : Version depuis Git tag

Fenêtre de terminal
# Récupérer la version depuis le tag Git
VERSION=$(git describe --tags --abbrev=0 2>/dev/null || echo "0.0.0")
# Remplacer dans Chart.yaml
sed -i "s/^version:.*/version: $VERSION/" charts/my-app/Chart.yaml
# Packager
helm package ./charts/my-app

Option 3 : Semantic Release

# .releaserc.yaml pour semantic-release
plugins:
- "@semantic-release/commit-analyzer"
- "@semantic-release/release-notes-generator"
- ["@semantic-release/exec", {
"prepareCmd": "sed -i 's/^version:.*/version: ${nextRelease.version}/' charts/my-app/Chart.yaml"
}]

helm package produit une archive nommée d'après le nom et la version du chart, dans le répertoire courant. C'est ce fichier, et lui seul, qui sera poussé sur le registry :

Fenêtre de terminal
# Packager
helm package ./charts/my-app
# Résultat : my-app-1.2.3.tgz

Depuis Helm 3.8, un chart se publie sur un registry OCI, la même infrastructure que celle qui héberge vos images de conteneurs. Vous réutilisez donc l'authentification, les quotas et les politiques de rétention déjà en place, sans serveur de charts dédié à maintenir.

Le mot de passe transite par l'entrée standard avec --password-stdin : passé en argument, il apparaîtrait dans la liste des processus et dans les journaux du runner.

Fenêtre de terminal
# Login (une fois par job)
echo "$REGISTRY_PASSWORD" | helm registry login $REGISTRY_URL -u $REGISTRY_USER --password-stdin
# Push
helm push my-app-1.2.3.tgz oci://$REGISTRY_URL/charts

Un push réussi ne garantit pas que le chart soit lisible par ses futurs consommateurs, notamment si les droits du dépôt sont restreints. Cette lecture depuis le registry ferme la boucle :

Fenêtre de terminal
# Vérifier que le chart est accessible
helm show chart oci://$REGISTRY_URL/charts/my-app --version 1.2.3

Ce fichier assemble les quatre étapes précédentes en quatre stages. Deux mécanismes structurent l'ensemble : le modèle .helm-base, réutilisé par extends pour ne déclarer l'image qu'une fois, et les blocs rules, qui décident quand chaque job s'exécute. Le lint et les tests tournent sur les merge requests, la publication uniquement sur un tag.

.gitlab-ci.yml
stages:
- lint
- test
- package
- publish
variables:
HELM_VERSION: "3.14.0"
CHART_PATH: "charts/my-app"
REGISTRY_URL: "harbor.example.com/charts"
.helm-base:
image: alpine/helm:${HELM_VERSION}
before_script:
- helm version
lint:
extends: .helm-base
stage: lint
script:
- helm lint ${CHART_PATH} --strict
- helm template test ${CHART_PATH} > /dev/null
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
test:
extends: .helm-base
stage: test
image: alpine:3.24.1@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b
before_script:
- apk add --no-cache helm curl
- curl -sL https://github.com/yannh/kubeconform/releases/latest/download/kubeconform-linux-amd64.tar.gz | tar xz
- mv kubeconform /usr/local/bin/
script:
- helm template test ${CHART_PATH} | kubeconform -strict -summary
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
package:
extends: .helm-base
stage: package
script:
# Version depuis le tag Git ou CI_COMMIT_TAG
- |
if [ -n "$CI_COMMIT_TAG" ]; then
VERSION=$CI_COMMIT_TAG
else
VERSION="0.0.0-${CI_COMMIT_SHORT_SHA}"
fi
- sed -i "s/^version:.*/version: $VERSION/" ${CHART_PATH}/Chart.yaml
- helm package ${CHART_PATH}
artifacts:
paths:
- "*.tgz"
expire_in: 1 week
rules:
- if: $CI_COMMIT_TAG
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
publish:
extends: .helm-base
stage: publish
script:
- echo "${REGISTRY_PASSWORD}" | helm registry login ${REGISTRY_URL} -u ${REGISTRY_USER} --password-stdin
- helm push *.tgz oci://${REGISTRY_URL}
dependencies:
- package
rules:
- if: $CI_COMMIT_TAG

Même logique transposée aux workflows GitHub, avec deux différences notables. Les dépendances entre jobs s'expriment par needs au lieu des stages, et la publication vers GHCR ne réclame aucun secret à créer : le jeton fourni automatiquement au workflow suffit, à condition de lui accorder la permission packages: write.

.github/workflows/helm.yml
name: Helm CI/CD
on:
push:
branches: [main]
tags: ['v*']
pull_request:
branches: [main]
permissions: {}
env:
CHART_PATH: charts/my-app
REGISTRY: ghcr.io
REGISTRY_PATH: ${{ github.repository_owner }}/charts
jobs:
lint:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Set up Helm
uses: azure/setup-helm@fe7b79cd5ee1e45176fcad797de68ecaf3ca4814 # v4.2.0
with:
version: '3.14.0'
- name: Lint chart
run: |
helm lint ${{ env.CHART_PATH }} --strict
helm template test ${{ env.CHART_PATH }} > /dev/null
test:
runs-on: ubuntu-latest
needs: lint
permissions:
contents: read
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Set up Helm
uses: azure/setup-helm@fe7b79cd5ee1e45176fcad797de68ecaf3ca4814 # v4.2.0
- name: Install kubeconform
run: |
VERSION=v0.6.7
curl -fsSLO "https://github.com/yannh/kubeconform/releases/download/$VERSION/kubeconform-linux-amd64.tar.gz"
curl -fsSLO "https://github.com/yannh/kubeconform/releases/download/$VERSION/CHECKSUMS"
grep " kubeconform-linux-amd64.tar.gz$" CHECKSUMS | sha256sum -c -
tar -xzf kubeconform-linux-amd64.tar.gz kubeconform
sudo mv kubeconform /usr/local/bin/
- name: Validate manifests
run: helm template test ${{ env.CHART_PATH }} | kubeconform -strict -summary
publish:
runs-on: ubuntu-latest
needs: test
if: startsWith(github.ref, 'refs/tags/v')
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Set up Helm
uses: azure/setup-helm@fe7b79cd5ee1e45176fcad797de68ecaf3ca4814 # v4.2.0
- name: Get version from tag
id: version
run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
- name: Update Chart.yaml version
run: |
sed -i "s/^version:.*/version: ${{ steps.version.outputs.VERSION }}/" ${{ env.CHART_PATH }}/Chart.yaml
- name: Package chart
run: helm package ${{ env.CHART_PATH }}
- name: Login to GHCR
run: echo "${{ secrets.GITHUB_TOKEN }}" | helm registry login ${{ env.REGISTRY }} -u ${{ github.actor }} --password-stdin
- name: Push chart
run: helm push *.tgz oci://${{ env.REGISTRY }}/${{ env.REGISTRY_PATH }}

Promouvoir, c'est déployer exactement le même artefact d'un environnement au suivant, sans le reconstruire. Un chart repackagé entre la recette et la production n'est plus le chart testé, et la recette ne prouve alors plus rien. Seule la configuration change d'un environnement à l'autre, jamais la version du chart.

Les fichiers de values vivent hors du chart, dans un répertoire dédié. Le chart reste ainsi générique et publiable tel quel, tandis que les particularités d'environnement restent visibles et relisibles dans le dépôt.

  • Répertoirecharts/
    • Répertoiremy-app/
      • Chart.yaml
      • values.yaml Defaults
      • Répertoiretemplates/
    • Répertoirevalues/
      • values-dev.yaml Dev overrides
      • values-staging.yaml
      • values-prod.yaml

Les trois commandes sont identiques à deux détails près : le fichier de values et le namespace. Le --version reste le même partout, c'est précisément ce qui fait la promotion. La forme upgrade --install installe la release si elle n'existe pas encore et la met à jour sinon, ce qui rend la commande rejouable :

Fenêtre de terminal
# Dev (automatique sur main)
helm upgrade --install my-app oci://registry/charts/my-app \
--version 1.2.3 \
-f values/values-dev.yaml \
-n dev
# Staging (manuel ou tag)
helm upgrade --install my-app oci://registry/charts/my-app \
--version 1.2.3 \
-f values/values-staging.yaml \
-n staging
# Prod (approval + tag)
helm upgrade --install my-app oci://registry/charts/my-app \
--version 1.2.3 \
-f values/values-prod.yaml \
-n prod

La mise en production ne doit pas se déclencher toute seule. Le mot-clé when: manual transforme le job en bouton à cliquer dans l'interface, et le bloc environment fait apparaître la release dans le suivi des environnements GitLab avec son historique de déploiements.

# GitLab CI - promotion avec approval
deploy-staging:
stage: deploy
script:
- helm upgrade --install my-app oci://${REGISTRY}/my-app --version ${VERSION} -f values/values-staging.yaml -n staging
environment:
name: staging
rules:
- if: $CI_COMMIT_TAG
deploy-prod:
stage: deploy
script:
- helm upgrade --install my-app oci://${REGISTRY}/my-app --version ${VERSION} -f values/values-prod.yaml -n prod
environment:
name: production
when: manual # Approval requis
rules:
- if: $CI_COMMIT_TAG

Dans les pipelines précédentes, c'est le runner de CI qui détient les accès au cluster et pousse les changements. L'approche GitOps inverse ce sens : un agent installé dans le cluster surveille une source de vérité et applique lui-même l'état déclaré. La CI s'arrête alors à la publication du chart, et plus aucun identifiant de cluster ne circule dans les variables du pipeline.

Argo CD consomme le chart directement depuis le registry OCI : le champ targetRevision porte la version du chart, et syncPolicy.automated avec selfHeal ramène le cluster à l'état déclaré si quelqu'un le modifie à la main.

# Application Argo CD
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: my-app
namespace: argocd
spec:
project: default
source:
repoURL: oci://harbor.example.com/charts
chart: my-app
targetRevision: 1.2.3
helm:
valueFiles:
- values-prod.yaml
destination:
server: https://kubernetes.default.svc
namespace: prod
syncPolicy:
automated:
prune: true
selfHeal: true

Flux exprime la même intention avec une ressource HelmRelease, qui référence une source déclarée séparément. Le champ interval fixe la fréquence de réconciliation, ici toutes les cinq minutes.

# HelmRelease Flux
apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
name: my-app
namespace: prod
spec:
interval: 5m
chart:
spec:
chart: my-app
version: "1.2.3"
sourceRef:
kind: HelmRepository
name: my-charts
namespace: flux-system
values:
replicaCount: 3
# ... prod values

Ce lab met bout à bout tout ce qui précède sur un chart généré par helm create, donc valide dès le départ : l'objet de l'exercice est la pipeline, pas le chart. Comptez une trentaine de minutes, dont l'essentiel passe dans la configuration des accès au registry.

Objectif : Créer une pipeline qui lint, package et publie un chart sur push vers main ou tag.

  1. Structure du repo

    Fenêtre de terminal
    mkdir helm-cicd-lab && cd helm-cicd-lab
    git init
    # Créer un chart
    mkdir -p charts
    helm create charts/my-app
    # Créer les values par env
    mkdir -p values
    cp charts/my-app/values.yaml values/values-dev.yaml
    cp charts/my-app/values.yaml values/values-prod.yaml
  2. Créer la pipeline (GitLab ou GitHub selon votre environnement)

    Copier le fichier .gitlab-ci.yml ou .github/workflows/helm.yml ci-dessus.

  3. Configurer les secrets

    • GitLab : Settings → CI/CD → Variables

      • REGISTRY_URL
      • REGISTRY_USER
      • REGISTRY_PASSWORD
    • GitHub : Settings → Secrets and variables → Actions

      • GITHUB_TOKEN est automatique pour GHCR
  4. Pousser et déclencher

    Fenêtre de terminal
    git add .
    git commit -m "feat: initial chart"
    git push origin main
    # Créer un tag pour publish
    git tag v0.1.0
    git push origin v0.1.0
  5. Vérifier

    • Pipeline lint/test passe
    • Chart publié sur le registry
    • helm show chart oci://registry/charts/my-app --version 0.1.0

Critères de réussite :

  • Pipeline déclenchée sur push
  • helm lint exécuté sans erreur
  • Manifests validés avec kubeconform
  • Chart packagé et versionné depuis le tag
  • Publication automatique vers registry OCI

  • Lint : helm lint --strict + ct lint pour la qualité
  • Test : helm template + kubeconform pour valider les manifests
  • Package : versioning automatique depuis Git tag
  • Publish : helm push vers registry OCI
  • Promotion : même chart, values différents par environnement
  • GitOps : Argo CD / Flux pour les déploiements prod (recommandé)

Ce site vous est utile ?

Sachez que moins de 1% des lecteurs soutiennent ce site.

Je maintiens +700 guides gratuits, sans pub ni tracking. Un soutien, même symbolique, m'aide à couvrir l'hébergement et à garder ces ressources gratuites. Merci pour votre appui.

Le formulaire ne s'affiche pas ? Ouvrir Ko-fi dans un onglet.

Abonnez-vous et suivez mon actualité DevSecOps sur LinkedIn