Files
helm-charts/.github/workflows/validate-charts.yml
T
jklein a6c27ebef3 Prepare the Gitea move: measured CI deviations, self-hosted Renovate, issue migration script
Groundwork for #225, built on a throwaway pull request against the Gitea
instance rather than on assumptions. The move itself is not part of this
change - no Actions are switched off, no mirror is enabled, no branch
protection is touched.

What the probe run measured, and what follows from it:

- Which jobs need a Docker socket: only the install-test. Measured per
  job - detect-changed-charts, build-chart, the quantity render and the
  Trivy config scan all pass on `ubuntu-latest`, which on that instance
  has no socket. The install-test dies there at "Create k3d cluster"
  with "Cannot connect to the Docker daemon", an error naming k3d and
  never the runner label. Its `runs-on` is now an expression: the label
  `ubuntu-docker` on Gitea, `ubuntu-latest` on GitHub, because the
  former does not exist there and a chart PR would otherwise queue
  forever during the handover. Gitea evaluates it - the probe job was
  taken by the docker runner.

- The install-test still cannot pass on Gitea. That daemon is rootless
  with "Cgroup Driver: none"; the k3s node restarts immediately with an
  empty log because k3s needs cgroup delegation. The label is necessary
  and not sufficient, and the fix is the maintainer's.

- aquasecurity/setup-trivy cannot be used on Gitea: it checks out
  aquasecurity/trivy from github.com with `github.token`, which there is
  a Gitea token, and ends on "could not read Username for
  https://github.com". Replaced by the release tarball - one request,
  same behaviour on both forges, green on the probe run.

Also in this change:

- .github/workflows/renovate.yml, Renovate in self-operation: a daily
  scheduled run in the official container, inert on github.com via a
  `github.server_url` guard. Every rule proved by dry run instead of
  assumed - the pinned Renovate image is tracked by the built-in
  github-actions manager (44.80.0 -> 44.87.1, minor), TRIVY_VERSION is
  still tracked after setup-trivy is gone (v0.72.0 -> v0.74.0, minor),
  and `ubuntu-docker` is not extracted by the github-runners manager at
  all, so nothing will quietly "correct" it.

- ci/migrate-issues-to-gitea.sh, runnable, with --dry-run, and NOT run.
  Its header names what it cannot do: comment authorship, the #<n>
  references between issues, and attachments.

- CLAUDE.md and README.md on the new flow: which forge and which tool,
  auto-merge on green checks in place of the merge queue Gitea does not
  have, and the measured differences of the two CIs.

Refs #225 - deliberately no "Fixes": the move is not finished with this.
2026-09-14 17:07:31 +02:00

