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..099b066 --- /dev/null +++ b/.github/actions/helm-chart-lint/scripts/lint.sh @@ -0,0 +1,226 @@ +#!/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}" + +# `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. +# 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. +# `|| 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 '^$' || true; } | 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 '^$' || 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 — 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\.//')" + 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 "$LINE_RE" "$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..8cc3720 --- /dev/null +++ b/.github/actions/helm-chart-lint/tests/fixtures/bad-chart/templates/env-file.yaml @@ -0,0 +1,17 @@ +{{- 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 }}" +{{- 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/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/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-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..1511ea9 --- /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 (must match a published tag at https://github.com/helm-unittest/helm-unittest/releases, including the `v` prefix). + required: false + 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. + 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..9ae584e --- /dev/null +++ b/.github/workflows/test-helm-actions.yaml @@ -0,0 +1,93 @@ +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 expected violations + 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 + 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 + # 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) + 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" + + 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"