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 "", 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. # # And one assertion that spans TWO charts (Gitea #136), the only one here # that does: where a chart dials the Service of another chart of this # repository, its EGRESS rule must contain that chart's POD ports. kube-proxy # does the ClusterIP's destination NAT before the policy is evaluated, so a # rule naming the SERVICE port matches nothing wherever port and targetPort # differ - paperless-ngx shipped exactly that and lost Tika and Gotenberg in # production on 2026-10-15. The pairs are an explicit table in the script, # because no render can discover which Service another chart dials, and the # row is checked with the feature gate on AND off. # # 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-.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