352 lines
19 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 charts here have no dependencies. 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 ${{ matrix.chart }}"
needs: [detect-changed-charts, build-chart]
if: needs.detect-changed-charts.outputs.any-install == 'true'
# The one job in this repo that does not simply say ubuntu-latest, and the
# expression is not decoration - the same label means different machines on
# the two forges this repo currently lives on (#225).
#
# This is the only job that needs a Docker socket: it creates a k3d
# cluster, and k3d is a Docker client. On GitHub's hosted runners every
# label carries Docker, so the question never came up. On the Gitea
# instance it was MEASURED on a probe pull request, per job:
# detect-changed-charts, build-chart, the quantity render and the Trivy
# scan all pass on `ubuntu-latest` - none of them needs Docker - while the
# install-test dies there at "Create k3d cluster" with "Cannot connect to
# the Docker daemon at unix:///var/run/docker.sock", an error that points
# at k3d and never at the runner label. `ubuntu-docker` is the label that
# carries a socket, and Gitea does evaluate this expression: the probe job
# was picked up by the docker runner with labels ["ubuntu-docker"].
#
# Why an expression and not a plain `ubuntu-docker`: that label does not
# exist on GitHub, so every chart PR would sit queued forever for as long
# as this repo still builds on github.com - blocking the merge with no
# error anywhere. github.server_url is the one context value that separates
# the two without naming our instance.
#
# KNOWN, and not fixed by this line: `ubuntu-docker`'s daemon is ROOTLESS
# and reports "Cgroup Driver: none" plus "Running in rootless-mode without
# cgroups". k3d then gets as far as starting the k3s node, which restarts
# immediately with an empty log, because k3s needs cgroup delegation. Until
# the instance's Docker-in-Docker provides it, this job cannot pass on
# Gitea - see #225. The label is necessary, it is just not sufficient.
runs-on: ${{ github.server_url == 'https://github.com' && 'ubuntu-latest' || 'ubuntu-docker' }}
# Blocking since Renovate automerges patch/minor updates: a chart that no
# longer starts must stop the PR before it reaches main and publishes. This
# ends the stabilization phase of issue #22 - the job reported failures
# without blocking while it proved itself across several chart PRs.
timeout-minutes: 30
strategy:
fail-fast: false
# "install-charts", not "charts": the changed charts minus the ones in
# ci/excluded-charts.txt, filtered in detect-changed-charts. Excluded
# charts are named with their reason in that job's summary (issue #231).
#
# DO NOT try to move that decision into an "if" on this job, e.g.
# if: ... && !contains(fromJSON(needs.detect-changed-charts.outputs.excluded-charts), matrix.chart)
# to get a visible "Skipped" per chart instead of an absent entry.
# GitHub rejects it - jobs.<id>.if is evaluated BEFORE the matrix is
# expanded, so the matrix context does not exist there:
# context "matrix" is not allowed here.
# available contexts are "github", "inputs", "needs", "vars".
# Measured on run 33650724109 (PR #233, closed): the whole workflow ended
# in failure with zero jobs created. The per-chart value has to be gone
# from the matrix by the time this job is reached.
matrix:
chart: ${{ fromJSON(needs.detect-changed-charts.outputs.install-charts) }}
env:
K3D_VERSION: v5.9.0
K3S_IMAGE: docker.io/rancher/k3s:v1.35.5-k3s1
steps:
- name: "Checkout Repository"
uses: actions/checkout@v7
- name: "Install Helm"
uses: azure/setup-helm@v5
# freesailarr is the one chart whose install-test needs credentials:
# without a real Wireguard key gluetun never completes a handshake, and
# behind its kill switch not a single app container becomes Ready. The key
# comes from 1Password Connect like the registry credentials in
# publish-helm-chart.yml, and it is a CI-ONLY Mullvad key: Mullvad ties an
# address to a key and sends the tunnel to the endpoint it saw last, so
# reusing the production key would let every PR run pull the tunnel out
# from under the running stack - with the symptom in the cluster and the
# cause in a pull request. Loaded only for this chart; no other chart run
# touches the VPN key.
- name: "Configure OP-Connect"
if: matrix.chart == 'freesailarr'
uses: 1password/load-secrets-action/configure@v5
with:
connect-host: https://op-connect.jk-dev.de
- name: "Load freesailarr VPN credentials"
id: load-freesailarr-vpn
if: matrix.chart == 'freesailarr'
uses: 1password/load-secrets-action@v5
env:
OP_CONNECT_TOKEN: ${{ secrets.OP_CONNECT_TOKEN }}
FREESAILARR_WG_KEY: op://GitHub-Actions/freesailarr-mullvad-test-config/credential
FREESAILARR_WG_ADDRESSES: op://GitHub-Actions/freesailarr-mullvad-test-config/ip-address
# The same trap publish-helm-chart.yml documents at length: v5 of
# load-secrets-action does NOT export the loaded values as environment
# variables for later steps, only its step outputs carry them, and the
# loading step logs "Populating variable" and stays green either way. So
# the values are mapped into each step's own env block via
# steps.<id>.outputs.* and read as quoted shell variables. The ":?" guards
# run before the cluster is even created and name the op:// field that is
# missing - an empty key would otherwise reach gluetun and look like a
# broken chart instead of a missing secret. They print the variable name,
# never its value.
- name: "Verify the VPN credentials arrived"
if: matrix.chart == 'freesailarr'
env:
FREESAILARR_WG_KEY: ${{ steps.load-freesailarr-vpn.outputs.FREESAILARR_WG_KEY }}
FREESAILARR_WG_ADDRESSES: ${{ steps.load-freesailarr-vpn.outputs.FREESAILARR_WG_ADDRESSES }}
run: |-
: "${FREESAILARR_WG_KEY:?empty - check op://GitHub-Actions/freesailarr-mullvad-test-config/credential}"
: "${FREESAILARR_WG_ADDRESSES:?empty - check op://GitHub-Actions/freesailarr-mullvad-test-config/ip-address}"
echo "Both VPN values arrived non-empty."
- name: "Install k3d"
run: curl -sSfL https://raw.githubusercontent.com/k3d-io/k3d/main/install.sh | TAG="$K3D_VERSION" bash
- name: "Create k3d cluster"
run: k3d cluster create install-test --image "$K3S_IMAGE" --wait --timeout 180s
# Every chart reaching this step is one the exclusion filter let through,
# so the script's SKIP branch is never taken here - it exists for local
# runs. The variables below are empty for every chart but the one that
# declares them in ci/<chart>/settings.env (REQUIRED_ENV); the script
# fails loudly if a declared one is missing, it never skips over it.
- name: "Install chart and wait for readiness"
env:
FREESAILARR_WG_KEY: ${{ steps.load-freesailarr-vpn.outputs.FREESAILARR_WG_KEY }}
FREESAILARR_WG_ADDRESSES: ${{ steps.load-freesailarr-vpn.outputs.FREESAILARR_WG_ADDRESSES }}
run: ci/run-install-test.sh "${{ matrix.chart }}"
- name: "Delete k3d cluster"
if: always()
run: k3d cluster delete install-test || true