1
0
mirror of https://github.com/Azure/setup-helm.git synced 2026-07-15 20:58:06 +00:00

Compare commits

...

2 Commits

Author SHA1 Message Date
Ogheneobukome Ejaife eb399fcd4f fix: preserve subpath base URLs in getHelmDownloadURL (#292)
Ensure downloadBaseURL ends with '/' before URL resolution so a subpath
mirror (e.g. https://example/kubernetes/helm) is preserved instead of having
its last path segment replaced. Fixes downloads for all subpath mirrors.
2026-07-15 12:45:05 -07:00
somaz 017211e1b1 Add version-file input to read the Helm version from a .tool-versions file (#281)
* feat: add version-file input to read helm version from .tool-versions

* feat: validate semver shape of helm version from .tool-versions
2026-06-23 13:07:39 -07:00
4 changed files with 254 additions and 6 deletions
+18 -1
View File
@@ -13,8 +13,25 @@ Acceptable values are latest or any semantic version string like v3.5.0 Use this
id: install 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] > [!NOTE]
> If something goes wrong with fetching the latest version the action will use the hardcoded default version (currently v3.18.3). 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 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.
The cached helm binary path is prepended to the PATH environment variable as well as stored in the helm-path output variable. 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 Refer to the action metadata file for details about all the inputs https://github.com/Azure/setup-helm/blob/master/action.yml
+4 -1
View File
@@ -3,8 +3,11 @@ description: 'Install a specific version of helm binary. Acceptable values are l
inputs: inputs:
version: version:
description: 'Version of helm' description: 'Version of helm'
required: true required: false
default: 'latest' default: 'latest'
version-file:
description: 'Path to a .tool-versions file to read the helm version from'
required: false
token: token:
description: GitHub token. Used to be required to fetch the latest version description: GitHub token. Used to be required to fetch the latest version
required: false required: false
+164 -1
View File
@@ -19,7 +19,8 @@ vi.mock('fs', async (importOriginal) => {
readdirSync: vi.fn(), readdirSync: vi.fn(),
statSync: vi.fn(), statSync: vi.fn(),
chmodSync: vi.fn(), chmodSync: vi.fn(),
readFileSync: vi.fn() readFileSync: vi.fn(),
existsSync: vi.fn()
} }
}) })
@@ -139,6 +140,34 @@ describe('run.ts', () => {
expect(os.platform).toHaveBeenCalled() 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 () => { test('getLatestHelmVersion() - return the latest version of HELM', async () => {
const res = { const res = {
status: 200, status: 200,
@@ -161,6 +190,140 @@ describe('run.ts', () => {
expect(run.getValidVersion('3.8.0')).toBe('v3.8.0') 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 semver-shaped', () => {
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 a valid semantic version"
)
})
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)
})
// 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')
})
test('walkSync() - return path to the all files matching fileToFind in dir', () => { test('walkSync() - return path to the all files matching fileToFind in dir', () => {
vi.mocked(fs.readdirSync).mockImplementation((file, _?) => { vi.mocked(fs.readdirSync).mockImplementation((file, _?) => {
if (file == 'mainFolder') if (file == 'mainFolder')
+68 -3
View File
@@ -13,11 +13,27 @@ const helmToolName = 'helm'
export const stableHelmVersion = 'v3.18.4' export const stableHelmVersion = 'v3.18.4'
export async function run() { export async function run() {
let version = core.getInput('version', {required: true}) let version = core.getInput('version')
const versionFile = core.getInput('version-file')
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) {
version = 'latest'
}
if (version !== 'latest' && version[0] !== 'v') { if (version !== 'latest' && version[0] !== 'v') {
core.info('Getting latest Helm version')
version = getValidVersion(version) version = getValidVersion(version)
core.info(`Normalized Helm version to '${version}'`)
} }
if (version.toLocaleLowerCase() === 'latest') { if (version.toLocaleLowerCase() === 'latest') {
version = await getLatestHelmVersion() version = await getLatestHelmVersion()
@@ -46,6 +62,51 @@ export function getValidVersion(version: string): string {
return 'v' + version return 'v' + version
} }
// Matches a semantic version (major.minor.patch) with an optional leading 'v'
// and optional pre-release / build-metadata suffixes, e.g. '3.14.0', 'v3.14.0',
// '3.14.0-rc.1'.
const semVerShape = /^v?\d+\.\d+\.\d+(?:-[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)
}
// 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)) {
throw new Error(
`The helm version '${version}' in '${filePath}' is not a valid semantic version`
)
}
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 // Gets the latest helm version or returns a default stable if getting latest fails
export async function getLatestHelmVersion(): Promise<string> { export async function getLatestHelmVersion(): Promise<string> {
try { try {
@@ -78,7 +139,11 @@ export function getExecutableExtension(): string {
export function getHelmDownloadURL(baseURL: string, version: string): string { export function getHelmDownloadURL(baseURL: string, version: string): string {
const urlPath = `helm-${version}-${getPlatform()}-${getArch()}.${getArchiveExtension()}` const urlPath = `helm-${version}-${getPlatform()}-${getArch()}.${getArchiveExtension()}`
const url = new URL(urlPath, baseURL) // 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)
return url.toString() return url.toString()
} }