Aller au contenu
English
English
Conteneurs & Orchestration medium

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

70 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 chaîne reprend donc les étapes classiques d'une livraison logicielle, appliquées à un paquet YAML. Le déploiement est volontairement séparé : rien n'oblige la chaîne qui publie à être celle qui 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 : elle appartient souvent à une autre chaîne, voire à un outil GitOps.

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

Chaque étape ne valide qu'une seule chose, et échoue vite si cette chose ne va pas. Ce découpage a une raison très pratique : quand la chaîne casse, le nom du job suffit à savoir ce qui est en cause, sans lire les journaux.

É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 du 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, qui est le rôle de l'étape suivante. C'est le job le plus rapide, à placer en premier.

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 jeu de 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. Elle ne demande aucun accès à un cluster, ce qui la rend exécutable sur n'importe quel agent.

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 sans traitement supplémentaire :

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 : maintenu, rapide, et capable de gérer 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, et le seul qui exige un vrai cluster : Helm installe la release, puis exécute les Pods portant l'annotation helm.sh/hook: test, et considère le test réussi si leur conteneur sort en code 0. C'est ce code de retour qui fait échouer l'étape, pas le contenu des journaux.

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 du Chart.yaml est ce qui identifie le chart sur le registre, et un registre 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 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 et vérifiable.

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 registre : tout ce qui n'y est pas n'existe pas pour le consommateur.

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

Les deux drapeaux --version et --app-version évitent le sed -i de l'option 2 : ils surchargent les champs du Chart.yaml au moment de l'empaquetage, sans jamais toucher le fichier du dépôt. Mesuré sur Helm v4.1.0, un chart nommé my-app en version: 0.1.0 :

/chemin/my-app-9.9.9.tgz
helm package ./charts/my-app --version 9.9.9 --app-version 7.7.7

L'archive s'appelle my-app-9.9.9.tgz et le Chart.yaml qu'elle contient porte version: 9.9.9 et appVersion: 7.7.7, tandis que charts/my-app/Chart.yaml reste sur 0.1.0 dans le dépôt. C'est exactement ce que réclame une chaîne d'intégration : dériver le numéro du tag Git ou du numéro de construction, sans commit de version et donc sans le commit de retour qui déclencherait la pipeline suivante.


Depuis Helm 3.8, un chart se publie sur un registre 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 de l'agent, où il resterait bien après la fin du job.

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 consommateurs, notamment si les droits du dépôt sont restreints. Cette lecture depuis le registre ferme la boucle, et c'est la seule façon de vérifier ce que verra quelqu'un d'autre :

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 autant de 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 tourne sur les demandes de fusion, la publication uniquement sur un tag.

Notez que l'image n'est plus construite à partir d'une variable. Un épinglage par digest interdit l'interpolation : alpine/helm:${HELM_VERSION} désigne un tag mutable, que son éditeur peut redéplacer sur un autre contenu, alors que alpine/helm:4.3.0@sha256:… désigne un contenu précis. On perd la commodité d'une variable, on gagne la certitude que deux exécutions de la même pipeline utilisent le même binaire Helm.

Notez aussi les apostrophes autour de la commande sed, qui ne sont pas décoratives. Un élément de liste YAML non quoté qui contient une séquence deux-points suivie d'une espace est interprété comme un dictionnaire, pas comme une chaîne. Sans elles, GitLab refuse la pipeline avant de créer le moindre job :

jobs:package:script config should be a string or a nested array of strings

Le piège ne se voit pas à la lecture, puisque le sed est parfaitement valide côté shell. Il ne frappe que les éléments de liste : le workflow GitHub plus bas y échappe parce que son sed vit dans un bloc run: |, un scalaire littéral où les deux-points ne sont plus interprétés.

La forme longue de image, avec entrypoint: [""], n'est pas non plus un ornement. L'image alpine/helm déclare ENTRYPOINT ["helm"] : elle est faite pour s'utiliser comme un exécutable, pas comme un environnement de travail. Or GitLab Runner démarre le conteneur de job avec un shell, et la commande devient alors helm sh :

