Skip to content

DESIGN 0005: Helm Chart for repo-guardian

Status: Implemented Author: Donald Gifford Date: 2026-03-14

Overview

Replace the existing Kustomize-based deployment (deploy/base/ + overlays) with a Helm chart that packages repo-guardian for Kubernetes. The chart will live in charts/repo-guardian/ and use the same tooling ecosystem as server-price-tracker: helm-cr for releases, helm-ct for chart testing, helm-diff for upgrade previews, and helm-docs for auto-generated documentation.

Goals and Non-Goals

Goals

  • Package all repo-guardian Kubernetes resources into a single Helm chart.
  • Support all current deployment scenarios: dev (dry-run), prod (live), and Tailscale Funnel sidecar.
  • Integrate helm-cr, helm-ct, helm-diff, and helm-docs into the repo tooling.
  • Add Makefile targets and CI workflows for chart linting, testing, and release.
  • Provide values.yaml defaults that match the current Kustomize base configuration.
  • Support optional Tailscale sidecar injection via values toggle.
  • Generate chart README automatically with helm-docs.

Non-Goals

  • Remove Kustomize overlays immediately (deprecate, then remove later).
  • Support Helm Operator or Flux HelmRelease CRDs (plain helm install first).
  • Package third-party dependencies as subcharts (e.g., Prometheus, Grafana).
  • Multi-tenant or multi-instance deployment patterns.

Background

repo-guardian currently deploys via Kustomize with a base and overlays for dev, prod, and Tailscale environments. While Kustomize works for simple patching, the project needs:

  • Parameterized configuration -- environment-specific values without maintaining separate overlay patches for each field.
  • Release management -- versioned chart packages hosted on GitHub Pages via chart-releaser, consistent with the server-price-tracker pattern.
  • Chart testing -- structured lint and install tests via chart-testing (ct) with kind clusters in CI.
  • Self-documenting values -- helm-docs generates a README from value annotations, reducing drift between docs and code.

The server-price-tracker repo provides a proven reference for this tooling stack.

Detailed Design

Chart Structure

charts/repo-guardian/
  Chart.yaml              # apiVersion v2, type: application
  values.yaml             # Default values with helm-docs annotations
  README.md               # Auto-generated by helm-docs
  .helmignore             # Ignore patterns
  ci/
    ci-values.yaml        # Minimal values for ct install (stub image, no probes)
  templates/
    _helpers.tpl          # Name, fullname, labels, selectorLabels helpers
    deployment.yaml       # Main deployment (app + optional tailscale sidecar)
    service.yaml          # ClusterIP service (http + metrics ports)
    serviceaccount.yaml   # Optional ServiceAccount
    configmap.yaml        # Template files (CODEOWNERS, dependabot, renovate)
    secret.yaml           # GitHub App credentials (app-id, webhook-secret, private-key)
    servicemonitor.yaml   # Optional Prometheus ServiceMonitor
    NOTES.txt             # Post-install instructions
  tests/
    deployment_test.yaml  # helm-unittest cases
    service_test.yaml
    configmap_test.yaml
    secret_test.yaml

Chart.yaml

apiVersion: v2
name: repo-guardian
description: GitHub App that automates repository onboarding and compliance
type: application
version: 0.1.0
appVersion: "0.6.0"

version tracks the chart independently from appVersion (the Go binary version). chart-releaser bumps version on release.

Values Design

# -- Number of replicas
replicaCount: 1

image:
  # -- Container image repository
  repository: ghcr.io/donaldgifford/repo-guardian
  # -- Image pull policy
  pullPolicy: IfNotPresent
  # -- Overrides the image tag (default: appVersion)
  tag: ""

# -- Image pull secrets
imagePullSecrets: []
# -- Override the chart name
nameOverride: ""
# -- Override the full release name
fullnameOverride: ""

serviceAccount:
  # -- Create a ServiceAccount
  create: true
  # -- Annotations for the ServiceAccount
  annotations: {}
  # -- Override the ServiceAccount name
  name: ""

# -- Pod annotations
podAnnotations: {}
# -- Pod labels
podLabels: {}

# -- Pod security context
podSecurityContext: {}

# -- Container security context
securityContext:
  readOnlyRootFilesystem: true
  runAsNonRoot: true
  runAsUser: 65534

service:
  # -- Service type
  type: ClusterIP
  # -- Webhook HTTP port
  httpPort: 80
  # -- Metrics port
  metricsPort: 9090

# -- Container resource requests and limits
resources:
  requests:
    cpu: 100m
    memory: 128Mi
  limits:
    cpu: 500m
    memory: 256Mi

