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 }} 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 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 - 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 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 - name: "Install Trivy" uses: aquasecurity/setup-trivy@v0.3.1 with: version: ${{ env.TRIVY_VERSION }} cache: true # 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-chart-changed == 'true' runs-on: ubuntu-latest # 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 matrix: chart: ${{ fromJSON(needs.detect-changed-charts.outputs.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: "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 # Excluded charts (see ci/excluded-charts.txt) are skipped inside the # script with their documented reason. The variables below are empty for # every other chart and are read only by the chart 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