diff --git a/src/run.test.ts b/src/run.test.ts index 30b313d2..ea729340 100644 --- a/src/run.test.ts +++ b/src/run.test.ts @@ -145,15 +145,14 @@ describe('run.ts', () => { status: 200, text: async () => 'v9.99.999' } as Response - vi.stubGlobal('fetch', vi.fn().mockReturnValue(res)) + vi.spyOn(globalThis, 'fetch').mockResolvedValue(res) expect(await run.getLatestHelmVersion()).toBe('v9.99.999') }) test('getLatestHelmVersion() - return the stable version of HELM when simulating a network error', async () => { const errorMessage: string = 'Network Error' - vi.stubGlobal( - 'fetch', - vi.fn().mockRejectedValueOnce(new Error(errorMessage)) + vi.spyOn(globalThis, 'fetch').mockRejectedValueOnce( + new Error(errorMessage) ) expect(await run.getLatestHelmVersion()).toBe(run.stableHelmVersion) }) @@ -209,17 +208,24 @@ describe('run.ts', () => { ).toThrow("No helm version found in '.tool-versions'") }) - test('getVersionFromToolVersionsFile() - throw when the helm version is not semver-shaped', () => { + test('getVersionFromToolVersionsFile() - throw when the helm version is not valid', () => { 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" + "The helm version 'latest' in '.tool-versions' is not valid. Provide a full version (e.g. '3.14.0') or a major.minor version (e.g. '3.14' or '3.14.x')" ) }) + test('getVersionFromToolVersionsFile() - accept a major.minor version', () => { + vi.mocked(fs.existsSync).mockReturnValue(true) + vi.mocked(fs.readFileSync).mockReturnValue('helm 3.14') + + expect(run.getVersionFromToolVersionsFile('.tool-versions')).toBe('3.14') + }) + 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) @@ -232,6 +238,24 @@ describe('run.ts', () => { expect(run.isSemVerShaped('abc')).toBe(false) }) + test('isMajorMinorShaped() - accept major.minor with or without a v prefix', () => { + expect(run.isMajorMinorShaped('3.14')).toBe(true) + expect(run.isMajorMinorShaped('v3.14')).toBe(true) + }) + + test('isMajorMinorShaped() - accept a wildcard patch (.x / .*)', () => { + expect(run.isMajorMinorShaped('3.14.x')).toBe(true) + expect(run.isMajorMinorShaped('v3.14.x')).toBe(true) + expect(run.isMajorMinorShaped('3.14.*')).toBe(true) + expect(run.isMajorMinorShaped('v3.14.*')).toBe(true) + }) + + test('isMajorMinorShaped() - reject full versions and other values', () => { + expect(run.isMajorMinorShaped('3.14.0')).toBe(false) + expect(run.isMajorMinorShaped('latest')).toBe(false) + expect(run.isMajorMinorShaped('3')).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 = () => { @@ -296,6 +320,152 @@ describe('run.ts', () => { expect(toolCache.find).toHaveBeenCalledWith('helm', 'v3.5.0') }) + // Spies on global fetch so HEAD probes report the given set of versions as + // existing (200) and everything else as missing (404). Using vi.spyOn (rather + // than vi.stubGlobal) lets restoreAllMocks() in afterEach reliably restore the + // real fetch, so the mock never leaks into later tests. + const stubPatchProbes = (existing: string[]) => { + const present = new Set(existing) + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + const version = + String(input).match(/helm-(v\d+\.\d+\.\d+)-/)?.[1] ?? '' + const ok = present.has(version) + return {ok, status: ok ? 200 : 404} as Response + }) + } + + test('helmPatchExists() - return true when the artifact responds 200', async () => { + vi.mocked(os.platform).mockReturnValue('linux') + vi.mocked(os.arch).mockReturnValue('x64') + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + status: 200 + } as Response) + + expect(await run.helmPatchExists(downloadBaseURL, 'v3.14.4')).toBe(true) + }) + + test('helmPatchExists() - return false when the artifact responds 404', async () => { + vi.mocked(os.platform).mockReturnValue('linux') + vi.mocked(os.arch).mockReturnValue('x64') + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: false, + status: 404 + } as Response) + + expect(await run.helmPatchExists(downloadBaseURL, 'v3.14.99')).toBe(false) + }) + + test('helmPatchExists() - throw on a non-404 error status', async () => { + vi.mocked(os.platform).mockReturnValue('linux') + vi.mocked(os.arch).mockReturnValue('x64') + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: false, + status: 429 + } as Response) + + await expect( + run.helmPatchExists(downloadBaseURL, 'v3.14.4') + ).rejects.toThrow('Unexpected HTTP 429') + }) + + test('resolveLatestPatchVersion() - return the newest existing patch', async () => { + vi.mocked(os.platform).mockReturnValue('linux') + vi.mocked(os.arch).mockReturnValue('x64') + stubPatchProbes(['v3.14.0', 'v3.14.1', 'v3.14.2', 'v3.14.3', 'v3.14.4']) + + expect(await run.resolveLatestPatchVersion(downloadBaseURL, '3.14')).toBe( + 'v3.14.4' + ) + }) + + test('resolveLatestPatchVersion() - tolerate a skipped patch number', async () => { + vi.mocked(os.platform).mockReturnValue('linux') + vi.mocked(os.arch).mockReturnValue('x64') + stubPatchProbes(['v3.14.0', 'v3.14.1', 'v3.14.3']) + + expect( + await run.resolveLatestPatchVersion(downloadBaseURL, 'v3.14') + ).toBe('v3.14.3') + }) + + test('resolveLatestPatchVersion() - accept a wildcard patch (.x / .*)', async () => { + vi.mocked(os.platform).mockReturnValue('linux') + vi.mocked(os.arch).mockReturnValue('x64') + stubPatchProbes(['v3.12.0', 'v3.12.1', 'v3.12.2', 'v3.12.3']) + + expect( + await run.resolveLatestPatchVersion(downloadBaseURL, 'v3.12.x') + ).toBe('v3.12.3') + expect( + await run.resolveLatestPatchVersion(downloadBaseURL, '3.12.*') + ).toBe('v3.12.3') + }) + + test('resolveLatestPatchVersion() - throw when the minor has no releases', async () => { + vi.mocked(os.platform).mockReturnValue('linux') + vi.mocked(os.arch).mockReturnValue('x64') + stubPatchProbes([]) + + await expect( + run.resolveLatestPatchVersion(downloadBaseURL, '9.99') + ).rejects.toThrow('No Helm releases found for 9.99') + }) + + test('resolveLatestPatchVersion() - propagate network errors', async () => { + vi.mocked(os.platform).mockReturnValue('linux') + vi.mocked(os.arch).mockReturnValue('x64') + vi.spyOn(globalThis, 'fetch').mockRejectedValue( + new Error('Network Error') + ) + + await expect( + run.resolveLatestPatchVersion(downloadBaseURL, '3.14') + ).rejects.toThrow('Network Error') + }) + + test('resolveLatestPatchVersion() - throw when the host reports every patch as existing', async () => { + vi.mocked(os.platform).mockReturnValue('linux') + vi.mocked(os.arch).mockReturnValue('x64') + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ok: true} as Response) + + await expect( + run.resolveLatestPatchVersion(downloadBaseURL, '3.14') + ).rejects.toThrow('exceeded 100 probes') + }) + + test('run() - resolve the latest patch for a major.minor version input', async () => { + stubDownloadChain() + inputs('3.14', '') + stubPatchProbes(['v3.14.0', 'v3.14.1', 'v3.14.2', 'v3.14.3', 'v3.14.4']) + + await run.run() + + expect(toolCache.find).toHaveBeenCalledWith('helm', 'v3.14.4') + }) + + test('run() - resolve the latest patch for a wildcard patch version input', async () => { + stubDownloadChain() + inputs('v3.12.x', '') + stubPatchProbes(['v3.12.0', 'v3.12.1', 'v3.12.2', 'v3.12.3']) + + await run.run() + + expect(toolCache.find).toHaveBeenCalledWith('helm', 'v3.12.3') + }) + + test('run() - resolve the latest patch for a major.minor version-file entry', async () => { + stubDownloadChain() + inputs('', '.tool-versions') + vi.mocked(fs.existsSync).mockReturnValue(true) + vi.mocked(fs.readFileSync).mockReturnValue('helm 3.14') + stubPatchProbes(['v3.14.0', 'v3.14.1', 'v3.14.2', 'v3.14.3', 'v3.14.4']) + + await run.run() + + expect(toolCache.find).toHaveBeenCalledWith('helm', 'v3.14.4') + }) + test('walkSync() - return path to the all files matching fileToFind in dir', () => { vi.mocked(fs.readdirSync).mockImplementation((file, _?) => { if (file == 'mainFolder') diff --git a/src/run.ts b/src/run.ts index ff7c2857..3bcab6f3 100644 --- a/src/run.ts +++ b/src/run.ts @@ -31,16 +31,18 @@ export async function run() { version = 'latest' } - if (version !== 'latest' && version[0] !== 'v') { - version = getValidVersion(version) - core.info(`Normalized Helm version to '${version}'`) - } + const downloadBaseURL = core.getInput('downloadBaseURL', {required: false}) + if (version.toLocaleLowerCase() === 'latest') { version = await getLatestHelmVersion() + } else if (isMajorMinorShaped(version)) { + version = await resolveLatestPatchVersion(downloadBaseURL, version) + core.info(`Resolved latest patch Helm version to '${version}'`) + } else if (version[0] !== 'v') { + version = getValidVersion(version) + core.info(`Normalized Helm version to '${version}'`) } - const downloadBaseURL = core.getInput('downloadBaseURL', {required: false}) - core.startGroup(`Installing ${version}`) const cachedPath = await downloadHelm(downloadBaseURL, version) core.endGroup() @@ -72,6 +74,17 @@ export function isSemVerShaped(version: string): boolean { return semVerShape.test(version) } +// Matches a major.minor version with an optional leading 'v' and either no +// patch component or a wildcard patch ('.x' / '.*'), e.g. '3.14', 'v3.14', +// '3.14.x', 'v3.14.*'. +const majorMinorShape = /^v?\d+\.\d+(?:\.[x*])?$/ + +// Returns true when version is a major.minor value (optionally with a wildcard +// patch such as '.x' or '.*') +export function isMajorMinorShaped(version: string): boolean { + return majorMinorShape.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)) { @@ -82,9 +95,9 @@ export function getVersionFromToolVersionsFile(filePath: string): string { if (!version) { throw new Error(`No helm version found in '${filePath}'`) } - if (!isSemVerShaped(version)) { + if (!isSemVerShaped(version) && !isMajorMinorShaped(version)) { throw new Error( - `The helm version '${version}' in '${filePath}' is not a valid semantic version` + `The helm version '${version}' in '${filePath}' is not valid. Provide a full version (e.g. '3.14.0') or a major.minor version (e.g. '3.14' or '3.14.x')` ) } return version @@ -121,6 +134,75 @@ export async function getLatestHelmVersion(): Promise { } } +// Number of consecutive missing patches to probe before concluding the walk, +// and an upper bound to keep resolution from running unbounded. +const patchLookahead = 3 +const maxPatch = 100 + +// Sends a HEAD request for the given version's download URL. Returns true when +// the artifact exists (2xx) and false only when it is definitively absent +// (404). Any other status (403/405/429/5xx, ...) and genuine network errors are +// thrown, so transient failures, rate-limiting, or a host that disallows HEAD +// are never mistaken for a missing patch. +export async function helmPatchExists( + baseURL: string, + version: string +): Promise { + const url = getHelmDownloadURL(baseURL, version) + const response = await fetch(url, {method: 'HEAD'}) + if (response.ok) { + return true + } + if (response.status === 404) { + return false + } + throw new Error( + `Unexpected HTTP ${response.status} while checking for Helm artifact at ${url}` + ) +} + +// Resolves a major.minor value (e.g. '3.14' or 'v3.14') to the newest available +// patch (e.g. 'v3.14.4') by probing the download host for sequential patches. +// Only 'major.minor.n' URLs are probed, so prereleases are never considered. +export async function resolveLatestPatchVersion( + baseURL: string, + version: string +): Promise { + const [major, minor] = ( + version[0] === 'v' ? version.slice(1) : version + ).split('.') + + if (!(await helmPatchExists(baseURL, `v${major}.${minor}.0`))) { + throw new Error(`No Helm releases found for ${major}.${minor}`) + } + + let latestPatch = 0 + let consecutiveMisses = 0 + for ( + let patch = 1; + patch <= maxPatch && consecutiveMisses < patchLookahead; + patch++ + ) { + if (await helmPatchExists(baseURL, `v${major}.${minor}.${patch}`)) { + latestPatch = patch + consecutiveMisses = 0 + } else { + consecutiveMisses++ + } + } + + // The look-ahead is what should end the walk. Exhausting maxPatch without a + // trailing run of misses means the host answered 200 for every probe (e.g. a + // catch-all mirror), so the resolved version cannot be trusted. + if (consecutiveMisses < patchLookahead) { + throw new Error( + `Unable to resolve latest patch for ${major}.${minor} (exceeded ${maxPatch} probes)` + ) + } + + return `v${major}.${minor}.${latestPatch}` +} + export function getArch(): string { return os.arch() === 'x64' ? 'amd64' : os.arch() }