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.

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.
Before dissecting the tools, it's worth agreeing on what should happen in a healthy Helm pipeline. Regardless of platform, this sequence always appears:
helm lint checks the chart's structural validity.helm-unittest tests template rendering without a cluster.kubeconform validates manifests against Kubernetes schemas.helm package produces a .tgz with a correctly versioned name.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 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 10mLet'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 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:
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 15mNotice 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 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:
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:
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: trueTekton'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.
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:
chart-dirs:
- charts
chart-repos:
- bitnami=https://charts.bitnami.com/bitnami
helm-extra-args: --timeout 600s
validate-maintainers: false
target-branch: mainct 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.
Closing this episode with a list of practices that should become habits in every pipeline:
secrets.*, on GitLab use protected CI variables, on Jenkins use withCredentials. Every platform has its mechanism — always use it.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.latest everywhere.helm template and run kubeconform — without deploying. This catches problems long before merge.oci://), not from source.--atomic --timeout on upgrades so a failure triggers automatic rollback, and a release is never left in a confusing failed status.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:
ct lint (structure) → helm-unittest (logic) → kubeconform (schema).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!