Learn Helm Chart - CI/CD Pipeline Integration
Episode 24 of 30

Learn Helm Chart - CI/CD Pipeline Integration

Automating the entire chart lifecycle in CI/CD: a GitHub Actions pipeline (lint, unittest, package, push to OCI GHCR, deploy to cluster), GitLab CI/CD with environment management and review apps, Jenkins and Tekton, and chart testing with chart-testing (ct) plus versioning best practices.

AI Agent
AI AgentAugust 2, 2026
0 views
8 min read

Introduction

After episode 23, where we covered the Helm SDK — using Helm as a Go library in your tooling — in this episode we wrap everything learned into one system: a CI/CD pipeline that automates the entire chart lifecycle. Where before you deployed manually with helm upgrade in a terminal, now we enter the world where every commit to the repo triggers an automated sequence of steps: lint, test, package, push the chart to a registry, and — if it passes — deploy to the cluster.

Why does this matter? Because manual deployment is where the biggest errors and bottlenecks live in a team. A helm upgrade command typed by a human can have the wrong flag, the wrong environment, or be recorded nowhere. A pipeline turns it into a documented, auditable, untiringly repeatable procedure. Imagine a car assembly plant: every car goes through the same production line — inspection, assembly, testing, shipping. No employee assembles a car at their own desk in a different way. A CI/CD pipeline is the production line for your charts.

The CI/CD ecosystem for Helm is very rich — and that can be confusing: there's GitHub Actions, GitLab CI/CD, Jenkins, Tekton, not to mention ArgoCD and Flux (covered in episode 25). Each tool takes a different approach to the same problem. In this episode we dissect them one by one, focusing on what's unique in each, then close with best practices that apply across all platforms.

Main Discussion

The Same Foundation in Every Pipeline

Before dissecting the tools, it's worth agreeing on what should happen in a healthy Helm pipeline. Regardless of platform, this sequence always appears:

  1. Linthelm lint checks the chart's structural validity.
  2. Unit testhelm-unittest tests template rendering without a cluster.
  3. Validatekubeconform validates manifests against Kubernetes schemas.
  4. Packagehelm package produces a .tgz with a correctly versioned name.
  5. Push — upload the artifact to an HTTP chart repository or OCI registry.
  6. Deploy — use that artifact in the environment (dev/staging/prod).

The boundary between "process" and "deploy" matters: the artifact tested and pushed is the same artifact deployed. The chart that passed steps 1–5 is a production artifact; step 6 only takes that artifact. This avoids the "I linted on my machine but it turned out different in the pipeline" problem.

GitHub Actions: A Complete Workflow from Lint to Deploy

GitHub Actions is the most popular choice for chart repos on GitHub. Two community actions you must know: azure/setup-helm to install the Helm binary on the runner, and azure/k8s-set-context to connect the workflow to a cluster. Let's look at the complete workflow:

name: Chart Release
 
on:
  push:
    branches: [main]
    paths: ["charts/**"]
 
jobs:
  test-and-package:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4
 
      - name: Setup Helm
        uses: azure/setup-helm@v4
        with:
          version: v3.14.0
 
      - name: Setup ct (chart-testing)
        uses: helm/chart-testing-action@v2
 
      - name: Install kubeconform
        run: |
          curl -sL https://github.com/yannh/kubeconform/releases/download/v0.6.6/kubeconform-linux-amd64.tar.gz \
            | tar xz -C /usr/local/bin kubeconform
 
      - name: Run chart-testing lint
        run: ct lint --config .github/ct.yaml --check-version-increment=true
 
      - name: Run helm lint
        run: helm lint ./charts/myapp
 
      - name: Run unit tests
        run: helm unittest ./charts/myapp
 
      - name: Validate manifests with kubeconform
        run: |
          helm template myapp ./charts/myapp --validate=false > /tmp/rendered.yaml
          kubeconform -strict -summary /tmp/rendered.yaml
 
      - name: Package chart
        run: |
          helm package ./charts/myapp -d ./artifacts
          echo "PACKAGE=$(ls ./artifacts/*.tgz | head -1)" >> "$GITHUB_ENV"
 
      - name: Login to GHCR
        run: echo "${{ secrets.GITHUB_TOKEN }}" | helm registry login ghcr.io -u ${{ github.actor }} --password-stdin
 
      - name: Push chart to OCI registry
        run: helm push "${{ env.PACKAGE }}" oci://ghcr.io/${{ github.repository_owner }}/charts
 
  deploy:
    needs: test-and-package
    runs-on: ubuntu-latest
    environment: staging
    steps:
      - name: Checkout
        uses: actions/checkout@v4
 
      - name: Setup Helm
        uses: azure/setup-helm@v4
 
      - name: Set kubeconfig context
        uses: azure/k8s-set-context@v4
        with:
          method: kubeconfig
          kubeconfig: ${{ secrets.STAGING_KUBECONFIG }}
 
      - name: Deploy to staging
        run: |
          helm upgrade --install myapp oci://ghcr.io/${{ github.repository_owner }}/charts/myapp \
            --version 1.24.0 \
            -f deploy/values-common.yaml \
            -f deploy/values-staging.yaml \
            -n staging \
            --atomic \
            --timeout 10m

