Compare commits

..

2 Commits

Author SHA1 Message Date
David Gamero 8a3c06fb8b Fix helm latest version retrieval command syntax 2025-07-22 19:52:56 +00:00
David Gamero b375d008a1 Update helm version retrieval to use JSON output for latest version 2025-07-22 19:37:51 +00:00
17 changed files with 4276 additions and 2763 deletions
+2 -2
View File
@@ -13,7 +13,7 @@ jobs:
# Steps represent a sequence of tasks that will be executed as part of the job
steps:
- uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 #v10.4.0
- uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 #v9.1.0
name: Setting issue as idle
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
@@ -24,7 +24,7 @@ jobs:
operations-per-run: 100
exempt-issue-labels: 'backlog'
- uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 #v10.4.0
- uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 #v9.1.0
name: Setting PR as idle
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
+26 -28
View File
@@ -7,22 +7,15 @@ on:
jobs:
trigger-integration-tests:
name: Trigger Integration tests
strategy:
matrix:
os:
- ubuntu-latest
- ubuntu-24.04-arm
- macos-latest # arm
fail-fast: false
runs-on: ${{ matrix.os }}
runs-on: ubuntu-latest
env:
HELM_3_18_4: 'v3.18.4'
HELM_3_18_0: 'v3.18.0'
HELM_NO_V: '3.18.4'
HELM_3_8_0: 'v3.8.0'
HELM_3_7_2: 'v3.7.2'
HELM_NO_V: '3.5.0'
PR_BASE_REF: ${{ github.event.pull_request.base.ref }}
steps:
- name: Check out repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: npm install and build
id: action-npm-build
run: |
@@ -34,41 +27,41 @@ jobs:
- name: Setup helm
uses: ./
with:
version: ${{ env.HELM_3_18_4 }}
- name: Validate helm 3.18.4
version: ${{ env.HELM_3_8_0 }}
- name: Validate helm 3.8.0
run: |
if [[ $(helm version) != *$HELM_3_18_4* ]]; then
echo "HELM VERSION INCORRECT: HELM VERSION DOES NOT CONTAIN v3.18.4"
if [[ $(helm version) != *$HELM_3_8_0* ]]; then
echo "HELM VERSION INCORRECT: HELM VERSION DOES NOT CONTAIN v3.8.0"
echo "HELM VERSION OUTPUT: $(helm version)"
exit 1
else
echo "HELM VERSION $HELM_3_18_4 INSTALLED SUCCESSFULLY"
echo "HELM VERSION $HELM_3_8_0 INSTALLED SUCCESSFULLY"
fi
- name: Setup helm 3.18.0
- name: Setup helm 3.7.2
uses: ./
with:
version: ${{ env.HELM_3_18_0 }}
- name: Validate 3.18.0
version: ${{ env.HELM_3_7_2 }}
- name: Validate 3.7.2
run: |
if [[ $(helm version) != *$HELM_3_18_0* ]]; then
echo "HELM VERSION INCORRECT: HELM VERSION DOES NOT CONTAIN v3.18.0"
if [[ $(helm version) != *$HELM_3_7_2* ]]; then
echo "HELM VERSION INCORRECT: HELM VERSION DOES NOT CONTAIN v3.7.2"
echo "HELM VERSION OUTPUT: $(helm version)"
exit 1
else
echo "HELM VERSION $HELM_3_18_0 INSTALLED SUCCESSFULLY"
echo "HELM VERSION $HELM_3_7_2 INSTALLED SUCCESSFULLY"
fi
- name: Setup helm 3.18.4 with no v in version
- name: Setup helm 3.5.0 with no v in version
uses: ./
with:
version: ${{ env.HELM_NO_V }}
- name: Validate 3.18.4 without v in version
- name: Validate 3.5.0 without v in version
run: |
if [[ $(helm version) != *$HELM_NO_V* ]]; then
echo "HELM VERSION INCORRECT: HELM VERSION DOES NOT CONTAIN 3.18.4"
echo "HELM VERSION INCORRECT: HELM VERSION DOES NOT CONTAIN v3.5.0"
echo "HELM VERSION OUTPUT: $(helm version)"
exit 1
else
echo "HELM VERSION $HELM_NO_V INSTALLED SUCCESSFULLY"
echo "HELM VERSION $HELM_3_5_0 INSTALLED SUCCESSFULLY"
fi
- name: Setup helm latest version
uses: ./
@@ -79,7 +72,12 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
HELM_LATEST=$(curl -s https://get.helm.sh/helm-latest-version)
HELM_LATEST=$(gh release list \
--repo helm/helm \
--exclude-drafts \
--exclude-pre-releases \
--json name,isLatest \
--jq '.[] | select(.isLatest)|.name' | awk '{print $2}')
if [[ $(helm version) != *$HELM_LATEST* ]]; then
echo "HELM VERSION INCORRECT: HELM VERSION DOES NOT CONTAIN $HELM_LATEST"
+1 -1
View File
@@ -10,7 +10,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Enforce Prettier
run: npx prettier --check .
+10
View File
@@ -0,0 +1,10 @@
name: Tag and create release draft
on:
push:
branches:
- releases/*
jobs:
tag-and-release:
uses: OliverMKing/javascript-release-workflow/.github/workflows/tag-and-release.yml@c753e1545b144562237cd1177a95bab21a785cff # main
+2 -11
View File
@@ -11,18 +11,9 @@ on: # rebuild any PRs and main branch changes
jobs:
build: # make sure build/ci works properly
strategy:
matrix:
os:
- ubuntu-latest
- ubuntu-24.04-arm
- windows-latest
- windows-11-arm
- macos-latest # arm
fail-fast: false
runs-on: ${{ matrix.os }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Run L0 tests.
run: |
-3
View File
@@ -65,6 +65,3 @@ coverage
# Transpiled JS
lib/
# Planning docs
docs/
-65
View File
@@ -1,70 +1,5 @@
# Change Log
## [5.0.1] - 2026-06-23
### Fixed
- #271 [Fix TypeScript 6.0 compatibility errors in run.ts](https://github.com/Azure/setup-helm/pull/271)
- #278 [fix: use chmod 755 instead of 777 for downloaded helm binary and folder](https://github.com/Azure/setup-helm/pull/278)
### Changed
- #286 [Bump actions/checkout from 6.0.3 to 7.0.0 in /.github/workflows in the actions group](https://github.com/Azure/setup-helm/pull/286)
- #285 [Bump the actions group with 2 updates](https://github.com/Azure/setup-helm/pull/285)
- #284 [Bump undici](https://github.com/Azure/setup-helm/pull/284)
- #283 [Bump the actions group with 5 updates](https://github.com/Azure/setup-helm/pull/283)
- #280 [Bump actions/checkout from 6.0.2 to 6.0.3 in /.github/workflows in the actions group](https://github.com/Azure/setup-helm/pull/280)
- #279 [Bump the actions group with 2 updates](https://github.com/Azure/setup-helm/pull/279)
- #277 [Bump vitest from 4.1.7 to 4.1.8 in the actions group](https://github.com/Azure/setup-helm/pull/277)
- #276 [chore: remove deprecated OliverMKing release workflow, pin to SHA](https://github.com/Azure/setup-helm/pull/276)
- #275 [Bump actions/stale from 10.2.0 to 10.3.0 in /.github/workflows in the actions group](https://github.com/Azure/setup-helm/pull/275)
- #274 [Bump the actions group with 3 updates](https://github.com/Azure/setup-helm/pull/274)
- #273 [Bump the actions group with 2 updates](https://github.com/Azure/setup-helm/pull/273)
- #269 [Bump the actions group across 1 directory with 7 updates](https://github.com/Azure/setup-helm/pull/269)
- #268 [Bump vite from 8.0.0 to 8.0.5](https://github.com/Azure/setup-helm/pull/268)
- #260 [Migrate to ESM with esbuild/vitest and upgrade to node24 (v5.0.0)](https://github.com/Azure/setup-helm/pull/260)
## [5.0.0] - 2026-03-23
### Changed
- #259 [Update Node.js runtime from node20 to node24](https://github.com/Azure/setup-helm/pull/259)
- #263 [Bump undici](https://github.com/Azure/setup-helm/pull/263)
- #257 [Bump undici and @actions/http-client](https://github.com/Azure/setup-helm/pull/257)
- #256 [Bump minimatch](https://github.com/Azure/setup-helm/pull/256)
- #248 [Bump the actions group with 2 updates](https://github.com/Azure/setup-helm/pull/248)
- #247 [Bump the actions group with 3 updates](https://github.com/Azure/setup-helm/pull/247)
- #246 [Bump @types/node from 25.0.2 to 25.0.3 in the actions group](https://github.com/Azure/setup-helm/pull/246)
- #245 [Bump the actions group with 3 updates](https://github.com/Azure/setup-helm/pull/245)
- #243 [Bump the actions group with 2 updates](https://github.com/Azure/setup-helm/pull/243)
- #240 [Bump prettier from 3.6.2 to 3.7.3 in the actions group](https://github.com/Azure/setup-helm/pull/240)
- #229 [Bump the actions group across 1 directory with 3 updates](https://github.com/Azure/setup-helm/pull/229)
- #231 [Bump js-yaml from 3.14.1 to 3.14.2](https://github.com/Azure/setup-helm/pull/231)
- #234 [Bump glob from 10.4.5 to 10.5.0](https://github.com/Azure/setup-helm/pull/234)
- #225 [Fix build error](https://github.com/Azure/setup-helm/pull/225)
- #222 [Bump @types/node from 24.7.2 to 24.8.1 in the actions group](https://github.com/Azure/setup-helm/pull/222)
- #220 [Bump the actions group across 1 directory with 4 updates](https://github.com/Azure/setup-helm/pull/220)
- #216 [Bump the actions group across 1 directory with 4 updates](https://github.com/Azure/setup-helm/pull/216)
- #213 [Bump the actions group with 2 updates](https://github.com/Azure/setup-helm/pull/213)
- #211 [Bump undici](https://github.com/Azure/setup-helm/pull/211)
- #212 [Bump jest from 30.0.5 to 30.1.2 in the actions group](https://github.com/Azure/setup-helm/pull/212)
- #210 [Bump @types/node from 24.2.1 to 24.3.0 in the actions group](https://github.com/Azure/setup-helm/pull/210)
## [4.3.1] - 2025-08-12
### Changed
- #167 [Pinning Action Dependencies for Security and Reliability](https://github.com/Azure/setup-helm/pull/167)
- #181 [Fix types, and update node version.](https://github.com/Azure/setup-helm/pull/181)
- #191 [chore(tests): Mock arch to make tests pass on arm host](https://github.com/Azure/setup-helm/pull/191)
- #192 [chore: remove unnecessary prebuild script](https://github.com/Azure/setup-helm/pull/192)
- #203 [Update helm version retrieval to use JSON output for latest version](https://github.com/Azure/setup-helm/pull/203)
- #207 [ci(workflows): update helm version to v3.18.4 and add matrix for tests](https://github.com/Azure/setup-helm/pull/207)
### Added
- #197 [Add pre-commit hook](https://github.com/Azure/setup-helm/pull/197)
## [4.3.0] - 2025-02-15
- #152 feat: log when restoring from cache
+2 -19
View File
@@ -7,31 +7,14 @@ Install a specific version of helm binary on the runner.
Acceptable values are latest or any semantic version string like v3.5.0 Use this action in workflow to define which version of helm will be used. v2+ of this action only support Helm3.
```yaml
- uses: azure/setup-helm@v5
- uses: azure/setup-helm@v4.3.0
with:
version: '<version>' # default is latest (stable)
id: install
```
Alternatively, the version can be read from a [`.tool-versions`](https://asdf-vm.com/manage/configuration.html) file (the format used by [asdf](https://asdf-vm.com/) and [mise](https://mise.jdx.dev/)) via the `version-file` input:
```yaml
- uses: azure/setup-helm@v5.0.0
with:
version-file: .tool-versions
id: install
```
The action reads the version declared for the `helm` tool, for example:
```
helm 3.18.4
```
If both `version` and `version-file` are set, an explicitly requested `version` takes precedence and `version-file` is ignored (a warning is emitted). Because `version` defaults to `latest`, `version-file` is only ignored when you set `version` to a specific value other than `latest`; if `version` is left at its default, the version from `version-file` is used.
> [!NOTE]
> If something goes wrong with fetching the latest version the action will use the hardcoded default version (currently v3.18.4). If you rely on a certain version higher than the default, you should explicitly use that version instead of latest.
> If something goes wrong with fetching the latest version the action will use the hardcoded default stable version (currently v3.13.3). If you rely on a certain version higher than the default, you should explicitly use that version instead of latest.
The cached helm binary path is prepended to the PATH environment variable as well as stored in the helm-path output variable.
Refer to the action metadata file for details about all the inputs https://github.com/Azure/setup-helm/blob/master/action.yml
+2 -5
View File
@@ -3,11 +3,8 @@ description: 'Install a specific version of helm binary. Acceptable values are l
inputs:
version:
description: 'Version of helm'
required: false
required: true
default: 'latest'
version-file:
description: 'Path to a .tool-versions file to read the helm version from'
required: false
token:
description: GitHub token. Used to be required to fetch the latest version
required: false
@@ -23,5 +20,5 @@ outputs:
branding:
color: 'blue'
runs:
using: 'node24'
using: 'node20'
main: 'lib/index.js'
+18
View File
@@ -0,0 +1,18 @@
module.exports = {
clearMocks: true,
moduleFileExtensions: ['js', 'ts'],
testEnvironment: 'node',
testMatch: ['**/*.test.ts'],
transform: {
'^.+\\.ts$': 'ts-jest'
},
verbose: true,
coverageThreshold: {
global: {
branches: 0,
functions: 14,
lines: 27,
statements: 27
}
}
}
+4027 -1829
View File
File diff suppressed because it is too large Load Diff
+18 -17
View File
@@ -1,35 +1,36 @@
{
"name": "setuphelm",
"version": "5.0.1",
"version": "4.3.0",
"private": true,
"description": "Setup helm",
"author": "Anumita Shenoy",
"license": "MIT",
"type": "module",
"dependencies": {
"@actions/core": "^3.0.1",
"@actions/exec": "^3.0.0",
"@actions/io": "^3.0.2",
"@actions/tool-cache": "4.0.0",
"@octokit/action": "^8.0.4",
"semver": "^7.8.5"
"@actions/core": "^1.11.1",
"@actions/exec": "^1.1.1",
"@actions/io": "^1.1.2",
"@actions/tool-cache": "2.0.2",
"@octokit/action": "^8.0.2",
"semver": "^7.7.2"
},
"main": "lib/index.js",
"scripts": {
"build": "tsc --noEmit && esbuild src/index.ts --bundle --platform=node --target=node20 --format=esm --outfile=lib/index.js --banner:js=\"import { createRequire } from 'module';const require = createRequire(import.meta.url);\"",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test-coverage": "vitest run --coverage",
"prebuild": "npm i ncc",
"build": "ncc build src/index.ts -o lib",
"test": "jest",
"test-coverage": "jest --coverage",
"format": "prettier --write .",
"format-check": "prettier --check .",
"prepare": "husky"
},
"devDependencies": {
"@types/node": "^26.1.1",
"esbuild": "^0.28",
"@types/jest": "^30.0.0",
"@types/node": "^24.0.13",
"@vercel/ncc": "^0.38.3",
"husky": "^9.1.7",
"prettier": "^3.9.5",
"typescript": "^7.0.2",
"vitest": "^4"
"jest": "^30.0.4",
"prettier": "^3.6.2",
"ts-jest": "^29.4.0",
"typescript": "^5.8.3"
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
import {run} from './run.js'
import {run} from './run'
import * as core from '@actions/core'
run().catch(core.setFailed)
+91 -542
View File
@@ -1,63 +1,8 @@
import {vi, describe, test, expect, afterEach} from 'vitest'
import * as path from 'path'
// Mock os module
vi.mock('os', async (importOriginal) => {
const actual = await importOriginal<typeof import('os')>()
return {
...actual,
platform: vi.fn(),
arch: vi.fn()
}
})
// Mock fs module
vi.mock('fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('fs')>()
return {
...actual,
readdirSync: vi.fn(),
statSync: vi.fn(),
chmodSync: vi.fn(),
readFileSync: vi.fn(),
existsSync: vi.fn()
}
})
// Mock @actions/core
vi.mock('@actions/core', async (importOriginal) => {
const actual = await importOriginal<typeof import('@actions/core')>()
return {
...actual,
getInput: vi.fn(),
info: vi.fn(),
debug: vi.fn(),
warning: vi.fn(),
startGroup: vi.fn(),
endGroup: vi.fn(),
addPath: vi.fn(),
setOutput: vi.fn(),
setFailed: vi.fn()
}
})
// Mock @actions/tool-cache
vi.mock('@actions/tool-cache', async (importOriginal) => {
const actual = await importOriginal<typeof import('@actions/tool-cache')>()
return {
...actual,
find: vi.fn(),
downloadTool: vi.fn(),
extractZip: vi.fn(),
extractTar: vi.fn(),
cacheDir: vi.fn()
}
})
import * as run from './run.js'
import * as run from './run'
import * as os from 'os'
import * as toolCache from '@actions/tool-cache'
import * as fs from 'fs'
import * as path from 'path'
import * as core from '@actions/core'
describe('run.ts', () => {
@@ -65,26 +10,26 @@ describe('run.ts', () => {
// Cleanup mocks after each test to ensure that subsequent tests are not affected by the mocks.
afterEach(() => {
vi.restoreAllMocks()
jest.restoreAllMocks()
})
test('getExecutableExtension() - return .exe when os is Windows', () => {
vi.mocked(os.platform).mockReturnValue('win32')
jest.spyOn(os, 'platform').mockReturnValue('win32')
expect(run.getExecutableExtension()).toBe('.exe')
expect(os.platform).toHaveBeenCalled()
})
test('getExecutableExtension() - return empty string for non-windows OS', () => {
vi.mocked(os.platform).mockReturnValue('darwin')
jest.spyOn(os, 'platform').mockReturnValue('darwin')
expect(run.getExecutableExtension()).toBe('')
expect(os.platform).toHaveBeenCalled()
})
test('getHelmDownloadURL() - return the URL to download helm for Linux amd64', () => {
vi.mocked(os.platform).mockReturnValue('linux')
vi.mocked(os.arch).mockReturnValue('x64')
jest.spyOn(os, 'platform').mockReturnValue('linux')
jest.spyOn(os, 'arch').mockReturnValue('x64')
const expected = 'https://test.tld/helm-v3.8.0-linux-amd64.tar.gz'
expect(run.getHelmDownloadURL(downloadBaseURL, 'v3.8.0')).toBe(expected)
@@ -93,8 +38,8 @@ describe('run.ts', () => {
})
test('getHelmDownloadURL() - return the URL to download helm for Linux arm64', () => {
vi.mocked(os.platform).mockReturnValue('linux')
vi.mocked(os.arch).mockReturnValue('arm64')
jest.spyOn(os, 'platform').mockReturnValue('linux')
jest.spyOn(os, 'arch').mockReturnValue('arm64')
const expected = 'https://test.tld/helm-v3.8.0-linux-arm64.tar.gz'
expect(run.getHelmDownloadURL(downloadBaseURL, 'v3.8.0')).toBe(expected)
@@ -103,8 +48,8 @@ describe('run.ts', () => {
})
test('getHelmDownloadURL() - return the URL to download helm for Darwin x64', () => {
vi.mocked(os.platform).mockReturnValue('darwin')
vi.mocked(os.arch).mockReturnValue('x64')
jest.spyOn(os, 'platform').mockReturnValue('darwin')
jest.spyOn(os, 'arch').mockReturnValue('x64')
const expected = 'https://test.tld/helm-v3.8.0-darwin-amd64.tar.gz'
expect(run.getHelmDownloadURL(downloadBaseURL, 'v3.8.0')).toBe(expected)
@@ -113,8 +58,8 @@ describe('run.ts', () => {
})
test('getHelmDownloadURL() - return the URL to download helm for Darwin arm64', () => {
vi.mocked(os.platform).mockReturnValue('darwin')
vi.mocked(os.arch).mockReturnValue('arm64')
jest.spyOn(os, 'platform').mockReturnValue('darwin')
jest.spyOn(os, 'arch').mockReturnValue('arm64')
const expected = 'https://test.tld/helm-v3.8.0-darwin-arm64.tar.gz'
expect(run.getHelmDownloadURL(downloadBaseURL, 'v3.8.0')).toBe(expected)
@@ -122,454 +67,57 @@ describe('run.ts', () => {
expect(os.arch).toHaveBeenCalled()
})
test('getHelmDownloadURL() - return the URL to download helm for Windows x64', () => {
vi.mocked(os.platform).mockReturnValue('win32')
vi.mocked(os.arch).mockReturnValue('x64')
test('getHelmDownloadURL() - return the URL to download helm for Windows', () => {
jest.spyOn(os, 'platform').mockReturnValue('win32')
jest.spyOn(os, 'arch').mockReturnValue('x64')
const expected = 'https://test.tld/helm-v3.8.0-windows-amd64.zip'
expect(run.getHelmDownloadURL(downloadBaseURL, 'v3.8.0')).toBe(expected)
expect(os.platform).toHaveBeenCalled()
})
test('getHelmDownloadURL() - return the URL to download helm for Windows arm64', () => {
vi.mocked(os.platform).mockReturnValue('win32')
vi.mocked(os.arch).mockReturnValue('arm64')
const expected = 'https://test.tld/helm-v3.8.0-windows-arm64.zip'
expect(run.getHelmDownloadURL(downloadBaseURL, 'v3.8.0')).toBe(expected)
expect(os.platform).toHaveBeenCalled()
})
test('getHelmDownloadURL() - preserve a subpath base URL without a trailing slash', () => {
vi.mocked(os.platform).mockReturnValue('linux')
vi.mocked(os.arch).mockReturnValue('x64')
const expected =
'https://mirror.example/kubernetes/helm/helm-v3.8.0-linux-amd64.tar.gz'
expect(
run.getHelmDownloadURL(
'https://mirror.example/kubernetes/helm',
'v3.8.0'
)
).toBe(expected)
})
test('getHelmDownloadURL() - preserve a subpath base URL with a trailing slash', () => {
vi.mocked(os.platform).mockReturnValue('linux')
vi.mocked(os.arch).mockReturnValue('x64')
const expected =
'https://mirror.example/kubernetes/helm/helm-v3.8.0-linux-amd64.tar.gz'
expect(
run.getHelmDownloadURL(
'https://mirror.example/kubernetes/helm/',
'v3.8.0'
)
).toBe(expected)
})
test('getLatestHelmVersion() - return the latest version of HELM', async () => {
const res = {
status: 200,
text: async () => 'v9.99.999'
} as Response
vi.spyOn(globalThis, 'fetch').mockResolvedValue(res)
global.fetch = jest.fn().mockReturnValue(res)
expect(await run.getLatestHelmVersion()).toBe('v9.99.999')
})
test('getLatestHelmVersion() - return the stable version of HELM when simulating a network error', async () => {
const errorMessage: string = 'Network Error'
vi.spyOn(globalThis, 'fetch').mockRejectedValueOnce(
new Error(errorMessage)
)
expect(await run.getLatestHelmVersion()).toBe(run.stableHelmVersion)
global.fetch = jest.fn().mockRejectedValueOnce(new Error(errorMessage))
expect(await run.getLatestHelmVersion()).toBe('v3.13.3')
})
test('getValidVersion() - return version with v prepended', () => {
expect(run.getValidVersion('3.8.0')).toBe('v3.8.0')
})
test('parseToolVersions() - return the helm version from .tool-versions content', () => {
const content = ['nodejs 20.11.0', 'helm 3.14.0', 'terraform 1.7.0'].join(
'\n'
)
expect(run.parseToolVersions(content)).toBe('3.14.0')
})
test('parseToolVersions() - ignore comments and blank lines', () => {
const content = ['# tools', '', ' helm 3.15.2 ', ''].join('\n')
expect(run.parseToolVersions(content)).toBe('3.15.2')
})
test('parseToolVersions() - return the first version when several are listed', () => {
expect(run.parseToolVersions('helm 3.14.0 3.13.0')).toBe('3.14.0')
})
test('parseToolVersions() - return empty string when helm is not declared', () => {
expect(run.parseToolVersions('nodejs 20.11.0')).toBe('')
})
test('getVersionFromToolVersionsFile() - read the helm version from a file', () => {
vi.mocked(fs.existsSync).mockReturnValue(true)
vi.mocked(fs.readFileSync).mockReturnValue('helm 3.14.0')
expect(run.getVersionFromToolVersionsFile('.tool-versions')).toBe(
'3.14.0'
)
expect(fs.readFileSync).toHaveBeenCalledWith('.tool-versions', 'utf8')
})
test('getVersionFromToolVersionsFile() - throw when the file does not exist', () => {
vi.mocked(fs.existsSync).mockReturnValue(false)
expect(() =>
run.getVersionFromToolVersionsFile('missing.tool-versions')
).toThrow("The version-file 'missing.tool-versions' does not exist")
})
test('getVersionFromToolVersionsFile() - throw when no helm version is present', () => {
vi.mocked(fs.existsSync).mockReturnValue(true)
vi.mocked(fs.readFileSync).mockReturnValue('nodejs 20.11.0')
expect(() =>
run.getVersionFromToolVersionsFile('.tool-versions')
).toThrow("No helm version found in '.tool-versions'")
})
test('getVersionFromToolVersionsFile() - throw when the helm version is not valid', () => {
vi.mocked(fs.existsSync).mockReturnValue(true)
vi.mocked(fs.readFileSync).mockReturnValue('helm latest')
expect(() =>
run.getVersionFromToolVersionsFile('.tool-versions')
).toThrow(
"The helm version 'latest' in '.tool-versions' is not valid. Provide a full version (e.g. '3.14.0') or a major.minor version (e.g. '3.14' or '3.14.x')"
)
})
test('getVersionFromToolVersionsFile() - accept a major.minor version', () => {
vi.mocked(fs.existsSync).mockReturnValue(true)
vi.mocked(fs.readFileSync).mockReturnValue('helm 3.14')
expect(run.getVersionFromToolVersionsFile('.tool-versions')).toBe('3.14')
})
test('isSemVerShaped() - accept semver-shaped versions with or without a v prefix', () => {
expect(run.isSemVerShaped('3.14.0')).toBe(true)
expect(run.isSemVerShaped('v3.14.0')).toBe(true)
expect(run.isSemVerShaped('3.14.0-rc.1')).toBe(true)
})
test('isSemVerShaped() - reject values that are not semver-shaped', () => {
expect(run.isSemVerShaped('latest')).toBe(false)
expect(run.isSemVerShaped('3.14')).toBe(false)
expect(run.isSemVerShaped('abc')).toBe(false)
})
test('isMajorMinorShaped() - accept major.minor with or without a v prefix', () => {
expect(run.isMajorMinorShaped('3.14')).toBe(true)
expect(run.isMajorMinorShaped('v3.14')).toBe(true)
})
test('isMajorMinorShaped() - accept a wildcard patch (.x / .*)', () => {
expect(run.isMajorMinorShaped('3.14.x')).toBe(true)
expect(run.isMajorMinorShaped('v3.14.x')).toBe(true)
expect(run.isMajorMinorShaped('3.14.*')).toBe(true)
expect(run.isMajorMinorShaped('v3.14.*')).toBe(true)
})
test('isMajorMinorShaped() - reject full versions and other values', () => {
expect(run.isMajorMinorShaped('3.14.0')).toBe(false)
expect(run.isMajorMinorShaped('latest')).toBe(false)
expect(run.isMajorMinorShaped('3')).toBe(false)
})
// Stubs the download chain so run() resolves to a cached helm binary,
// letting these tests focus on version-vs-version-file resolution.
const stubDownloadChain = () => {
vi.mocked(os.platform).mockReturnValue('linux')
vi.mocked(os.arch).mockReturnValue('x64')
vi.mocked(toolCache.find).mockReturnValue('pathToCachedDir')
vi.mocked(fs.chmodSync).mockImplementation(() => {})
vi.mocked(fs.readdirSync).mockReturnValue([
'helm' as unknown as fs.Dirent<NonSharedBuffer>
])
vi.mocked(fs.statSync).mockReturnValue({
isDirectory: () => false
} as fs.Stats)
}
const inputs = (version: string, versionFile: string) =>
vi.mocked(core.getInput).mockImplementation((name: string) => {
if (name === 'version') return version
if (name === 'version-file') return versionFile
if (name === 'downloadBaseURL') return downloadBaseURL
return ''
})
test('run() - resolve the version from version-file when version is not set', async () => {
stubDownloadChain()
inputs('', '.tool-versions')
vi.mocked(fs.existsSync).mockReturnValue(true)
vi.mocked(fs.readFileSync).mockReturnValue('helm 3.14.0')
await run.run()
expect(core.warning).not.toHaveBeenCalled()
expect(toolCache.find).toHaveBeenCalledWith('helm', 'v3.14.0')
expect(core.setOutput).toHaveBeenCalledWith(
'helm-path',
path.join('pathToCachedDir', 'helm')
)
})
test('run() - resolve the version from version-file when version is left at the latest default', async () => {
stubDownloadChain()
inputs('latest', '.tool-versions')
vi.mocked(fs.existsSync).mockReturnValue(true)
vi.mocked(fs.readFileSync).mockReturnValue('helm 3.14.0')
await run.run()
expect(core.warning).not.toHaveBeenCalled()
expect(toolCache.find).toHaveBeenCalledWith('helm', 'v3.14.0')
})
test('run() - warn and prefer version over version-file when both are set', async () => {
stubDownloadChain()
inputs('3.5.0', '.tool-versions')
await run.run()
expect(core.warning).toHaveBeenCalledWith(
`Both 'version' and 'version-file' inputs are specified, only 'version' will be used.`
)
expect(fs.readFileSync).not.toHaveBeenCalled()
expect(toolCache.find).toHaveBeenCalledWith('helm', 'v3.5.0')
})
// Spies on global fetch so HEAD probes report the given set of versions as
// existing (200) and everything else as missing (404). Using vi.spyOn (rather
// than vi.stubGlobal) lets restoreAllMocks() in afterEach reliably restore the
// real fetch, so the mock never leaks into later tests.
const stubPatchProbes = (existing: string[]) => {
const present = new Set(existing)
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
const version =
String(input).match(/helm-(v\d+\.\d+\.\d+)-/)?.[1] ?? ''
const ok = present.has(version)
return {ok, status: ok ? 200 : 404} as Response
})
}
test('helmPatchExists() - return true when the artifact responds 200', async () => {
vi.mocked(os.platform).mockReturnValue('linux')
vi.mocked(os.arch).mockReturnValue('x64')
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
ok: true,
status: 200
} as Response)
expect(await run.helmPatchExists(downloadBaseURL, 'v3.14.4')).toBe(true)
})
test('helmPatchExists() - return false when the artifact responds 404', async () => {
vi.mocked(os.platform).mockReturnValue('linux')
vi.mocked(os.arch).mockReturnValue('x64')
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
ok: false,
status: 404
} as Response)
expect(await run.helmPatchExists(downloadBaseURL, 'v3.14.99')).toBe(false)
})
test('helmPatchExists() - throw on a non-404 error status', async () => {
vi.mocked(os.platform).mockReturnValue('linux')
vi.mocked(os.arch).mockReturnValue('x64')
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
ok: false,
status: 429
} as Response)
await expect(
run.helmPatchExists(downloadBaseURL, 'v3.14.4')
).rejects.toThrow('Unexpected HTTP 429')
})
test('resolveLatestPatchVersion() - return the newest existing patch', async () => {
vi.mocked(os.platform).mockReturnValue('linux')
vi.mocked(os.arch).mockReturnValue('x64')
stubPatchProbes(['v3.14.0', 'v3.14.1', 'v3.14.2', 'v3.14.3', 'v3.14.4'])
expect(await run.resolveLatestPatchVersion(downloadBaseURL, '3.14')).toBe(
'v3.14.4'
)
})
test('resolveLatestPatchVersion() - use the blob listing in a single request when supported', async () => {
vi.mocked(os.platform).mockReturnValue('linux')
vi.mocked(os.arch).mockReturnValue('x64')
const listing =
'<EnumerationResults>' +
'<Blob><Name>helm-v3.14.0-linux-amd64.tar.gz</Name></Blob>' +
'<Blob><Name>helm-v3.14.0-rc.1-linux-amd64.tar.gz</Name></Blob>' +
'<Blob><Name>helm-v3.14.4-linux-amd64.tar.gz</Name></Blob>' +
'<Blob><Name>helm-v3.14.4-linux-amd64.tar.gz.sha256</Name></Blob>' +
'</EnumerationResults>'
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
ok: true,
text: async () => listing
} as Response)
expect(await run.resolveLatestPatchVersion(downloadBaseURL, '3.14')).toBe(
'v3.14.4'
)
// A single listing request; no per-patch HEAD probing.
expect(fetchSpy).toHaveBeenCalledTimes(1)
})
test('resolveLatestPatchVersion() - fall back to probing when listing is unavailable', async () => {
vi.mocked(os.platform).mockReturnValue('linux')
vi.mocked(os.arch).mockReturnValue('x64')
const present = new Set(['v3.14.0', 'v3.14.1', 'v3.14.2'])
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
const url = String(input)
if (url.includes('comp=list')) {
return {ok: false, status: 404} as Response
}
const version = url.match(/helm-(v\d+\.\d+\.\d+)-/)?.[1] ?? ''
const ok = present.has(version)
return {ok, status: ok ? 200 : 404} as Response
})
expect(await run.resolveLatestPatchVersion(downloadBaseURL, '3.14')).toBe(
'v3.14.2'
)
})
test('resolveLatestPatchViaListing() - return null when the response is not a listing', async () => {
vi.mocked(os.platform).mockReturnValue('linux')
vi.mocked(os.arch).mockReturnValue('x64')
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
ok: true,
text: async () => '<html>directory index, not a blob listing</html>'
} as Response)
expect(
await run.resolveLatestPatchViaListing(downloadBaseURL, '3', '14')
).toBeNull()
})
test('resolveLatestPatchVersion() - tolerate a skipped patch number', async () => {
vi.mocked(os.platform).mockReturnValue('linux')
vi.mocked(os.arch).mockReturnValue('x64')
stubPatchProbes(['v3.14.0', 'v3.14.1', 'v3.14.3'])
expect(
await run.resolveLatestPatchVersion(downloadBaseURL, 'v3.14')
).toBe('v3.14.3')
})
test('resolveLatestPatchVersion() - accept a wildcard patch (.x / .*)', async () => {
vi.mocked(os.platform).mockReturnValue('linux')
vi.mocked(os.arch).mockReturnValue('x64')
stubPatchProbes(['v3.12.0', 'v3.12.1', 'v3.12.2', 'v3.12.3'])
expect(
await run.resolveLatestPatchVersion(downloadBaseURL, 'v3.12.x')
).toBe('v3.12.3')
expect(
await run.resolveLatestPatchVersion(downloadBaseURL, '3.12.*')
).toBe('v3.12.3')
})
test('resolveLatestPatchVersion() - throw when the minor has no releases', async () => {
vi.mocked(os.platform).mockReturnValue('linux')
vi.mocked(os.arch).mockReturnValue('x64')
stubPatchProbes([])
await expect(
run.resolveLatestPatchVersion(downloadBaseURL, '9.99')
).rejects.toThrow('No Helm releases found for 9.99')
})
test('resolveLatestPatchVersion() - propagate network errors', async () => {
vi.mocked(os.platform).mockReturnValue('linux')
vi.mocked(os.arch).mockReturnValue('x64')
vi.spyOn(globalThis, 'fetch').mockRejectedValue(
new Error('Network Error')
)
await expect(
run.resolveLatestPatchVersion(downloadBaseURL, '3.14')
).rejects.toThrow('Network Error')
})
test('resolveLatestPatchVersion() - throw when the host reports every patch as existing', async () => {
vi.mocked(os.platform).mockReturnValue('linux')
vi.mocked(os.arch).mockReturnValue('x64')
vi.spyOn(globalThis, 'fetch').mockResolvedValue({ok: true} as Response)
await expect(
run.resolveLatestPatchVersion(downloadBaseURL, '3.14')
).rejects.toThrow('exceeded 100 probes')
})
test('run() - resolve the latest patch for a major.minor version input', async () => {
stubDownloadChain()
inputs('3.14', '')
stubPatchProbes(['v3.14.0', 'v3.14.1', 'v3.14.2', 'v3.14.3', 'v3.14.4'])
await run.run()
expect(toolCache.find).toHaveBeenCalledWith('helm', 'v3.14.4')
})
test('run() - resolve the latest patch for a wildcard patch version input', async () => {
stubDownloadChain()
inputs('v3.12.x', '')
stubPatchProbes(['v3.12.0', 'v3.12.1', 'v3.12.2', 'v3.12.3'])
await run.run()
expect(toolCache.find).toHaveBeenCalledWith('helm', 'v3.12.3')
})
test('run() - resolve the latest patch for a major.minor version-file entry', async () => {
stubDownloadChain()
inputs('', '.tool-versions')
vi.mocked(fs.existsSync).mockReturnValue(true)
vi.mocked(fs.readFileSync).mockReturnValue('helm 3.14')
stubPatchProbes(['v3.14.0', 'v3.14.1', 'v3.14.2', 'v3.14.3', 'v3.14.4'])
await run.run()
expect(toolCache.find).toHaveBeenCalledWith('helm', 'v3.14.4')
})
test('walkSync() - return path to the all files matching fileToFind in dir', () => {
vi.mocked(fs.readdirSync).mockImplementation((file, _?) => {
jest.spyOn(fs, 'readdirSync').mockImplementation((file, _) => {
if (file == 'mainFolder')
return [
'file1' as unknown as fs.Dirent<NonSharedBuffer>,
'file2' as unknown as fs.Dirent<NonSharedBuffer>,
'folder1' as unknown as fs.Dirent<NonSharedBuffer>,
'folder2' as unknown as fs.Dirent<NonSharedBuffer>
'file1' as unknown as fs.Dirent<Buffer<ArrayBufferLike>>,
'file2' as unknown as fs.Dirent<Buffer<ArrayBufferLike>>,
'folder1' as unknown as fs.Dirent<Buffer<ArrayBufferLike>>,
'folder2' as unknown as fs.Dirent<Buffer<ArrayBufferLike>>
]
if (file == path.join('mainFolder', 'folder1'))
return [
'file11' as unknown as fs.Dirent<NonSharedBuffer>,
'file12' as unknown as fs.Dirent<NonSharedBuffer>
'file11' as unknown as fs.Dirent<Buffer<ArrayBufferLike>>,
'file12' as unknown as fs.Dirent<Buffer<ArrayBufferLike>>
]
if (file == path.join('mainFolder', 'folder2'))
return [
'file21' as unknown as fs.Dirent<NonSharedBuffer>,
'file22' as unknown as fs.Dirent<NonSharedBuffer>
'file21' as unknown as fs.Dirent<Buffer<ArrayBufferLike>>,
'file22' as unknown as fs.Dirent<Buffer<ArrayBufferLike>>
]
return []
})
vi.mocked(fs.statSync).mockImplementation((file) => {
jest.spyOn(core, 'debug').mockImplementation()
jest.spyOn(fs, 'statSync').mockImplementation((file) => {
const isDirectory =
(file as string).toLowerCase().indexOf('file') == -1 ? true : false
return {isDirectory: () => isDirectory} as fs.Stats
@@ -583,27 +131,28 @@ describe('run.ts', () => {
})
test('walkSync() - return empty array if no file with name fileToFind exists', () => {
vi.mocked(fs.readdirSync).mockImplementation((file, _?) => {
jest.spyOn(fs, 'readdirSync').mockImplementation((file, _) => {
if (file == 'mainFolder')
return [
'file1' as unknown as fs.Dirent<NonSharedBuffer>,
'file2' as unknown as fs.Dirent<NonSharedBuffer>,
'folder1' as unknown as fs.Dirent<NonSharedBuffer>,
'folder2' as unknown as fs.Dirent<NonSharedBuffer>
'file1' as unknown as fs.Dirent<Buffer<ArrayBufferLike>>,
'file2' as unknown as fs.Dirent<Buffer<ArrayBufferLike>>,
'folder1' as unknown as fs.Dirent<Buffer<ArrayBufferLike>>,
'folder2' as unknown as fs.Dirent<Buffer<ArrayBufferLike>>
]
if (file == path.join('mainFolder', 'folder1'))
return [
'file11' as unknown as fs.Dirent<NonSharedBuffer>,
'file12' as unknown as fs.Dirent<NonSharedBuffer>
'file11' as unknown as fs.Dirent<Buffer<ArrayBufferLike>>,
'file12' as unknown as fs.Dirent<Buffer<ArrayBufferLike>>
]
if (file == path.join('mainFolder', 'folder2'))
return [
'file21' as unknown as fs.Dirent<NonSharedBuffer>,
'file22' as unknown as fs.Dirent<NonSharedBuffer>
'file21' as unknown as fs.Dirent<Buffer<ArrayBufferLike>>,
'file22' as unknown as fs.Dirent<Buffer<ArrayBufferLike>>
]
return []
})
vi.mocked(fs.statSync).mockImplementation((file) => {
jest.spyOn(core, 'debug').mockImplementation()
jest.spyOn(fs, 'statSync').mockImplementation((file) => {
const isDirectory =
(file as string).toLowerCase().indexOf('file') == -1 ? true : false
return {isDirectory: () => isDirectory} as fs.Stats
@@ -615,18 +164,18 @@ describe('run.ts', () => {
})
test('findHelm() - change access permissions and find the helm in given directory', () => {
vi.mocked(fs.chmodSync).mockImplementation(() => {})
vi.mocked(fs.readdirSync).mockImplementation((file, _?) => {
jest.spyOn(fs, 'chmodSync').mockImplementation(() => {})
jest.spyOn(fs, 'readdirSync').mockImplementation((file, _) => {
if (file == 'mainFolder')
return ['helm.exe' as unknown as fs.Dirent<NonSharedBuffer>]
return ['helm.exe' as unknown as fs.Dirent<Buffer<ArrayBufferLike>>]
return []
})
vi.mocked(fs.statSync).mockImplementation((file) => {
jest.spyOn(fs, 'statSync').mockImplementation((file) => {
const isDirectory =
(file as string).indexOf('folder') == -1 ? false : true
return {isDirectory: () => isDirectory} as fs.Stats
})
vi.mocked(os.platform).mockReturnValue('win32')
jest.spyOn(os, 'platform').mockReturnValue('win32')
expect(run.findHelm('mainFolder')).toBe(
path.join('mainFolder', 'helm.exe')
@@ -634,15 +183,15 @@ describe('run.ts', () => {
})
test('findHelm() - throw error if executable not found', () => {
vi.mocked(fs.chmodSync).mockImplementation(() => {})
vi.mocked(fs.readdirSync).mockImplementation((file, _?) => {
jest.spyOn(fs, 'chmodSync').mockImplementation(() => {})
jest.spyOn(fs, 'readdirSync').mockImplementation((file, _) => {
if (file == 'mainFolder') return []
return []
})
vi.mocked(fs.statSync).mockImplementation((file) => {
jest.spyOn(fs, 'statSync').mockImplementation((file) => {
return {isDirectory: () => true} as fs.Stats
})
vi.mocked(os.platform).mockReturnValue('win32')
jest.spyOn(os, 'platform').mockReturnValue('win32')
expect(() => run.findHelm('mainFolder')).toThrow(
'Helm executable not found in path mainFolder'
@@ -650,19 +199,20 @@ describe('run.ts', () => {
})
test('downloadHelm() - download helm and return path to it', async () => {
vi.mocked(toolCache.find).mockReturnValue('')
vi.mocked(toolCache.downloadTool).mockResolvedValue('pathToTool')
jest.spyOn(toolCache, 'find').mockReturnValue('')
jest.spyOn(toolCache, 'downloadTool').mockResolvedValue('pathToTool')
const response = JSON.stringify([{tag_name: 'v4.0.0'}])
vi.mocked(fs.readFileSync).mockReturnValue(response)
vi.mocked(os.platform).mockReturnValue('win32')
vi.mocked(os.arch).mockReturnValue('x64')
vi.mocked(fs.chmodSync).mockImplementation(() => {})
vi.mocked(toolCache.extractZip).mockResolvedValue('extractedPath')
vi.mocked(toolCache.cacheDir).mockResolvedValue('pathToCachedDir')
vi.mocked(fs.readdirSync).mockImplementation((file, _?) => [
'helm.exe' as unknown as fs.Dirent<NonSharedBuffer>
])
vi.mocked(fs.statSync).mockImplementation((file) => {
jest.spyOn(fs, 'readFileSync').mockReturnValue(response)
jest.spyOn(os, 'platform').mockReturnValue('win32')
jest.spyOn(fs, 'chmodSync').mockImplementation(() => {})
jest.spyOn(toolCache, 'extractZip').mockResolvedValue('extractedPath')
jest.spyOn(toolCache, 'cacheDir').mockResolvedValue('pathToCachedDir')
jest
.spyOn(fs, 'readdirSync')
.mockImplementation((file, _) => [
'helm.exe' as unknown as fs.Dirent<Buffer<ArrayBufferLike>>
])
jest.spyOn(fs, 'statSync').mockImplementation((file) => {
const isDirectory =
(file as string).indexOf('folder') == -1 ? false : true
return {isDirectory: () => isDirectory} as fs.Stats
@@ -675,21 +225,20 @@ describe('run.ts', () => {
expect(toolCache.downloadTool).toHaveBeenCalledWith(
'https://test.tld/helm-v4.0.0-windows-amd64.zip'
)
expect(fs.chmodSync).toHaveBeenCalledWith('pathToTool', '755')
expect(fs.chmodSync).toHaveBeenCalledWith('pathToTool', '777')
expect(toolCache.extractZip).toHaveBeenCalledWith('pathToTool')
expect(fs.chmodSync).toHaveBeenCalledWith(
path.join('pathToCachedDir', 'helm.exe'),
'755'
'777'
)
})
test('downloadHelm() - throw error if unable to download', async () => {
vi.mocked(toolCache.find).mockReturnValue('')
vi.mocked(toolCache.downloadTool).mockImplementation(async () => {
jest.spyOn(toolCache, 'find').mockReturnValue('')
jest.spyOn(toolCache, 'downloadTool').mockImplementation(async () => {
throw 'Unable to download'
})
vi.mocked(os.platform).mockReturnValue('win32')
vi.mocked(os.arch).mockReturnValue('x64')
jest.spyOn(os, 'platform').mockReturnValue('win32')
const downloadUrl = 'https://test.tld/helm-v3.2.1-windows-amd64.zip'
await expect(run.downloadHelm(downloadBaseURL, 'v3.2.1')).rejects.toThrow(
@@ -700,17 +249,18 @@ describe('run.ts', () => {
})
test('downloadHelm() - return path to helm tool with same version from toolCache', async () => {
vi.mocked(toolCache.find).mockReturnValue('pathToCachedDir')
vi.mocked(toolCache.cacheDir).mockResolvedValue('pathToCachedDir')
vi.mocked(toolCache.downloadTool).mockResolvedValue('pathToTool')
vi.mocked(toolCache.extractZip).mockResolvedValue('extractedPath')
vi.mocked(os.platform).mockReturnValue('win32')
vi.mocked(os.arch).mockReturnValue('x64')
vi.mocked(fs.chmodSync).mockImplementation(() => {})
vi.mocked(fs.readdirSync).mockReturnValue([
'helm.exe' as unknown as fs.Dirent<NonSharedBuffer>
])
vi.mocked(fs.statSync).mockImplementation((file) => {
jest.spyOn(toolCache, 'find').mockReturnValue('pathToCachedDir')
jest.spyOn(toolCache, 'cacheDir').mockResolvedValue('pathToCachedDir')
jest.spyOn(toolCache, 'downloadTool').mockResolvedValue('pathToTool')
jest.spyOn(toolCache, 'extractZip').mockResolvedValue('extractedPath')
jest.spyOn(os, 'platform').mockReturnValue('win32')
jest.spyOn(fs, 'chmodSync').mockImplementation(() => {})
jest
.spyOn(fs, 'readdirSync')
.mockReturnValue([
'helm.exe' as unknown as fs.Dirent<Buffer<ArrayBufferLike>>
])
jest.spyOn(fs, 'statSync').mockImplementation((file) => {
const isDirectory =
(file as string).indexOf('folder') == -1 ? false : true
return {isDirectory: () => isDirectory} as fs.Stats
@@ -722,21 +272,20 @@ describe('run.ts', () => {
expect(toolCache.find).toHaveBeenCalledWith('helm', 'v3.2.1')
expect(fs.chmodSync).toHaveBeenCalledWith(
path.join('pathToCachedDir', 'helm.exe'),
'755'
'777'
)
})
test('downloadHelm() - throw error is helm is not found in path', async () => {
vi.mocked(toolCache.find).mockReturnValue('')
vi.mocked(toolCache.downloadTool).mockResolvedValue('pathToTool')
vi.mocked(toolCache.cacheDir).mockResolvedValue('pathToCachedDir')
vi.mocked(toolCache.downloadTool).mockResolvedValue('pathToTool')
vi.mocked(toolCache.extractZip).mockResolvedValue('extractedPath')
vi.mocked(os.platform).mockReturnValue('win32')
vi.mocked(os.arch).mockReturnValue('x64')
vi.mocked(fs.chmodSync).mockImplementation(() => {})
vi.mocked(fs.readdirSync).mockImplementation((file, _?) => [])
vi.mocked(fs.statSync).mockImplementation((file) => {
jest.spyOn(toolCache, 'find').mockReturnValue('')
jest.spyOn(toolCache, 'downloadTool').mockResolvedValue('pathToTool')
jest.spyOn(toolCache, 'cacheDir').mockResolvedValue('pathToCachedDir')
jest.spyOn(toolCache, 'downloadTool').mockResolvedValue('pathToTool')
jest.spyOn(toolCache, 'extractZip').mockResolvedValue('extractedPath')
jest.spyOn(os, 'platform').mockReturnValue('win32')
jest.spyOn(fs, 'chmodSync').mockImplementation()
jest.spyOn(fs, 'readdirSync').mockImplementation((file, _) => [])
jest.spyOn(fs, 'statSync').mockImplementation((file) => {
const isDirectory =
(file as string).indexOf('folder') == -1 ? false : true
return {isDirectory: () => isDirectory} as fs.Stats
@@ -749,7 +298,7 @@ describe('run.ts', () => {
expect(toolCache.downloadTool).toHaveBeenCalledWith(
'https://test.tld/helm-v3.2.1-windows-amd64.zip'
)
expect(fs.chmodSync).toHaveBeenCalledWith('pathToTool', '755')
expect(fs.chmodSync).toHaveBeenCalledWith('pathToTool', '777')
expect(toolCache.extractZip).toHaveBeenCalledWith('pathToTool')
})
})
+16 -220
View File
@@ -10,45 +10,27 @@ import * as toolCache from '@actions/tool-cache'
import * as core from '@actions/core'
const helmToolName = 'helm'
export const stableHelmVersion = 'v3.18.4'
const stableHelmVersion = 'v3.13.3'
export async function run() {
let version = core.getInput('version')
const versionFile = core.getInput('version-file')
let version = core.getInput('version', {required: true})
if (versionFile) {
if (version && version !== 'latest') {
core.warning(
`Both 'version' and 'version-file' inputs are specified, only 'version' will be used.`
)
} else {
version = getVersionFromToolVersionsFile(versionFile)
core.info(`Resolved Helm version '${version}' from '${versionFile}'`)
}
if (version !== 'latest' && version[0] !== 'v') {
core.info('Getting latest Helm version')
version = getValidVersion(version)
}
if (!version) {
version = 'latest'
if (version.toLocaleLowerCase() === 'latest') {
version = await getLatestHelmVersion()
}
const downloadBaseURL = core.getInput('downloadBaseURL', {required: false})
if (version.toLocaleLowerCase() === 'latest') {
version = await getLatestHelmVersion()
} else if (isMajorMinorShaped(version)) {
version = await resolveLatestPatchVersion(downloadBaseURL, version)
core.info(`Resolved latest patch Helm version to '${version}'`)
} else if (version[0] !== 'v') {
version = getValidVersion(version)
core.info(`Normalized Helm version to '${version}'`)
}
core.startGroup(`Installing ${version}`)
const cachedPath = await downloadHelm(downloadBaseURL, version)
core.endGroup()
try {
if (!process.env['PATH']?.startsWith(path.dirname(cachedPath))) {
if (!process.env['PATH'].startsWith(path.dirname(cachedPath))) {
core.addPath(path.dirname(cachedPath))
}
} catch {
@@ -64,64 +46,6 @@ export function getValidVersion(version: string): string {
return 'v' + version
}
// Matches a complete semantic version (major.minor.patch with optional
// pre-release / build metadata). This is the official regex suggested at
// https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string
// with an added optional leading 'v' to accept Helm-style tags (e.g. 'v3.14.0').
const semVerShape =
/^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/
// Returns true when version looks like a semantic version
export function isSemVerShaped(version: string): boolean {
return semVerShape.test(version)
}
// Matches a major.minor version with an optional leading 'v' and either no
// patch component or a wildcard patch ('.x' / '.*'), e.g. '3.14', 'v3.14',
// '3.14.x', 'v3.14.*'.
const majorMinorShape = /^v?\d+\.\d+(?:\.[x*])?$/
// Returns true when version is a major.minor value (optionally with a wildcard
// patch such as '.x' or '.*')
export function isMajorMinorShaped(version: string): boolean {
return majorMinorShape.test(version)
}
// Reads a .tool-versions file and returns the helm version declared in it
export function getVersionFromToolVersionsFile(filePath: string): string {
if (!fs.existsSync(filePath)) {
throw new Error(`The version-file '${filePath}' does not exist`)
}
const content = fs.readFileSync(filePath, 'utf8')
const version = parseToolVersions(content)
if (!version) {
throw new Error(`No helm version found in '${filePath}'`)
}
if (!isSemVerShaped(version) && !isMajorMinorShaped(version)) {
throw new Error(
`The helm version '${version}' in '${filePath}' is not valid. Provide a full version (e.g. '3.14.0') or a major.minor version (e.g. '3.14' or '3.14.x')`
)
}
return version
}
// Parses .tool-versions content (asdf/mise format) and returns the first
// helm version, or an empty string when none is declared. Lines look like
// `helm 3.14.0`; comments (#) and blank lines are ignored.
export function parseToolVersions(content: string): string {
for (const line of content.split(/\r?\n/)) {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith('#')) {
continue
}
const [tool, version] = trimmed.split(/\s+/)
if (tool === helmToolName && version) {
return version
}
}
return ''
}
// Gets the latest helm version or returns a default stable if getting latest fails
export async function getLatestHelmVersion(): Promise<string> {
try {
@@ -130,136 +54,12 @@ export async function getLatestHelmVersion(): Promise<string> {
return release
} catch (err) {
core.warning(
`Error while fetching latest Helm release: ${err instanceof Error ? err.message : String(err)}. Using default version ${stableHelmVersion}`
`Error while fetching latest Helm release: ${err.toString()}. Using default version ${stableHelmVersion}`
)
return stableHelmVersion
}
}
// Number of consecutive missing patches to probe before concluding the walk,
// and an upper bound to keep resolution from running unbounded.
const patchLookahead = 3
const maxPatch = 100
// Sends a HEAD request for the given version's download URL. Returns true when
// the artifact exists (2xx) and false only when it is definitively absent
// (404). Any other status (403/405/429/5xx, ...) and genuine network errors are
// thrown, so transient failures, rate-limiting, or a host that disallows HEAD
// are never mistaken for a missing patch.
export async function helmPatchExists(
baseURL: string,
version: string
): Promise<boolean> {
const url = getHelmDownloadURL(baseURL, version)
const response = await fetch(url, {method: 'HEAD'})
if (response.ok) {
return true
}
if (response.status === 404) {
return false
}
throw new Error(
`Unexpected HTTP ${response.status} while checking for Helm artifact at ${url}`
)
}
// Attempts to resolve the latest patch in a single request via the Azure Blob
// container-listing API that backs get.helm.sh. Returns the newest stable patch
// version, or null when the host does not support listing (any non-listing
// response, empty result, or error) so the caller can fall back to probing.
// Only 'major.minor.patch-<platform>' names are matched, so prereleases and
// sidecar files (.sha256, ...) are ignored.
export async function resolveLatestPatchViaListing(
baseURL: string,
major: string,
minor: string
): Promise<string | null> {
let body: string
try {
const listURL = new URL(baseURL)
listURL.searchParams.set('restype', 'container')
listURL.searchParams.set('comp', 'list')
listURL.searchParams.set('prefix', `helm-v${major}.${minor}.`)
const response = await fetch(listURL.toString())
if (!response.ok) {
return null
}
body = await response.text()
} catch {
return null
}
if (!body.includes('<EnumerationResults')) {
return null
}
const patchPattern = new RegExp(
`helm-v${major}\\.${minor}\\.(\\d+)-(?:darwin|linux|windows)`,
'g'
)
let latestPatch = -1
for (const match of body.matchAll(patchPattern)) {
const patch = Number(match[1])
if (patch > latestPatch) {
latestPatch = patch
}
}
if (latestPatch < 0) {
return null
}
return `v${major}.${minor}.${latestPatch}`
}
// Resolves a major.minor value (e.g. '3.14' or 'v3.14') to the newest available
// patch (e.g. 'v3.14.4'). Fast path: a single container-listing request (which
// get.helm.sh supports). When the host does not support listing, it falls back
// to probing the download host for sequential patches. Only 'major.minor.n'
// artifacts are considered, so prereleases are never selected.
export async function resolveLatestPatchVersion(
baseURL: string,
version: string
): Promise<string> {
const [major, minor] = (
version[0] === 'v' ? version.slice(1) : version
).split('.')
const listed = await resolveLatestPatchViaListing(baseURL, major, minor)
if (listed) {
return listed
}
if (!(await helmPatchExists(baseURL, `v${major}.${minor}.0`))) {
throw new Error(`No Helm releases found for ${major}.${minor}`)
}
let latestPatch = 0
let consecutiveMisses = 0
for (
let patch = 1;
patch <= maxPatch && consecutiveMisses < patchLookahead;
patch++
) {
if (await helmPatchExists(baseURL, `v${major}.${minor}.${patch}`)) {
latestPatch = patch
consecutiveMisses = 0
} else {
consecutiveMisses++
}
}
// The look-ahead is what should end the walk. Exhausting maxPatch without a
// trailing run of misses means the host answered 200 for every probe (e.g. a
// catch-all mirror), so the resolved version cannot be trusted.
if (consecutiveMisses < patchLookahead) {
throw new Error(
`Unable to resolve latest patch for ${major}.${minor} (exceeded ${maxPatch} probes)`
)
}
return `v${major}.${minor}.${latestPatch}`
}
export function getArch(): string {
return os.arch() === 'x64' ? 'amd64' : os.arch()
}
@@ -278,11 +78,7 @@ export function getExecutableExtension(): string {
export function getHelmDownloadURL(baseURL: string, version: string): string {
const urlPath = `helm-${version}-${getPlatform()}-${getArch()}.${getArchiveExtension()}`
// Ensure the base ends with '/' so a subpath mirror (e.g.
// 'https://example/kubernetes/helm') is preserved; otherwise URL resolution
// replaces the last path segment and points at the wrong location.
const base = baseURL.endsWith('/') ? baseURL : `${baseURL}/`
const url = new URL(urlPath, base)
const url = new URL(urlPath, baseURL)
return url.toString()
}
@@ -309,7 +105,7 @@ export async function downloadHelm(
)
}
fs.chmodSync(helmDownloadPath, '755')
fs.chmodSync(helmDownloadPath, '777')
const extractedPath =
getPlatform() === 'windows'
? await toolCache.extractZip(helmDownloadPath)
@@ -329,13 +125,13 @@ export async function downloadHelm(
)
}
fs.chmodSync(helmpath, '755')
fs.chmodSync(helmpath, '777')
return helmpath
}
export function findHelm(rootFolder: string): string {
fs.chmodSync(rootFolder, '755')
let filelist: string[] = []
fs.chmodSync(rootFolder, '777')
var filelist: string[] = []
walkSync(rootFolder, filelist, helmToolName + getExecutableExtension())
if (!filelist || filelist.length == 0) {
throw new Error(
@@ -346,8 +142,8 @@ export function findHelm(rootFolder: string): string {
}
}
export function walkSync(dir, filelist, fileToFind) {
const files = fs.readdirSync(dir)
export var walkSync = function (dir, filelist, fileToFind) {
var files = fs.readdirSync(dir)
filelist = filelist || []
files.forEach(function (file) {
if (fs.statSync(path.join(dir, file)).isDirectory()) {
+60 -10
View File
@@ -1,14 +1,64 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"rootDir": "./src",
"outDir": "./lib",
"noImplicitAny": false,
"skipLibCheck": true,
"noEmit": true,
"types": ["node", "vitest/globals"]
/* Basic Options */
// "incremental": true, /* Enable incremental compilation */
"target": "es6" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */,
"module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */,
// "allowJs": true, /* Allow javascript files to be compiled. */
// "checkJs": true, /* Report errors in .js files. */
// "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */
// "declaration": true, /* Generates corresponding '.d.ts' file. */
// "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */
// "sourceMap": true, /* Generates corresponding '.map' file. */
// "outFile": "./", /* Concatenate and emit output to single file. */
"outDir": "./lib" /* Redirect output structure to the directory. */,
"rootDir": "./src" /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */,
// "composite": true, /* Enable project compilation */
// "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */
// "removeComments": true, /* Do not emit comments to output. */
// "noEmit": true, /* Do not emit outputs. */
// "importHelpers": true, /* Import emit helpers from 'tslib'. */
// "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
// "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
/* Strict Type-Checking Options */
//"strict": true, /* Enable all strict type-checking options. */
"noImplicitAny": false /* Raise error on expressions and declarations with an implied 'any' type. */,
// "strictNullChecks": true, /* Enable strict null checks. */
// "strictFunctionTypes": true, /* Enable strict checking of function types. */
// "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */
// "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */
// "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
// "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */
/* Additional Checks */
// "noUnusedLocals": true, /* Report errors on unused locals. */
// "noUnusedParameters": true, /* Report errors on unused parameters. */
// "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
// "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
/* Module Resolution Options */
// "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
// "baseUrl": "./", /* Base directory to resolve non-absolute module names. */
// "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
// "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
// "typeRoots": [], /* List of folders to include type definitions from. */
// "types": [], /* Type declaration files to be included in compilation. */
// "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
// "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
// "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
/* Source Map Options */
// "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */
// "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */
/* Experimental Options */
// "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
// "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
"skipLibCheck": true
},
"exclude": ["node_modules", "**/*.test.ts", "vitest.config.ts"]
"exclude": ["node_modules", "**/*.test.ts"]
}
-10
View File
@@ -1,10 +0,0 @@
import {defineConfig} from 'vitest/config'
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['**/*.test.ts'],
clearMocks: true
}
})