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 "", 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, measured # on the Gitea probe run of #225. 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 ends with "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 dead 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 the same on both platforms, and the # Renovate comment above still keeps TRIVY_VERSION current. - 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' # NOT ubuntu-latest, and the one job in this repo that deviates. 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 distinction never came up; on the Gitea instance this repo # is moving to (#225) the labels mean different machines: `ubuntu-latest` # has no socket, `ubuntu-docker` has. Measured on the probe PR, not # assumed - on ubuntu-latest the step "Create k3d cluster" fails 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. Every other # job here (lint/package, the quantity render, the Trivy config scan) was # measured green on ubuntu-latest and stays there. # # The label is unknown to GitHub Actions. While this repo still builds on # github.com the job therefore stays queued - that is the intended state # for the window in which CI is being handed over, and it is the reason # this line must not be "corrected" back to ubuntu-latest. runs-on: 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..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..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: "PROBE runner facts (remove before merge)" run: | echo "labels/runner: ${RUNNER_NAME:-?} os=${RUNNER_OS:-?} arch=${RUNNER_ARCH:-?}" echo "nproc: $(nproc)" free -m || true df -h / || true echo "--- docker ---" docker version || echo "NO DOCKER CLIENT/DAEMON" docker info --format '{{.ServerVersion}} storage={{.Driver}} cpus={{.NCPU}} mem={{.MemTotal}}' || echo "docker info FAILED" echo "--- toolchain ---" node --version || echo "no node" npx --version || echo "no npx" python3 --version || echo "no python3" git --version; curl --version | head -1 - 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 --no-rollback - name: "PROBE why did k3s not come up (remove before merge)" if: failure() run: | docker ps -a echo "=== docker logs k3d-install-test-server-0 ===" docker logs k3d-install-test-server-0 2>&1 | tail -100 || true echo "=== docker info ===" docker info || true echo "=== host cgroup ===" cat /proc/self/cgroup || true ls /sys/fs/cgroup | head -30 || true # 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//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