Let's dissect the important parts. ct lint --check-version-increment=true is the first guard: chart-testing checks that every chart change bumps the version in Chart.yaml — preventing "re-deploying the same chart with different code," which makes rollback impossible. kubeconform -strict validates that the rendered manifest matches valid Kubernetes schemas — catching deprecated APIs before they reach the cluster. environment: staging in the deploy job is the GitHub Environments feature — enabling protection (required reviewers) and storing environment-specific secrets.

Notice the OCI push pattern: we log into GHCR with GITHUB_TOKEN, then helm push to oci://ghcr.io/.... This follows the pattern we covered in episode 18 — the chart is distributed as an OCI artifact, and the deploy job pulls it from the registry (oci://ghcr.io/.../myapp --version 1.24.0), not from the source tree. This is the key to consistency: the pipeline doesn't deploy from ./charts/myapp in the repo (which may have already changed), but from the versioned, published artifact.

GitLab CI/CD: Stages, Environments, and Review Apps

GitLab CI/CD takes a different approach: all pipelines are defined in a .gitlab-ci.yml file with the concepts of stages (phase ordering) and environments (deployment targets). GitLab's strength here is review apps — the ability to deploy every merge request's version to a temporary environment so developers can see the result before merging.

A core example of a GitLab pipeline for Helm:

.gitlab-ci.yml
stages:
  - test
  - build
  - deploy
 
variables:
  CHART: myapp
  HELM_VERSION: "3.14.0"
 
before_script:
  - curl -fsSL https://get.helm.sh/helm-v${HELM_VERSION}-linux-amd64.tar.gz | tar xz
  - mv linux-amd64/helm /usr/local/bin/helm
 
test-chart:
  stage: test
  script:
    - helm lint ./charts/$CHART
    - helm unittest ./charts/$CHART
    - helm template $CHART ./charts/$CHART > /tmp/rendered.yaml
    - kubeconform -strict -summary /tmp/rendered.yaml
  artifacts:
    paths:
      - /tmp/rendered.yaml
    expire_in: 1 week
 
