Files
helm-charts/.github/workflows/validate-charts.yml
jklein 6a3b05503e
Validate Helm Charts / Detect changed charts (pull_request) Successful in 3s
Validate Helm Charts / Build ${{ fromJSON(needs.detect-changed-charts.outputs.charts) }} (pull_request) Skipped
Validate Helm Charts / Install-test (pull_request) Skipped
Validate Helm Charts / Resource quantities (pull_request) Successful in 10s
Validate Helm Charts / Trivy config ${{ fromJSON(needs.detect-changed-charts.outputs.scan-charts) }} (pull_request) Successful in 21s
Make the all-charts install-test repeatable, as one shared job
The install-test matrix follows the charts a pull request changes, so a
chart nobody has touched since the forge move carries a green history from
a different runner - and that green says nothing about today. Gitea PR #48
showed what the answer is worth: correcting a comment in 21 charts made
every one of them "changed" and turned the run into the first all-charts
install-test after the move, 19 of 20 matrix charts Ready in 12m41s and
exactly one not (rss-bridge, Gitea #51). By accident.

The install-test job moves into the reusable .github/workflows/
install-test.yml and is called twice: by validate-charts.yml with the
charts a pull request changed, and by the new install-test-all-charts.yml
with all of them. Not copied for the second caller - a second copy is a
second chance to test differently, unnoticed, which is the argument that
gives ci/excluded-charts.txt exactly one parser. The parts that would
drift are the ones that were paid for once already: the pinned kubectl
with its skew check, envsubst, the containerised-job detection, the
per-leg cluster name.

Nothing about how a chart is installed changed. The moved job differs from
its predecessor in the matrix source (inputs.charts instead of the detect
job's output), an id on the install step, and a step that records the
result; every other step is byte-identical.

The all-charts run is workflow_dispatch only - a schedule would spend
compute every day on a question that changes rarely - with an optional
subset input, which is what lets a single chart be tested against the
runner as Gitea #51 needs. One throwaway cluster per chart, no reuse:
reuse saves time and changes what the test proves. An unknown or excluded
chart name, and an empty matrix, are loud errors rather than a green run
over nothing.

Every leg writes its own outcome into the job summary next to the four
facts that decide what the outcome means - date, runner label, K3S_IMAGE,
kubectl version - and a final job records the verdict over the matrix. A
green run nobody notes is unknown again next time. The excluded charts
appear with their justification, from the one parser, as in the pull
request pipeline. 20 charts in 12m41s is written down as the expectation,
so a later deviation is visible as one.

Fixes #52
2026-09-27 14:30:39 +00:00

258 lines
13 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@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@v7
with:
fetch-depth: 0
- name: "Install Helm"
uses: azure/setup-helm@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@v7
- name: "Install Helm"
uses: azure/setup-helm@v5
- name: "Render every chart with bare integer quantities"
run: ci/run-quantity-render-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.74.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@v7
- name: "Install Helm"
uses: azure/setup-helm@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