Packaging, Registries and Provenance

Turning a directory into a versioned artifact, publishing to an OCI registry rather than an index.yaml, and giving consumers something they can actually verify.

intermediate 18 min lesson hands-on task included

A chart in a directory is a project. A chart in a registry, versioned and signed, is a product other people can depend on. The difference is a handful of commands and one decision about where it lives.


Topic 1: Packaging

TWO WAYS TO SHIP A CHART — OCI IS THE ONE TO CHOOSE NOW helm package mychart-0.4.1.tgz helm package --sign + .tgz.prov provenance helm push … oci:// a registry, like an image helm install oci:// by version or by digest OCI REGISTRY (current) helm push mychart-0.4.1.tgz oci://ghcr.io/acme/charts helm install app oci://ghcr.io/acme/charts/mychart --version 0.4.1 Same auth, same RBAC and same scanning as your images. No index.yaml to publish or keep consistent. CLASSIC HTTP REPO (still common) helm repo add · helm repo update · index.yaml A directory of .tgz files plus a generated index. Fine, and it is a second artifact store to run, authenticate and back up. PROVENANCE — WHAT A CONSUMER CAN ACTUALLY VERIFY helm package --sign --key 'release@acme' → mychart-0.4.1.tgz.prov (hash + signature) helm install … --verify # refuses unless the signature checks out Or sign the OCI artifact with cosign, which most teams already run for images — one tool, one policy, both artifact types.
The top row is the whole publishing pipeline. The two panels compare where the artifact lands — and OCI is the answer for anything new, because it reuses the registry, auth and scanning you already have for images.
helm package ./checkout
# → checkout-0.4.1.tgz        ← the CHART version, not appVersion

helm package ./checkout --version 0.4.2 --app-version 1.6.1
helm package ./checkout --dependency-update

The filename comes from name and version in Chart.yaml. Packaging the same version twice with different content is the mistake that breaks consumer caches — every content change needs a version bump, and CI should enforce it.

.helmignore decides what is excluded, and getting it wrong ships things you did not intend:

.git/
.github/
*.tgz
values-local.yaml
ci/
tests/
.vscode/

After packaging, look inside before you publish. It takes five seconds and it is how you find the values-dev.yaml with a password in it:

tar -tzf checkout-0.4.1.tgz

Topic 2: OCI Registries — the Current Answer

Charts are OCI artifacts. They live in the same registry as your images, with the same authentication, the same access control and the same scanning:

helm registry login ghcr.io -u USERNAME --password-stdin <<< "$GITHUB_TOKEN"

helm push checkout-0.4.1.tgz oci://ghcr.io/acme/charts
# → Pushed: ghcr.io/acme/charts/checkout:0.4.1
#   Digest: sha256:9f8e7d...

helm install checkout oci://ghcr.io/acme/charts/checkout --version 0.4.1
helm upgrade --install checkout oci://ghcr.io/acme/charts/checkout --version 0.4.1

helm show values oci://ghcr.io/acme/charts/checkout --version 0.4.1
helm pull oci://ghcr.io/acme/charts/checkout --version 0.4.1 --untar

What this removes: no index.yaml to generate and keep consistent, no separate chart server to run and back up, no second set of credentials. What it adds: charts appear in your registry’s UI, retention policies and vulnerability scanning alongside the images they deploy.

As a dependency, an OCI repository looks the same as any other:

dependencies:
  - name: common
    version: 1.2.0
    repository: oci://ghcr.io/acme/charts

Note there is no helm repo add for OCI — you reference the full URL, and helm registry login handles auth. That difference trips people who expect the repo-list workflow.


Topic 3: The Classic Repository, Because You Will Meet It

A Helm repository is a directory of .tgz files plus a generated index.yaml:

