Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
137 changes: 136 additions & 1 deletion src/run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
})

Expand Down Expand Up @@ -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')
Expand Down
65 changes: 63 additions & 2 deletions src/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Comment on lines +16 to +20
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()
Expand Down Expand Up @@ -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) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we add a semver-shape regex check here for the helm 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 {
Expand Down