Files
helm-charts/.github/workflows/validate-charts.yml
T
jklein 36bcfd3b11 feat(ci): assert every rendered NetworkPolicy against its own render
The part that carries the rollout of Gitea #5. 29 charts get a policy that is
on by default, and every way one of them can be wrong is SILENT: the API server
accepts a policy that filters nothing just as readily as one that filters
everything, and neither is a render error, so neither the build nor a rollout
sees it.

ci/run-networkpolicy-test.sh holds every rendered NetworkPolicy against the pod
specs, probes and Services of the SAME render:

  1. the podSelector really matches a pod of that render - otherwise the object
     sits in the namespace, is listed by kubectl, reads as a boundary and
     filters nothing, usually because a label was renamed in another file;
  2. every INGRESS port is a containerPort of the selected pods, which catches
     the NodePort mistake mechanically. Egress ports are deliberately excluded:
     an egress port belongs to the DESTINATION, and comparing it would fail
     every chart that reaches a database;
  3. no ingress rule allows every source by accident. "from: []" always fails -
     it allows everything while reading as a restriction. A MISSING "from" is
     the documented open form and is accepted only where the render justifies
     it: every port of the rule is addressed by a network probe, or published by
     a NodePort Service. That is stricter than the plan's wording, which banned
     both forms, and it had to be, because the precedents use the second one on
     purpose;
  4. the probe path. For every port a NETWORK probe addresses, the policy either
     leaves it unrestricted or the chart offers networkPolicy.probeSources AND
     says in NOTES.txt that a list missing the node's IP leaves the pod
     permanently un-Ready. exec probes are excluded by design - they run inside
     the container and put no packet on the network, which is exactly why
     valkey and minecraft-server can restrict their port by source with no
     probeSources at all.

Two guards beyond the four: a chart that renders NO policy fails, because that
is how this issue decayed in the first place - garage and keycloak were both
created after #5 and both arrived without one. And a "#" comment inside a
rendered policy fails, because it is part of the object and is shipped to the
cluster.

Helm is the parser through ci/yaml2json, as in the other render tests: the
runners have neither pyyaml nor yq, and a hand-written parser could read
nothing, find nothing and leave this green over a chart it never looked at.

Every failure path was run, not just the green ones, and one of them earned its
keep immediately: the probeSources check was a substring test, so a chart whose
key had been deleted while the comment above it still discussed the value
passed. It now looks for the key.

Part of #5
2026-10-14 13:56:06 +00:00

434 lines
23 KiB
YAML