mkdir -p repo && cp checkout-0.4.1.tgz repo/
helm repo index repo --url https://charts.acme.example
helm repo index repo --url https://charts.acme.example --merge repo/index.yaml
# index.yaml
apiVersion: v1
entries:
  checkout:
    - apiVersion: v2
      name: checkout
      version: 0.4.1
      appVersion: "1.6.0"
      created: "2026-08-18T09:12:00Z"
      digest: sha256:9f8e7d...
      urls: [https://charts.acme.example/checkout-0.4.1.tgz]
generated: "2026-08-18T09:12:00Z"

Consumers use the familiar flow:

helm repo add acme https://charts.acme.example
helm repo update
helm search repo acme/
helm install checkout acme/checkout --version 0.4.1

Hosting options in ascending order of effort: GitHub Pages with the chart-releaser action (which packages, publishes and updates index.yaml on every tag), an S3 or GCS bucket with a plugin, or a full artifact repository like Harbor, Artifactory or Nexus.

helm repo update is a local cache refresh. “The new version does not exist” from a consumer is usually a stale cache, and it is the first thing to check.


Topic 4: Provenance and Signing

A .prov file contains the chart’s hash and a signature over it:

gpg --full-generate-key
gpg --export-secret-keys > ~/.gnupg/secring.gpg     # Helm still wants the legacy format

helm package ./checkout --sign --key 'release@acme.example' \
  --keyring ~/.gnupg/secring.gpg
# → checkout-0.4.1.tgz
#   checkout-0.4.1.tgz.prov

helm verify checkout-0.4.1.tgz --keyring ~/.gnupg/pubring.gpg
helm install checkout acme/checkout --verify --keyring ~/.gnupg/pubring.gpg

--verify refuses to install unless the signature checks out — which is the point: a consumer can prove the chart came from you and has not been altered, without trusting the transport or the registry.

Cosign is the pragmatic alternative for OCI-hosted charts, and it is what most teams already run for images:

cosign sign ghcr.io/acme/charts/checkout:0.4.1
cosign verify ghcr.io/acme/charts/checkout:0.4.1 \
  --certificate-identity-regexp '.*' --certificate-oidc-issuer https://token.actions.githubusercontent.com

Keyless signing ties the signature to a CI identity rather than to a key somebody has to store — one tool and one policy covering both images and charts, which is a materially simpler thing to operate than a GPG keyring per release engineer.

Pin by digest where certainty matters. A version tag can be overwritten in most registries; a digest cannot:

helm install checkout oci://ghcr.io/acme/charts/checkout@sha256:9f8e7d...

Topic 5: Releasing a Chart From CI

The pipeline that makes chart releases boring:

# on: push tags 'checkout-v*'
steps:
  - uses: actions/checkout@v4

  - name: Lint and template every values shape
    run: |
      helm lint charts/checkout --strict
      for f in charts/checkout/ci/*-values.yaml; do
        helm template checkout charts/checkout -f "$f" > /dev/null
      done

  - name: Install into kind and run chart tests
    run: |
      kind create cluster
      helm install checkout charts/checkout --wait --timeout 5m
      helm test checkout

  - name: Package and push
    run: |
      helm dependency build charts/checkout
      helm package charts/checkout --destination dist/
      helm registry login ghcr.io -u "${{ github.actor }}" --password-stdin <<< "${{ secrets.GITHUB_TOKEN }}"
      helm push dist/checkout-*.tgz oci://ghcr.io/${{ github.repository_owner }}/charts

Three properties worth building in from the start:

  • The version comes from the tag, so a release cannot be published without a version bump.
  • helm dependency build, not update — publish exactly what the lock file pins.
  • Install into a throwaway cluster and run helm test before publishing. A chart that has never been installed by CI is a chart nobody has verified.

For teams on GitHub Pages, chart-releaser (cr) does the packaging, release creation and index.yaml update in one action, and is worth using rather than scripting the same steps.


Topic 6: Consuming Third-Party Charts Safely

helm show chart bitnami/postgresql            # metadata, maintainers, version
helm show values bitnami/postgresql | less    # the interface
helm pull bitnami/postgresql --version 15.5.0 --untar
helm template test ./postgresql | grep -E 'image:|kind: (ClusterRole|Role)|privileged|hostPath'

The four questions to answer before a public chart runs in production:

  1. Which images, from which registry? Mirror them into your own registry if you have one.
  2. What RBAC does it request? Cluster-scoped permissions need a justification.
  3. Does it mount anything from the host? hostPath, hostNetwork, privileged — all worth reading in the rendered output rather than in the values file.
  4. Is it maintained? An abandoned chart pins an abandoned image with abandoned CVEs.

Mirror what you depend on. A public chart repository going away, rate-limiting, or removing an old version is a real outage during an incident — and mirroring into your own OCI registry is one helm pull and one helm push.

Try it yourself: publish a chart to a local registry:2 container, install from it, then modify a byte in the .tgz and run helm verify. Watching the verification fail is what makes provenance concrete rather than ceremonial.

Common mistake: publishing a new chart build under a version that already exists. Consumers who cached the old one get different content under the same version, helm dependency build produces different results on different machines, and the resulting “works on my cluster” is genuinely hard to diagnose. Enforce a version bump in CI — a three-line check comparing the chart version against published tags prevents it permanently.