livenessProbe:
  httpGet:
    path: /healthz
    port: http
  initialDelaySeconds: 5
  periodSeconds: 15

readinessProbe:
  httpGet:
    path: /readyz
    port: http
  initialDelaySeconds: 5
  periodSeconds: 10

# -- repo-guardian application configuration (env vars)
config:
  # -- GitHub App ID
  appId: ""
  # -- GitHub organization name
  org: ""
  # -- Webhook listen port
  port: 8080
  # -- Metrics listen port
  metricsPort: 9090
  # -- Log level (debug, info, warn, error)
  logLevel: "info"
  # -- Dry run mode
  dryRun: false
  # -- Worker count for check queue
  workerCount: 5
  # -- Queue size for check queue
  queueSize: 100
  # -- Reconciliation schedule interval (Go duration)
  scheduleInterval: "168h"
  # -- Skip forked repositories
  skipForks: true
  # -- Skip archived repositories
  skipArchived: true

# -- GitHub App secrets
secrets:
  # -- Create secret resource (false = use existing secret)
  create: true
  # -- Name of existing secret (when create=false)
  existingSecret: ""
  # -- GitHub webhook secret
  webhookSecret: ""
  # -- GitHub App private key (PEM format)
  privateKey: ""
  # -- Mount private key as file (true) or env var (false)
  privateKeyAsFile: true

# -- Template overrides for CODEOWNERS, dependabot, renovate
templates:
  # -- Custom CODEOWNERS template (empty = use embedded default)
  codeowners: ""
  # -- Custom dependabot template
  dependabot: ""
  # -- Custom renovate template
  renovate: ""

# -- Webhook IP allowlist configuration
webhookIPAllowlist:
  # -- Enable GitHub IP allowlist middleware
  enabled: true
  # -- Allow requests when allowlist unavailable
  failOpen: false
  # -- Trust X-Forwarded-For proxy headers
  trustProxyHeaders: false

# -- Tailscale Funnel sidecar
tailscale:
  # -- Enable Tailscale sidecar container
  enabled: false
  # -- Tailscale container image
  image: ghcr.io/tailscale/tailscale:latest
  # -- Tailscale hostname (becomes <hostname>.<tailnet>.ts.net)
  hostname: repo-guardian
  # -- Use userspace networking (no CAP_NET_ADMIN needed)
  userspace: true
  # -- Name of existing secret containing 'authkey'
  authKeySecret: tailscale-auth
  # -- Create RBAC for Tailscale state management
  rbac:
    create: true

serviceMonitor:
  # -- Create Prometheus ServiceMonitor
  enabled: false
  # -- Scrape interval
  interval: 30s
  # -- Additional labels for ServiceMonitor
  labels: {}

# -- Additional environment variables
extraEnv: []
# -- Additional volumes
extraVolumes: []
# -- Additional volume mounts
extraVolumeMounts: []

# -- Node selector
nodeSelector: {}
# -- Tolerations
tolerations: []
# -- Affinity rules
affinity: {}

