Helm Chart Pipelines on GitHub Actions
A Helm chart pipeline on GitHub Actions lints, renders, and installs the chart into a kind cluster, then packages and pushes it to an OCI registry on a tag.
Last verified:
A Helm chart pipeline on GitHub Actions is two jobs with different triggers: a chart test job on every pull request that lints the chart, renders it, and installs it into a throwaway cluster, and a publish job gated on a tag that packages the chart and pushes it to an OCI registry. The test job needs Docker and a few cores, the publish job needs credentials and almost no compute, and pricing them as one job is the usual reason a chart repository costs more than it should.
This page gives the five stages of a chart pipeline and what each one catches, a workflow file with both jobs, the registry publishing path with the credential handling written out, runner sizes and per-minute rates for each job, and the bottlenecks that show up once the pipeline runs on every chart change.
Overview
A chart is a directory of templates plus a values.yaml, so most of what can go wrong is invisible until something renders it or applies it. The pipeline exists to move each class of failure earlier than the cluster.
| Stage | Command | What it catches | Needs a cluster |
|---|---|---|---|
| Lint | helm lint or ct lint | Missing required fields, invalid Chart.yaml, values that fail the JSON schema, a chart version that was not bumped | No |
| Template | helm template | Template errors, malformed YAML after substitution, manifests that reference values nobody set | No |
| Test | ct install | Manifests the API server rejects, images that fail to pull, workloads that never become ready, failing chart test hooks | Yes |
| Package | helm package | A chart directory that cannot be archived under the version in Chart.yaml | No |
| Publish | helm push | Credential and permission problems on the registry | No |
helm lint examines a chart for possible issues and reports them as warnings and errors (helm lint reference). helm template renders the templates locally and prints the manifests without contacting a cluster (helm template reference). The chart-testing tool wraps both and adds the checks a chart repository needs across changed charts, including the rule that a changed chart carries an incremented version (helm/chart-testing).
Only the test stage needs a Kubernetes API server, and kind supplies one by running cluster nodes as Docker containers on the job machine (kind quick start). That single dependency decides the platform for the job. The chart test job belongs on Linux because macOS runners cannot run Docker, which is documented with the rest of the catalog in the cloud runners documentation.
Packaging and publishing are separate from all of it. helm package produces a versioned .tgz archive from the chart directory (helm package reference), and Helm pushes that archive to any OCI compliant registry, which is where charts live alongside the images they deploy (Helm registries topic). The container registry definition covers what the registry stores on the other end.
Configuration
The workflow below runs the chart test job on pull requests that touch charts/, across two Kubernetes versions, and runs the publish job only on a tag push.
name: charts
on:
pull_request:
paths: ["charts/**"]
push:
tags: ["chart-v*"]
permissions:
contents: read
jobs:
chart-test:
if: github.event_name == 'pull_request'
runs-on: warp-ubuntu-latest-x64-4x
strategy:
fail-fast: false
matrix:
k8s: ["v1.31.4", "v1.32.2"]
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: azure/setup-helm@v4
with:
version: v3.16.2
- uses: helm/chart-testing-action@v2
- name: Lint changed charts
run: ct lint --target-branch ${{ github.event.repository.default_branch }}
- name: Render with the CI values
run: helm template api charts/api --values charts/api/ci/test-values.yaml
- uses: helm/kind-action@v1
with:
node_image: kindest/node:${{ matrix.k8s }}
- name: Install and run chart tests
run: ct install --target-branch ${{ github.event.repository.default_branch }}
publish:
if: startsWith(github.ref, 'refs/tags/chart-v')
runs-on: warp-ubuntu-latest-x64-2x
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: azure/setup-helm@v4
with:
version: v3.16.2
- name: Package the chart
run: helm package charts/api --destination dist
- name: Log in to the registry
run: |
echo "${{ secrets.GITHUB_TOKEN }}" \
| helm registry login ghcr.io \
--username ${{ github.actor }} \
--password-stdin
- name: Push the chart
run: helm push dist/*.tgz oci://ghcr.io/${{ github.repository_owner }}/chartsFour details in that file carry the design.
The trigger split is the security boundary. on.push.tags starts the publish job only for a matching tag ref (events that trigger workflows), so a pull request from a fork runs the test job and can never reach the publish job. The if guards make the intent readable in the run list when both triggers live in one file.
Permissions are scoped per job. The workflow-level permissions block is contents: read, and packages: write is granted on the publish job alone, which is the narrowest grant that lets helm push write to GitHub Packages (automatic token authentication). Charts and container images share one registry path under the same owner (working with the GitHub container registry).
The credential never appears as a process argument. helm registry login --password-stdin reads the token from standard input, which keeps it out of the process table and out of any shell trace the job prints.
The chart version is the release. ct lint fails when a changed chart keeps its old version, so a tag that publishes an unchanged version is caught on the pull request rather than at helm push.
Publishing to a cloud registry instead
For ECR or another cloud registry, replace the login step with a short lived credential exchanged through OpenID Connect (security hardening with OpenID Connect). The job adds id-token: write to its permissions and stores no registry password:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::111122223333:role/github-actions-charts
aws-region: us-east-1
- name: Log in to ECR
run: |
aws ecr get-login-password --region us-east-1 \
| helm registry login 111122223333.dkr.ecr.us-east-1.amazonaws.com \
--username AWS --password-stdinEach WarpBuild runner is a virtual machine created for one job and destroyed when the job ends, with its own encrypted storage volume, and WarpBuild does not access or store build secrets (security documentation). The credential in a publish job therefore lives as long as the job does.
Sizing
Size the two jobs separately, because only one of them runs a cluster. Rates are from the pricing page, billed per minute, checked on 2026-08-13.
| Label | vCPU | RAM | Storage | USD per minute | Job it fits |
|---|---|---|---|---|---|
warp-ubuntu-latest-x64-2x | 2 | 8 GB | 150GB SSD | $0.004 | Lint, template, package, publish |
warp-ubuntu-latest-x64-4x | 4 | 16 GB | 150GB SSD | $0.008 | ct install against one kind cluster |
warp-ubuntu-latest-x64-8x | 8 | 32 GB | 150GB SSD | $0.016 | Several charts per job or a multi node kind cluster |
warp-ubuntu-latest-arm64-4x | 4 | 16 GB | 150GB SSD | $0.006 | The same test job when every image under test publishes an arm64 manifest |
The 4 vCPU row is the practical floor for the test job. A kind cluster runs an API server, etcd, a scheduler, and a controller manager as containers before your chart installs anything, and the workloads the chart creates then share the same machine. The lint, template, package, and publish steps are close to idle by comparison and belong on the 2 vCPU row.
Worked model for a chart repository
Assumptions: 12 charts in one repository, 110 pull request runs a month, each fanning out to the two Kubernetes versions in the matrix, so 220 chart test jobs at 7 minutes each. Releases are tagged 24 times a month and the publish job takes 3 minutes. Substitute your own run counts from the workflow run history.
| Line | Arithmetic | Monthly |
|---|---|---|
| Chart test jobs | 220 x 7 min x $0.008 | $12.32 |
| Publish jobs | 24 x 3 min x $0.004 | $0.29 |
| Total | $12.61 |
Those same 1,540 chart test minutes list at $0.012 per minute on the 4-core Linux larger runner, which is $18.48 (GitHub Actions billing reference, checked on 2026-08-13). Stated as list price arithmetic: warp-ubuntu-latest-x64-4x (4 vCPU, 16 GB) costs $0.008 per minute against $0.012 per minute for the 4-core Linux larger runner (4 vCPU, 16 GB): 33 percent lower list price. GitHub list price checked on 2026-08-13.
Bottlenecks
Cluster creation repeats per matrix entry
Every matrix job builds its own kind cluster from scratch and pulls the kindest/node image for its Kubernetes version. Runner storage is ephemeral and is deleted when the runner terminates, so no local image layer survives to the next run. Two Kubernetes versions means paying that startup twice per pull request. Pin the node image tags so the pull is a cache hit at the registry rather than a resolution of a floating tag, and keep the matrix to the versions you actually support.
Readiness waiting is billed runner time
ct install waits for the release to become ready, and a chart with a slow init container or a long --timeout holds the runner while nothing computes. Set the timeout to the value your chart needs rather than a round number, and keep the waiting job on the smallest size that runs the cluster, since a 4 vCPU runner waits at $0.008 per minute while an 8 vCPU runner waits at $0.016 for the same result.
Images pulled into the cluster
A chart under test pulls every image it references into the kind cluster. Charts that deploy a database, a cache, and an application pull three images per matrix entry, and those bytes are the largest part of many chart test jobs. Point the CI values file at the smallest images that still exercise the templates, and set imagePullPolicy: IfNotPresent for anything you preload with kind load.
Chart version churn on a shared repository
ct lint requires an incremented version for each changed chart, so two pull requests touching the same chart collide on the version bump and the second one fails after merge conflicts are resolved. Keeping one chart per pull request, or bumping the version as the first commit of a change, removes most of that friction.
Fork pull requests carry no secrets
The token available to a workflow run from a fork is read-only, which is what makes the tag gate above the right shape rather than an inconvenience. A chart test job that quietly depends on a registry secret passes on branches and fails on every external contribution. Keep the test path free of credentials, and let the deployment job guide cover the pipeline stage where credentials are unavoidable.
What a bigger runner does not fix
A template that renders an invalid manifest, a values schema that contradicts the chart, or a readiness probe pointed at the wrong port fails the same way on every runner size. Faster machines shorten the loop on those failures without preventing them.
Proof
Public OSS repositories running warp- labels are checkable evidence, and the workflow file is one click away. kintsugi-tax/killbill-kintsugi-plugin runs its release workflow on warp-ubuntu-latest-x64-2x, building the plugin with mvn -B clean verify before it publishes, which is the same small-runner shape as the publish job on this page (checked on 2026-08-13). triggerdotdev/trigger.dev runs its end-to-end matrix on warp-ubuntu-latest-x64-4x and warp-windows-latest-x64-8x, which is the matrix fan-out shape the chart test job uses (checked on 2026-08-13). Both labels come from the catalog on the cloud runners page.
Every cost figure above carries its arithmetic, a source link, and a checked-on date, and the same per-minute rates appear on the pricing page. For the job that consumes the published chart, see Kubernetes deploy jobs on GitHub Actions.
FAQ
How do I test a Helm chart in GitHub Actions?
Run ct lint to catch schema and chart version problems, render the chart with helm template, then create a throwaway Kubernetes cluster with helm/kind-action and run ct install against it. kind runs its nodes as Docker containers, so the job belongs on a Linux runner such as warp-ubuntu-latest-x64-4x at $0.008 per minute. macOS runners cannot run Docker and cannot host the cluster.
How do I publish a Helm chart to an OCI registry from GitHub Actions?
Run helm package to produce the versioned .tgz, authenticate with helm registry login using --password-stdin, and push with helm push dist/chart.tgz oci://<registry>/<owner>/charts. Gate the job on a tag push so no pull request can publish, and grant packages: write on that job alone when the target is GitHub Packages.
What does a Helm chart pipeline cost on WarpBuild runners?
Start with $10 in free credits
Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.