From 737ec82e8c2c8feb9e4b040f49da6333c166b587 Mon Sep 17 00:00:00 2001 From: Thomas Tvedt Date: Thu, 10 Sep 2026 12:54:01 +0200 Subject: [PATCH 1/4] 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/4] 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() From 6082c95dbf561f613f047dd3561d4f9441fb0fe4 Mon Sep 17 00:00:00 2001 From: Thomas Seljen Tvedt Date: Wed, 4 Nov 2026 09:23:53 +0100 Subject: [PATCH 3/4] fix: keep latest-fallback on by default Installing the built-in default version when 'latest' cannot be determined is documented behavior for v5, so 'latest-fallback' now defaults to 'true' and callers opt out with 'false'. The warning emitted on fallback names the input, so the downgrade is easy to trace and to turn off. --- README.md | 4 ++-- action.yml | 4 ++-- src/run.test.ts | 18 +++++++++--------- src/run.ts | 16 +++++++++------- 4 files changed, 22 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index c0de340f..7ad398ff 100644 --- a/README.md +++ b/README.md @@ -31,13 +31,13 @@ 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, 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'`: +> If fetching the latest version fails, the action retries a few times and then uses the hardcoded default version (currently v3.18.4). That default can be a major version behind what `latest` resolves to. To fail the step with the underlying error instead, set `latest-fallback` to `'false'`: > > ```yaml > - uses: azure/setup-helm@v5 > with: > version: 'latest' -> latest-fallback: 'true' +> latest-fallback: 'false' > ``` > > If you rely on a certain version, you should explicitly request that version instead of `latest`. diff --git a/action.yml b/action.yml index 5b0e9465..f9198e0c 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 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." + description: "When 'version' is 'latest' and the latest version cannot be determined after retrying, install the built-in default version instead of failing. Set to 'false' to fail the step instead, since the built-in default can be a major version behind what 'latest' resolves to." required: false - default: '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 1462e4d9..33a66486 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 and the fallback is enabled', async () => { + test('getLatestHelmVersion() - return the stable version of HELM when every attempt fails', async () => { const errorMessage: string = 'Network Error' const fetchSpy = vi .spyOn(globalThis, 'fetch') .mockRejectedValue(new Error(errorMessage)) - expect(await getLatestHelmVersionWithoutDelay(true)).toBe( + expect(await getLatestHelmVersionWithoutDelay()).toBe( run.stableHelmVersion ) expect(fetchSpy).toHaveBeenCalledTimes(3) @@ -223,12 +223,12 @@ describe('run.ts', () => { ) }) - test('getLatestHelmVersion() - throw when every attempt fails', async () => { + test('getLatestHelmVersion() - throw when every attempt fails and the fallback is off', async () => { const errorMessage: string = 'Network Error' const fetchSpy = vi .spyOn(globalThis, 'fetch') .mockRejectedValue(new Error(errorMessage)) - await expect(getLatestHelmVersionWithoutDelay()).rejects.toThrow( + await expect(getLatestHelmVersionWithoutDelay(false)).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 = 'false' + latestFallback: string = '' ) => vi.mocked(core.getInput).mockImplementation((name: string) => { if (name === 'version') return version @@ -571,9 +571,9 @@ describe('run.ts', () => { ).rejects.toThrow('exceeded 100 probes') }) - test('run() - fail when latest cannot be determined', async () => { + test('run() - fail when latest cannot be determined and latest-fallback is false', async () => { stubDownloadChain() - inputs('latest', '') + inputs('latest', '', 'false') vi.spyOn(globalThis, 'fetch').mockRejectedValue( new Error('Network Error') ) @@ -589,9 +589,9 @@ describe('run.ts', () => { expect(toolCache.find).not.toHaveBeenCalled() }) - test('run() - install the default version when latest-fallback is true', async () => { + test('run() - install the default version when latest cannot be determined and latest-fallback is left at its default', async () => { stubDownloadChain() - inputs('latest', '', 'true') + inputs('latest', '') vi.spyOn(globalThis, 'fetch').mockRejectedValue( new Error('Network Error') ) diff --git a/src/run.ts b/src/run.ts index 2889a7c6..520c07b7 100644 --- a/src/run.ts +++ b/src/run.ts @@ -34,8 +34,10 @@ export async function run() { const downloadBaseURL = core.getInput('downloadBaseURL', {required: false}) if (version.toLocaleLowerCase() === 'latest') { + // Only an explicit 'false' turns the fallback off, matching the + // input's default. const fallbackToDefault = - core.getInput('latest-fallback').toLowerCase() === 'true' + core.getInput('latest-fallback').toLowerCase() !== 'false' version = await getLatestHelmVersion(fallbackToDefault) } else if (isMajorMinorShaped(version)) { version = await resolveLatestPatchVersion(downloadBaseURL, version) @@ -158,12 +160,12 @@ export async function fetchLatestHelmVersion(): Promise { } } -// 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. +// Gets the latest helm version. When it cannot be determined, returns the +// built-in default version with a warning, or throws when fallbackToDefault +// is off. The default version can be a major behind what 'latest' resolves +// to, so the warning names the input that turns the fallback off. export async function getLatestHelmVersion( - fallbackToDefault = false + fallbackToDefault = true ): Promise { try { return await fetchLatestHelmVersion() @@ -175,7 +177,7 @@ export async function getLatestHelmVersion( ) } core.warning( - `Error while fetching latest Helm release: ${message}. Using default version ${stableHelmVersion}` + `Error while fetching latest Helm release: ${message}. Using default version ${stableHelmVersion}. Set 'latest-fallback' to 'false' to fail the step instead` ) return stableHelmVersion } From 2e69937b8cf2ab6704ba481a75f9e3baa107d9a0 Mon Sep 17 00:00:00 2001 From: Thomas Seljen Tvedt Date: Wed, 4 Nov 2026 09:24:00 +0100 Subject: [PATCH 4/4] fix: cancel the unread body of a failed latest version response A non-2xx response from the latest version URL is turned into an error without reading its body. Cancel the body first, so its connection is released before the next attempt instead of waiting for garbage collection. --- src/run.test.ts | 15 +++++++++++++++ src/run.ts | 3 +++ 2 files changed, 18 insertions(+) diff --git a/src/run.test.ts b/src/run.test.ts index 33a66486..78057301 100644 --- a/src/run.test.ts +++ b/src/run.test.ts @@ -209,6 +209,21 @@ describe('run.ts', () => { expect(core.warning).not.toHaveBeenCalled() }) + test('getLatestHelmVersion() - cancel the unread body of a non-success response before retrying', async () => { + const cancel = vi.fn() + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValueOnce( + new Response(new ReadableStream({cancel}), {status: 503}) + ) + .mockResolvedValueOnce(latestVersionResponse('v9.99.999')) + expect(await getLatestHelmVersionWithoutDelay()).toBe('v9.99.999') + expect(cancel).toHaveBeenCalledTimes(1) + expect(cancel.mock.invocationCallOrder[0]).toBeLessThan( + fetchSpy.mock.invocationCallOrder[1] + ) + }) + test('getLatestHelmVersion() - return the stable version of HELM when every attempt fails', async () => { const errorMessage: string = 'Network Error' const fetchSpy = vi diff --git a/src/run.ts b/src/run.ts index 520c07b7..5183169b 100644 --- a/src/run.ts +++ b/src/run.ts @@ -142,6 +142,9 @@ export async function fetchLatestHelmVersion(): Promise { try { const response = await fetch(latestVersionURL) if (!response.ok) { + // The body is never read, so cancel it to release the connection + // before the next attempt. + await response.body?.cancel() throw new Error( `Unexpected HTTP ${response.status} from ${latestVersionURL}` )