diff --git a/.github/workflows/__shared-ci.yml b/.github/workflows/__shared-ci.yml index e6085145..eeddeab9 100644 --- a/.github/workflows/__shared-ci.yml +++ b/.github/workflows/__shared-ci.yml @@ -51,6 +51,12 @@ jobs: permissions: contents: read + test-action-helm-update-chart-values: + needs: linter + uses: ./.github/workflows/__test-action-helm-update-chart-values.yml + permissions: + contents: read + test-action-helm-release-chart: needs: linter uses: ./.github/workflows/__test-action-helm-release-chart.yml diff --git a/.github/workflows/__test-action-helm-update-chart-values.yml b/.github/workflows/__test-action-helm-update-chart-values.yml new file mode 100644 index 00000000..501b4093 --- /dev/null +++ b/.github/workflows/__test-action-helm-update-chart-values.yml @@ -0,0 +1,100 @@ +--- +name: Test for "helm/update-chart-values" action +run-name: Test for "actions/helm/update-chart-values" action + +on: # yamllint disable-line rule:truthy + workflow_call: + +permissions: {} + +jobs: + test-simple-chart: + name: Test for "helm/update-chart-values" action with simple chart + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Act + uses: ./actions/helm/update-chart-values + with: + path: tests/charts/application + values: | + [ + { "path": ".image.registry", "value": "ghcr.io" }, + { + "path": ".image.repository", + "value": "hoverkraft-tech/ci-github-container/application" + }, + { "path": ".image.tag", "value": "0.2.0" } + ] + + - name: Assert - Check updated chart files + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const assert = require('node:assert'); + const fs = require('node:fs'); + + const chartContent = fs.readFileSync('tests/charts/application/Chart.yaml', 'utf8'); + assert.match(chartContent, /name:\s+"test-application"/, 'root chart name should stay unchanged'); + assert.match(chartContent, /version:\s+0\.0\.0/, 'root chart version should stay unchanged'); + assert.match(chartContent, /appVersion:\s+"0\.0\.0"/, 'root chart appVersion should stay unchanged'); + + const valuesContent = fs.readFileSync('tests/charts/application/values.yaml', 'utf8'); + assert.match(valuesContent, /registry:\s+"ghcr\.io"/, 'image registry should be updated'); + assert.match(valuesContent, /repository:\s+"hoverkraft-tech\/ci-github-container\/application"/, 'image repository should be updated'); + assert.match(valuesContent, /tag:\s+"0\.2\.0"/, 'image tag should be updated'); + + test-umbrella-chart: + name: Test for "helm/update-chart-values" action with umbrella chart + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Act + uses: ./actions/helm/update-chart-values + with: + path: tests/charts/umbrella-application + values: | + [ + { "file": "charts/app/values.yaml", "path": ".image.registry", "value": "ghcr.io" }, + { + "file": "charts/app/values.yaml", + "path": ".image.repository", + "value": "hoverkraft-tech/ci-github-container/umbrella-application" + }, + { "file": "charts/app/values.yaml", "path": ".image.tag", "value": "0.2.0" } + ] + + - name: Assert - Check updated umbrella chart files + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const assert = require('node:assert'); + const fs = require('node:fs'); + + const rootChartContent = fs.readFileSync('tests/charts/umbrella-application/Chart.yaml', 'utf8'); + assert.match(rootChartContent, /name:\s+test-umbrella-application/, 'root chart name should stay unchanged'); + assert.match(rootChartContent, /version:\s+0\.1\.0/, 'root chart version should stay unchanged'); + assert.match(rootChartContent, /appVersion:\s+"0\.1\.0"/, 'root chart appVersion should stay unchanged'); + assert.match(rootChartContent, /version:\s+0\.0\.0\s+condition:\s+app\.enabled/s, 'local dependency version in Chart.yaml should stay unchanged'); + + const childChartContent = fs.readFileSync('tests/charts/umbrella-application/charts/app/Chart.yaml', 'utf8'); + assert.match(childChartContent, /version:\s+0\.0\.0/, 'child chart version should stay unchanged'); + assert.match(childChartContent, /appVersion:\s+"0\.0\.0"/, 'child chart appVersion should stay unchanged'); + + const childValuesContent = fs.readFileSync('tests/charts/umbrella-application/charts/app/values.yaml', 'utf8'); + assert.match(childValuesContent, /registry:\s+"ghcr\.io"/, 'child image registry should be updated'); + assert.match(childValuesContent, /repository:\s+"hoverkraft-tech\/ci-github-container\/umbrella-application"/, 'child image repository should be updated'); + assert.match(childValuesContent, /tag:\s+"0\.2\.0"/, 'child image tag should be updated'); + + const chartLockContent = fs.readFileSync('tests/charts/umbrella-application/Chart.lock', 'utf8'); + assert.match(chartLockContent, /version:\s+0\.0\.0/, 'local dependency version in Chart.lock should stay unchanged'); diff --git a/README.md b/README.md index 8b40c1d1..8594502c 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,8 @@ _Actions dedicated to packaging, validating, and publishing Helm charts for Kube #### - [Parse chart URI](actions/helm/parse-chart-uri/README.md) +#### - [Update chart values](actions/helm/update-chart-values/README.md) + #### - [Release chart](actions/helm/release-chart/README.md) #### - [Test chart](actions/helm/test-chart/README.md) diff --git a/actions/helm/release-chart/action.yml b/actions/helm/release-chart/action.yml index 3f2aaec4..549cb5de 100644 --- a/actions/helm/release-chart/action.yml +++ b/actions/helm/release-chart/action.yml @@ -78,18 +78,18 @@ runs: steps: - uses: hoverkraft-tech/ci-github-common/actions/checkout@4c9d51717dc04d823dac2dc9ac2857e7b3069454 # 0.35.0 - - id: chart-values-updates + - id: chart-tag-updates uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: INPUT_PATH: ${{ inputs.path }} INPUT_TAG: ${{ inputs.tag }} INPUT_UPDATE_TAG_PATHS: ${{ inputs.update-tag-paths }} + INPUT_VALUES: ${{ inputs.values }} REPOSITORY_NAME: ${{ github.event.repository.name }} with: script: | const path = require('node:path'); - const yqUpdates = {}; const basePath = process.env.INPUT_PATH; if (!basePath) { throw new Error(`"path" input is missing`); @@ -100,153 +100,111 @@ runs: throw new Error(`"tag" input is missing`); } - const updateTagPaths = process.env.INPUT_UPDATE_TAG_PATHS.trim().split(',').map(p => p.trim()).filter(p => p); - - // Chart.yml files - const globber = await glob.create(`${basePath}/**/Chart.yaml`, {followSymbolicLinks: false}) - for await (const chartFile of globber.globGenerator()) { - const filePath = path.relative(process.env.GITHUB_WORKSPACE, chartFile); - if (!yqUpdates[filePath]) { - yqUpdates[filePath] = []; - } - - const isRootChart = filePath === path.join(basePath, "Chart.yaml"); - if (isRootChart) { - // Update name for root chart - yqUpdates[filePath].push(`.name = "${process.env.REPOSITORY_NAME}"`); - - // Update dependencies version where repository starts with file:// - if (updateTagPaths.includes('.version')) { - yqUpdates[filePath].push( - `(. as $doc | (select(has("dependencies")) | (.dependencies[] | select(.repository == "file://*")).version = "${tag}") // $doc)` - ); - } - } - - // Update tag fields - for (const path of updateTagPaths) { - yqUpdates[filePath].push(`${path} = "${tag}"`); - } - } - - // values.yml files - const chartValuesInput = `${{ inputs.values }}`; - if (chartValuesInput) { - - // Check if is valid Json - let chartValues = null; + const inputValues = process.env.INPUT_VALUES; + let chartValues = []; + if (inputValues) { try { - chartValues = JSON.parse(chartValuesInput); + chartValues = JSON.parse(inputValues); } catch (error) { throw new Error(`"values" input is not a valid JSON: ${error}`); } - // Check if is an array if (!Array.isArray(chartValues)) { throw new Error(`"values" input is not an array`); } + } - if (chartValues.length) { - const defaultValuesPath = "values.yaml"; - - // Check each item - for (const key in chartValues) { - const chartValue = chartValues[key]; - if (typeof chartValue !== 'object') { - throw new Error(`"values[${key}]" input is not an object`); - } - - // Check mandatory properties - for (const property of ['path', 'value']) { - if (!chartValue.hasOwnProperty(property)) { - throw new Error(`"values[${key}].${property}" input is missing`); - } - } + const updateTagPaths = process.env.INPUT_UPDATE_TAG_PATHS.trim().split(',').map(p => p.trim()).filter(p => p); + const chartRootPath = path.resolve(process.env.GITHUB_WORKSPACE ?? '.', basePath); + const generatedValues = []; - const valueFilePath = chartValue['file'] ? chartValue['file'] : defaultValuesPath; - const filePath = `${basePath}/${valueFilePath}`; + const globber = await glob.create(`${basePath}/**/Chart.yaml`, { followSymbolicLinks: false }); + for await (const chartFile of globber.globGenerator()) { + const filePath = path.relative(chartRootPath, chartFile); - if (!yqUpdates[filePath]) { - yqUpdates[filePath] = []; - } + const isRootChart = filePath === 'Chart.yaml'; + if (isRootChart) { + generatedValues.push({ + file: filePath, + path: '.name', + value: process.env.REPOSITORY_NAME, + }); - yqUpdates[filePath].push(`${chartValue.path} = "${chartValue.value}"`); + if (updateTagPaths.includes('.version')) { + generatedValues.push({ + file: filePath, + path: '(.dependencies[]? | select(.repository | test("^file://")).version)', + value: tag, + }); } } - } - // Build yq commands - const yqCommands = Object.entries(yqUpdates).map(([filePath, updates]) => { - return `yq -i '${updates.join(' | ')}' ${filePath}`; - }); + for (const path of updateTagPaths) { + generatedValues.push({ + file: filePath, + path, + value: tag, + }); + } + } - core.setOutput('yq-command', yqCommands.join('\n')); + core.setOutput('values', JSON.stringify([...generatedValues, ...chartValues])); - - uses: mikefarah/yq@751d8ad57b84f1794661bc70c0afb92a22ad7b3c # v4.53.2 + - uses: ./actions/helm/update-chart-values with: - cmd: | - ${{ steps.chart-values-updates.outputs.yq-command }} + path: ${{ inputs.path }} + values: ${{ steps.chart-tag-updates.outputs.values }} - name: Setup Node.js uses: hoverkraft-tech/ci-github-nodejs/actions/setup-node@a10d5e32daef8e060c49fe617833fb0d53476f22 # 0.24.0 with: working-directory: ${{ github.action_path }} - - name: Rewrite the Chart.lock to match with updated ombrella dependencies if any + - name: Rewrite the Chart.lock to match with updated umbrella dependencies if any uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: + INPUT_PATH: ${{ inputs.path }} + INPUT_TAG: ${{ inputs.tag }} NODE_PATH: ${{ github.action_path }}/node_modules with: script: | const fs = require('node:fs'); - const path = require('node:path'); const crypto = require('node:crypto'); - const yaml = require("yaml"); + const yaml = require('yaml'); - const rootChartFile = `${{ inputs.path }}/Chart.yaml`; + const rootChartFile = process.env.INPUT_PATH + '/Chart.yaml'; const rootChartFileContent = yaml.parse(fs.readFileSync(rootChartFile, 'utf8')); - // Check if the root chart has dependencies if (!rootChartFileContent.dependencies || rootChartFileContent.dependencies.length === 0) { return; } - const chartLockFile = `${{ inputs.path }}/Chart.lock`; + const chartLockFile = process.env.INPUT_PATH + '/Chart.lock'; if (!fs.existsSync(chartLockFile)) { return core.setFailed(`Chart.lock file not found: ${chartLockFile}`); } const chartLockFileContent = yaml.parse(fs.readFileSync(chartLockFile, 'utf8')); - // Update ombrella dependencies versions let hasLocalDependencies = false; const dependencies = chartLockFileContent.dependencies; - // Check if dependencies are empt for (const dependency of dependencies) { - const isLocalDependency = dependency.repository.startsWith("file://") && dependency.version === "0.0.0"; + const isLocalDependency = dependency.repository.startsWith('file://') && dependency.version === '0.0.0'; - // Check if the dependency is a local file if (isLocalDependency) { - // Update the version to the tag - dependency.version = `${{ inputs.tag }}`; + dependency.version = process.env.INPUT_TAG; hasLocalDependencies = true; } } - // If no local dependencies, exit if (!hasLocalDependencies) { return; } - // Update generated chartLockFileContent.generated = new Date().toISOString(); - // Update global digest. - - // See Helm hashReq function: https://github.com/helm/helm/blob/99c065789ef8c45bade24d4bc2d33432595de956/internal/resolver/resolver.go#L214 function hashReq(req, lock) { - // Sort the dependencies req = req.map(sortDependencyFields); lock = lock.map(sortDependencyFields); @@ -261,8 +219,6 @@ runs: return hash.digest('hex'); } - // Should respect the Helm struct order - // See https://github.com/helm/helm/blob/99c065789ef8c45bade24d4bc2d33432595de956/pkg/chart/v2/dependency.go#L24 function sortDependencyFields(dependency) { const fieldOrder = [ 'name', @@ -274,10 +230,9 @@ runs: 'import-values', 'alias', ]; - // Sort the dependency fields const sortedDependency = {}; for (const field of fieldOrder) { - if (dependency.hasOwnProperty(field)) { + if (Object.prototype.hasOwnProperty.call(dependency, field)) { sortedDependency[field] = dependency[field]; } } @@ -288,21 +243,21 @@ runs: const req = rootChartFileContent.dependencies; const lock = dependencies; - const hash = hashReq(req, lock); - chartLockFileContent.digest = hash; + chartLockFileContent.digest = hashReq(req, lock); const updatedChartLockFileContent = yaml.stringify(chartLockFileContent); core.debug(`Updated Chart.lock file content:\n${updatedChartLockFileContent}`); - // Update Chart.lock file fs.writeFileSync(chartLockFile, updatedChartLockFileContent, 'utf8'); - 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 - echo "${{ inputs.helm-repositories }}" | while read -r line; do + printf '%s\n' "$INPUT_HELM_REPOSITORIES" | while IFS= read -r line; do if [ -z "$line" ]; then continue fi @@ -312,10 +267,12 @@ runs: done - shell: bash + env: + INPUT_PATH: ${{ inputs.path }} run: | echo "Building charts dependencies" - CHART_ROOT_DIR="$(pwd)/${{ inputs.path }}" + CHART_ROOT_DIR="$(pwd)/$INPUT_PATH" CHART_FILES=$(find "$CHART_ROOT_DIR" -name "Chart.yaml") # If no files found, exit diff --git a/actions/helm/update-chart-values/README.md b/actions/helm/update-chart-values/README.md new file mode 100644 index 00000000..7466ac41 --- /dev/null +++ b/actions/helm/update-chart-values/README.md @@ -0,0 +1,126 @@ + + +# ![Icon](data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJmZWF0aGVyIGZlYXRoZXItZWRpdCIgY29sb3I9ImJsdWUiPjxwYXRoIGQ9Ik0xMSA0SDRhMiAyIDAgMCAwLTIgMnYxNGEyIDIgMCAwIDAgMiAyaDE0YTIgMiAwIDAgMCAyLTJ2LTciPjwvcGF0aD48cGF0aCBkPSJNMTguNSAyLjVhMi4xMjEgMi4xMjEgMCAwIDEgMyAzTDEyIDE1bC00IDEgMS00IDEwLjUtMTAuNXoiPjwvcGF0aD48L3N2Zz4=) GitHub Action: Update chart values + +
+ Update chart values +
+ +--- + + + + + +[![Marketplace](https://img.shields.io/badge/Marketplace-update--chart--values-blue?logo=github-actions)](https://github.com/marketplace/actions/update-chart-values) +[![Release](https://img.shields.io/github/v/release/hoverkraft-tech/ci-github-container)](https://github.com/hoverkraft-tech/ci-github-container/releases) +[![License](https://img.shields.io/github/license/hoverkraft-tech/ci-github-container)](http://choosealicense.com/licenses/mit/) +[![Stars](https://img.shields.io/github/stars/hoverkraft-tech/ci-github-container?style=social)](https://img.shields.io/github/stars/hoverkraft-tech/ci-github-container?style=social) +[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](https://github.com/hoverkraft-tech/ci-github-container/blob/main/CONTRIBUTING.md) + + + + + +## Overview + +Updates Helm chart values files before release. + + + + + +## Usage + +````yaml +- uses: hoverkraft-tech/ci-github-container/actions/helm/update-chart-values@2b647ed6f11d50cb6beb6d56333e68ba2c804826 # 0.33.1 + with: + # Path to the chart to update + # This input is required. + path: "" + + # Define charts values to be filled. + # See https://mikefarah.gitbook.io/yq/. + # Format: `[{ file, path, value }]`. + # + # Example: + # + # ```json + # [ + # { + # "file": "charts/application/charts/api/values.yaml", + # "path": ".image.registry", "value": "ghcr.io" + # } + # ] + # ``` + values: "" +```` + + + + + +## Inputs + +| **Input** | **Description** | **Required** | **Default** | +| ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------ | ----------- | +| **`path`** | Path to the chart to update | **true** | - | +| **`values`** | Define charts values to be filled. | **false** | - | +| | See . | | | +| | Format: `[{ file, path, value }]`. | | | +| | | | | +| | Example: | | | +| | | | | +| |
[
 {
 "file": "charts/application/charts/api/values.yaml",
 "path": ".image.registry", "value": "ghcr.io"
 }
]
| | | + + + + + + + + + + + + + + + + +## Contributing + +Contributions are welcome! Please see the [contributing guidelines](https://github.com/hoverkraft-tech/ci-github-container/blob/main/CONTRIBUTING.md) for more details. + + + + + + + + +## License + +This project is licensed under the MIT License. + +SPDX-License-Identifier: MIT + +Copyright © 2026 hoverkraft + +For more details, see the [license](http://choosealicense.com/licenses/mit/). + + + + + +--- + +This documentation was automatically generated by [CI Dokumentor](https://github.com/hoverkraft-tech/ci-dokumentor). + + + + diff --git a/actions/helm/update-chart-values/action.yml b/actions/helm/update-chart-values/action.yml new file mode 100644 index 00000000..5731ff55 --- /dev/null +++ b/actions/helm/update-chart-values/action.yml @@ -0,0 +1,97 @@ +name: "Update chart values" +description: | + Updates Helm chart values files before release. +author: hoverkraft +branding: + icon: edit + color: blue + +inputs: + path: + description: "Path to the chart to update" + required: true + values: + description: | + Define charts values to be filled. + See https://mikefarah.gitbook.io/yq/. + Format: `[{ file, path, value }]`. + + Example: + + ```json + [ + { + "file": "charts/application/charts/api/values.yaml", + "path": ".image.registry", "value": "ghcr.io" + } + ] + ``` + required: false +runs: + using: "composite" + steps: + - id: chart-values-updates + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + INPUT_PATH: ${{ inputs.path }} + INPUT_VALUES: ${{ inputs.values }} + with: + script: | + const yqUpdates = {}; + const basePath = process.env.INPUT_PATH; + if (!basePath) { + throw new Error(`"path" input is missing`); + } + + // values.yaml files + const chartValuesInput = process.env.INPUT_VALUES; + if (chartValuesInput) { + let chartValues = null; + try { + chartValues = JSON.parse(chartValuesInput); + } catch (error) { + throw new Error(`"values" input is not a valid JSON: ${error}`); + } + + if (!Array.isArray(chartValues)) { + throw new Error(`"values" input is not an array`); + } + + if (chartValues.length) { + const defaultValuesPath = 'values.yaml'; + + for (const key in chartValues) { + const chartValue = chartValues[key]; + if (typeof chartValue !== 'object' || chartValue === null) { + throw new Error(`"values[${key}]" input is not an object`); + } + + for (const property of ['path', 'value']) { + if (!Object.prototype.hasOwnProperty.call(chartValue, property)) { + throw new Error(`"values[${key}].${property}" input is missing`); + } + } + + const valueFilePath = chartValue.file ? chartValue.file : defaultValuesPath; + const filePath = `${basePath}/${valueFilePath}`; + + if (!yqUpdates[filePath]) { + yqUpdates[filePath] = []; + } + + yqUpdates[filePath].push(`${chartValue.path} = "${chartValue.value}"`); + } + } + } + + const yqCommands = Object.entries(yqUpdates).map(([filePath, updates]) => { + return `yq -i '${updates.join(' | ')}' ${filePath}`; + }); + + core.setOutput('yq-command', yqCommands.join('\n')); + + - if: ${{ steps.chart-values-updates.outputs.yq-command != '' }} + uses: mikefarah/yq@751d8ad57b84f1794661bc70c0afb92a22ad7b3c # v4.53.2 + with: + cmd: | + ${{ steps.chart-values-updates.outputs.yq-command }}