diff --git a/actions/helm/prepare-chart/README.md b/actions/helm/prepare-chart/README.md new file mode 100644 index 00000000..286489bb --- /dev/null +++ b/actions/helm/prepare-chart/README.md @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/actions/helm/prepare-chart/action.yml b/actions/helm/prepare-chart/action.yml new file mode 100644 index 00000000..4a1baae7 --- /dev/null +++ b/actions/helm/prepare-chart/action.yml @@ -0,0 +1,84 @@ +--- +name: "Prepare Helm Chart" +description: | + Add Helm repositories and build chart dependencies + for all charts found under a path. +author: hoverkraft +branding: + icon: package + color: blue + +inputs: + path: + description: "Path containing the chart(s) to prepare" + required: true + helm-repositories: + description: | + List of Helm repositories to add before building chart dependencies. + See https://helm.sh/docs/helm/helm_repo_add/. + required: false + +runs: + using: "composite" + steps: + - name: Add Helm repositories + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + INPUT_HELM_REPOSITORIES: ${{ inputs.helm-repositories }} + with: + script: | + const repositories = (process.env.INPUT_HELM_REPOSITORIES ?? '') + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0); + + for (const repository of repositories) { + await exec.exec('helm', [ + 'repo', + 'add', + ...repository.split(/\s+/), + ]); + } + + - name: Build chart dependencies + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + INPUT_PATH: ${{ inputs.path }} + with: + script: | + const path = require('node:path'); + + const chartPath = process.env.INPUT_PATH; + if (!chartPath) { + core.setFailed('"path" input is missing'); + return; + } + + core.info('Building charts dependencies'); + + const chartRootDir = path.resolve( + process.env.GITHUB_WORKSPACE ?? '.', + chartPath, + ); + const chartFiles = []; + const globber = await glob.create( + `${chartPath}/**/Chart.yaml`, + { followSymbolicLinks: false }, + ); + + for await (const chartFile of globber.globGenerator()) { + chartFiles.push(chartFile); + } + + if (chartFiles.length === 0) { + core.info(`No charts found in ${chartRootDir}`); + return; + } + + chartFiles.sort(); + + for (const chartFile of chartFiles) { + const chartDir = path.dirname(chartFile); + core.info(`Building dependencies for ${chartDir}`); + await exec.exec('helm', ['dependency', 'build', chartDir]); + } diff --git a/actions/helm/release-chart/action.yml b/actions/helm/release-chart/action.yml index 549cb5de..0e47e63f 100644 --- a/actions/helm/release-chart/action.yml +++ b/actions/helm/release-chart/action.yml @@ -252,45 +252,12 @@ runs: - uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 # v5.0.0 - - shell: bash - env: - INPUT_HELM_REPOSITORIES: ${{ inputs.helm-repositories }} - run: | - # For each line in the input, add the Helm repository - printf '%s\n' "$INPUT_HELM_REPOSITORIES" | while IFS= read -r line; do - if [ -z "$line" ]; then - continue - fi - - # shellcheck disable=SC2086 - helm repo add $line - done - - - shell: bash - env: - INPUT_PATH: ${{ inputs.path }} - run: | - echo "Building charts dependencies" - - CHART_ROOT_DIR="$(pwd)/$INPUT_PATH" - CHART_FILES=$(find "$CHART_ROOT_DIR" -name "Chart.yaml") - - # If no files found, exit - if [ -z "$CHART_FILES" ]; then - echo "No charts found in $CHART_ROOT_DIR" - exit 0 - fi - - # For each chart, build dependencies - for chart in $CHART_FILES; do - if [ ! -f "$chart" ]; then - continue - fi - - CHART_DIR=$(dirname "$chart") - echo "Building dependencies for $CHART_DIR" - helm dependency build "$CHART_DIR" - done + - name: Prepare chart dependencies + uses: ./actions/helm/prepare-chart + with: + path: ${{ inputs.path }} + helm-repositories: >- + ${{ inputs.helm-repositories }} - id: chart-releaser uses: appany/helm-oci-chart-releaser@d94988c92bed2e09c6113981f15f8bb495d10943 # v0.5.0 diff --git a/actions/helm/test-chart/action.yml b/actions/helm/test-chart/action.yml index 45974c3a..3291b04a 100644 --- a/actions/helm/test-chart/action.yml +++ b/actions/helm/test-chart/action.yml @@ -66,11 +66,15 @@ inputs: runs: using: "composite" steps: - - shell: bash + - name: Validate inputs if: ${{ inputs.enable-lint != 'true' && inputs.enable-install != 'true' }} - run: | - echo "::error ::At least one of 'enable-lint' or 'enable-install' input must be true" - exit 1 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + core.setFailed( + "At least one of 'enable-lint' or 'enable-install' " + + 'input must be true', + ); - uses: hoverkraft-tech/ci-github-common/actions/checkout@4c9d51717dc04d823dac2dc9ac2857e7b3069454 # 0.35.0 with: @@ -78,14 +82,27 @@ runs: - name: Check for .tools-version file id: check-tools-version - shell: bash - working-directory: ${{ inputs.working-directory }} - run: | - if [[ -f .tools-version ]]; then - echo "tools-version-exists=true" >> "$GITHUB_OUTPUT" - else - echo "tools-version-exists=false" >> "$GITHUB_OUTPUT" - fi + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + WORKING_DIRECTORY: ${{ inputs.working-directory }} + with: + script: | + const fs = require('node:fs'); + const path = require('node:path'); + + const workingDirectory = path.resolve( + process.env.GITHUB_WORKSPACE ?? '.', + process.env.WORKING_DIRECTORY ?? '.', + ); + const toolsVersionPath = path.join( + workingDirectory, + '.tools-version', + ); + + core.setOutput( + 'tools-version-exists', + fs.existsSync(toolsVersionPath) ? 'true' : 'false', + ); - name: Install tools with asdf if: ${{ steps.check-tools-version.outputs.tools-version-exists == 'true' }} @@ -93,63 +110,92 @@ runs: - name: Check for ct.yaml file id: check-ct-yaml - shell: bash - working-directory: ${{ inputs.working-directory }} - run: | - if [[ -f ct.yaml ]]; then - echo "path=ct.yaml" >> "$GITHUB_OUTPUT" - fi + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + WORKING_DIRECTORY: ${{ inputs.working-directory }} + with: + script: | + const fs = require('node:fs'); + const path = require('node:path'); + + const workingDirectory = path.resolve( + process.env.GITHUB_WORKSPACE ?? '.', + process.env.WORKING_DIRECTORY ?? '.', + ); + const ctConfigPath = path.join(workingDirectory, 'ct.yaml'); + + if (fs.existsSync(ctConfigPath)) { + core.setOutput('path', 'ct.yaml'); + } - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 - name: Set up chart-testing uses: helm/chart-testing-action@6ec842c01de15ebb84c8627d2744a0c2f2755c9f # v2.8.0 - - shell: bash - run: | - # For each line in the input, add the Helm repository - echo "${{ inputs.helm-repositories }}" | while read -r line; do - if [ -z "$line" ]; then - continue - fi - - # shellcheck disable=SC2086 - helm repo add $line - done + - name: Prepare chart dependencies + uses: ./actions/helm/prepare-chart + with: + path: ${{ inputs.working-directory }} + helm-repositories: >- + ${{ inputs.helm-repositories }} - name: Prepare ct variables id: prepare-ct-variables - shell: bash - run: | - if [ "${{ inputs.check-diff-only }}" == "true" ]; then - if [ "${{ github.event_name }}" == "pull_request" ]; then - TARGET_BRANCH="${{ github.event.pull_request.base.ref }}" - else - TARGET_BRANCH="${{ github.event.repository.default_branch }}" - fi - CT_ARGS="--target-branch $TARGET_BRANCH" - fi - - if [ -n "${{ steps.check-ct-yaml.outputs.path }}" ]; then - CT_ARGS="$CT_ARGS --config ${{ steps.check-ct-yaml.outputs.path }}" - fi - - if [ -z "$CT_ARGS" ]; then - CT_ARGS="--all" - fi - - echo "args=$CT_ARGS" >> "$GITHUB_OUTPUT" - - # Namespace for the test cluster - NAMESPACE="test-chart-${{ github.run_id}}-$(uuidgen)" - echo "namespace=$NAMESPACE" >> "$GITHUB_OUTPUT" + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + INPUT_CHECK_DIFF_ONLY: ${{ inputs.check-diff-only }} + CT_CONFIG_PATH: ${{ steps.check-ct-yaml.outputs.path }} + with: + script: | + const { randomUUID } = require('node:crypto'); + + const args = []; + + if (process.env.INPUT_CHECK_DIFF_ONLY === 'true') { + const targetBranch = context.eventName === 'pull_request' + ? context.payload.pull_request?.base?.ref + : context.payload.repository?.default_branch; + + if (targetBranch) { + args.push('--target-branch', targetBranch); + } + } + + if (process.env.CT_CONFIG_PATH) { + args.push('--config', process.env.CT_CONFIG_PATH); + } + + if (args.length === 0) { + args.push('--all'); + } + + core.setOutput('args', args.join(' ')); + core.setOutput('args-json', JSON.stringify(args)); + core.setOutput( + 'namespace', + `test-chart-${process.env.GITHUB_RUN_ID}-${randomUUID()}`, + ); - name: Run chart-testing (lint) if: ${{ inputs.enable-lint == 'true' }} - shell: bash - working-directory: ${{ inputs.working-directory }} - run: | - ct lint ${{ steps.prepare-ct-variables.outputs.args }} + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + WORKING_DIRECTORY: ${{ inputs.working-directory }} + CT_ARGS_JSON: ${{ steps.prepare-ct-variables.outputs.args-json }} + with: + script: | + const path = require('node:path'); + + const workingDirectory = path.resolve( + process.env.GITHUB_WORKSPACE ?? '.', + process.env.WORKING_DIRECTORY ?? '.', + ); + const ctArgs = JSON.parse(process.env.CT_ARGS_JSON ?? '[]'); + + await exec.exec('ct', ['lint', ...ctArgs], { + cwd: workingDirectory, + }); - name: Create kind cluster if: ${{ inputs.enable-install == 'true' }} @@ -158,72 +204,85 @@ runs: - name: Install default OCI registry secrets id: oci-registry-secret if: ${{ inputs.enable-install == 'true' && inputs.oci-registry != '' && inputs.oci-registry-username != '' && inputs.oci-registry-password != '' }} - shell: bash - run: | - SECRET_NAME="regcred" - echo "oci-registry-secret=$SECRET_NAME" >> "$GITHUB_OUTPUT" - - # See https://kubernetes.io/docs/tasks/configure-pod-container/pull-image-private-registry/ - NAMESPACE="${{ steps.prepare-ct-variables.outputs.namespace }}" - kubectl create namespace "$NAMESPACE" - - SECRET_NAME="regcred" - DOCKER_REGISTRY="${{ inputs.oci-registry }}" - DOCKER_USERNAME="${{ inputs.oci-registry-username }}" - DOCKER_PASSWORD="${{ inputs.oci-registry-password }}" - - kubectl --context kind-chart-testing create secret docker-registry "$SECRET_NAME" \ - --namespace="$NAMESPACE" \ - --docker-server=$DOCKER_REGISTRY \ - --docker-username=$DOCKER_USERNAME \ - --docker-password=$DOCKER_PASSWORD + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + NAMESPACE: ${{ steps.prepare-ct-variables.outputs.namespace }} + INPUT_OCI_REGISTRY: ${{ inputs.oci-registry }} + INPUT_OCI_REGISTRY_USERNAME: ${{ inputs.oci-registry-username }} + INPUT_OCI_REGISTRY_PASSWORD: ${{ inputs.oci-registry-password }} + with: + script: | + const secretName = 'regcred'; + const namespace = process.env.NAMESPACE; + + core.setOutput('name', secretName); + + await exec.exec('kubectl', ['create', 'namespace', namespace]); + await exec.exec('kubectl', [ + '--context', + 'kind-chart-testing', + 'create', + 'secret', + 'docker-registry', + secretName, + `--namespace=${namespace}`, + `--docker-server=${process.env.INPUT_OCI_REGISTRY}`, + `--docker-username=${process.env.INPUT_OCI_REGISTRY_USERNAME}`, + `--docker-password=${process.env.INPUT_OCI_REGISTRY_PASSWORD}`, + ]); - name: Run chart-testing (install) if: ${{ inputs.enable-install == 'true' }} - shell: bash - working-directory: ${{ inputs.working-directory }} env: + WORKING_DIRECTORY: ${{ inputs.working-directory }} + CT_ARGS_JSON: ${{ steps.prepare-ct-variables.outputs.args-json }} + NAMESPACE: ${{ steps.prepare-ct-variables.outputs.namespace }} + INPUT_HELM_SET: ${{ inputs.helm-set }} + OCI_REGISTRY_SECRET: ${{ steps.oci-registry-secret.outputs.name }} HELM_EXPERIMENTAL_OCI: true - run: | - NAMESPACE="${{ steps.prepare-ct-variables.outputs.namespace }}" - - HELM_SET="namespace=$NAMESPACE - ${{ inputs.helm-set }}" - - OCI_REGISTRY_SECRET="${{ steps.oci-registry-secret.outputs.oci-registry-secret }}" - if [ -n "$OCI_REGISTRY_SECRET" ]; then - # Ensure secret exists - kubectl get secret "$OCI_REGISTRY_SECRET" --output=yaml --namespace=$NAMESPACE - - HELM_SET="$HELM_SET - imagePullSecrets[0].name=${OCI_REGISTRY_SECRET}" - fi - - HELM_EXTRA_SET_ARGS="" - if [ -n "$HELM_SET" ]; then - IFS=$'\n' read -r -d '' -a lines <<< "$HELM_SET" || true - for line in "${lines[@]}"; do - if [ -z "$line" ]; then - continue - fi - # Remove leading and trailing whitespace - line=$(echo "$line" | xargs) || true - - # Escape commas in the line - line=$(echo "$line" | sed 's/,/\\,/g') || true - - if [ -n "$HELM_EXTRA_SET_ARGS" ]; then - HELM_EXTRA_SET_ARGS="${HELM_EXTRA_SET_ARGS}," - fi - - HELM_EXTRA_SET_ARGS="${HELM_EXTRA_SET_ARGS}${line}" - done - fi - - HELM_EXTRA_SET_ARGS="--set=${HELM_EXTRA_SET_ARGS}" - - COMMAND="ct install ${{ steps.prepare-ct-variables.outputs.args }} --namespace $NAMESPACE --helm-extra-set-args='${HELM_EXTRA_SET_ARGS}'" - echo "::debug ::$COMMAND" - - # shellcheck disable=SC2086 - eval $COMMAND + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const path = require('node:path'); + + const workingDirectory = path.resolve( + process.env.GITHUB_WORKSPACE ?? '.', + process.env.WORKING_DIRECTORY ?? '.', + ); + const namespace = process.env.NAMESPACE; + const ctArgs = JSON.parse(process.env.CT_ARGS_JSON ?? '[]'); + const helmSetLines = [ + `namespace=${namespace}`, + ...(process.env.INPUT_HELM_SET ?? '').split(/\r?\n/), + ] + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .map((line) => line.replaceAll(',', '\\,')); + + const ociRegistrySecret = process.env.OCI_REGISTRY_SECRET ?? ''; + if (ociRegistrySecret) { + await exec.exec('kubectl', [ + 'get', + 'secret', + ociRegistrySecret, + '--output=yaml', + `--namespace=${namespace}`, + ]); + + helmSetLines.push( + `imagePullSecrets[0].name=${ociRegistrySecret}`, + ); + } + + const helmExtraSetArgs = `--set=${helmSetLines.join(',')}`; + + await exec.exec('ct', [ + 'install', + ...ctArgs, + '--namespace', + namespace, + '--helm-extra-set-args', + helmExtraSetArgs, + ], { + cwd: workingDirectory, + });