diff --git a/.github/scripts/publish-helm-chart/run.sh b/.github/scripts/publish-helm-chart/run.sh new file mode 100644 index 0000000..c34c188 --- /dev/null +++ b/.github/scripts/publish-helm-chart/run.sh @@ -0,0 +1,185 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Packages a Helm chart once per version and pushes each tarball to +# ChartMuseum. Called from the publish-helm-chart reusable workflow so the +# yq/helm/jq branching lives in a shellcheck-clean script with bats +# coverage instead of inline YAML. +# +# The helm and helm-cm-push plugin must be installed and on PATH before +# invocation; the workflow handles that. +# +# Required environment variables: +# CHART_DIRECTORY Path to the Helm chart directory (e.g. "chart"). +# CHART_NAME Value written to .name in Chart.yaml. Also used +# to derive the packaged tarball filename +# (-.tgz) and, when +# REPUBLISH_LATEST=true, the `helm search repo` +# query. +# CHART_VERSIONS_JSON JSON array of chart versions to publish, e.g. +# '["1.2.3"]' or '["0.0.0-latest","0.0.0-abc123"]'. +# Must be non-empty; each entry is published as a +# separate tarball. +# CHART_MUSEUM_URL ChartMuseum base URL (e.g. https://charts.loft.sh/). +# CHART_MUSEUM_USER ChartMuseum username. +# CHART_MUSEUM_PASSWORD ChartMuseum password. +# +# Optional environment variables: +# CHART_DESCRIPTION If set, written to .description in Chart.yaml. +# APP_VERSION If set, passed as --app-version to `helm package`. +# Not written to Chart.yaml so callers who want a +# decoupled Chart.yaml-level edit can do so via +# VALUES_EDITS (or by editing Chart.yaml directly +# before invoking this script). +# VALUES_EDITS Newline-separated `jsonpath=value` pairs applied +# via `yq` to /values.yaml. +# Values are treated as strings. Example: +# product=vcluster-pro +# REPUBLISH_LATEST "true" to re-push the repo's latest semver after +# the initial push, to keep it at the top of the +# index. Default: "false". + +: "${CHART_DIRECTORY:?CHART_DIRECTORY is required}" +: "${CHART_NAME:?CHART_NAME is required}" +: "${CHART_VERSIONS_JSON:?CHART_VERSIONS_JSON is required}" +: "${CHART_MUSEUM_URL:?CHART_MUSEUM_URL is required}" +: "${CHART_MUSEUM_USER:?CHART_MUSEUM_USER is required}" +: "${CHART_MUSEUM_PASSWORD:?CHART_MUSEUM_PASSWORD is required}" + +CHART_DESCRIPTION="${CHART_DESCRIPTION:-}" +APP_VERSION="${APP_VERSION:-}" +VALUES_EDITS="${VALUES_EDITS:-}" +REPUBLISH_LATEST="${REPUBLISH_LATEST:-false}" + +CHART_YAML="${CHART_DIRECTORY}/Chart.yaml" +VALUES_YAML="${CHART_DIRECTORY}/values.yaml" + +if [ ! -f "${CHART_YAML}" ]; then + echo "Error: ${CHART_YAML} does not exist" >&2 + exit 1 +fi + +# Parse CHART_VERSIONS_JSON into a bash array. We require jq rather than +# relying on string splitting so that versions containing metadata (e.g. +# "0.0.0-abc+build") flow through unmodified. +if ! VERSIONS_RAW=$(jq -r '.[]' <<<"${CHART_VERSIONS_JSON}" 2>/dev/null); then + echo "Error: CHART_VERSIONS_JSON is not valid JSON: ${CHART_VERSIONS_JSON}" >&2 + exit 1 +fi + +VERSIONS=() +while IFS= read -r v; do + [ -n "${v}" ] && VERSIONS+=("${v}") +done <<<"${VERSIONS_RAW}" + +if [ "${#VERSIONS[@]}" -eq 0 ]; then + echo "Error: CHART_VERSIONS_JSON must contain at least one version" >&2 + exit 1 +fi + +# --- Chart.yaml edits ------------------------------------------------------- + +echo "Setting .name = \"${CHART_NAME}\" in ${CHART_YAML}" +CHART_NAME="${CHART_NAME}" yq -i '.name = strenv(CHART_NAME)' "${CHART_YAML}" + +if [ -n "${CHART_DESCRIPTION}" ]; then + echo "Setting .description in ${CHART_YAML}" + CHART_DESCRIPTION="${CHART_DESCRIPTION}" \ + yq -i '.description = strenv(CHART_DESCRIPTION)' "${CHART_YAML}" +fi + +# --- values.yaml edits ------------------------------------------------------ + +if [ -n "${VALUES_EDITS}" ]; then + if [ ! -f "${VALUES_YAML}" ]; then + echo "Error: values-edits provided but ${VALUES_YAML} does not exist" >&2 + exit 1 + fi + while IFS= read -r edit; do + [ -z "${edit}" ] && continue + if [[ "${edit}" != *=* ]]; then + echo "Error: values-edits entry must be of the form jsonpath=value: ${edit}" >&2 + exit 1 + fi + path="${edit%%=*}" + value="${edit#*=}" + echo "Setting .${path} = \"${value}\" in ${VALUES_YAML}" + VALUES_EDIT_PATH="${path}" VALUES_EDIT_VALUE="${value}" \ + yq -i 'eval("." + strenv(VALUES_EDIT_PATH)) = strenv(VALUES_EDIT_VALUE)' \ + "${VALUES_YAML}" + done <<<"${VALUES_EDITS}" +fi + +# --- Package ----------------------------------------------------------------- + +PACKAGE_DIR=$(mktemp -d) +trap 'rm -rf "${PACKAGE_DIR}"' EXIT + +PACKAGE_ARGS=() +if [ -n "${APP_VERSION}" ]; then + PACKAGE_ARGS+=(--app-version "${APP_VERSION}") +fi + +TARBALLS=() +for version in "${VERSIONS[@]}"; do + echo "Packaging ${CHART_NAME} version ${version}" + helm package "${CHART_DIRECTORY}" \ + --version "${version}" \ + "${PACKAGE_ARGS[@]}" \ + --destination "${PACKAGE_DIR}" + TARBALLS+=("${PACKAGE_DIR}/${CHART_NAME}-${version}.tgz") +done + +# --- Push -------------------------------------------------------------------- + +echo "Adding chartmuseum repo" +helm repo add chartmuseum "${CHART_MUSEUM_URL}" \ + --username "${CHART_MUSEUM_USER}" \ + --password "${CHART_MUSEUM_PASSWORD}" + +for tarball in "${TARBALLS[@]}"; do + echo "Pushing ${tarball} to chartmuseum" + helm cm-push --force "${tarball}" chartmuseum +done + +# --- Republish latest (optional) -------------------------------------------- +# +# ChartMuseum's /index.yaml orders entries by upload time, not semver. When +# we push a patch release for an older minor line, tools that read the first +# entry (helm v2-style indexing) get the patch instead of the true latest. +# If REPUBLISH_LATEST=true, we detect the repo's highest semver and, if it +# differs from what we just pushed, re-pull and re-push it so it becomes the +# most-recently-uploaded version. + +if [ "${REPUBLISH_LATEST}" = "true" ]; then + echo "Checking whether latest semver needs to be re-pushed" + helm repo update chartmuseum + + LATEST=$(helm search repo "chartmuseum/${CHART_NAME}" --versions -o json | + jq -e -r '[.[].version] | sort_by(split(".") | map(tonumber? // 0)) | reverse | .[0] // empty') || { + echo "Error: Could not determine latest version from helm repo" >&2 + exit 1 + } + + # Compare against the highest version we just published. We only ever + # set REPUBLISH_LATEST=true from release workflows with a single version, + # but using `sort -V | tail -n1` keeps the logic correct if a caller ever + # passes multiple. + PUSHED_MAX=$(printf '%s\n' "${VERSIONS[@]}" | sort -V | tail -n1) + + if [ "${LATEST}" != "${PUSHED_MAX}" ]; then + echo "Re-pushing latest version ${LATEST} to ensure it's first in index" + REPULL_DIR=$(mktemp -d) + trap 'rm -rf "${PACKAGE_DIR}" "${REPULL_DIR}"' EXIT + if ! helm pull "chartmuseum/${CHART_NAME}" --version "${LATEST}" \ + --destination "${REPULL_DIR}"; then + echo "Error: Failed to pull chart version ${LATEST}" >&2 + exit 1 + fi + helm cm-push --force "${REPULL_DIR}/${CHART_NAME}-${LATEST}.tgz" chartmuseum + else + echo "Pushed version ${PUSHED_MAX} is already the repo's latest; nothing to re-push" + fi +fi + +echo "Chart publish complete: ${CHART_NAME} ${CHART_VERSIONS_JSON}" diff --git a/.github/scripts/publish-helm-chart/test/run.bats b/.github/scripts/publish-helm-chart/test/run.bats new file mode 100644 index 0000000..7e39c05 --- /dev/null +++ b/.github/scripts/publish-helm-chart/test/run.bats @@ -0,0 +1,314 @@ +#!/usr/bin/env bats +# Tests for run.sh +# +# Stubs `helm` so we can assert on the package/push argument vector and +# fixture out search/pull responses. Uses the real `yq` and `jq` so the +# Chart.yaml/values.yaml edits and JSON parsing are validated end-to-end. + +SCRIPT="$BATS_TEST_DIRNAME/../run.sh" + +setup() { + # The script uses mikefarah/yq syntax (`strenv()`, `-i` for in-place). + # Skip if a different yq (e.g. the Python kislyuk/yq) is on PATH. + if ! command -v yq >/dev/null || ! yq --version 2>&1 | grep -q "mikefarah"; then + skip "mikefarah/yq is not installed (the Python yq is incompatible)" + fi + + TEST_DIR=$(mktemp -d) + export TEST_DIR + + MOCK_DIR="$TEST_DIR/mock" + mkdir -p "$MOCK_DIR" + + export HELM_CALLS="$TEST_DIR/helm_calls" + export HELM_PACKAGE_DIR="$TEST_DIR/helm_packages" + export HELM_SEARCH_OUTPUT="$TEST_DIR/helm_search_output" + export HELM_SEARCH_EXIT="$TEST_DIR/helm_search_exit" + export HELM_PULL_FAIL="$TEST_DIR/helm_pull_fail" + + : > "$HELM_CALLS" + echo "0" > "$HELM_SEARCH_EXIT" + : > "$HELM_PULL_FAIL" + + # Stub helm: + # - record every invocation as a tab-separated line in HELM_CALLS + # - `helm package --version V [--app-version A] --destination D` + # creates an empty file at D/-V.tgz so the script's + # subsequent cm-push call sees a real path + # - `helm search repo` emits HELM_SEARCH_OUTPUT (or exits with + # HELM_SEARCH_EXIT on non-zero) + # - `helm pull` creates an empty tarball so cm-push has something to + # reference, unless HELM_PULL_FAIL is non-empty + cat > "$MOCK_DIR/helm" <<'MOCK' +#!/usr/bin/env bash +printf '%s\n' "$*" >> "$HELM_CALLS" + +case "$1" in + package) + chart_dir="" + version="" + dest="" + while [ $# -gt 0 ]; do + case "$1" in + package) shift ;; + --version) version="$2"; shift 2 ;; + --app-version) shift 2 ;; + --destination) dest="$2"; shift 2 ;; + *) chart_dir="$1"; shift ;; + esac + done + name=$(yq -r '.name' "$chart_dir/Chart.yaml") + mkdir -p "$dest" + : > "$dest/${name}-${version}.tgz" + ;; + search) + if [ "$(cat "$HELM_SEARCH_EXIT")" != "0" ]; then + exit "$(cat "$HELM_SEARCH_EXIT")" + fi + cat "$HELM_SEARCH_OUTPUT" + ;; + pull) + if [ -s "$HELM_PULL_FAIL" ]; then + exit 1 + fi + chart_ref="" + version="" + dest="" + while [ $# -gt 0 ]; do + case "$1" in + pull) shift ;; + --version) version="$2"; shift 2 ;; + --destination) dest="$2"; shift 2 ;; + *) chart_ref="$1"; shift ;; + esac + done + name="${chart_ref#chartmuseum/}" + mkdir -p "$dest" + : > "$dest/${name}-${version}.tgz" + ;; +esac +exit 0 +MOCK + chmod +x "$MOCK_DIR/helm" + + export PATH="$MOCK_DIR:$PATH" + + # Standard chart fixture + CHART_DIR="$TEST_DIR/chart" + mkdir -p "$CHART_DIR" + cat > "$CHART_DIR/Chart.yaml" <<'YAML' +apiVersion: v2 +name: original-name +description: original description +version: 0.0.0 +appVersion: 0.0.0 +YAML + cat > "$CHART_DIR/values.yaml" <<'YAML' +product: original +foo: + bar: baz +YAML + + # Default env vars + export CHART_DIRECTORY="$CHART_DIR" + export CHART_NAME="my-chart" + export CHART_VERSIONS_JSON='["1.2.3"]' + export CHART_MUSEUM_URL="https://charts.example.com/" + export CHART_MUSEUM_USER="user" + export CHART_MUSEUM_PASSWORD="pass" +} + +teardown() { + rm -rf "$TEST_DIR" +} + +# Helper: count helm calls matching a fixed-string pattern. Uses `-e --` so +# patterns starting with `-` are treated as text, not flags. +helm_call_count() { + grep -c -F -e "$1" -- "$HELM_CALLS" || true +} + +# --- Required env validation ------------------------------------------------ + +@test "fails when CHART_DIRECTORY is missing" { + unset CHART_DIRECTORY + run bash "$SCRIPT" + [ "$status" -ne 0 ] +} + +@test "fails when CHART_NAME is missing" { + unset CHART_NAME + run bash "$SCRIPT" + [ "$status" -ne 0 ] +} + +@test "fails when CHART_VERSIONS_JSON is missing" { + unset CHART_VERSIONS_JSON + run bash "$SCRIPT" + [ "$status" -ne 0 ] +} + +@test "fails when CHART_MUSEUM_URL is missing" { + unset CHART_MUSEUM_URL + run bash "$SCRIPT" + [ "$status" -ne 0 ] +} + +@test "fails when Chart.yaml does not exist" { + rm "$CHART_DIRECTORY/Chart.yaml" + run bash "$SCRIPT" + [ "$status" -ne 0 ] + [[ "$output" == *"does not exist"* ]] +} + +# --- CHART_VERSIONS_JSON parsing -------------------------------------------- + +@test "fails on invalid JSON" { + CHART_VERSIONS_JSON='not-json' run bash "$SCRIPT" + [ "$status" -ne 0 ] + [[ "$output" == *"not valid JSON"* ]] +} + +@test "fails on empty array" { + CHART_VERSIONS_JSON='[]' run bash "$SCRIPT" + [ "$status" -ne 0 ] + [[ "$output" == *"at least one version"* ]] +} + +# --- Chart.yaml edits ------------------------------------------------------- + +@test "sets .name in Chart.yaml" { + CHART_NAME="my-renamed-chart" run bash "$SCRIPT" + [ "$status" -eq 0 ] + [ "$(yq -r '.name' "$CHART_DIRECTORY/Chart.yaml")" = "my-renamed-chart" ] +} + +@test "sets .description in Chart.yaml when CHART_DESCRIPTION provided" { + CHART_DESCRIPTION="My new description" run bash "$SCRIPT" + [ "$status" -eq 0 ] + [ "$(yq -r '.description' "$CHART_DIRECTORY/Chart.yaml")" = "My new description" ] +} + +@test "leaves .description unchanged when CHART_DESCRIPTION not provided" { + run bash "$SCRIPT" + [ "$status" -eq 0 ] + [ "$(yq -r '.description' "$CHART_DIRECTORY/Chart.yaml")" = "original description" ] +} + +# --- values.yaml edits ------------------------------------------------------ + +@test "applies single VALUES_EDITS entry" { + VALUES_EDITS="product=vcluster-pro" run bash "$SCRIPT" + [ "$status" -eq 0 ] + [ "$(yq -r '.product' "$CHART_DIRECTORY/values.yaml")" = "vcluster-pro" ] +} + +@test "applies multiple VALUES_EDITS entries" { + VALUES_EDITS="product=vcluster-pro +foo.bar=qux" run bash "$SCRIPT" + [ "$status" -eq 0 ] + [ "$(yq -r '.product' "$CHART_DIRECTORY/values.yaml")" = "vcluster-pro" ] + [ "$(yq -r '.foo.bar' "$CHART_DIRECTORY/values.yaml")" = "qux" ] +} + +@test "fails on malformed VALUES_EDITS entry" { + VALUES_EDITS="no-equals-sign" run bash "$SCRIPT" + [ "$status" -ne 0 ] + [[ "$output" == *"jsonpath=value"* ]] +} + +@test "fails when VALUES_EDITS provided but values.yaml missing" { + rm "$CHART_DIRECTORY/values.yaml" + VALUES_EDITS="product=vcluster-pro" run bash "$SCRIPT" + [ "$status" -ne 0 ] + [[ "$output" == *"values.yaml does not exist"* ]] +} + +@test "ignores blank lines in VALUES_EDITS" { + VALUES_EDITS=" +product=vcluster-pro + +" run bash "$SCRIPT" + [ "$status" -eq 0 ] + [ "$(yq -r '.product' "$CHART_DIRECTORY/values.yaml")" = "vcluster-pro" ] +} + +# --- helm package ----------------------------------------------------------- + +@test "packages once per version" { + CHART_VERSIONS_JSON='["1.2.3","0.0.0-latest"]' run bash "$SCRIPT" + [ "$status" -eq 0 ] + [ "$(helm_call_count "package $CHART_DIRECTORY --version 1.2.3")" -eq 1 ] + [ "$(helm_call_count "package $CHART_DIRECTORY --version 0.0.0-latest")" -eq 1 ] +} + +@test "passes --app-version when APP_VERSION is set" { + APP_VERSION="head-abc123" run bash "$SCRIPT" + [ "$status" -eq 0 ] + [ "$(helm_call_count "--app-version head-abc123")" -eq 1 ] +} + +@test "omits --app-version when APP_VERSION is empty" { + run bash "$SCRIPT" + [ "$status" -eq 0 ] + [ "$(helm_call_count "--app-version")" -eq 0 ] +} + +# --- helm cm-push ----------------------------------------------------------- + +@test "pushes one tarball per version with chart-name-derived filename" { + CHART_NAME="vcluster-head" + CHART_VERSIONS_JSON='["0.0.0-latest","0.0.0-abc1234"]' run bash "$SCRIPT" + [ "$status" -eq 0 ] + [ "$(helm_call_count "cm-push --force")" -eq 2 ] + grep -F "vcluster-head-0.0.0-latest.tgz" "$HELM_CALLS" + grep -F "vcluster-head-0.0.0-abc1234.tgz" "$HELM_CALLS" +} + +@test "adds chartmuseum repo before pushing" { + run bash "$SCRIPT" + [ "$status" -eq 0 ] + # The repo add line must precede any cm-push line in the call log. + awk '/repo add chartmuseum/{seen=1} /cm-push/{if(!seen){print "push before add"; exit 1}}' "$HELM_CALLS" +} + +# --- Republish latest ------------------------------------------------------- + +@test "republish-latest is a no-op when pushed version equals repo latest" { + cat > "$HELM_SEARCH_OUTPUT" <<'JSON' +[{"name":"chartmuseum/my-chart","version":"1.2.3"}, + {"name":"chartmuseum/my-chart","version":"1.2.2"}] +JSON + REPUBLISH_LATEST=true run bash "$SCRIPT" + [ "$status" -eq 0 ] + [[ "$output" == *"already the repo's latest"* ]] + [ "$(helm_call_count "pull chartmuseum/my-chart")" -eq 0 ] +} + +@test "republish-latest re-pushes when repo has a newer version" { + cat > "$HELM_SEARCH_OUTPUT" <<'JSON' +[{"name":"chartmuseum/my-chart","version":"4.7.0"}, + {"name":"chartmuseum/my-chart","version":"1.2.3"}] +JSON + CHART_VERSIONS_JSON='["1.2.4"]' REPUBLISH_LATEST=true run bash "$SCRIPT" + [ "$status" -eq 0 ] + [[ "$output" == *"Re-pushing latest version 4.7.0"* ]] + [ "$(helm_call_count "pull chartmuseum/my-chart --version 4.7.0")" -eq 1 ] + grep -F "my-chart-4.7.0.tgz" "$HELM_CALLS" +} + +@test "republish-latest defaults to false (no search call)" { + run bash "$SCRIPT" + [ "$status" -eq 0 ] + [ "$(helm_call_count "search repo")" -eq 0 ] +} + +@test "republish-latest fails loudly when helm pull fails" { + cat > "$HELM_SEARCH_OUTPUT" <<'JSON' +[{"name":"chartmuseum/my-chart","version":"4.7.0"}] +JSON + echo "fail" > "$HELM_PULL_FAIL" + CHART_VERSIONS_JSON='["1.2.4"]' REPUBLISH_LATEST=true run bash "$SCRIPT" + [ "$status" -ne 0 ] + [[ "$output" == *"Failed to pull"* ]] +} diff --git a/.github/workflows/publish-helm-chart.yaml b/.github/workflows/publish-helm-chart.yaml new file mode 100644 index 0000000..12b25d7 --- /dev/null +++ b/.github/workflows/publish-helm-chart.yaml @@ -0,0 +1,122 @@ +name: Publish Helm chart (reusable) + +on: + workflow_call: + inputs: + chart-name: + description: "Helm chart name. Written to .name in Chart.yaml; also determines the packaged tarball filename." + type: string + required: true + chart-description: + description: "Optional value written to .description in Chart.yaml. When empty, the existing description is preserved." + type: string + required: false + default: "" + app-version: + description: "Optional value passed as --app-version to `helm package`. When empty, the chart's existing appVersion is used." + type: string + required: false + default: "" + chart-versions: + description: "JSON array of chart versions to publish. Each entry is packaged and pushed as -.tgz. Examples: '[\"1.2.3\"]' or '[\"0.0.0-latest\",\"0.0.0-abc1234\"]'." + type: string + required: true + chart-directory: + description: "Path to the Helm chart source directory." + type: string + required: false + default: chart + values-edits: + description: "Optional newline-separated `jsonpath=value` pairs applied via yq to /values.yaml. Values are written as strings." + type: string + required: false + default: "" + helm-version: + description: "Helm CLI version to install." + type: string + required: false + # renovate: datasource=github-releases depName=helm/helm + default: v3.20.0 + ref: + description: "Optional git ref to checkout before publishing (e.g. a release tag). Defaults to the caller's GITHUB_REF." + type: string + required: false + default: "" + republish-latest: + description: "When true, after pushing, query ChartMuseum for the highest semver of and re-push it so it becomes the most recently uploaded entry. Use for stable release publishing into a multi-line release stream." + type: boolean + required: false + default: false + chart-museum-url: + description: "ChartMuseum base URL." + type: string + required: false + default: https://charts.loft.sh/ + secrets: + chart-museum-user: + description: "ChartMuseum username." + required: true + chart-museum-password: + description: "ChartMuseum password." + required: true + +jobs: + publish: + name: Publish ${{ inputs.chart-name }} + runs-on: ubuntu-22.04 + timeout-minutes: 15 + permissions: + contents: read + steps: + - name: Resolve reusable workflow ref + id: wref + env: + WORKFLOW_REF: ${{ github.workflow_ref }} + run: | + # `github.workflow_sha` resolves to the caller's commit in + # workflow_call, so we parse `owner/repo/path@ref` from + # workflow_ref to get the ref of the reusable workflow itself. + echo "ref=${WORKFLOW_REF##*@}" >> "${GITHUB_OUTPUT}" + + - name: Check out caller repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ inputs.ref }} + persist-credentials: false + + - name: Check out reusable workflow scripts + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + repository: loft-sh/github-actions + ref: ${{ steps.wref.outputs.ref }} + persist-credentials: false + sparse-checkout: .github/scripts/publish-helm-chart + path: .github-actions-scripts + + - name: Set up Helm + uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4.3.1 + with: + version: ${{ inputs.helm-version }} + + - name: Install helm-push plugin + run: helm plugin install https://github.com/chartmuseum/helm-push.git + + - name: Set up yq + uses: dcarbone/install-yq-action@4075b4dca348d74bd83f2bf82d30f25d7c54539b # v1.3.1 + with: + # renovate: datasource=github-releases depName=mikefarah/yq + version: v4.45.1 + + - name: Publish chart + env: + CHART_DIRECTORY: ${{ inputs.chart-directory }} + CHART_NAME: ${{ inputs.chart-name }} + CHART_DESCRIPTION: ${{ inputs.chart-description }} + APP_VERSION: ${{ inputs.app-version }} + CHART_VERSIONS_JSON: ${{ inputs.chart-versions }} + VALUES_EDITS: ${{ inputs.values-edits }} + REPUBLISH_LATEST: ${{ inputs.republish-latest }} + CHART_MUSEUM_URL: ${{ inputs.chart-museum-url }} + CHART_MUSEUM_USER: ${{ secrets.chart-museum-user }} # zizmor: ignore[secrets-outside-env] -- passed via workflow_call, not a repo secret + CHART_MUSEUM_PASSWORD: ${{ secrets.chart-museum-password }} # zizmor: ignore[secrets-outside-env] -- passed via workflow_call, not a repo secret + run: .github-actions-scripts/.github/scripts/publish-helm-chart/run.sh diff --git a/.github/workflows/test-publish-helm-chart.yaml b/.github/workflows/test-publish-helm-chart.yaml new file mode 100644 index 0000000..c0897be --- /dev/null +++ b/.github/workflows/test-publish-helm-chart.yaml @@ -0,0 +1,25 @@ +name: Test publish-helm-chart + +on: + pull_request: + paths: + - '.github/scripts/publish-helm-chart/**' + - '.github/workflows/publish-helm-chart.yaml' + +permissions: {} + +jobs: + bats: + name: Run bats tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - uses: dcarbone/install-yq-action@4075b4dca348d74bd83f2bf82d30f25d7c54539b # v1.3.1 + with: + # renovate: datasource=github-releases depName=mikefarah/yq + version: v4.45.1 + - uses: bats-core/bats-action@77d6fb60505b4d0d1d73e48bd035b55074bbfb43 # 4.0.0 + with: + tests: .github/scripts/publish-helm-chart/test diff --git a/CLAUDE.md b/CLAUDE.md index 4fba40d..0cd6dc7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,6 +7,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co - Test semver-validation: `make test-semver-validation` - Test linear-pr-commenter: `make test-linear-pr-commenter` - Test linear-release-sync: `make test-linear-release-sync` +- Test publish-helm-chart: `make test-publish-helm-chart` (requires mikefarah/yq on PATH) - Build linear-release-sync binary: `make build-linear-release-sync` - Lint workflows: `make lint` (requires actionlint and zizmor) diff --git a/Makefile b/Makefile index 904cb0d..5d1e0a6 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: test test-semver-validation test-linear-pr-commenter test-release-notification test-linear-release-sync test-cleanup-head-charts test-ci-test-notify test-auto-approve-bot-prs build-linear-release-sync lint help +.PHONY: test test-semver-validation test-linear-pr-commenter test-release-notification test-linear-release-sync test-cleanup-head-charts test-ci-test-notify test-auto-approve-bot-prs test-publish-helm-chart build-linear-release-sync lint help ACTIONS_DIR := .github/actions SCRIPTS_DIR := .github/scripts @@ -6,7 +6,7 @@ SCRIPTS_DIR := .github/scripts help: ## show this help @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " %-30s %s\n", $$1, $$2}' -test: test-semver-validation test-linear-pr-commenter test-release-notification test-linear-release-sync test-cleanup-head-charts test-auto-approve-bot-prs test-ci-test-notify ## run all action tests +test: test-semver-validation test-linear-pr-commenter test-release-notification test-linear-release-sync test-cleanup-head-charts test-auto-approve-bot-prs test-ci-test-notify test-publish-helm-chart ## run all action tests test-semver-validation: ## run semver-validation unit tests cd $(ACTIONS_DIR)/semver-validation && npm ci --silent && NODE_OPTIONS=--experimental-vm-modules npx jest --ci --coverage --watchAll=false @@ -29,6 +29,9 @@ test-auto-approve-bot-prs: ## run auto-approve-bot-prs bats tests test-ci-test-notify: ## run ci-test-notify bats tests bats $(ACTIONS_DIR)/ci-test-notify/test/build-payload.bats +test-publish-helm-chart: ## run publish-helm-chart bats tests (requires mikefarah/yq on PATH) + bats $(SCRIPTS_DIR)/publish-helm-chart/test/run.bats + build-linear-release-sync: ## build linear-release-sync binary (linux/amd64) cd $(ACTIONS_DIR)/linear-release-sync/src && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o ../linear-release-sync-linux-amd64 . diff --git a/README.md b/README.md index 76d8d44..25411e0 100644 --- a/README.md +++ b/README.md @@ -147,6 +147,67 @@ jobs: - `reporter` (optional, default: `github-pr-review`): reviewdog reporter type +### Publish Helm Chart + +Packages a Helm chart and pushes one tarball per version to ChartMuseum. +Handles release pushes (single semver, optional `--app-version`) and head +pushes (multiple `0.0.0-*` versions) under the same contract. Optionally +re-pushes the repo's highest semver afterwards so it stays first in the +upload-ordered ChartMuseum index. + +**Location:** `.github/workflows/publish-helm-chart.yaml` + +**Usage (release push):** + +```yaml +jobs: + publish-chart: + permissions: + contents: read + uses: loft-sh/github-actions/.github/workflows/publish-helm-chart.yaml@publish-helm-chart/v1 + with: + chart-name: vcluster + app-version: 1.2.3 + chart-versions: '["1.2.3"]' + ref: v1.2.3 + secrets: + chart-museum-user: ${{ secrets.CHART_MUSEUM_USER }} + chart-museum-password: ${{ secrets.CHART_MUSEUM_PASSWORD }} +``` + +**Usage (head/dev push):** + +```yaml +jobs: + push-head-chart: + permissions: + contents: read + uses: loft-sh/github-actions/.github/workflows/publish-helm-chart.yaml@publish-helm-chart/v1 + with: + chart-name: vcluster-head + chart-description: "vCluster HEAD - Development builds from main branch" + app-version: head-${{ github.sha }} + chart-versions: '["0.0.0-latest","0.0.0-${{ github.sha }}"]' + secrets: + chart-museum-user: ${{ secrets.CHART_MUSEUM_USER }} + chart-museum-password: ${{ secrets.CHART_MUSEUM_PASSWORD }} +``` + +**Inputs:** + +- `chart-name` (required): chart name written to `Chart.yaml` and used in the tarball filename +- `chart-description` (optional): value written to `.description` in `Chart.yaml` +- `app-version` (optional): passed as `--app-version` to `helm package` +- `chart-versions` (required): JSON array of versions, e.g. `'["1.2.3"]'` +- `chart-directory` (optional, default: `chart`): chart source path +- `values-edits` (optional): newline-separated `jsonpath=value` pairs applied via yq to `/values.yaml` +- `helm-version` (optional, default: `v3.20.0`) +- `ref` (optional): git ref to checkout (e.g. release tag) +- `republish-latest` (optional, default: `false`): re-push highest semver to keep it first in the ChartMuseum index +- `chart-museum-url` (optional, default: `https://charts.loft.sh/`) + +**Secrets:** `chart-museum-user`, `chart-museum-password`. + ## Testing Run all action tests locally: diff --git a/renovate.json b/renovate.json index 1d229ef..bc9ce33 100644 --- a/renovate.json +++ b/renovate.json @@ -41,6 +41,12 @@ "matchStrings": ["renovate@(?[^\\s]+)"], "depNameTemplate": "renovate", "datasourceTemplate": "npm" + }, + { + "customType": "regex", + "description": "Update versions annotated with `# renovate: datasource=X depName=Y` in workflow inputs", + "managerFilePatterns": ["/^\\.github/workflows/.+\\.ya?ml$/"], + "matchStrings": ["# renovate: datasource=(?\\S+) depName=(?\\S+)\\s+(?:default|version): (?\\S+)"] } ] }