From 470797e02eaf1da9ccaf578423c7ef2c3375560e Mon Sep 17 00:00:00 2001 From: chgl Date: Fri, 11 Sep 2026 01:39:17 +0200 Subject: [PATCH 1/2] central workflow --- .github/workflows/ci.yaml | 2 +- .github/workflows/standard-chart-publish.yaml | 219 +++++++++++++++++- .trivyignore | 6 +- charts/test-chart/values.yaml | 26 ++- 4 files changed, 237 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 6259106..9febe4f 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -75,7 +75,7 @@ jobs: secrets: github-token: ${{ secrets.GITHUB_TOKEN }} - publish-chart: + chart: uses: $/.github/workflows/standard-chart-publish.yaml permissions: contents: read diff --git a/.github/workflows/standard-chart-publish.yaml b/.github/workflows/standard-chart-publish.yaml index 568ea82..0534177 100644 --- a/.github/workflows/standard-chart-publish.yaml +++ b/.github/workflows/standard-chart-publish.yaml @@ -1,4 +1,4 @@ -name: Standard workflow for publishing a Helm chart as an OCI artifact +name: Standard workflow for testing and publishing a Helm chart as an OCI artifact permissions: contents: read @@ -7,6 +7,10 @@ defaults: run: shell: bash +env: + # renovate: datasource=github-releases depName=helm/chart-testing + CT_VERSION: 3.14.0 + on: workflow_call: inputs: @@ -20,7 +24,7 @@ on: default: "ghcr.io/${{ github.repository }}" type: string version: - description: "Overrides the chart version from Chart.yaml. Passed to `helm package --version`." + description: "Overrides the chart version from Chart.yaml. Passed to `helm package --version`. If unset, a release event uses its tag name and a pull_request event gets a `-pr.` prerelease version." required: false default: "" type: string @@ -30,7 +34,7 @@ on: default: "" type: string enable-dependency-update: - description: "If enabled, runs `helm dependency update` before packaging the chart." + description: "If enabled, runs `helm dependency update` before linting/testing/packaging the chart." required: false default: true type: boolean @@ -44,6 +48,46 @@ on: required: false default: true type: boolean + target-branch: + description: "Branch to diff against when detecting changed charts for linting/testing" + required: false + default: "${{ github.event.repository.default_branch }}" + type: string + lint-config: + description: "Path to a ct lint configuration file. Leave empty to use ct's defaults." + required: false + default: "" + type: string + lint-all: + description: "If enabled, lints all charts next to chart-path on every run instead of only ones changed vs. target-branch" + required: false + default: true + type: boolean + enable-install: + description: "If enabled, spins up a kind cluster and installs charts changed vs. target-branch using ct install" + required: false + default: true + type: boolean + install-config: + description: "Path to a ct install configuration file. Leave empty to use ct's defaults." + required: false + default: "" + type: string + k8s-versions: + description: "JSON array of kind node image versions to test installation against" + required: false + default: '["1.34.8", "1.35.5", "1.36.1"]' + type: string + enable-pr-release: + description: "If enabled, also publishes the chart (as a prerelease version) for pull_request events, in addition to release events" + required: false + default: true + type: boolean + extra-helm-repos: + description: "Extra Helm chart repositories to add before linting/testing/packaging, as newline-separated 'name=url' pairs" + required: false + default: "" + type: string outputs: chart-name: value: ${{ jobs.publish.outputs.chart-name }} @@ -63,9 +107,158 @@ on: required: true jobs: + lint: + name: lint charts + runs-on: ubuntu-24.04 + steps: + - name: Harden Runner + uses: step-security/harden-runner@e14015d583714f6e62063499dc959a02595150a1 # v2.21.1 + with: + egress-policy: audit # change to 'egress-policy: block' after couple of runs + + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Set up chart-testing + uses: helm/chart-testing-action@6ec842c01de15ebb84c8627d2744a0c2f2755c9f # v2.8.0 + with: + version: ${{ env.CT_VERSION }} + + - name: Add extra Helm chart repositories + if: ${{ inputs.extra-helm-repos != '' }} + env: + EXTRA_REPOS: ${{ inputs.extra-helm-repos }} + run: | + while IFS='=' read -r name url; do + [ -z "${name}" ] && continue + helm repo add "${name}" "${url}" + done <<< "${EXTRA_REPOS}" + + - name: Update chart dependencies + if: ${{ inputs.enable-dependency-update }} + env: + CHART_DIRS: ${{ inputs.chart-path }} + run: | + CHART_DIRS="$(dirname "${CHART_DIRS}")" + find "${CHART_DIRS}" -maxdepth 1 ! -path "${CHART_DIRS}" -type d -exec helm dependency update {} \; + + - name: Run chart-testing (lint) + env: + CHART_DIRS: ${{ inputs.chart-path }} + TARGET_BRANCH: ${{ inputs.target-branch }} + LINT_CONFIG: ${{ inputs.lint-config }} + LINT_ALL: ${{ inputs.lint-all }} + run: | + # maintainer validation requires every Chart.yaml to declare a + # maintainers list with a valid GitHub/GitLab/Bitbucket account, + # which most charts don't bother with; disabled by default here to + # keep the workflow usable out of the box, same as the org's own + # charts repo does in its ct.yaml. + ARGS=(--chart-dirs "$(dirname "${CHART_DIRS}")" --target-branch "${TARGET_BRANCH}" --validate-maintainers=false) + if [ -n "${LINT_CONFIG}" ]; then + ARGS+=(--config "${LINT_CONFIG}") + fi + if [ "${LINT_ALL}" = "true" ]; then + ARGS+=(--all) + fi + ct lint "${ARGS[@]}" + + install: + name: install charts (k8s ${{ matrix.k8s-version }}) + runs-on: ubuntu-24.04 + if: ${{ inputs.enable-install }} + needs: + - lint + strategy: + fail-fast: false + matrix: + k8s-version: ${{ fromJSON(inputs.k8s-versions) }} + steps: + - name: Harden Runner + uses: step-security/harden-runner@e14015d583714f6e62063499dc959a02595150a1 # v2.21.1 + with: + egress-policy: audit # change to 'egress-policy: block' after couple of runs + + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Set up chart-testing + uses: helm/chart-testing-action@6ec842c01de15ebb84c8627d2744a0c2f2755c9f # v2.8.0 + with: + version: ${{ env.CT_VERSION }} + + - name: Add extra Helm chart repositories + if: ${{ inputs.extra-helm-repos != '' }} + env: + EXTRA_REPOS: ${{ inputs.extra-helm-repos }} + run: | + while IFS='=' read -r name url; do + [ -z "${name}" ] && continue + helm repo add "${name}" "${url}" + done <<< "${EXTRA_REPOS}" + + - name: Update chart dependencies + if: ${{ inputs.enable-dependency-update }} + env: + CHART_DIRS: ${{ inputs.chart-path }} + run: | + CHART_DIRS="$(dirname "${CHART_DIRS}")" + find "${CHART_DIRS}" -maxdepth 1 ! -path "${CHART_DIRS}" -type d -exec helm dependency update {} \; + + - name: Run chart-testing (list-changed) + id: list-changed + env: + CHART_DIRS: ${{ inputs.chart-path }} + TARGET_BRANCH: ${{ inputs.target-branch }} + run: | + changed=$(ct list-changed --chart-dirs "$(dirname "${CHART_DIRS}")" --target-branch "${TARGET_BRANCH}") + if [[ -n "${changed}" ]]; then + echo "changed=true" >> "${GITHUB_OUTPUT}" + fi + + - name: Create kind cluster + if: ${{ steps.list-changed.outputs.changed == 'true' }} + uses: helm/kind-action@06c1ae10762d3b9c1644e7fe69596ae519e015a2 # v1.15.0 + with: + cluster_name: kind-cluster-k8s-${{ matrix.k8s-version }} + node_image: kindest/node:v${{ matrix.k8s-version }} + + - name: Run chart-testing (install) + if: ${{ steps.list-changed.outputs.changed == 'true' }} + env: + CHART_DIRS: ${{ inputs.chart-path }} + TARGET_BRANCH: ${{ inputs.target-branch }} + INSTALL_CONFIG: ${{ inputs.install-config }} + run: | + ARGS=(--chart-dirs "$(dirname "${CHART_DIRS}")" --target-branch "${TARGET_BRANCH}") + if [ -n "${INSTALL_CONFIG}" ]; then + ARGS+=(--config "${INSTALL_CONFIG}") + fi + # freshly-created kind clusters can still be finishing up + # ClusterIP/DNS/webhook wiring, so a first install attempt can race + # that and fail transiently; retrying is simpler and more robust + # than guessing a long-enough upfront sleep. + for i in 1 2 3; do + if ct install "${ARGS[@]}"; then + exit 0 + fi + echo "ct install failed (attempt ${i}/3), retrying in 10s..." + sleep 10 + done + exit 1 + publish: name: publish chart runs-on: ubuntu-24.04 + needs: + - lint + if: ${{ github.event_name == 'release' || (github.event_name == 'pull_request' && inputs.enable-pr-release) }} permissions: contents: read packages: write # to push the chart and its signature/attestation to ghcr.io @@ -94,20 +287,28 @@ jobs: run: | helm dependency update . - - name: Lint chart - working-directory: ${{ inputs.chart-path }} - run: | - helm lint . - - name: Read chart metadata id: chart_meta working-directory: ${{ inputs.chart-path }} env: VERSION_OVERRIDE: ${{ inputs.version }} APP_VERSION_OVERRIDE: ${{ inputs.app-version }} + EVENT_NAME: ${{ github.event_name }} + RELEASE_TAG: ${{ github.event.release.tag_name }} + PR_NUMBER: ${{ github.event.pull_request.number }} + FULL_SHA: ${{ github.sha }} run: | NAME="$(yq '.name' Chart.yaml)" - VERSION="${VERSION_OVERRIDE:-$(yq '.version' Chart.yaml)}" + if [ -n "${VERSION_OVERRIDE}" ]; then + VERSION="${VERSION_OVERRIDE}" + elif [ "${EVENT_NAME}" = "release" ]; then + # strip an optional leading "v", e.g. v1.2.3 -> 1.2.3 + VERSION="${RELEASE_TAG#v}" + elif [ "${EVENT_NAME}" = "pull_request" ]; then + VERSION="$(yq '.version' Chart.yaml)-pr${PR_NUMBER}.${FULL_SHA:0:7}" + else + VERSION="$(yq '.version' Chart.yaml)" + fi APP_VERSION="${APP_VERSION_OVERRIDE:-$(yq '.appVersion' Chart.yaml)}" { echo "name=${NAME}" diff --git a/.trivyignore b/.trivyignore index 54cfc5a..1bb552b 100644 --- a/.trivyignore +++ b/.trivyignore @@ -10,5 +10,7 @@ AVD-KSV-0018 # charts/test-chart: namespace is set at install time (helm install -n), not # hardcoded in a reusable chart template AVD-KSV-0110 -# charts/test-chart: default scaffold exposes the stock nginx image's port 80 -AVD-KSV-0117 +# charts/test-chart: nginxinc/nginx-unprivileged isn't on trivy's default +# trusted-registry allowlist, but it's the whole reason this pod can run +# under the hardened securityContext below +AVD-KSV-0125 diff --git a/charts/test-chart/values.yaml b/charts/test-chart/values.yaml index b94e960..abee2b3 100644 --- a/charts/test-chart/values.yaml +++ b/charts/test-chart/values.yaml @@ -6,8 +6,11 @@ replicaCount: 1 # This sets the container image more information can be found here: https://kubernetes.io/docs/concepts/containers/images/ +# nginx-unprivileged (rather than stock nginx) is used because it can +# actually run under the hardened securityContext below (non-root, all +# capabilities dropped) -- it listens on 8080 instead of 80. image: - repository: nginx + repository: nginxinc/nginx-unprivileged # This sets the pull policy for images. pullPolicy: IfNotPresent # Overrides the image tag whose default is the chart appVersion. @@ -60,7 +63,7 @@ service: # This sets the service type more information can be found here: https://kubernetes.io/docs/concepts/services-networking/service/#publishing-services-service-types type: ClusterIP # This sets the ports more information can be found here: https://kubernetes.io/docs/concepts/services-networking/service/#field-spec-ports - port: 80 + port: 8080 # This block is for setting up the ingress for more information can be found here: https://kubernetes.io/docs/concepts/services-networking/ingress/ ingress: @@ -148,14 +151,29 @@ autoscaling: # targetMemoryUtilizationPercentage: 80 # Additional volumes on the output Deployment definition. -volumes: [] +# nginx-unprivileged needs these writable even with readOnlyRootFilesystem +# enabled above, since it can't write to its default cache/pid paths as a +# non-root user. +volumes: + - name: tmp + emptyDir: {} + - name: cache + emptyDir: {} + - name: run + emptyDir: {} # - name: foo # secret: # secretName: mysecret # optional: false # Additional volumeMounts on the output Deployment definition. -volumeMounts: [] +volumeMounts: + - name: tmp + mountPath: /tmp + - name: cache + mountPath: /var/cache/nginx + - name: run + mountPath: /var/run # - name: foo # mountPath: "/etc/foo" # readOnly: true From 30d987d1b7f9a5642f59bd65f90d5013138f81dc Mon Sep 17 00:00:00 2001 From: chgl Date: Fri, 11 Sep 2026 01:58:00 +0200 Subject: [PATCH 2/2] fix(chart): use wget --spider in test-connection hook readOnlyRootFilesystem on the wget container broke the helm test hook: wget defaults to saving the response body to ./index.html, which fails on a read-only root. --spider checks reachability without writing anything, which is all this hook needs anyway. Co-Authored-By: Claude Sonnet 5 --- charts/test-chart/templates/tests/test-connection.yaml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/charts/test-chart/templates/tests/test-connection.yaml b/charts/test-chart/templates/tests/test-connection.yaml index 48106b3..c48df0d 100644 --- a/charts/test-chart/templates/tests/test-connection.yaml +++ b/charts/test-chart/templates/tests/test-connection.yaml @@ -21,7 +21,10 @@ spec: - name: wget image: busybox:1.36.1 command: ['wget'] - args: ['{{ include "test-chart.fullname" . }}:{{ .Values.service.port }}'] + # --spider: check reachability without downloading/writing the + # response body, which would otherwise fail under the read-only root + # filesystem below (wget defaults to saving it as ./index.html). + args: ['--spider', '{{ include "test-chart.fullname" . }}:{{ .Values.service.port }}'] securityContext: allowPrivilegeEscalation: false capabilities: