From 737ec82e8c2c8feb9e4b040f49da6333c166b587 Mon Sep 17 00:00:00 2001 From: Thomas Tvedt Date: Thu, 10 Sep 2026 12:54:01 +0200 Subject: [PATCH 1/2] fix: retry latest version lookup and add latest-fallback opt-out Resolving 'version: latest' made a single request to https://get.helm.sh/helm-latest-version and, on any failure, silently installed the hard-coded default version. Since 'latest' resolves to Helm 4, that fallback moves a job back a whole major version while the step stays green. Retry the lookup a few times with a short backoff, treat non-2xx responses as failures instead of installing the response body, and add a 'latest-fallback' input so callers can make the step fail instead of installing the default version. The default keeps the existing behavior. --- README.md | 9 +++- action.yml | 4 ++ src/run.test.ts | 111 +++++++++++++++++++++++++++++++++++++++++++----- src/run.ts | 58 ++++++++++++++++++++++--- 4 files changed, 164 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 386ef019..6d780507 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,14 @@ 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 after a few retries, the action will use the hardcoded default version (currently v3.18.4) and emit a warning. If you rely on a certain version higher than the default, you should explicitly use that version instead of latest. To make the step fail instead of installing the default version, set `latest-fallback` to `'false'`: +> +> ```yaml +> - uses: azure/setup-helm@v5 +> with: +> version: 'latest' +> latest-fallback: 'false' +> ``` 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 diff --git a/action.yml b/action.yml index 572af494..3b196df9 100644 --- a/action.yml +++ b/action.yml @@ -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, install the built-in default version instead of failing. Set to 'false' to fail the step instead." + required: false + default: 'true' outputs: helm-path: description: 'Path to the cached helm binary' diff --git a/src/run.test.ts b/src/run.test.ts index 8d3de9d0..ace84918 100644 --- a/src/run.test.ts +++ b/src/run.test.ts @@ -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', () => { @@ -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', 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()).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 and the fallback is disabled', async () => { + const errorMessage: string = 'Network Error' + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockRejectedValue(new Error(errorMessage)) + await expect(getLatestHelmVersionWithoutDelay(false)).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', () => { @@ -299,11 +349,16 @@ describe('run.ts', () => { } as fs.Stats) } - const inputs = (version: string, versionFile: string) => + const inputs = ( + version: string, + versionFile: string, + latestFallback: string = 'true' + ) => 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 '' }) @@ -516,6 +571,42 @@ describe('run.ts', () => { ).rejects.toThrow('exceeded 100 probes') }) + test('run() - install the default version when latest cannot be determined', async () => { + stubDownloadChain() + inputs('latest', '') + 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() - fail when latest cannot be determined and latest-fallback is false', async () => { + stubDownloadChain() + inputs('latest', '', 'false') + 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() - resolve the latest patch for a major.minor version input', async () => { stubDownloadChain() inputs('3.14', '') diff --git a/src/run.ts b/src/run.ts index 728c7199..f55f80cb 100644 --- a/src/run.ts +++ b/src/run.ts @@ -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() !== 'false' + version = await getLatestHelmVersion(fallbackToDefault) } else if (isMajorMinorShaped(version)) { version = await resolveLatestPatchVersion(downloadBaseURL, version) core.info(`Resolved latest patch Helm version to '${version}'`) @@ -122,15 +124,57 @@ 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 { +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 { + 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, either falls +// back to the built-in default version (with a warning) or throws, depending +// on fallbackToDefault. +export async function getLatestHelmVersion( + fallbackToDefault = true +): Promise { 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 } From adc95ab225c6572e78b715663b6f8fd8cc2c2003 Mon Sep 17 00:00:00 2001 From: Thomas Seljen Tvedt Date: Thu, 10 Sep 2026 13:21:20 +0200 Subject: [PATCH 2/2] fix: do not fall back across a major version by default Make 'latest-fallback' default to 'false', so an exhausted lookup fails the step with the underlying error instead of installing the built-in default version, which can be a major behind what 'latest' resolves to. Setting the input to 'true' restores the previous behavior. --- README.md | 6 ++++-- action.yml | 4 ++-- src/run.test.ts | 36 ++++++++++++++++++------------------ src/run.ts | 11 ++++++----- 4 files changed, 30 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 6d780507..c0de340f 100644 --- a/README.md +++ b/README.md @@ -31,14 +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 fetching the latest version fails after a few retries, the action will use the hardcoded default version (currently v3.18.4) and emit a warning. If you rely on a certain version higher than the default, you should explicitly use that version instead of latest. To make the step fail instead of installing the default version, set `latest-fallback` to `'false'`: +> 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: 'false' +> 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 diff --git a/action.yml b/action.yml index 3b196df9..5b0e9465 100644 --- a/action.yml +++ b/action.yml @@ -18,9 +18,9 @@ inputs: required: false default: 'https://get.helm.sh' latest-fallback: - description: "When 'version' is 'latest' and the latest version cannot be determined, install the built-in default version instead of failing. Set to 'false' to fail the step instead." + 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: 'true' + default: 'false' outputs: helm-path: description: 'Path to the cached helm binary' diff --git a/src/run.test.ts b/src/run.test.ts index ace84918..1462e4d9 100644 --- a/src/run.test.ts +++ b/src/run.test.ts @@ -209,12 +209,12 @@ describe('run.ts', () => { expect(core.warning).not.toHaveBeenCalled() }) - test('getLatestHelmVersion() - return the stable version of HELM when every attempt fails', async () => { + test('getLatestHelmVersion() - return the stable version of HELM when every attempt fails and the fallback is enabled', async () => { const errorMessage: string = 'Network Error' const fetchSpy = vi .spyOn(globalThis, 'fetch') .mockRejectedValue(new Error(errorMessage)) - expect(await getLatestHelmVersionWithoutDelay()).toBe( + expect(await getLatestHelmVersionWithoutDelay(true)).toBe( run.stableHelmVersion ) expect(fetchSpy).toHaveBeenCalledTimes(3) @@ -223,12 +223,12 @@ describe('run.ts', () => { ) }) - test('getLatestHelmVersion() - throw when every attempt fails and the fallback is disabled', async () => { + 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(false)).rejects.toThrow( + await expect(getLatestHelmVersionWithoutDelay()).rejects.toThrow( `Unable to determine the latest Helm version: ${errorMessage}` ) expect(fetchSpy).toHaveBeenCalledTimes(3) @@ -352,7 +352,7 @@ describe('run.ts', () => { const inputs = ( version: string, versionFile: string, - latestFallback: string = 'true' + latestFallback: string = 'false' ) => vi.mocked(core.getInput).mockImplementation((name: string) => { if (name === 'version') return version @@ -571,7 +571,7 @@ describe('run.ts', () => { ).rejects.toThrow('exceeded 100 probes') }) - test('run() - install the default version when latest cannot be determined', async () => { + test('run() - fail when latest cannot be determined', async () => { stubDownloadChain() inputs('latest', '') vi.spyOn(globalThis, 'fetch').mockRejectedValue( @@ -580,31 +580,31 @@ describe('run.ts', () => { vi.useFakeTimers() const pending = run.run() + pending.catch(() => {}) await vi.runAllTimersAsync() - await pending - - expect(core.warning).toHaveBeenCalledWith( - expect.stringContaining(run.stableHelmVersion) + await expect(pending).rejects.toThrow( + 'Unable to determine the latest Helm version: Network Error' ) - expect(toolCache.find).toHaveBeenCalledWith('helm', run.stableHelmVersion) + + expect(toolCache.find).not.toHaveBeenCalled() }) - test('run() - fail when latest cannot be determined and latest-fallback is false', async () => { + test('run() - install the default version when latest-fallback is true', async () => { stubDownloadChain() - inputs('latest', '', 'false') + inputs('latest', '', 'true') 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' - ) + await pending - expect(toolCache.find).not.toHaveBeenCalled() + 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 () => { diff --git a/src/run.ts b/src/run.ts index f55f80cb..2889a7c6 100644 --- a/src/run.ts +++ b/src/run.ts @@ -35,7 +35,7 @@ export async function run() { if (version.toLocaleLowerCase() === 'latest') { const fallbackToDefault = - core.getInput('latest-fallback').toLowerCase() !== 'false' + core.getInput('latest-fallback').toLowerCase() === 'true' version = await getLatestHelmVersion(fallbackToDefault) } else if (isMajorMinorShaped(version)) { version = await resolveLatestPatchVersion(downloadBaseURL, version) @@ -158,11 +158,12 @@ export async function fetchLatestHelmVersion(): Promise { } } -// Gets the latest helm version. When it cannot be determined, either falls -// back to the built-in default version (with a warning) or throws, depending -// on fallbackToDefault. +// 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 = true + fallbackToDefault = false ): Promise { try { return await fetchLatestHelmVersion()