Error: unknown command "sh" for "helm"

Le job meurt avant la première ligne du script. Neutraliser l'entrypoint rend l'image utilisable comme n'importe quelle autre. La règle vaut pour toute image d'outil dont l'entrypoint est le binaire lui-même, cas fréquent chez kubectl, terraform ou aws-cli.

Enfin, deux variables séparent l'hôte du chemin, et ce n'est pas une coquetterie non plus. helm registry login veut un nom d'hôte seul, helm push oci:// veut l'hôte suivi du chemin de dépôt. Réutiliser une seule variable pour les deux casse la connexion :

registry login currently only supports registry hostname, not a repository path
Error: invalid reference: invalid registry "harbor.example.com/charts"

C'est la même règle que le guide de migration énonce pour l'authentification OCI en v4, et que le workflow GitHub plus bas applique déjà, avec REGISTRY d'un côté et REGISTRY_PATH de l'autre.

Enfin, ${CI_COMMIT_TAG#v} retire le v du tag. Un tag Git s'écrit par convention v1.2.3, une version de chart est du SemVer nu, 1.2.3. Sans cette coupe, le chart publié porte version: v1.2.3 et ne correspond plus à ce que produit le workflow GitHub à partir du même tag. Deux chaînes du même dépôt livreraient alors deux versions différentes de la même chose.

.gitlab-ci.yml
stages:
- lint
- test
- package
- publish
variables:
CHART_PATH: "charts/my-app"
REGISTRY_HOST: "harbor.example.com"
REGISTRY_PATH: "charts"
.helm-base:
image:
name: alpine/helm:4.3.0@sha256:a6cf54599ccb99d90cf0712b30f03fdb3cab062e6b94e0418cc4db7e8a1464b2
entrypoint: [""]
before_script:
- helm version --short
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#v}
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_HOST} -u ${REGISTRY_USER} --password-stdin
- helm push *.tgz oci://${REGISTRY_HOST}/${REGISTRY_PATH}
dependencies:
- package
rules:
- if: $CI_COMMIT_TAG

Même logique transposée aux workflows GitHub, avec deux différences. Les dépendances entre jobs s'expriment par needs plutôt que par des stages, et la publication vers GHCR ne réclame aucun secret à créer : le jeton fourni automatiquement 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: '4.3.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
with:
version: '4.3.0'
- name: Install kubeconform
run: |
VERSION=v0.8.0
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
with:
version: '4.3.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, au lieu d'être noyées dans les templates.

  • 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, et c'est précisément ce qui fait la promotion. La forme upgrade --install installe la release si elle n'existe pas 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, et le bloc environment fait apparaître la release dans le suivi des environnements, 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 chaînes précédentes, c'est l'agent d'intégration continue 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 chaîne s'arrête alors à la publication du chart, et plus aucun identifiant de cluster ne circule dans ses variables.

Argo CD consomme le chart directement depuis le registre 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 chaîne, pas le chart. Comptez une trentaine de minutes, dont l'essentiel passe dans la configuration des accès au registre.

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

Trois questions sur l'industrialisation : le rôle d'index.yaml, l'ordre des étapes d'une chaîne, et pourquoi --strict s'impose en intégration continue.

Contrôle de connaissances

Validez vos connaissances avec ce quiz interactif

3 questions
5 min.
80% requis

Informations

  • Le chronomètre démarre au clic sur Démarrer
  • Questions à choix multiples, vrai/faux et réponses courtes
  • Vous pouvez naviguer entre les questions
  • Les résultats détaillés sont affichés à la fin

Lance le quiz et démarre le chronomètre

  • Flux CD : Laisser un opérateur déployer le chart depuis Git plutôt qu'un job de pipeline.
  • GitLab CI : Les mécanismes de pipeline sur lesquels s'appuie ce packaging.
  • GitHub Actions : La même chaîne, côté GitHub.

Ce site vous est utile ?

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

Je maintiens ce site gratuitement, sans publicité, sans profilage et sans compte à créer. 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