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
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,16 @@ 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 fetching the latest version fails, the action retries a few times and then fails the step with the underlying error. It does not install the hardcoded default version (currently v3.18.4), because that default can be a major version behind what `latest` resolves to. To install the default version instead of failing, set `latest-fallback` to `'true'`:
>
> ```yaml
> - uses: azure/setup-helm@v5
> with:
> version: 'latest'
> latest-fallback: 'true'
> ```
>
> If you rely on a certain version, you should explicitly request 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
4 changes: 4 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ inputs:
description: 'Set the download base URL'
required: false
default: 'https://get.helm.sh'
latest-fallback:
description: "When 'version' is 'latest' and the latest version cannot be determined after retrying, install the built-in default version instead of failing. Off by default, because the built-in default can be a major version behind what 'latest' resolves to."
required: false
default: 'false'
outputs:
helm-path:
description: 'Path to the cached helm binary'
Expand Down
111 changes: 101 additions & 10 deletions src/run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ describe('run.ts', () => {
// Cleanup mocks after each test to ensure that subsequent tests are not affected by the mocks.
afterEach(() => {
vi.restoreAllMocks()
vi.useRealTimers()
})

test('getExecutableExtension() - return .exe when os is Windows', () => {
Expand Down Expand Up @@ -168,21 +169,70 @@ describe('run.ts', () => {
).toBe(expected)
})

test('getLatestHelmVersion() - return the latest version of HELM', async () => {
const res = {
const latestVersionResponse = (version: string) =>
({
ok: true,
status: 200,
text: async () => 'v9.99.999'
} as Response
vi.spyOn(globalThis, 'fetch').mockResolvedValue(res)
text: async () => version
}) as Response

// Runs a getLatestHelmVersion() call under fake timers so the retry
// backoff does not slow the test down.
const getLatestHelmVersionWithoutDelay = async (
fallbackToDefault?: boolean
) => {
vi.useFakeTimers()
const pending = run.getLatestHelmVersion(fallbackToDefault)
// Attach a no-op handler so a rejection is not reported as unhandled
// while the timers are being advanced; the caller still awaits it.
pending.catch(() => {})
await vi.runAllTimersAsync()
return pending
}

test('getLatestHelmVersion() - return the latest version of HELM', async () => {
const fetchSpy = vi
.spyOn(globalThis, 'fetch')
.mockResolvedValue(latestVersionResponse('v9.99.999'))
expect(await run.getLatestHelmVersion()).toBe('v9.99.999')
expect(fetchSpy).toHaveBeenCalledTimes(1)
})

test('getLatestHelmVersion() - retry a transient failure and return the latest version', async () => {
const fetchSpy = vi
.spyOn(globalThis, 'fetch')
.mockRejectedValueOnce(new Error('Network Error'))
.mockResolvedValueOnce({ok: false, status: 503} as Response)
.mockResolvedValueOnce(latestVersionResponse('v9.99.999'))
expect(await getLatestHelmVersionWithoutDelay()).toBe('v9.99.999')
expect(fetchSpy).toHaveBeenCalledTimes(3)
expect(core.warning).not.toHaveBeenCalled()
})

test('getLatestHelmVersion() - return the stable version of HELM when simulating a network error', async () => {
test('getLatestHelmVersion() - return the stable version of HELM when every attempt fails and the fallback is enabled', async () => {
const errorMessage: string = 'Network Error'
vi.spyOn(globalThis, 'fetch').mockRejectedValueOnce(
new Error(errorMessage)
const fetchSpy = vi
.spyOn(globalThis, 'fetch')
.mockRejectedValue(new Error(errorMessage))
expect(await getLatestHelmVersionWithoutDelay(true)).toBe(
run.stableHelmVersion
)
expect(fetchSpy).toHaveBeenCalledTimes(3)
expect(core.warning).toHaveBeenCalledWith(
expect.stringContaining(errorMessage)
)
expect(await run.getLatestHelmVersion()).toBe(run.stableHelmVersion)
})

test('getLatestHelmVersion() - throw when every attempt fails', async () => {
const errorMessage: string = 'Network Error'
const fetchSpy = vi
.spyOn(globalThis, 'fetch')
.mockRejectedValue(new Error(errorMessage))
await expect(getLatestHelmVersionWithoutDelay()).rejects.toThrow(
`Unable to determine the latest Helm version: ${errorMessage}`
)
expect(fetchSpy).toHaveBeenCalledTimes(3)
expect(core.warning).not.toHaveBeenCalled()
})

test('getValidVersion() - return version with v prepended', () => {
Expand Down Expand Up @@ -299,11 +349,16 @@ describe('run.ts', () => {
} as fs.Stats)
}

const inputs = (version: string, versionFile: string) =>
const inputs = (
version: string,
versionFile: string,
latestFallback: string = 'false'
) =>
vi.mocked(core.getInput).mockImplementation((name: string) => {
if (name === 'version') return version
if (name === 'version-file') return versionFile
if (name === 'downloadBaseURL') return downloadBaseURL
if (name === 'latest-fallback') return latestFallback
return ''
})

Expand Down Expand Up @@ -516,6 +571,42 @@ describe('run.ts', () => {
).rejects.toThrow('exceeded 100 probes')
})

test('run() - fail when latest cannot be determined', async () => {
stubDownloadChain()
inputs('latest', '')
vi.spyOn(globalThis, 'fetch').mockRejectedValue(
new Error('Network Error')
)
vi.useFakeTimers()

const pending = run.run()
pending.catch(() => {})
await vi.runAllTimersAsync()
await expect(pending).rejects.toThrow(
'Unable to determine the latest Helm version: Network Error'
)

expect(toolCache.find).not.toHaveBeenCalled()
})

test('run() - install the default version when latest-fallback is true', async () => {
stubDownloadChain()
inputs('latest', '', 'true')
vi.spyOn(globalThis, 'fetch').mockRejectedValue(
new Error('Network Error')
)
vi.useFakeTimers()

const pending = run.run()
await vi.runAllTimersAsync()
await pending

expect(core.warning).toHaveBeenCalledWith(
expect.stringContaining(run.stableHelmVersion)
)
expect(toolCache.find).toHaveBeenCalledWith('helm', run.stableHelmVersion)
})

test('run() - resolve the latest patch for a major.minor version input', async () => {
stubDownloadChain()
inputs('3.14', '')
Expand Down
59 changes: 52 additions & 7 deletions src/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,9 @@ export async function run() {
const downloadBaseURL = core.getInput('downloadBaseURL', {required: false})

if (version.toLocaleLowerCase() === 'latest') {
version = await getLatestHelmVersion()
const fallbackToDefault =
core.getInput('latest-fallback').toLowerCase() === 'true'
version = await getLatestHelmVersion(fallbackToDefault)
} else if (isMajorMinorShaped(version)) {
version = await resolveLatestPatchVersion(downloadBaseURL, version)
core.info(`Resolved latest patch Helm version to '${version}'`)
Expand Down Expand Up @@ -122,15 +124,58 @@ export function parseToolVersions(content: string): string {
return ''
}

// Gets the latest helm version or returns a default stable if getting latest fails
export async function getLatestHelmVersion(): Promise<string> {
const latestVersionURL = 'https://get.helm.sh/helm-latest-version'

// Number of attempts made to fetch the latest version before giving up, and
// the wait before the second attempt (each further wait doubles the previous).
const latestVersionAttempts = 3
const latestVersionRetryDelayMs = 1000

// Fetches the latest helm version. A failed request or a non-2xx response is
// retried with a short backoff, so a single transient failure does not decide
// the outcome. Throws the last error once every attempt has failed.
export async function fetchLatestHelmVersion(): Promise<string> {
let delayMs = latestVersionRetryDelayMs
for (let attempt = 1; ; attempt++) {
try {
const response = await fetch(latestVersionURL)
if (!response.ok) {
throw new Error(
`Unexpected HTTP ${response.status} from ${latestVersionURL}`
)
}
return (await response.text()).trim()
} catch (err) {
if (attempt >= latestVersionAttempts) {
throw err
}
core.info(
`Attempt ${attempt} of ${latestVersionAttempts} to fetch the latest Helm version failed: ${err instanceof Error ? err.message : String(err)}. Retrying in ${delayMs}ms`
)
await new Promise((resolve) => setTimeout(resolve, delayMs))
delayMs *= 2
}
}
}

// Gets the latest helm version. When it cannot be determined, throws, or
// falls back to the built-in default version with a warning when
// fallbackToDefault is set. The default version can be a major behind what
// 'latest' resolves to, so falling back to it is opt-in.
export async function getLatestHelmVersion(
fallbackToDefault = false
): Promise<string> {
try {
const response = await fetch('https://get.helm.sh/helm-latest-version')
const release = (await response.text()).trim()
return release
return await fetchLatestHelmVersion()
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
if (!fallbackToDefault) {
throw new Error(
`Unable to determine the latest Helm version: ${message}. Set 'latest-fallback' to 'true' to install the default version ${stableHelmVersion} instead, or request a specific version`
)
}
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: ${message}. Using default version ${stableHelmVersion}`
)
return stableHelmVersion
}
Expand Down