name: Validate Helm Charts
on:
pull_request:
jobs:
detect-changed-charts:
name: Detect changed charts
runs-on: ubuntu-latest
outputs:
charts: ${{ steps.detect.outputs.charts }}
any-chart-changed: ${{ steps.detect.outputs.any-chart-changed }}
scan-charts: ${{ steps.detect.outputs.scan-charts }}
any-scan: ${{ steps.detect.outputs.any-scan }}
install-charts: ${{ steps.detect.outputs.install-charts }}
any-install: ${{ steps.detect.outputs.any-install }}
steps:
- name: "Checkout Repository"
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
- name: "Detect changed charts"
id: detect
run: |
CHANGED_FILES=$(git diff --name-only "origin/${{ github.base_ref }}...HEAD")
CHARTS="[]"
CHANGED_DIRS=$(echo "$CHANGED_FILES" | cut -d/ -f1 | sort -u)
for DIR in $CHANGED_DIRS; do
if [ -f "$DIR/Chart.yaml" ]; then
CHARTS=$(echo "$CHARTS" | jq -c --arg dir "$DIR" '. + [$dir]')
fi
done
echo "charts=$CHARTS" >> "$GITHUB_OUTPUT"
if [ "$CHARTS" = "[]" ]; then
echo "any-chart-changed=false" >> "$GITHUB_OUTPUT"
echo "No chart directories changed - nothing to build."
else
echo "any-chart-changed=true" >> "$GITHUB_OUTPUT"
echo "Changed charts: $CHARTS"
fi
# The Trivy scan normally follows the changed charts. But a PR that
# changes the scanner itself touches no chart at all, and would leave
# the change untested. In that case scan a fixed sample instead, one
# chart per interesting case: a documented deviation (samba), a fully
# hardened one (cyberchef), and one with two workloads
# (matrix-synapse).
SCAN_CHARTS="$CHARTS"
if [ "$CHARTS" = "[]" ] && echo "$CHANGED_FILES" | grep -qE '^(ci/(run-trivy-config\.sh|trivy/)|\.github/workflows/validate-charts\.yml)'; then
SCAN_CHARTS='["samba","cyberchef","matrix-synapse"]'
echo "Scanner changed without a chart change - scanning the sample: $SCAN_CHARTS"
fi
echo "scan-charts=$SCAN_CHARTS" >> "$GITHUB_OUTPUT"
if [ "$SCAN_CHARTS" = "[]" ]; then
echo "any-scan=false" >> "$GITHUB_OUTPUT"
else
echo "any-scan=true" >> "$GITHUB_OUTPUT"
fi
# --- The install-test matrix: changed charts minus the excluded ones
# (issue #231). A chart in ci/excluded-charts.txt used to get a job
# that ran the script, printed SKIP and exited 0 - and GitHub knows
# only the exit code, so the check list showed a green tick for a
# chart nothing was ever attempted on. Dropping it here means no job
# exists for it and nothing claims it passed.
#
# The list is NOT parsed here. ci/run-install-test.sh --list-excluded
# emits "<chart><TAB><reason>", and that script is the one parser;
# re-implementing the format in YAML would be a second chance to
# select different charts, silently. The comparison below is jq's
# "!=" on whole strings, so a chart name is never read as a pattern.
INSTALL_CHARTS="$CHARTS"
SUMMARY_ROWS=""
while IFS=$'\t' read -r EX_NAME EX_REASON; do
[ -n "$EX_NAME" ] || continue
if [ "$(echo "$INSTALL_CHARTS" | jq -r --arg c "$EX_NAME" 'index($c) != null')" = "true" ]; then
# A "|" in the justification would break out of the table cell.
SUMMARY_ROWS="${SUMMARY_ROWS}| \`$EX_NAME\` | ${EX_REASON//|/\\|} |"$'\n'
echo "Excluded from the install-test: $EX_NAME - $EX_REASON"
fi
INSTALL_CHARTS="$(echo "$INSTALL_CHARTS" | jq -c --arg c "$EX_NAME" 'map(select(. != $c))')"
done <<< "$(ci/run-install-test.sh --list-excluded)"
echo "install-charts=$INSTALL_CHARTS" >> "$GITHUB_OUTPUT"
if [ "$INSTALL_CHARTS" = "[]" ]; then
echo "any-install=false" >> "$GITHUB_OUTPUT"
else
echo "any-install=true" >> "$GITHUB_OUTPUT"
fi
# An absent job says "not run" but never why, and an absence is easy
# to miss entirely. So every excluded chart of this PR is named here
# WITH its documented justification - which is the question a review
# actually asks. This summary is the visible half of the change; the
# dropped matrix entry is only the half that stops the lie.
if [ -n "$SUMMARY_ROWS" ]; then
{
echo "### Charts excluded from the install-test"
echo
echo "These charts are changed by this PR but listed in \`ci/excluded-charts.txt\`, so they get **no** install-test job. There is no green tick for them, because nothing was attempted (issue #231)."
echo
echo "| Chart | Documented reason |"
echo "| --- | --- |"
printf '%s' "$SUMMARY_ROWS"
echo
} >> "$GITHUB_STEP_SUMMARY"
fi
echo "Install-test matrix: $INSTALL_CHARTS"
build-chart:
name: "Build ${{ matrix.chart }}"
needs: detect-changed-charts
if: needs.detect-changed-charts.outputs.any-chart-changed == 'true'
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
chart: ${{ fromJSON(needs.detect-changed-charts.outputs.charts) }}
steps:
- name: "Checkout Repository"
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
- name: "Install Helm"
uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 # v5
# Both steps below refuse to run while a chart declares a dependency that
# is not in its charts/ directory - lint with "[WARNING] chart directory
# is missing these dependencies", package with "found in Chart.yaml, but
# missing in charts/ directory", both exiting 1. Charts without a
# dependencies: block are skipped without a word, so this costs nothing
# for the rest of the repo. See ci/helm-dependencies.sh for why it is
# "update" and not "build".
- name: "Fetch chart dependencies"
run: ci/helm-dependencies.sh "${{ matrix.chart }}"
- name: "Lint Helm Chart"
run: helm lint --strict "${{ matrix.chart }}"
- name: "Package Helm Chart"
run: helm package "${{ matrix.chart }}"
- name: "Check Chart Version was bumped"
# template-chart is a scaffold and never published, its version is meaningless
if: matrix.chart != 'template-chart'
run: |
BASE_VERSION=$(git show "origin/${{ github.base_ref }}:${{ matrix.chart }}/Chart.yaml" 2>/dev/null | grep '^version:' | awk '{print $2}' || true)
NEW_VERSION=$(grep '^version:' "${{ matrix.chart }}/Chart.yaml" | awk '{print $2}')
if [ -z "$BASE_VERSION" ]; then
echo "New chart (does not exist on ${{ github.base_ref }}), version: $NEW_VERSION"
elif [ "$BASE_VERSION" = "$NEW_VERSION" ]; then
echo "::error file=${{ matrix.chart }}/Chart.yaml::Chart '${{ matrix.chart }}' was modified but its version was not bumped (still $BASE_VERSION). Bump 'version:' in Chart.yaml, otherwise the publish on main will collide with the already published version."
exit 1
else
echo "Version bump OK: $BASE_VERSION -> $NEW_VERSION"
fi
quantity-render:
name: "Resource quantities"
runs-on: ubuntu-latest
# The one job in this workflow that deliberately ignores the changed-chart
# matrix and runs over *every* chart (issue #157). The bug it guards
# against - resource quantities formatted with printf "%v", where float64
# flips to exponential notation above ~1e6 - lives in the resources helper,
# and that helper is copied into every chart's templates/_helpers.tpl,
# because sharing it would mean a library chart (README, guideline 14). So
# it comes back by copy: a new chart started from an older one, or from
# template-chart before the fix.
# In that PR the chart is new and its copy of the bug is in no diff anyone
# reads. Running over everything also means there is no list to maintain -
# adding a chart enrolls it. Two renders per chart, no cluster, a few
# seconds in total; it depends on no other job and finishes long before
# build-chart, so it adds no wall-clock time to the pipeline.
steps:
- name: "Checkout Repository"
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- name: "Install Helm"
uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 # v5
- name: "Render every chart with bare integer quantities"
run: ci/run-quantity-render-test.sh
selector-match:
name: "Selector match"
runs-on: ubuntu-latest
# The second job that ignores the changed-chart matrix and runs over *every*
# chart (Gitea #96), and for the same reason as quantity-render above: the
# mistake arrives by copy into a chart whose own diff nobody reads for it.
#
# What it asserts: every PodDisruptionBudget selector and every
# topologySpreadConstraints labelSelector a chart renders really matches the
# labels of a pod template in the SAME render. Both objects fail SILENTLY
# when their selector matches nothing - the spread constraint becomes a
# condition over zero pods and is trivially satisfied, the budget does its
# arithmetic over zero pods and never blocks an eviction - so neither a
# render error nor the install-test can catch it, and with
# whenUnsatisfiable: DoNotSchedule the object reads like a guarantee while
# distributing nothing.
#
# It needs no cluster and no Trivy: ~14 seconds for the whole repository,
# depends on no other job, and finishes well inside build-chart. The YAML is
# parsed by Helm itself through ci/yaml2json, because the runners have
# neither pyyaml nor yq and a hand-written parser would be able to read
# nothing and report nothing - see the script's header.
steps:
- name: "Checkout Repository"
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- name: "Install Helm"
uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 # v5
- name: "Check every rendered selector against the rendered pod labels"
run: ci/run-selector-match-test.sh
httproute-match:
name: "HTTPRoute match"
runs-on: ubuntu-latest
# The third job that ignores the changed-chart matrix and runs over *every*
# chart (Gitea #114), and for the same reason as the two above: the mistake
# arrives by copy into a chart whose own diff nobody reads for it - and this
# convention is about to be copied into 20 more charts when the sweep runs.
#
# What it asserts: every HTTPRoute a chart renders names a Service, on a
# port, that the SAME render creates, and its parentRefs is not empty. Both
# failures are SILENT and neither is reachable from the other checks:
# parentRefs: [] attaches the route to no Gateway (created, schema-valid,
# routes nothing, no event and no condition), and a backendRef is resolved
# by name with nothing validating that the name exists. The real Gateway-API
# CRDs in the install-test prove the object is WELL-FORMED; they accept both
# of these, because an empty list and a dangling name are valid YAML
# against that schema.
#
# What is NOT provable anywhere, and is written down rather than implied: no
# Gateway controller runs in CI, so no route is ever reconciled. That
# traffic flows is not part of this pipeline's claims.
#
# No cluster and no Trivy; ~3 seconds for the whole repository, depends on
# no other job. The YAML is parsed by Helm through ci/yaml2json, because the
# runners have neither pyyaml nor yq and a hand-written parser could read
# nothing and report nothing - see the script's header.
steps:
- name: "Checkout Repository"
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- name: "Install Helm"
uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 # v5
- name: "Check every rendered HTTPRoute against the rendered Services"
run: ci/run-httproute-match-test.sh
bump-level:
name: "Bump level"
runs-on: ubuntu-latest
# The fourth job that ignores the changed-chart matrix, and the only one
# that touches no chart at all: it tests ci/renovate-bump-chart.sh, the
# postUpgradeTasks hook that computes a chart's `version:` after Renovate
# moved an app version (Gitea #128).
#
# Why it is a job rather than a review: since #128 the hook takes the bump
# level from Renovate's update type, so an app minor becomes a chart minor.
# That makes it deciding logic, and its failure mode is silence - a wrong
# level is a VALID version string, so the PR goes green, the chart
# publishes, and the only symptom is a number that understates what it
# ships. Nothing else in this pipeline reads that number.
#
# It needs no Helm, no cluster and no Python: each case is a throwaway
# `git init` under mktemp with one commit plus a dirty Chart.yaml - the
# state the hook really runs in, since it reads the base commit - and the
# assertion is one `version:` line read back with sed. About a second for
# all 49 cases, and it depends on no other job.
steps:
- name: "Checkout Repository"
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- name: "Check every bump level of the version-bump hook"
run: ci/run-bump-level-test.sh
yaml-scalar:
name: "YAML scalars"
runs-on: ubuntu-latest
# The fifth job that ignores the changed-chart matrix, and the fourth that
# runs over *every* chart (Gitea #11), for the same reason as the three
# render tests above: the unquoted
# form arrives by copy into a chart whose own diff nobody reads for it, and
# it is in seven charts right now.
#
# What it asserts: every CronJob schedule, every env[].value and every label
# and annotation value a chart renders is a STRING, which is what Kubernetes
# types all three as. A value rendered without | quote fails in one of two
# ways and the quiet one is the dangerous one: the render aborts with a YAML
# error naming the TEMPLATE while the operator's value appears nowhere in
# it, or - when the content is a legal plain scalar that simply is not a
# string - the render succeeds and the API server rejects the object at
# apply time with "cannot unmarshal bool into Go struct field
# EnvVar.value of type string".
#
# WHY THIS JOB EXISTS AT ALL, rather than the install-test covering it: the
# object that carried the defect is rendered by no fixture. paperless-ngx'
# consume CronJob sits behind smbConnection.enabled, false in the defaults
# and in every committed fixture, so nothing in this repository ever
# produced it - which is how an unquoted schedule shipped for months. The
# overlays under ci/yaml-scalar/ switch such objects on and feed them
# hostile values, and the script fails when an overlay stops reaching its
# object.
#
# No cluster and no Trivy; the whole repository in well under a minute,
# depends on no other job. The YAML is parsed by Helm through ci/yaml2json,
# because the runners have neither pyyaml nor yq and a hand-written parser
# could read nothing and report nothing - see the script's header.
steps:
- name: "Checkout Repository"
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- name: "Install Helm"
uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 # v5
- name: "Check every rendered scalar that must be a string"
run: ci/run-yaml-scalar-render-test.sh
networkpolicy:
name: "NetworkPolicy"
runs-on: ubuntu-latest
# The sixth job that ignores the changed-chart matrix and the fifth that runs
# over *every* chart (Gitea #5), for the same reason as the render tests
# above: the mistake arrives by copy into a chart whose own diff nobody reads
# for it - and since #5 this convention is in all 29 installable charts, so
# there is a lot of copy.
#
# What it asserts, on every NetworkPolicy a chart renders, against the pod
# specs, probes and Services of the SAME render: the podSelector really
# matches a pod; every ingress port is a containerPort of those pods; no
# ingress rule allows every source by accident; and every port a NETWORK
# probe addresses is either left unrestricted or comes with
# networkPolicy.probeSources and the consequence written into NOTES.txt.
# Plus two guards of its own: a chart that renders NO policy fails, and a
# rendered "#" comment fails.
#
# Every one of those failures is SILENT. The API server accepts a policy
# whose selector matches nothing - it then sits in the namespace, is listed
# by kubectl, reads as a boundary and filters exactly nothing - just as
# readily as it accepts one naming a NodePort number, which matches no
# traffic at all because the policy is evaluated at the pod's interface
# after kube-proxy has already translated the port. Neither is a render
# error and neither denies anything, so neither the build nor a rollout can
# see it.
#
# It does NOT replace the install-test, which is the real net under this
# convention: that one proves ENFORCEMENT on a live k3s with its policy
# controller running, and it is what makes "all charts at once, default on"
# defensible. This job is the cheap half, and it covers the two charts the
# install-test cannot reach at all - satisfactory and template-chart, both
# in ci/excluded-charts.txt.
#
# No cluster and no Trivy; a few seconds for the whole repository, depends
# on no other job. The YAML is parsed by Helm through ci/yaml2json, because
# the runners have neither pyyaml nor yq and a hand-written parser could read
# nothing and report nothing - see the script's header.
steps:
- name: "Checkout Repository"
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- name: "Install Helm"
uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 # v5
- name: "Check every rendered NetworkPolicy against the render it belongs to"
run: ci/run-networkpolicy-test.sh
trivy-config:
name: "Trivy config ${{ matrix.chart }}"
needs: detect-changed-charts
if: needs.detect-changed-charts.outputs.any-scan == 'true'
runs-on: ubuntu-latest
# Reporting only (issue #84): the scan renders each changed chart and lists
# what `trivy config` finds, but it never fails the job - see
# TRIVY_CONFIG_MODE below. Deliberately not a gate yet: the repo has several
# documented deviations, and a check that goes red on those every time is one
# people learn to scroll past. Depends only on detect-changed-charts, so it
# runs beside build-chart and adds no wall-clock time to the pipeline.
strategy:
fail-fast: false
matrix:
chart: ${{ fromJSON(needs.detect-changed-charts.outputs.scan-charts) }}
env:
# renovate: datasource=github-releases depName=aquasecurity/trivy
TRIVY_VERSION: v0.75.0
# "report" always exits 0. Switch to "gate" to make a finding that no
# documented exception covers fail the job - the intended step once the
# seccomp gap (#84) is closed.
TRIVY_CONFIG_MODE: report
steps:
- name: "Checkout Repository"
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- name: "Install Helm"
uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 # v5
# A plain release download rather than aquasecurity/setup-trivy, and the
# reason is measured on the Gitea probe PR of #225, not assumed. That
# action runs actions/checkout against aquasecurity/trivy on github.com
# and hands it `github.token` - which on Gitea is a GITEA token.
# github.com answers 401, checkout retries twice and the job ends on
# "fatal: could not read Username for 'https://github.com': terminal
# prompts disabled", an error naming neither the action nor the token.
# Its actions/cache step is inert on the instance as well ("Cache action
# is only supported on GHES version >= 3.5"), and fetching the nested
# actions cost 4m30s before the job even started. One tarball behaves
# identically on both forges, and the `# renovate:` comment above still
# keeps TRIVY_VERSION current - verified by dry run: pinned to v0.72.0
# the lookup offers v0.74.0 as a minor update.
- name: "Install Trivy"
run: |
curl -sSfL -o /tmp/trivy.tar.gz \
"https://github.com/aquasecurity/trivy/releases/download/${TRIVY_VERSION}/trivy_${TRIVY_VERSION#v}_Linux-64bit.tar.gz"
mkdir -p "$HOME/.local/bin"
tar -xzf /tmp/trivy.tar.gz -C "$HOME/.local/bin" trivy
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
"$HOME/.local/bin/trivy" --version
# Findings land in the job summary, not only in the log: exceptions with
# their reasons come from ci/trivy/trivyignore.yaml, the trusted-registry
# list from ci/trivy/data/ksv0125.yaml.
- name: "Scan rendered manifests"
run: ci/run-trivy-config.sh "${{ matrix.chart }}"
install-test:
name: "Install-test"
needs: [detect-changed-charts, build-chart]
if: needs.detect-changed-charts.outputs.any-install == 'true'
# The job itself lives in .github/workflows/install-test.yml and is called
# from here and from install-test-all-charts.yml (Gitea #52). Everything
# that decides HOW a chart is installed moved there unchanged - the runner
# label expression, the pinned kubectl with its skew check, envsubst, the
# containerised-job detection, the per-leg cluster name, the freesailarr VPN
# credentials. What stayed here is WHICH charts are tested and whether the
# job runs at all. A copied job would be a second chance to test
# differently, unnoticed, which is the same argument that gives
# ci/excluded-charts.txt exactly one parser.
#
# "install-charts", not "charts": the changed charts minus the ones in
# ci/excluded-charts.txt, filtered in detect-changed-charts, which also
# names every excluded chart of this pull request with its documented
# reason in its job summary (issue #231). The called workflow's matrix
# comment carries the rest of that lesson, including why the decision
# cannot be made per matrix leg instead.
uses: ./.github/workflows/install-test.yml
with:
charts: ${{ needs.detect-changed-charts.outputs.install-charts }}
# As the 24 publish-<chart>.yml hulls do, and measured to work on this
# instance (README, "Where this repo lives"). The called workflow declares
# exactly one secret, OP_CONNECT_TOKEN, and only its freesailarr leg reads
# it.
secrets: inherit