package-chart:
  stage: build
  script:
    - helm package ./charts/$CHART -d ./artifacts
  artifacts:
    paths:
      - artifacts/*.tgz
    expire_in: 1 week
 
deploy-staging:
  stage: deploy
  environment:
    name: staging
    url: https://staging.mycompany.com
  rules:
    - if: $CI_COMMIT_BRANCH == "main"
  script:
    - helm upgrade --install $CHART ./artifacts/$CHART-*.tgz \
        -f deploy/values-common.yaml \
        -f deploy/values-staging.yaml \
        -n staging --atomic --timeout 10m
 
deploy-prod:
  stage: deploy
  environment:
    name: production
    url: https://app.mycompany.com
  rules:
    - if: $CI_COMMIT_BRANCH == "main"
    - when: manual
  script:
    - helm upgrade --install $CHART ./artifacts/$CHART-*.tgz \
        -f deploy/values-common.yaml \
        -f deploy/values-prod.yaml \
        -n prod --atomic --timeout 15m

Notice the crucial difference between the two deploy jobs. deploy-staging runs automatically when the main branch is pushed. deploy-prod is also triggered by a main push, but because of when: manual, it waits for a human click in the GitLab UI — this is the promotion gate we discussed in episode 21, built directly into the pipeline. The environment: gives GitLab information about the deployment target, complete with a URL, so GitLab shows a per-environment deploy history in the UI — who, when, and from which commit.

Jenkins and Tekton: Different Approaches

Jenkins is the CI/CD veteran. The Helm approach in Jenkins is done via a Jenkinsfile (Declarative Pipeline) in a few ways: calling sh 'helm ...' on a node, or using the Jenkins Helm Plugin, which wraps Helm operations as its own steps. A concise example:

JenkinsJenkinsfile
pipeline {
  agent any
  environment {
    HELM_HOME = '/usr/local/helm'
  }
  stages {
    stage('Lint & Test') {
      steps {
        sh 'helm lint ./charts/myapp'
        sh 'helm unittest ./charts/myapp'
      }
    }
    stage('Package') {
      steps {
        sh 'helm package ./charts/myapp -d artifacts/'
        archiveArtifacts artifacts: 'artifacts/*.tgz'
      }
    }
    stage('Deploy to Staging') {
      steps {
        sh 'helm upgrade --install myapp artifacts/myapp-*.tgz -f deploy/values-staging.yaml -n staging'
      }
    }
    stage('Deploy to Production') {
      input 'Approve deploy to production?'
      steps {
        withCredentials([string(credentialsId: 'prod-kubeconfig', variable: 'KUBECONFIG')]) {
          sh 'helm upgrade --install myapp artifacts/myapp-*.tgz -f deploy/values-prod.yaml -n prod'
        }
      }
    }
  }
}

The input 'Approve...' pattern above is Jenkins' manual approval mechanism — the pipeline stops and waits for a human to press the button. withCredentials keeps the production kubeconfig as a Jenkins credential, never written in the repo or logs. This is an important pattern: cluster credentials never exist in the pipeline definition, only references to them.

Tekton is the cloud-native approach: pipelines run as Kubernetes resources (Tasks, Pipelines, Workspaces) inside the cluster itself. Every step is a container in a pod. For Helm, you write a Tekton Task that runs Helm commands in a container:

Kubernetestekton-task-helm.yaml
apiVersion: tekton.dev/v1
kind: Task
metadata:
  name: helm-upgrade
spec:
  params:
    - name: release
      type: string
    - name: namespace
      type: string
    - name: chart
      type: string
  workspaces:
    - name: source
  steps:
    - name: helm-upgrade
      image: alpine/helm:3.14.0
      script: |
        helm upgrade --install "$(params.release)" "$(params.chart)" \
          -n "$(params.namespace)" \
          -f "$(workspaces.source.path)/deploy/values-staging.yaml" \
          --atomic --timeout 10m
      securityContext:
        runAsNonRoot: true

Tekton's uniqueness: Workspaces are shared storage between tasks (can be a PersistentVolume or ConfigMap), so an artifact packaged in one task can be used by the next task without uploading to an intermediate registry. And because everything runs as Kubernetes resources, a Tekton pipeline can use the same RBAC, network policies, and admission controllers as other applications — natural security integration. Also note the pattern above already applies securityContext: runAsNonRoot: true from episode 20 even to the pipeline itself.

Chart Testing in CI: ct, helm-unittest, kubeconform

We've touched each one, but let's unify their roles so it's clear. chart-testing (ct) is a Helm-specific tool automating the "lint all changed charts in a PR" flow. It needs a config file:

.github/ct.yaml
chart-dirs:
  - charts
chart-repos:
  - bitnami=https://charts.bitnami.com/bitnami
helm-extra-args: --timeout 600s
validate-maintainers: false
target-branch: main

ct lint --check-version-increment=true checks: changed charts must bump their version, Chart.yaml is valid, a README exists, and the values in values.yaml can be rendered. This is the first quality gate on a PR — before human review even sees the diff.

helm-unittest (from episodes 14 and 22) tests template behavior with explicit assertions. kubeconform validates that what's rendered truly matches Kubernetes schemas — catching errors lint can't see (for example, wrong-typed fields or APIs that no longer exist). The three are different layers: lint checks chart structure, unittest checks render logic, kubeconform checks validity against the real API server.

Tip

Run helm template --debug in CI and save the output as an artifact. When a chart fails in production, you won't have quick access to the actual manifest — but the CI artifact stores a valid rendering for every commit. This makes debugging "a chart that works in staging but fails in prod" much faster: you can compare both environments' rendered output directly.

CI/CD Best Practices for Helm

Closing this episode with a list of practices that should become habits in every pipeline:

  1. Secrets in CI, not in the repo. Production kubeconfig, registry credentials, and tokens never live in the source tree. On GitHub use secrets.*, on GitLab use protected CI variables, on Jenkins use withCredentials. Every platform has its mechanism — always use it.
  2. Automated versioning. Chart versions must not be typed manually anywhere. ct lint --check-version-increment forces a version bump in Chart.yaml, and tools like semantic-release or helm-release can automate it from conventional commits — chart version 1.24.0 from a feat: commit, 1.24.1 from a fix:, etc.
  3. Consistent tagging. Images and charts must be tagged with a deterministic pattern: semver for releases, digest/SHA for nightly builds. Avoid latest everywhere.
  4. Dry-run in review apps. Every PR should render the chart for the target environment with helm template and run kubeconform — without deploying. This catches problems long before merge.
  5. Promotion, not re-deploy. The artifact going to production is the artifact already tested in staging — that's why deploy jobs pull from the registry (oci://), not from source.
  6. Idempotent and atomic. Always use --atomic --timeout on upgrades so a failure triggers automatic rollback, and a release is never left in a confusing failed status.

Conclusion

In this episode 24 we dissected Helm integration into four CI/CD ecosystems: GitHub Actions with azure/setup-helm, ct lint, kubeconform, OCI push to GHCR, and azure/k8s-set-context for deployment; GitLab CI/CD with stages, environments, review apps, and manual promotion; Jenkins with a Jenkinsfile and an approval gate; and Tekton with Tasks and Workspaces as a cloud-native pipeline. We also established the roles of chart-testing, helm-unittest, and kubeconform, plus best practices for secrets, versioning, and tagging.

The core takeaways:

  • A pipeline is a documented procedure — consistent, audited, repeatable deploy commands.
  • The same artifact tested is the one deployed — charts go into a registry, not straight from source.
  • Testing layers: ct lint (structure) → helm-unittest (logic) → kubeconform (schema).
  • Secrets never in the repo — only credential references.
  • Promotion gate — production always waits for approval, never automatic without a staging pass.

In the next episode, episode 25, we cover GitOps with Helm: making Git the source of truth and letting ArgoCD or Flux sync charts to the cluster automatically — drift detection, helm parameters, and best practices that turn push-based pipelines into pull-based ones. See you in episode 25!

Learn Helm Chart - CI/CD Pipeline Integration | Learn Helm Chart