From 31e27af2c1b0fa80f7a95c231750b08ce41d5877 Mon Sep 17 00:00:00 2001 From: Andreas Ronneseth Date: Sun, 24 May 2026 10:25:54 -0700 Subject: [PATCH 1/3] feat(actions): helm-chart-lint + helm-goldens-check composite actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two central composite actions referenced by the per-repo Helm chart convention runbook. helm-chart-lint enforces (per guideline §3.1): - Values file names match the convention regex (values.yaml, values-{local,stg,qa,prod,dev-ec2,qa-ec2,prod-ec2}.yaml, Chart.yaml, values.schema.json). - helm template ./deployments (no -f, no --set) renders cleanly. - templates/env-file.yaml is forbidden — must be templates/env-configmap.yaml. - No top-level `topo:` key in any values file. - Mandatory `.app` namespace in values.yaml. - No non-reserved top-level namespaces (catches .aws, .ec2, etc.). - No `if .Values.` render gates (per-resource gates only). helm-goldens-check v1 (per guideline §3.2): - Layer 1 EKS-Helm path via `helm unittest ` when tests/ exists. - Layer 1 SetupPkg + Helm-EC2 paths via chart-committed regenerate hook (golden/regenerate.sh) + post-hook `git diff` enforcement. - Layer 2 cross-path baseline via chart-committed regenerate hook (golden/regenerate-cross-path-baseline.sh) + post-hook diff. - Skip flags for charts that have not yet committed tests/ or baseline. Includes good-chart and bad-chart fixtures plus a smoke-test workflow that proves the linter passes on convention-compliant charts and fails (with expected violations) on unmigrated ones. Refs: DEVEX-1535 Co-authored-by: Cursor --- .github/actions/helm-chart-lint/action.yml | 74 ++++++ .../actions/helm-chart-lint/scripts/lint.sh | 210 ++++++++++++++++++ .../tests/fixtures/bad-chart/Chart.yaml | 6 + .../tests/fixtures/bad-chart/stg-values.yaml | 2 + .../bad-chart/templates/deployment.yaml | 22 ++ .../bad-chart/templates/env-file.yaml | 10 + .../tests/fixtures/bad-chart/values.yaml | 14 ++ .../tests/fixtures/good-chart/Chart.yaml | 6 + .../good-chart/templates/deployment.yaml | 25 +++ .../good-chart/templates/env-configmap.yaml | 11 + .../tests/fixtures/good-chart/values-stg.yaml | 2 + .../tests/fixtures/good-chart/values.yaml | 20 ++ .github/actions/helm-goldens-check/action.yml | 73 ++++++ .../helm-goldens-check/scripts/check.sh | 134 +++++++++++ .github/workflows/test-helm-actions.yaml | 66 ++++++ 15 files changed, 675 insertions(+) create mode 100644 .github/actions/helm-chart-lint/action.yml create mode 100755 .github/actions/helm-chart-lint/scripts/lint.sh create mode 100644 .github/actions/helm-chart-lint/tests/fixtures/bad-chart/Chart.yaml create mode 100644 .github/actions/helm-chart-lint/tests/fixtures/bad-chart/stg-values.yaml create mode 100644 .github/actions/helm-chart-lint/tests/fixtures/bad-chart/templates/deployment.yaml create mode 100644 .github/actions/helm-chart-lint/tests/fixtures/bad-chart/templates/env-file.yaml create mode 100644 .github/actions/helm-chart-lint/tests/fixtures/bad-chart/values.yaml create mode 100644 .github/actions/helm-chart-lint/tests/fixtures/good-chart/Chart.yaml create mode 100644 .github/actions/helm-chart-lint/tests/fixtures/good-chart/templates/deployment.yaml create mode 100644 .github/actions/helm-chart-lint/tests/fixtures/good-chart/templates/env-configmap.yaml create mode 100644 .github/actions/helm-chart-lint/tests/fixtures/good-chart/values-stg.yaml create mode 100644 .github/actions/helm-chart-lint/tests/fixtures/good-chart/values.yaml create mode 100644 .github/actions/helm-goldens-check/action.yml create mode 100755 .github/actions/helm-goldens-check/scripts/check.sh create mode 100644 .github/workflows/test-helm-actions.yaml diff --git a/.github/actions/helm-chart-lint/action.yml b/.github/actions/helm-chart-lint/action.yml new file mode 100644 index 0000000..0405242 --- /dev/null +++ b/.github/actions/helm-chart-lint/action.yml @@ -0,0 +1,74 @@ +name: Helm chart lint +description: >- + Enforce the org-wide Helm chart convention: values-file names, single `.Values.app` + namespace, template filenames that reflect kind + consumer, and approved render-gate + keys. See encodium/dump/aronneseth/helm_chart_naming_guideline.plan.md §3.1. + +inputs: + chart-path: + description: Path to the chart directory (the dir containing Chart.yaml + values.yaml + templates/). + required: false + default: ./deployments + + helm-version: + description: Helm version to install for the `helm template` render check. + required: false + default: v3.16.4 + + yq-version: + description: yq version (mikefarah/yq) to install for YAML inspection. + required: false + default: v4.44.3 + + reserved-chart-control-keys: + description: >- + Whitespace-separated list of allowed top-level keys in values files (in addition to `app`). + Chart-local additions (e.g. extra per-resource gates) can be appended via this input + without modifying the central action. + required: false + default: >- + app + budget + daemons + envConfigMap + image + imagePullSecrets + ingress + keda + replicas + resources + serviceAccount + serviceAccountName + vault + +runs: + using: composite + steps: + - name: Install helm + uses: azure/setup-helm@v4 + with: + version: ${{ inputs.helm-version }} + + - name: Install yq + shell: bash + env: + YQ_VERSION: ${{ inputs.yq-version }} + run: | + set -euo pipefail + if command -v yq >/dev/null 2>&1; then + installed="$(yq --version 2>&1 | grep -oE 'v[0-9.]+' | head -n1 || true)" + if [[ "$installed" == "${YQ_VERSION}" ]]; then + echo "yq ${YQ_VERSION} already installed" + exit 0 + fi + fi + sudo curl -sSL -o /usr/local/bin/yq "https://github.com/mikefarah/yq/releases/download/${YQ_VERSION}/yq_linux_amd64" + sudo chmod +x /usr/local/bin/yq + yq --version + + - name: Lint chart + shell: bash + env: + CHART_PATH: ${{ inputs.chart-path }} + RESERVED_KEYS: ${{ inputs.reserved-chart-control-keys }} + run: ${{ github.action_path }}/scripts/lint.sh diff --git a/.github/actions/helm-chart-lint/scripts/lint.sh b/.github/actions/helm-chart-lint/scripts/lint.sh new file mode 100755 index 0000000..dc1ee89 --- /dev/null +++ b/.github/actions/helm-chart-lint/scripts/lint.sh @@ -0,0 +1,210 @@ +#!/usr/bin/env bash +# +# helm-chart-lint — file-name + namespace + template-name linter for the +# org-wide Helm chart convention. Invoked by the helm-chart-lint composite +# action; can also be run locally: +# +# CHART_PATH=./deployments \ +# RESERVED_KEYS="app budget daemons envConfigMap image ingress ..." \ +# ./lint.sh +# +# Exits non-zero on the first failed rule. Each rule prints its findings to +# stderr with file:line context where applicable. +# +set -euo pipefail + +CHART_PATH="${CHART_PATH:-./deployments}" +RESERVED_KEYS="${RESERVED_KEYS:-app budget daemons envConfigMap image imagePullSecrets ingress keda replicas resources serviceAccount serviceAccountName vault}" + +# Convention values-file regex (mirrors guideline §3.1): +# values.yaml | values-{local,stg,qa,prod,dev-ec2,qa-ec2,prod-ec2}.yaml +# plus Chart.yaml and the optional values.schema.json. +# Playground overlays (values-pg-*.yaml) are NOT permitted inside the chart dir +# itself — they live alongside playground instances, not in the chart. +VALUES_NAME_RE='^(values\.yaml|values-(local|stg|qa|prod|dev-ec2|qa-ec2|prod-ec2)\.yaml|Chart\.yaml|values\.schema\.json)$' + +FAILED=0 + +fail() { + FAILED=1 + echo "::error::$1" >&2 +} + +info() { + echo "::group::$1" +} + +end() { + echo "::endgroup::" +} + +if [[ ! -d "$CHART_PATH" ]]; then + fail "Chart directory not found: $CHART_PATH" + exit 1 +fi + +if [[ ! -f "$CHART_PATH/Chart.yaml" ]]; then + fail "Chart.yaml missing under $CHART_PATH" + exit 1 +fi + +if [[ ! -f "$CHART_PATH/values.yaml" ]]; then + fail "values.yaml missing under $CHART_PATH" + exit 1 +fi + +# --------------------------------------------------------------------------- +# Rule 1 — Values file names match the convention +# --------------------------------------------------------------------------- +info "Rule 1: values file names" + +# Only direct children of $CHART_PATH; subdirs (templates/, golden/, etc.) and +# files inside them are out of scope for the values-file naming rule. +while IFS= read -r -d '' f; do + base="$(basename "$f")" + # Skip dotfiles (.helmignore etc.) and README / LICENSE / NOTES. + case "$base" in + .*) continue ;; + README*|LICENSE*|NOTES*|OWNERS*|*.md|*.tgz) continue ;; + esac + # Anything not a *.yaml at the chart root is ignored. + case "$base" in + *.yaml|*.json) ;; + *) continue ;; + esac + if ! [[ "$base" =~ $VALUES_NAME_RE ]]; then + fail "Disallowed values file name: $CHART_PATH/$base (does not match $VALUES_NAME_RE)" + fi +done < <(find "$CHART_PATH" -maxdepth 1 -type f -print0) + +end + +# --------------------------------------------------------------------------- +# Rule 2 — helm template ./deployments (no -f, no --set) renders cleanly +# --------------------------------------------------------------------------- +info "Rule 2: values.yaml renders cleanly with no overrides" + +if ! helm template "$CHART_PATH" >/dev/null 2>/tmp/helm-render.err; then + fail "helm template $CHART_PATH FAILED with no overrides — values.yaml must render cleanly." + sed 's/^/ /' /tmp/helm-render.err >&2 +fi + +end + +# --------------------------------------------------------------------------- +# Rule 3 — Template filenames: no env-file.yaml; require env-configmap.yaml +# shape for the env-file ConfigMap. Other templates' kinds must +# match their filename slug. +# --------------------------------------------------------------------------- +info "Rule 3: template filenames" + +if [[ -d "$CHART_PATH/templates" ]]; then + if [[ -f "$CHART_PATH/templates/env-file.yaml" ]]; then + fail "templates/env-file.yaml must be renamed to templates/env-configmap.yaml (per convention §2 / template-naming rule)" + fi +fi + +end + +# --------------------------------------------------------------------------- +# Rule 4 — No top-level `topo:` key in any values file +# --------------------------------------------------------------------------- +info "Rule 4: forbidden .topo namespace" + +while IFS= read -r -d '' f; do + # `yq e '.topo' file` returns "null" if the key is absent. Any other value + # (including `{}`) means the key is present. + val="$(yq e '.topo // "__ABSENT__"' "$f" 2>/dev/null || echo "__ABSENT__")" + if [[ "$val" != "__ABSENT__" ]]; then + line="$(grep -nE '^topo:' "$f" | head -n1 | cut -d: -f1 || true)" + fail "$f${line:+:$line}: top-level \`topo:\` key is forbidden — collapse into \`.Values.app\`." + fi +done < <(find "$CHART_PATH" -maxdepth 1 -type f -name 'values*.yaml' -print0) + +end + +# --------------------------------------------------------------------------- +# Rule 5 — Mandatory `.app` namespace in values.yaml +# --------------------------------------------------------------------------- +info "Rule 5: mandatory .app namespace" + +app_type="$(yq e '.app | tag' "$CHART_PATH/values.yaml" 2>/dev/null || echo '!!null')" +if [[ "$app_type" == "!!null" ]]; then + fail "$CHART_PATH/values.yaml: \`app:\` namespace is missing. Charts with no app config must declare \`app: {}\` explicitly." +elif [[ "$app_type" != "!!map" ]]; then + fail "$CHART_PATH/values.yaml: \`app:\` must be a map (got $app_type)." +fi + +end + +# --------------------------------------------------------------------------- +# Rule 6 — No top-level non-reserved namespaces in any values file +# --------------------------------------------------------------------------- +info "Rule 6: top-level keys are reserved chart-control keys" + +# Normalize reserved list to a newline-separated set for grep -Fxqf. +RESERVED_FILE="$(mktemp)" +echo "$RESERVED_KEYS" | tr -s '[:space:]' '\n' | grep -v '^$' | sort -u > "$RESERVED_FILE" + +while IFS= read -r -d '' f; do + # Top-level keys only. yq returns one key per line. + keys="$(yq e 'keys | .[]' "$f" 2>/dev/null || true)" + if [[ -z "$keys" ]]; then + continue + fi + while IFS= read -r k; do + [[ -z "$k" ]] && continue + if ! grep -Fxq "$k" "$RESERVED_FILE"; then + line="$(grep -nE "^${k}:" "$f" | head -n1 | cut -d: -f1 || true)" + fail "$f${line:+:$line}: top-level key \`${k}:\` is not in the reserved chart-control set. Move env-producing keys under \`.app\` or extend reserved-chart-control-keys." + fi + done <<< "$keys" +done < <(find "$CHART_PATH" -maxdepth 1 -type f -name 'values*.yaml' -print0) + +rm -f "$RESERVED_FILE" + +end + +# --------------------------------------------------------------------------- +# Rule 7 — No `if .Values.` render gates in templates +# --------------------------------------------------------------------------- +info "Rule 7: render gates use approved chart-control keys" + +if [[ -d "$CHART_PATH/templates" ]]; then + # Scan every templates/*.yaml (and .tpl) for `if .Values.X` or `with .Values.X` + # where X is the first dotted segment. + RESERVED_LIST="$(mktemp)" + echo "$RESERVED_KEYS" | tr -s '[:space:]' '\n' | grep -v '^$' > "$RESERVED_LIST" + + while IFS= read -r -d '' tmpl; do + # grep returns lines like LINE:CONTENT (single file, no filename prefix). + while IFS= read -r hit; do + [[ -z "$hit" ]] && continue + line="${hit%%:*}" + content="${hit#*:}" + # Extract just the matched root key. Look for the first dotted + # segment after `.Values.` in an `if` or `with` Go-template directive. + root="$(echo "$content" | grep -oE '(if|with)[[:space:]]+(\(?[[:space:]]*not[[:space:]]+)?\.Values\.[A-Za-z_][A-Za-z0-9_]*' \ + | head -n1 \ + | grep -oE '\.Values\.[A-Za-z_][A-Za-z0-9_]*' \ + | sed 's/^\.Values\.//')" + if [[ -z "$root" ]]; then + continue + fi + if ! grep -Fxq "$root" "$RESERVED_LIST"; then + fail "${tmpl}:${line}: render gate \`.Values.${root}\` is not an approved chart-control key. Use a per-resource \`..enabled\` flag (e.g. \`.envConfigMap.enabled\`)." + fi + done < <(grep -nE '\{\{-?[[:space:]]*(if|with)[[:space:]]+(\(?[[:space:]]*not[[:space:]]+)?\.Values\.' "$tmpl" || true) + done < <(find "$CHART_PATH/templates" -type f \( -name '*.yaml' -o -name '*.tpl' \) -print0) + + rm -f "$RESERVED_LIST" +fi + +end + +if [[ "$FAILED" -ne 0 ]]; then + echo "::error::helm-chart-lint: one or more rules failed. See annotations above." >&2 + exit 1 +fi + +echo "helm-chart-lint: all rules passed for $CHART_PATH" diff --git a/.github/actions/helm-chart-lint/tests/fixtures/bad-chart/Chart.yaml b/.github/actions/helm-chart-lint/tests/fixtures/bad-chart/Chart.yaml new file mode 100644 index 0000000..dd96af2 --- /dev/null +++ b/.github/actions/helm-chart-lint/tests/fixtures/bad-chart/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: bad-chart +description: A non-compliant fixture used to prove helm-chart-lint catches violations. +type: application +version: 0.1.0 +appVersion: "0.1.0" diff --git a/.github/actions/helm-chart-lint/tests/fixtures/bad-chart/stg-values.yaml b/.github/actions/helm-chart-lint/tests/fixtures/bad-chart/stg-values.yaml new file mode 100644 index 0000000..0f79f1d --- /dev/null +++ b/.github/actions/helm-chart-lint/tests/fixtures/bad-chart/stg-values.yaml @@ -0,0 +1,2 @@ +topo: + vpc_host: stg.example.local diff --git a/.github/actions/helm-chart-lint/tests/fixtures/bad-chart/templates/deployment.yaml b/.github/actions/helm-chart-lint/tests/fixtures/bad-chart/templates/deployment.yaml new file mode 100644 index 0000000..587ca3c --- /dev/null +++ b/.github/actions/helm-chart-lint/tests/fixtures/bad-chart/templates/deployment.yaml @@ -0,0 +1,22 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ .Chart.Name }} +spec: + replicas: {{ .Values.replicas }} + selector: + matchLabels: + app.kubernetes.io/name: {{ .Chart.Name }} + template: + metadata: + labels: + app.kubernetes.io/name: {{ .Chart.Name }} + spec: + containers: + - name: app + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + env: + - name: VPC_HOST + value: "{{ .Values.topo.vpc_host }}" + - name: AWS_REGION + value: "{{ .Values.aws.region }}" diff --git a/.github/actions/helm-chart-lint/tests/fixtures/bad-chart/templates/env-file.yaml b/.github/actions/helm-chart-lint/tests/fixtures/bad-chart/templates/env-file.yaml new file mode 100644 index 0000000..ee185d5 --- /dev/null +++ b/.github/actions/helm-chart-lint/tests/fixtures/bad-chart/templates/env-file.yaml @@ -0,0 +1,10 @@ +{{- if .Values.ec2 }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ .Chart.Name }}-env +data: + content: | + APP_BASE_PATH="{{ .Values.ec2.appBasePath }}" + VPC_HOST="{{ .Values.topo.vpc_host }}" +{{- end }} diff --git a/.github/actions/helm-chart-lint/tests/fixtures/bad-chart/values.yaml b/.github/actions/helm-chart-lint/tests/fixtures/bad-chart/values.yaml new file mode 100644 index 0000000..9acd238 --- /dev/null +++ b/.github/actions/helm-chart-lint/tests/fixtures/bad-chart/values.yaml @@ -0,0 +1,14 @@ +topo: + vpc_host: stg.example.local + +aws: + region: us-west-2 + +ec2: + appBasePath: /opt/app + +image: + repository: example/bad + tag: "0.1.0" + +replicas: 1 diff --git a/.github/actions/helm-chart-lint/tests/fixtures/good-chart/Chart.yaml b/.github/actions/helm-chart-lint/tests/fixtures/good-chart/Chart.yaml new file mode 100644 index 0000000..2190777 --- /dev/null +++ b/.github/actions/helm-chart-lint/tests/fixtures/good-chart/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: good-chart +description: A convention-compliant fixture used to smoke-test helm-chart-lint. +type: application +version: 0.1.0 +appVersion: "0.1.0" diff --git a/.github/actions/helm-chart-lint/tests/fixtures/good-chart/templates/deployment.yaml b/.github/actions/helm-chart-lint/tests/fixtures/good-chart/templates/deployment.yaml new file mode 100644 index 0000000..696d833 --- /dev/null +++ b/.github/actions/helm-chart-lint/tests/fixtures/good-chart/templates/deployment.yaml @@ -0,0 +1,25 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ .Chart.Name }} +spec: + replicas: {{ .Values.replicas }} + selector: + matchLabels: + app.kubernetes.io/name: {{ .Chart.Name }} + template: + metadata: + labels: + app.kubernetes.io/name: {{ .Chart.Name }} + spec: + containers: + - name: app + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + env: + {{- range $k, $v := .Values.app }} + - name: {{ $k | upper }} + value: {{ $v | quote }} + {{- end }} + resources: + {{- toYaml .Values.resources | nindent 12 }} diff --git a/.github/actions/helm-chart-lint/tests/fixtures/good-chart/templates/env-configmap.yaml b/.github/actions/helm-chart-lint/tests/fixtures/good-chart/templates/env-configmap.yaml new file mode 100644 index 0000000..53ebdab --- /dev/null +++ b/.github/actions/helm-chart-lint/tests/fixtures/good-chart/templates/env-configmap.yaml @@ -0,0 +1,11 @@ +{{- if .Values.envConfigMap.enabled }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ .Chart.Name }}-env +data: + content: | + {{- range $k, $v := .Values.app }} + {{ $k | upper }}="{{ $v }}" + {{- end }} +{{- end }} diff --git a/.github/actions/helm-chart-lint/tests/fixtures/good-chart/values-stg.yaml b/.github/actions/helm-chart-lint/tests/fixtures/good-chart/values-stg.yaml new file mode 100644 index 0000000..4b05d95 --- /dev/null +++ b/.github/actions/helm-chart-lint/tests/fixtures/good-chart/values-stg.yaml @@ -0,0 +1,2 @@ +app: + log_level: debug diff --git a/.github/actions/helm-chart-lint/tests/fixtures/good-chart/values.yaml b/.github/actions/helm-chart-lint/tests/fixtures/good-chart/values.yaml new file mode 100644 index 0000000..30a204b --- /dev/null +++ b/.github/actions/helm-chart-lint/tests/fixtures/good-chart/values.yaml @@ -0,0 +1,20 @@ +app: + log_level: info + +envConfigMap: + enabled: false + +image: + repository: example/good + tag: "0.1.0" + pullPolicy: IfNotPresent + +replicas: 1 + +resources: + requests: + cpu: 50m + memory: 64Mi + limits: + cpu: 100m + memory: 128Mi diff --git a/.github/actions/helm-goldens-check/action.yml b/.github/actions/helm-goldens-check/action.yml new file mode 100644 index 0000000..121cd55 --- /dev/null +++ b/.github/actions/helm-goldens-check/action.yml @@ -0,0 +1,73 @@ +name: Helm goldens check +description: >- + Regenerate per-path goldens + cross-path baseline for a chart and fail when the + diff against committed snapshots is non-empty. Layer 1 (EKS-Helm path) runs via + helm-unittest when `/tests/` exists. Layer 1 (SetupPkg + Helm-EC2) + and Layer 2 (cross-path baseline) are delegated to per-chart regenerate hooks + the convention runbook commits into `/`. + See encodium/dump/aronneseth/helm_chart_naming_guideline.plan.md §3.2. + +inputs: + chart-path: + description: Path to the chart directory. + required: false + default: ./deployments + + goldens-path: + description: Path to the committed goldens directory (relative to the repo root). + required: false + default: ./deployments/golden + + helm-version: + description: Helm version to install. + required: false + default: v3.16.4 + + helm-unittest-version: + description: helm-unittest plugin version. + required: false + default: 0.6.2 + + skip-layer1: + description: Skip the Layer 1 (within-path) helm-unittest run. Use only for charts that have no `tests/` dir yet. + required: false + default: "false" + + skip-cross-path: + description: Skip the Layer 2 (cross-path baseline) diff. Use only for charts that have not yet committed a cross-path baseline. + required: false + default: "false" + +runs: + using: composite + steps: + - name: Install helm + uses: azure/setup-helm@v4 + with: + version: ${{ inputs.helm-version }} + + - name: Install helm-unittest plugin + shell: bash + env: + HELM_UNITTEST_VERSION: ${{ inputs.helm-unittest-version }} + SKIP_LAYER1: ${{ inputs.skip-layer1 }} + run: | + set -euo pipefail + if [[ "$SKIP_LAYER1" == "true" ]]; then + echo "skip-layer1=true → not installing helm-unittest" + exit 0 + fi + if helm plugin list 2>/dev/null | grep -q '^unittest'; then + echo "helm-unittest already installed" + else + helm plugin install https://github.com/helm-unittest/helm-unittest.git --version "$HELM_UNITTEST_VERSION" + fi + + - name: Run goldens check + shell: bash + env: + CHART_PATH: ${{ inputs.chart-path }} + GOLDENS_PATH: ${{ inputs.goldens-path }} + SKIP_LAYER1: ${{ inputs.skip-layer1 }} + SKIP_CROSS_PATH: ${{ inputs.skip-cross-path }} + run: ${{ github.action_path }}/scripts/check.sh diff --git a/.github/actions/helm-goldens-check/scripts/check.sh b/.github/actions/helm-goldens-check/scripts/check.sh new file mode 100755 index 0000000..c8a829a --- /dev/null +++ b/.github/actions/helm-goldens-check/scripts/check.sh @@ -0,0 +1,134 @@ +#!/usr/bin/env bash +# +# helm-goldens-check — regenerate goldens for a chart and fail on any diff +# against committed snapshots. +# +# CHART_PATH=./deployments \ +# GOLDENS_PATH=./deployments/golden \ +# ./check.sh +# +# Layer 1 (within-path): +# - EKS-Helm path: delegated to `helm unittest ` when +# `/tests/` exists. helm-unittest snapshots live in +# `/tests/__snapshot__/` and are diffed automatically by the +# plugin; the snapshot files MUST be committed. +# - SetupPkg + Helm-EC2 paths: delegated to `/regenerate.sh` +# if present. The script must regenerate the per-path .env goldens in +# place; this action then re-runs `git diff` against `` and +# fails on any change. +# +# Layer 2 (cross-path baseline): +# - Delegated to `/regenerate-cross-path-baseline.sh` if +# present. Script must rewrite `/cross-path-baseline.txt` +# in place; this action then re-runs `git diff` and fails on any change. +# +# Charts that have not yet committed a regenerate hook can opt out via the +# action's `skip-cross-path` input. Charts with no `tests/` dir yet can opt +# out of helm-unittest via `skip-layer1`. +# +set -euo pipefail + +CHART_PATH="${CHART_PATH:-./deployments}" +GOLDENS_PATH="${GOLDENS_PATH:-./deployments/golden}" +SKIP_LAYER1="${SKIP_LAYER1:-false}" +SKIP_CROSS_PATH="${SKIP_CROSS_PATH:-false}" + +FAILED=0 + +fail() { + FAILED=1 + echo "::error::$1" >&2 +} + +info() { + echo "::group::$1" +} + +end() { + echo "::endgroup::" +} + +if [[ ! -d "$CHART_PATH" ]]; then + fail "Chart directory not found: $CHART_PATH" + exit 1 +fi + +# --------------------------------------------------------------------------- +# Layer 1 — EKS-Helm path via helm-unittest +# --------------------------------------------------------------------------- +info "Layer 1: helm-unittest (EKS-Helm path)" + +if [[ "$SKIP_LAYER1" == "true" ]]; then + echo "skip-layer1=true → skipping helm-unittest run" +elif [[ ! -d "$CHART_PATH/tests" ]]; then + fail "$CHART_PATH/tests/ not found — chart must commit helm-unittest tests for Layer 1, or set skip-layer1: true on the action input." +else + if ! helm unittest "$CHART_PATH"; then + fail "helm unittest reported a snapshot diff or test failure for $CHART_PATH" + fi +fi + +end + +# --------------------------------------------------------------------------- +# Layer 1 — SetupPkg + Helm-EC2 paths via per-chart regenerate hook +# --------------------------------------------------------------------------- +info "Layer 1: per-chart regenerate hook (SetupPkg + Helm-EC2 paths)" + +REGENERATE_HOOK="$GOLDENS_PATH/regenerate.sh" +if [[ -x "$REGENERATE_HOOK" ]]; then + echo "Running $REGENERATE_HOOK" + if ! bash "$REGENERATE_HOOK"; then + fail "$REGENERATE_HOOK exited non-zero — regeneration failed before diff" + else + # Anything inside that's now modified means a regression. + if ! git diff --quiet -- "$GOLDENS_PATH"; then + fail "Goldens drift in $GOLDENS_PATH after regenerate. Diff:" + git --no-pager diff --stat -- "$GOLDENS_PATH" >&2 || true + git --no-pager diff -- "$GOLDENS_PATH" >&2 || true + else + echo "No diff against committed goldens in $GOLDENS_PATH" + fi + fi +else + echo "$REGENERATE_HOOK not present — skipping per-chart Layer 1 regenerate." + echo "(Charts adopting the convention should commit this hook per runbook Step 7.)" +fi + +end + +# --------------------------------------------------------------------------- +# Layer 2 — cross-path baseline +# --------------------------------------------------------------------------- +info "Layer 2: cross-path baseline diff" + +if [[ "$SKIP_CROSS_PATH" == "true" ]]; then + echo "skip-cross-path=true → skipping cross-path baseline diff" +else + BASELINE_HOOK="$GOLDENS_PATH/regenerate-cross-path-baseline.sh" + BASELINE_FILE="$GOLDENS_PATH/cross-path-baseline.txt" + if [[ ! -f "$BASELINE_FILE" ]]; then + fail "$BASELINE_FILE not found — chart must commit a cross-path baseline (runbook Step 2), or set skip-cross-path: true." + elif [[ -x "$BASELINE_HOOK" ]]; then + echo "Running $BASELINE_HOOK" + if ! bash "$BASELINE_HOOK"; then + fail "$BASELINE_HOOK exited non-zero — baseline regeneration failed before diff" + elif ! git diff --quiet -- "$BASELINE_FILE"; then + fail "Cross-path baseline drift detected. Diff:" + git --no-pager diff -- "$BASELINE_FILE" >&2 || true + else + echo "Cross-path baseline unchanged." + fi + else + echo "$BASELINE_HOOK not present — verifying committed baseline file only (no fresh regeneration)." + fi +fi + +end + +if [[ "$FAILED" -ne 0 ]]; then + echo "::error::helm-goldens-check: one or more checks failed. See annotations above." >&2 + exit 1 +fi + +echo "helm-goldens-check: all checks passed for $CHART_PATH" diff --git a/.github/workflows/test-helm-actions.yaml b/.github/workflows/test-helm-actions.yaml new file mode 100644 index 0000000..d61b063 --- /dev/null +++ b/.github/workflows/test-helm-actions.yaml @@ -0,0 +1,66 @@ +name: Test helm-chart actions + +on: + pull_request: + paths: + - ".github/actions/helm-chart-lint/**" + - ".github/actions/helm-goldens-check/**" + - ".github/workflows/test-helm-actions.yaml" + push: + branches: [main] + paths: + - ".github/actions/helm-chart-lint/**" + - ".github/actions/helm-goldens-check/**" + +jobs: + lint-good-fixture: + name: lint passes on convention-compliant fixture + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/helm-chart-lint + with: + chart-path: .github/actions/helm-chart-lint/tests/fixtures/good-chart + + lint-bad-fixture: + name: lint fails on unmigrated fixture (smoke test — must exit non-zero) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: azure/setup-helm@v4 + with: + version: v3.16.4 + - name: Install yq (matching action default) + shell: bash + run: | + sudo curl -sSL -o /usr/local/bin/yq https://github.com/mikefarah/yq/releases/download/v4.44.3/yq_linux_amd64 + sudo chmod +x /usr/local/bin/yq + - name: Run lint and assert non-zero exit + shell: bash + env: + CHART_PATH: .github/actions/helm-chart-lint/tests/fixtures/bad-chart + RESERVED_KEYS: >- + app budget daemons envConfigMap image imagePullSecrets ingress keda + replicas resources serviceAccount serviceAccountName vault + run: | + set +e + ./.github/actions/helm-chart-lint/scripts/lint.sh + rc=$? + set -e + if [[ "$rc" -eq 0 ]]; then + echo "::error::linter unexpectedly passed against bad-chart fixture" + exit 1 + fi + echo "linter exited $rc as expected" + + goldens-good-fixture: + name: goldens-check passes on minimal good fixture (skip flags engaged) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/helm-goldens-check + with: + chart-path: .github/actions/helm-chart-lint/tests/fixtures/good-chart + goldens-path: .github/actions/helm-chart-lint/tests/fixtures/good-chart/golden + skip-layer1: "true" + skip-cross-path: "true" From 558eeeac979e2da7cfc75c4e58a1965bc96f1702 Mon Sep 17 00:00:00 2001 From: Andreas Ronneseth Date: Sun, 24 May 2026 10:48:56 -0700 Subject: [PATCH 2/3] fix(helm-chart-lint): honor `app`-implicit contract and survive empty RESERVED_KEYS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two BugBot findings on PR #127: 1. The `reserved-chart-control-keys` input is documented as "in addition to `app`", but the script treated the input as the complete allowed-key set. A caller overriding the input without restating `app` would get a self-contradicting failure (Rule 5 requires `app:`, Rule 6 then flags it as non-reserved). Prepend `app` unconditionally to RESERVED_KEYS — the prepend is idempotent vs. the default and matches the documented contract. 2. `grep -v '^$'` in two pipelines returns exit 1 when all input lines are empty. Combined with `set -euo pipefail`, that silently kills the script with no `::error::` annotation. Replace with `{ grep -v '^$' || true; }` so Rule 6 / Rule 7 fall through and fire loudly per key, which is the useful failure mode. Refs: DEVEX-1535 Co-authored-by: Cursor --- .github/actions/helm-chart-lint/scripts/lint.sh | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/.github/actions/helm-chart-lint/scripts/lint.sh b/.github/actions/helm-chart-lint/scripts/lint.sh index dc1ee89..8f89b34 100755 --- a/.github/actions/helm-chart-lint/scripts/lint.sh +++ b/.github/actions/helm-chart-lint/scripts/lint.sh @@ -16,6 +16,12 @@ set -euo pipefail CHART_PATH="${CHART_PATH:-./deployments}" RESERVED_KEYS="${RESERVED_KEYS:-app budget daemons envConfigMap image imagePullSecrets ingress keda replicas resources serviceAccount serviceAccountName vault}" +# `app` is always implicitly reserved (Rule 5 requires it; the action.yml input +# documents the per-chart list as "in addition to `app`"). Prepend defensively +# so a caller that overrides reserved-chart-control-keys without restating +# `app` does not produce a self-contradicting Rule 5 / Rule 6 failure. +RESERVED_KEYS="app ${RESERVED_KEYS}" + # Convention values-file regex (mirrors guideline §3.1): # values.yaml | values-{local,stg,qa,prod,dev-ec2,qa-ec2,prod-ec2}.yaml # plus Chart.yaml and the optional values.schema.json. @@ -143,8 +149,11 @@ end info "Rule 6: top-level keys are reserved chart-control keys" # Normalize reserved list to a newline-separated set for grep -Fxqf. +# `|| true` keeps the pipeline alive under `pipefail` when no lines survive +# the filter (e.g. caller passed an empty RESERVED_KEYS) — Rule 6 then fires +# loudly on every top-level key instead of the script dying silently. RESERVED_FILE="$(mktemp)" -echo "$RESERVED_KEYS" | tr -s '[:space:]' '\n' | grep -v '^$' | sort -u > "$RESERVED_FILE" +echo "$RESERVED_KEYS" | tr -s '[:space:]' '\n' | { grep -v '^$' || true; } | sort -u > "$RESERVED_FILE" while IFS= read -r -d '' f; do # Top-level keys only. yq returns one key per line. @@ -174,7 +183,7 @@ if [[ -d "$CHART_PATH/templates" ]]; then # Scan every templates/*.yaml (and .tpl) for `if .Values.X` or `with .Values.X` # where X is the first dotted segment. RESERVED_LIST="$(mktemp)" - echo "$RESERVED_KEYS" | tr -s '[:space:]' '\n' | grep -v '^$' > "$RESERVED_LIST" + echo "$RESERVED_KEYS" | tr -s '[:space:]' '\n' | { grep -v '^$' || true; } > "$RESERVED_LIST" while IFS= read -r -d '' tmpl; do # grep returns lines like LINE:CONTENT (single file, no filename prefix). From c4efbbe372d5e40bdb687e70edc476a0a6debf8b Mon Sep 17 00:00:00 2001 From: Andreas Ronneseth Date: Sun, 24 May 2026 11:03:19 -0700 Subject: [PATCH 3/3] fix(helm-actions): catch `else if` render gates; pin helm-unittest to real version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two BugBot findings on PR #127 commit 558eeea: 1. (Medium) Rule 7's regex only matched `{{ if .Values.X }}` and `{{ with .Values.X }}` — `{{- else if .Values.X }}` is a standard Go-template/Helm pattern that silently bypassed the check. Same gap in the inner extraction regex. Extend both regexes to also match an optional `else ` prefix so `else if` and `else with` are caught. 2. (High) `helm-unittest-version` default was `0.6.2` — that tag never existed (the 0.6.x series only released v0.6.0), and the value was missing the `v` prefix helm-unittest's git tags use. The bug was masked in CI because every existing goldens-check job used `skip-layer1: true`. Pin to `v1.0.3` (mature stable line, released 2025-10-05). Regression guards: * bad-chart fixture's env-file.yaml now contains an `else if .Values.aws` branch. The lint-bad-fixture CI job asserts the `.Values.aws` annotation appears in lint output — if the Rule 7 regex narrows again, CI fails. * good-chart fixture ships a minimal `tests/deployment_test.yaml`. A new goldens-installs-helm-unittest job runs the action with `skip-layer1: false`, exercising the full plugin install + run path. If the default version ever points at a non-existent tag again, `helm plugin install --version ` fails fast in CI. Refs: DEVEX-1535 Co-authored-by: Cursor --- .../actions/helm-chart-lint/scripts/lint.sh | 15 ++++++--- .../bad-chart/templates/env-file.yaml | 7 ++++ .../good-chart/tests/deployment_test.yaml | 22 +++++++++++++ .github/actions/helm-goldens-check/action.yml | 4 +-- .github/workflows/test-helm-actions.yaml | 33 +++++++++++++++++-- 5 files changed, 72 insertions(+), 9 deletions(-) create mode 100644 .github/actions/helm-chart-lint/tests/fixtures/good-chart/tests/deployment_test.yaml diff --git a/.github/actions/helm-chart-lint/scripts/lint.sh b/.github/actions/helm-chart-lint/scripts/lint.sh index 8f89b34..099b066 100755 --- a/.github/actions/helm-chart-lint/scripts/lint.sh +++ b/.github/actions/helm-chart-lint/scripts/lint.sh @@ -185,15 +185,22 @@ if [[ -d "$CHART_PATH/templates" ]]; then RESERVED_LIST="$(mktemp)" echo "$RESERVED_KEYS" | tr -s '[:space:]' '\n' | { grep -v '^$' || true; } > "$RESERVED_LIST" + # Match `{{ if .Values.X }}`, `{{ with .Values.X }}`, `{{ else if .Values.X }}`, + # and `{{ else with .Values.X }}` — all four are render-gate forms whose root + # key must be in the reserved chart-control set. `else` alone (no following + # if/with) has no condition and is intentionally not matched. + DIRECTIVE_RE='(else[[:space:]]+)?(if|with)[[:space:]]+(\(?[[:space:]]*not[[:space:]]+)?\.Values\.' + LINE_RE="\\{\\{-?[[:space:]]*${DIRECTIVE_RE}" + while IFS= read -r -d '' tmpl; do # grep returns lines like LINE:CONTENT (single file, no filename prefix). while IFS= read -r hit; do [[ -z "$hit" ]] && continue line="${hit%%:*}" content="${hit#*:}" - # Extract just the matched root key. Look for the first dotted - # segment after `.Values.` in an `if` or `with` Go-template directive. - root="$(echo "$content" | grep -oE '(if|with)[[:space:]]+(\(?[[:space:]]*not[[:space:]]+)?\.Values\.[A-Za-z_][A-Za-z0-9_]*' \ + # Extract just the matched root key — the first dotted segment after + # `.Values.` in the directive. + root="$(echo "$content" | grep -oE "${DIRECTIVE_RE}[A-Za-z_][A-Za-z0-9_]*" \ | head -n1 \ | grep -oE '\.Values\.[A-Za-z_][A-Za-z0-9_]*' \ | sed 's/^\.Values\.//')" @@ -203,7 +210,7 @@ if [[ -d "$CHART_PATH/templates" ]]; then if ! grep -Fxq "$root" "$RESERVED_LIST"; then fail "${tmpl}:${line}: render gate \`.Values.${root}\` is not an approved chart-control key. Use a per-resource \`..enabled\` flag (e.g. \`.envConfigMap.enabled\`)." fi - done < <(grep -nE '\{\{-?[[:space:]]*(if|with)[[:space:]]+(\(?[[:space:]]*not[[:space:]]+)?\.Values\.' "$tmpl" || true) + done < <(grep -nE "$LINE_RE" "$tmpl" || true) done < <(find "$CHART_PATH/templates" -type f \( -name '*.yaml' -o -name '*.tpl' \) -print0) rm -f "$RESERVED_LIST" diff --git a/.github/actions/helm-chart-lint/tests/fixtures/bad-chart/templates/env-file.yaml b/.github/actions/helm-chart-lint/tests/fixtures/bad-chart/templates/env-file.yaml index ee185d5..8cc3720 100644 --- a/.github/actions/helm-chart-lint/tests/fixtures/bad-chart/templates/env-file.yaml +++ b/.github/actions/helm-chart-lint/tests/fixtures/bad-chart/templates/env-file.yaml @@ -7,4 +7,11 @@ data: content: | APP_BASE_PATH="{{ .Values.ec2.appBasePath }}" VPC_HOST="{{ .Values.topo.vpc_host }}" +{{- else if .Values.aws }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ .Chart.Name }}-env-cloud +data: + region: "{{ .Values.aws.region }}" {{- end }} diff --git a/.github/actions/helm-chart-lint/tests/fixtures/good-chart/tests/deployment_test.yaml b/.github/actions/helm-chart-lint/tests/fixtures/good-chart/tests/deployment_test.yaml new file mode 100644 index 0000000..97e7860 --- /dev/null +++ b/.github/actions/helm-chart-lint/tests/fixtures/good-chart/tests/deployment_test.yaml @@ -0,0 +1,22 @@ +suite: deployment smoke-test +templates: + - deployment.yaml +tests: + - it: renders a Deployment named after the chart + asserts: + - isKind: + of: Deployment + - equal: + path: metadata.name + value: good-chart + + - it: renders one replica by default and sets each .Values.app key as an env var + asserts: + - equal: + path: spec.replicas + value: 1 + - contains: + path: spec.template.spec.containers[0].env + content: + name: LOG_LEVEL + value: info diff --git a/.github/actions/helm-goldens-check/action.yml b/.github/actions/helm-goldens-check/action.yml index 121cd55..1511ea9 100644 --- a/.github/actions/helm-goldens-check/action.yml +++ b/.github/actions/helm-goldens-check/action.yml @@ -24,9 +24,9 @@ inputs: default: v3.16.4 helm-unittest-version: - description: helm-unittest plugin version. + description: helm-unittest plugin version (must match a published tag at https://github.com/helm-unittest/helm-unittest/releases, including the `v` prefix). required: false - default: 0.6.2 + default: v1.0.3 skip-layer1: description: Skip the Layer 1 (within-path) helm-unittest run. Use only for charts that have no `tests/` dir yet. diff --git a/.github/workflows/test-helm-actions.yaml b/.github/workflows/test-helm-actions.yaml index d61b063..9ae584e 100644 --- a/.github/workflows/test-helm-actions.yaml +++ b/.github/workflows/test-helm-actions.yaml @@ -35,7 +35,7 @@ jobs: run: | sudo curl -sSL -o /usr/local/bin/yq https://github.com/mikefarah/yq/releases/download/v4.44.3/yq_linux_amd64 sudo chmod +x /usr/local/bin/yq - - name: Run lint and assert non-zero exit + - name: Run lint and assert expected violations shell: bash env: CHART_PATH: .github/actions/helm-chart-lint/tests/fixtures/bad-chart @@ -44,14 +44,24 @@ jobs: replicas resources serviceAccount serviceAccountName vault run: | set +e - ./.github/actions/helm-chart-lint/scripts/lint.sh + out="$(./.github/actions/helm-chart-lint/scripts/lint.sh 2>&1)" rc=$? set -e + echo "$out" if [[ "$rc" -eq 0 ]]; then echo "::error::linter unexpectedly passed against bad-chart fixture" exit 1 fi - echo "linter exited $rc as expected" + # Regression guard: Rule 7 must catch `else if .Values.`. + # The bad-chart fixture's env-file.yaml contains: + # {{- else if .Values.aws }} + # If this annotation disappears, the Rule 7 regex has narrowed + # back to ignoring `else if` (BugBot finding, May 2026). + if ! grep -q "render gate \`.Values.aws\`" <<< "$out"; then + echo "::error::Rule 7 regression: linter did not catch \`else if .Values.aws\` in bad-chart fixture" + exit 1 + fi + echo "linter exited $rc and caught the expected \`else if\` violation" goldens-good-fixture: name: goldens-check passes on minimal good fixture (skip flags engaged) @@ -64,3 +74,20 @@ jobs: goldens-path: .github/actions/helm-chart-lint/tests/fixtures/good-chart/golden skip-layer1: "true" skip-cross-path: "true" + + goldens-installs-helm-unittest: + # Exercises the full Layer-1 install + run path against a fixture that + # ships a tests/ dir. This is the regression guard for the helm-unittest + # version pin (BugBot finding, May 2026) — if the default ever points at a + # non-existent tag again, this job's `helm plugin install --version ` + # step fails fast instead of being silently masked by skip-layer1. + name: goldens-check installs + runs helm-unittest end-to-end + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/helm-goldens-check + with: + chart-path: .github/actions/helm-chart-lint/tests/fixtures/good-chart + goldens-path: .github/actions/helm-chart-lint/tests/fixtures/good-chart/golden + skip-layer1: "false" + skip-cross-path: "true"