diff --git a/README.md b/README.md index 386ef019..c0de340f 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/action.yml b/action.yml index 572af494..5b0e9465 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 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' diff --git a/src/run.test.ts b/src/run.test.ts index 8d3de9d0..1462e4d9 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 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', () => { @@ -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 '' }) @@ -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', '') diff --git a/src/run.ts b/src/run.ts index 728c7199..2889a7c6 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() === 'true' + 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,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 { +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, 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 { 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 }