Add version-file input to read the Helm version from a .tool-versions file (#281)
Some checks failed
Run prettify / Prettier Check (push) Has been cancelled
Run unit tests. / build (ubuntu-24.04-arm) (push) Has been cancelled
Run unit tests. / build (ubuntu-latest) (push) Has been cancelled
Release Project / release (push) Has been cancelled
Run unit tests. / build (macos-latest) (push) Has been cancelled
Run unit tests. / build (windows-11-arm) (push) Has been cancelled
Run unit tests. / build (windows-latest) (push) Has been cancelled

* feat: add version-file input to read helm version from .tool-versions

* feat: validate semver shape of helm version from .tool-versions
This commit is contained in:
somaz 2026-06-24 05:07:39 +09:00 committed by GitHub
parent 95ecf4967d
commit 017211e1b1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 221 additions and 5 deletions

View file

@ -13,8 +13,25 @@ Acceptable values are latest or any semantic version string like v3.5.0 Use this
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.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.
Refer to the action metadata file for details about all the inputs https://github.com/Azure/setup-helm/blob/master/action.yml

View file

@ -3,8 +3,11 @@ description: 'Install a specific version of helm binary. Acceptable values are l
inputs:
version:
description: 'Version of helm'
required: true
required: false
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

View file

@ -19,7 +19,8 @@ vi.mock('fs', async (importOriginal) => {
readdirSync: vi.fn(),
statSync: vi.fn(),
chmodSync: vi.fn(),
readFileSync: vi.fn()
readFileSync: vi.fn(),
existsSync: vi.fn()
}
})
@ -161,6 +162,140 @@ describe('run.ts', () => {
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', () => {
vi.mocked(fs.readdirSync).mockImplementation((file, _?) => {
if (file == 'mainFolder')

View file

@ -13,11 +13,27 @@ const helmToolName = 'helm'
export const stableHelmVersion = 'v3.18.4'
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') {
core.info('Getting latest Helm version')
version = getValidVersion(version)
core.info(`Normalized Helm version to '${version}'`)
}
if (version.toLocaleLowerCase() === 'latest') {
version = await getLatestHelmVersion()
@ -46,6 +62,51 @@ export function getValidVersion(version: string): string {
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
export async function getLatestHelmVersion(): Promise<string> {
try {