Comment annotations (# --) are consumed by helm-docs to generate the README values table automatically.

Private Key Handling

The chart supports two modes for the GitHub App private key, matching the current Kustomize base:

  1. File mount (default): secrets.privateKeyAsFile: true mounts the private key from the Secret as a PEM file at /etc/repo-guardian/private-key/private-key.pem. Sets GITHUB_PRIVATE_KEY_PATH env var.

  2. Environment variable: secrets.privateKeyAsFile: false injects the private key directly via GITHUB_PRIVATE_KEY env var. Simpler but less secure (visible in kubectl describe pod).

Tailscale Sidecar

When tailscale.enabled: true, the chart injects:

  • A sidecar container running ghcr.io/tailscale/tailscale:latest with userspace networking.
  • A ConfigMap with the Funnel serve config (proxy 443 -> 127.0.0.1:8080).
  • An emptyDir volume for Tailscale state.
  • Optional RBAC (Role + RoleBinding) for Tailscale state management.

This replaces the current deploy/overlays/tailscale/ Kustomize overlay with a single tailscale.enabled: true toggle.

Template Helpers (_helpers.tpl)

Standard helpers following the server-price-tracker pattern:

Helper Output
repo-guardian.name Chart name (truncated to 63 chars)
repo-guardian.fullname Release-qualified name
repo-guardian.labels Standard Kubernetes labels
repo-guardian.selectorLabels Selector labels (name + instance)
repo-guardian.serviceAccountName ServiceAccount name
repo-guardian.secretName Secret resource name

OCI Registry Support

In addition to the default GitHub Pages chart repository (via chart-releaser), the chart supports pushing to OCI-compliant registries such as Amazon ECR. This is useful for production environments where charts are consumed from a private registry rather than a public GitHub Pages endpoint.

Push workflow:

# Login to ECR (or any OCI registry)
aws ecr get-login-password --region us-east-1 | \
  helm registry login --username AWS --password-stdin 123456789012.dkr.ecr.us-east-1.amazonaws.com

# Package the chart
helm package charts/repo-guardian

# Push to OCI registry
helm push repo-guardian-0.1.0.tgz oci://123456789012.dkr.ecr.us-east-1.amazonaws.com/helm-charts

Install from OCI registry:

helm install repo-guardian \
  oci://123456789012.dkr.ecr.us-east-1.amazonaws.com/helm-charts/repo-guardian \
  --version 0.1.0 \
  -f values-prod.yaml

Makefile targets:

Target Description
helm-push Push packaged chart to OCI registry (HELM_REGISTRY var)

CI workflow (.github/workflows/chart-release.yml addition):

An optional job publishes the chart to an OCI registry after the GitHub Pages release succeeds. The registry URL is configured via the HELM_OCI_REGISTRY repository secret. When the secret is not set, the OCI push step is skipped.

helm-oci-push:
  name: Push to OCI Registry
  needs: chart-release
  if: vars.HELM_OCI_REGISTRY != ''
  runs-on: ubuntu-latest
  permissions:
    id-token: write
  steps:
    - uses: actions/checkout@v6
    - uses: azure/setup-helm@v4
    - uses: aws-actions/configure-aws-credentials@v4
      with:
        role-to-arn: ${{ secrets.AWS_ROLE_ARN }}
        aws-region: us-east-1
    - name: Login to ECR
      run: |
        aws ecr get-login-password | \
          helm registry login --username AWS --password-stdin "${{ vars.HELM_OCI_REGISTRY }}"
    - name: Package and push
      run: |
        helm package charts/repo-guardian
        helm push repo-guardian-*.tgz "oci://${{ vars.HELM_OCI_REGISTRY }}"

ECR repository setup:

The ECR repository must be created before the first push. This is a one-time setup step:

aws ecr create-repository \
  --repository-name helm-charts/repo-guardian \
  --region us-east-1

Tooling Setup

mise.toml additions

# helm
helm = "3.19.0"
helm-cr = "latest"
helm-ct = "latest"
helm-diff = "latest"
helm-docs = "latest"

ct.yaml (chart-testing config)

chart-dirs:
  - charts
target-branch: main
check-version-increment: false
validate-maintainers: false
lint-conf: charts/.yamllint.yml

charts/.yamllint.yml

Separate yamllint config for charts that ignores template directories:

extends: default
rules:
  line-length:
    max: 150
    level: warning
  indentation:
    spaces: 2
    indent-sequences: true
  comments:
    min-spaces-from-content: 1
  document-start:
    present: true
  truthy:
    allowed-values: ["true", "false", "yes", "no", "on", "off"]
    check-keys: false
  empty-values: disable
ignore: |-
  charts/repo-guardian/templates/

Makefile Targets

Add targets to the existing Makefile matching the server-price-tracker pattern:

Target Description
helm-lint helm lint charts/repo-guardian
helm-template Render templates with default values
helm-template-ci Render templates with CI values
helm-package Package chart to .tgz
helm-unittest Run helm-unittest plugin tests
helm-test helm-lint + helm-unittest
helm-ct-lint ct lint --config ct.yaml --all
helm-ct-list-changed List changed charts since target branch
helm-ct-install Install and test in kind cluster
helm-docs Generate chart README with helm-docs
helm-diff-check Show diff between installed release and local chart
helm-cr-package Package chart with chart-releaser
helm-push Push packaged chart to OCI registry (HELM_REGISTRY var)

CI Workflows

Chart Release (.github/workflows/chart-release.yml)

Triggered manually (workflow_dispatch). Uses helm/chart-releaser-action@v1 to package and publish the chart to GitHub Pages.

name: Chart Release
on:
  workflow_dispatch: {}
permissions:
  contents: write
jobs:
  chart-release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
        with:
          ref: main
          fetch-depth: 0
          persist-credentials: true
      - name: Configure Git
        run: |
          git config user.name "github-actions[bot]"
          git config user.email "github-actions[bot]@users.noreply.github.com"
      - name: Set up Helm
        uses: azure/setup-helm@v4
      - name: Run chart-releaser
        uses: helm/chart-releaser-action@v1
        with:
          charts_dir: charts
          skip_existing: true
        env:
          CR_TOKEN: ${{ secrets.GITHUB_TOKEN }}

CI Additions (.github/workflows/ci.yml)

Add two jobs to the existing CI workflow:

Helm Unit Tests:

helm-unittest:
  name: Helm Unit Tests
  runs-on: ubuntu-latest
  steps:
    - uses: actions/checkout@v6
    - name: Run helm-unittest
      uses: d3adb5/helm-unittest-action@v2
      with:
        charts: charts/repo-guardian
        flags: --color

Helm Chart Test (ct lint + install):

helm-test:
  name: Helm Chart Test
  runs-on: ubuntu-latest
  steps:
    - uses: actions/checkout@v6
      with:
        fetch-depth: 0
    - uses: azure/setup-helm@v4
    - uses: actions/setup-python@v5
      with:
        python-version: "3.12"
    - uses: helm/chart-testing-action@v2
    - name: List changed charts
      id: list-changed
      run: |
        changed=$(ct list-changed --config ct.yaml)
        if [[ -n "$changed" ]]; then
          echo "changed=true" >> "$GITHUB_OUTPUT"
        fi
    - name: Lint charts
      if: steps.list-changed.outputs.changed == 'true'
      run: ct lint --config ct.yaml
    - name: Create kind cluster
      if: steps.list-changed.outputs.changed == 'true'
      uses: helm/kind-action@v1
    - name: Install and test charts
      if: steps.list-changed.outputs.changed == 'true'
      run: ct install --config ct.yaml

Helm Lint (add to existing lint-repo job):

- name: Lint Helm chart
  run: helm lint charts/repo-guardian/

Testing Strategy

helm-unittest

Unit tests in charts/repo-guardian/tests/ validate template rendering:

  • deployment_test.yaml -- replica count, image tag, env vars, volume mounts, Tailscale sidecar injection, probe configuration.
  • service_test.yaml -- port mappings, service type.
  • configmap_test.yaml -- template file content, custom template overrides.
  • secret_test.yaml -- secret creation vs existing secret reference, private key mount mode.
  • servicemonitor_test.yaml -- conditional creation, interval, labels.

ct lint

Chart-testing lints the chart YAML, validates Chart.yaml fields, and runs yamllint with the chart-specific config.

ct install

Chart-testing creates a kind cluster, installs the chart with ci/ci-values.yaml (stub image, disabled probes), and runs helm test to verify the release is healthy.

ci/ci-values.yaml

Minimal values that allow the chart to install in a kind cluster without real GitHub credentials:

image:
  repository: busybox
  tag: latest

config:
  appId: "12345"
  dryRun: true

secrets:
  webhookSecret: "test-secret"
  privateKey: "test-key"
  privateKeyAsFile: false

livenessProbe: {}
readinessProbe: {}

resources: {}

helm-diff

Used locally to preview changes before upgrading a release:

make helm-diff-check RELEASE=repo-guardian

Migration / Rollout Plan

  1. Phase 1: Create chart -- Add charts/repo-guardian/ with all templates, values, and tests. Kustomize overlays remain untouched.
  2. Phase 2: Add tooling -- Add helm, helm-cr, helm-ct, helm-diff, helm-docs to mise.toml. Add Makefile targets and CI workflows.
  3. Phase 3: Validate -- Deploy to Talos cluster using helm install and verify parity with Kustomize deployment.
  4. Phase 4: Deprecate Kustomize -- Mark deploy/ as deprecated in docs. Keep for one release cycle.
  5. Phase 5: Remove Kustomize -- Delete deploy/ directory after confirming all deployments use the Helm chart.

Open Questions

All resolved.

  1. ~~Kustomize removal timeline~~: Resolved. Keep Kustomize alongside Helm for now. Revisit removal after the Helm chart is validated on the Talos cluster and at least one tagged release has shipped with it.

  2. ~~GitHub Pages for chart repo~~: Resolved. Use gh-pages branch via chart-releaser as the default. Additionally, support pushing charts to an OCI-compliant registry (e.g., ECR) for production environments. See the OCI Registry Support section under Detailed Design.

  3. ~~helm-unittest plugin installation~~: Resolved. Already added to mise.toml. Local developers get it automatically via mise install.

  4. ~~Makefile structure~~: Resolved. Keep helm targets in the existing single Makefile. No modular split needed at this time.

Decisions

  1. Chart location: charts/repo-guardian/ (not deploy/helm/ or chart/). Matches the server-price-tracker convention and chart-releaser expectations.

  2. Tailscale as a values toggle, not a subchart: The Tailscale sidecar is simple enough to template directly. A subchart adds unnecessary complexity for a single container.

  3. helm-docs comment annotations: Use # -- comment style in values.yaml for auto-generated documentation. This is the helm-docs default format.

References