From c287889df6ce2cf47bcb1c2608c2e38948572ab7 Mon Sep 17 00:00:00 2001 From: Ogheneobukome Ejaife Date: Mon, 13 Jul 2026 19:34:51 -0700 Subject: [PATCH 1/2] feat: resolve latest patch for a major.minor version Allow the version input and version-file entries to be a major.minor value like 3.14 / v3.14, resolving to the newest available patch (e.g. v3.14.4). Resolution probes the download host directly: sequential HEAD requests for helm-v{major}.{minor}.{n} against downloadBaseURL, taking the highest that returns 200. Works with any file-serving mirror and needs no token or extra dependency, and cannot resolve to a version that is not downloadable. Only major.minor.n URLs are probed, so prereleases are never considered. - Add isMajorMinorShaped, helmPatchExists, and resolveLatestPatchVersion. - Walk stops after 3 consecutive 404s (look-ahead) to tolerate a skipped patch number, with a 100-probe safety cap; hitting the cap throws instead of returning a bogus version. - Wire resolution into run() and relax getVersionFromToolVersionsFile to accept a major.minor value. --- src/run.test.ts | 122 ++++++++++++++++++++++++++++++++++++++++++++++++ src/run.ts | 85 ++++++++++++++++++++++++++++++--- 2 files changed, 200 insertions(+), 7 deletions(-) diff --git a/src/run.test.ts b/src/run.test.ts index 30b313d2..b714352d 100644 --- a/src/run.test.ts +++ b/src/run.test.ts @@ -220,6 +220,13 @@ describe('run.ts', () => { ) }) + 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 +239,17 @@ 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() - 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 +314,110 @@ describe('run.ts', () => { expect(toolCache.find).toHaveBeenCalledWith('helm', 'v3.5.0') }) + // Stubs global fetch so HEAD probes report the given set of versions as + // existing (200) and everything else as missing (404). + const stubPatchProbes = (existing: string[]) => { + const present = new Set(existing) + vi.stubGlobal( + 'fetch', + vi.fn(async (url: string) => { + const version = url.match(/helm-(v\d+\.\d+\.\d+)-/)?.[1] ?? '' + return {ok: present.has(version)} 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.stubGlobal('fetch', vi.fn().mockResolvedValue({ok: true} 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.stubGlobal('fetch', vi.fn().mockResolvedValue({ok: false} as Response)) + + expect(await run.helmPatchExists(downloadBaseURL, 'v3.14.99')).toBe(false) + }) + + 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() - 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.stubGlobal( + 'fetch', + vi.fn().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.stubGlobal('fetch', vi.fn().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 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..349c7a9a 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,15 @@ export function isSemVerShaped(version: string): boolean { return semVerShape.test(version) } +// Matches a major.minor version with an optional leading 'v' and no patch +// component, e.g. '3.14' or 'v3.14'. +const majorMinorShape = /^v?\d+\.\d+$/ + +// Returns true when version is a major.minor value with no patch component +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,7 +93,7 @@ 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` ) @@ -121,6 +132,66 @@ 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 and returns true +// when the artifact exists (2xx). Genuine network errors are propagated so the +// caller can distinguish an outage from a missing patch. +export async function helmPatchExists( + baseURL: string, + version: string +): Promise { + const response = await fetch(getHelmDownloadURL(baseURL, version), { + method: 'HEAD' + }) + return response.ok +} + +// 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() } From 8b4df4ca99973e6917dd698b723dd69430f8d58c Mon Sep 17 00:00:00 2001 From: Ogheneobukome Ejaife Date: Tue, 14 Jul 2026 11:00:32 -0700 Subject: [PATCH 2/2] feat: support .x/.* wildcards and harden patch probing Extend the major.minor latest-patch resolution to accept wildcard patch syntax (`3.12.x`, `v3.12.x`, `3.12.*`), matching the syntax requested in Azure/setup-helm#109. The following smaller fixes were made based on Copilot review feedback: - helmPatchExists now treats only 404 as "patch absent"; any other non-2xx status (403/405/429/5xx) and network errors are thrown, so rate-limiting, outages, or a host that disallows HEAD can no longer be misread as a missing patch (which could yield a stale version or a false "No Helm releases found"). - Clarify the version-file validation error to reflect that both a full version and a major.minor value are accepted, with concrete examples. - Tests: use vi.spyOn(globalThis, 'fetch') instead of vi.stubGlobal so restoreAllMocks() reliably restores fetch and the mock never leaks across tests; add coverage for wildcards and the non-404 throw path. --- src/run.test.ts | 90 +++++++++++++++++++++++++++++++++++++------------ src/run.ts | 35 ++++++++++++------- 2 files changed, 92 insertions(+), 33 deletions(-) diff --git a/src/run.test.ts b/src/run.test.ts index b714352d..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,14 +208,14 @@ 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')" ) }) @@ -244,6 +243,13 @@ describe('run.ts', () => { 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) @@ -314,23 +320,27 @@ describe('run.ts', () => { expect(toolCache.find).toHaveBeenCalledWith('helm', 'v3.5.0') }) - // Stubs global fetch so HEAD probes report the given set of versions as - // existing (200) and everything else as missing (404). + // 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.stubGlobal( - 'fetch', - vi.fn(async (url: string) => { - const version = url.match(/helm-(v\d+\.\d+\.\d+)-/)?.[1] ?? '' - return {ok: present.has(version)} as Response - }) - ) + 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.stubGlobal('fetch', vi.fn().mockResolvedValue({ok: true} as Response)) + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + status: 200 + } as Response) expect(await run.helmPatchExists(downloadBaseURL, 'v3.14.4')).toBe(true) }) @@ -338,11 +348,27 @@ describe('run.ts', () => { test('helmPatchExists() - return false when the artifact responds 404', async () => { vi.mocked(os.platform).mockReturnValue('linux') vi.mocked(os.arch).mockReturnValue('x64') - vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ok: false} as Response)) + 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') @@ -363,6 +389,19 @@ describe('run.ts', () => { ).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') @@ -376,9 +415,8 @@ describe('run.ts', () => { test('resolveLatestPatchVersion() - propagate network errors', async () => { vi.mocked(os.platform).mockReturnValue('linux') vi.mocked(os.arch).mockReturnValue('x64') - vi.stubGlobal( - 'fetch', - vi.fn().mockRejectedValue(new Error('Network Error')) + vi.spyOn(globalThis, 'fetch').mockRejectedValue( + new Error('Network Error') ) await expect( @@ -389,7 +427,7 @@ describe('run.ts', () => { 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.stubGlobal('fetch', vi.fn().mockResolvedValue({ok: true} as Response)) + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ok: true} as Response) await expect( run.resolveLatestPatchVersion(downloadBaseURL, '3.14') @@ -406,6 +444,16 @@ describe('run.ts', () => { 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') diff --git a/src/run.ts b/src/run.ts index 349c7a9a..3bcab6f3 100644 --- a/src/run.ts +++ b/src/run.ts @@ -74,11 +74,13 @@ export function isSemVerShaped(version: string): boolean { return semVerShape.test(version) } -// Matches a major.minor version with an optional leading 'v' and no patch -// component, e.g. '3.14' or 'v3.14'. -const majorMinorShape = /^v?\d+\.\d+$/ +// 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 with no patch component +// 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) } @@ -95,7 +97,7 @@ export function getVersionFromToolVersionsFile(filePath: string): string { } 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 @@ -137,17 +139,26 @@ export async function getLatestHelmVersion(): Promise { const patchLookahead = 3 const maxPatch = 100 -// Sends a HEAD request for the given version's download URL and returns true -// when the artifact exists (2xx). Genuine network errors are propagated so the -// caller can distinguish an outage from a missing patch. +// 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 response = await fetch(getHelmDownloadURL(baseURL, version), { - method: 'HEAD' - }) - return response.ok + 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