diff --git a/.gitignore b/.gitignore index 8ecdde7c..4084df6c 100644 --- a/.gitignore +++ b/.gitignore @@ -65,3 +65,6 @@ coverage # Transpiled JS lib/ + +# Planning docs +docs/ diff --git a/src/run.test.ts b/src/run.test.ts index 30b313d2..18172190 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,206 @@ 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() - use the blob listing in a single request when supported', async () => { + vi.mocked(os.platform).mockReturnValue('linux') + vi.mocked(os.arch).mockReturnValue('x64') + const listing = + '' + + 'helm-v3.14.0-linux-amd64.tar.gz' + + 'helm-v3.14.0-rc.1-linux-amd64.tar.gz' + + 'helm-v3.14.4-linux-amd64.tar.gz' + + 'helm-v3.14.4-linux-amd64.tar.gz.sha256' + + '' + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + text: async () => listing + } as Response) + + expect(await run.resolveLatestPatchVersion(downloadBaseURL, '3.14')).toBe( + 'v3.14.4' + ) + // A single listing request; no per-patch HEAD probing. + expect(fetchSpy).toHaveBeenCalledTimes(1) + }) + + test('resolveLatestPatchVersion() - fall back to probing when listing is unavailable', async () => { + vi.mocked(os.platform).mockReturnValue('linux') + vi.mocked(os.arch).mockReturnValue('x64') + const present = new Set(['v3.14.0', 'v3.14.1', 'v3.14.2']) + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + const url = String(input) + if (url.includes('comp=list')) { + return {ok: false, status: 404} as Response + } + const version = url.match(/helm-(v\d+\.\d+\.\d+)-/)?.[1] ?? '' + const ok = present.has(version) + return {ok, status: ok ? 200 : 404} as Response + }) + + expect(await run.resolveLatestPatchVersion(downloadBaseURL, '3.14')).toBe( + 'v3.14.2' + ) + }) + + test('resolveLatestPatchViaListing() - return null when the response is not a listing', async () => { + vi.mocked(os.platform).mockReturnValue('linux') + vi.mocked(os.arch).mockReturnValue('x64') + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + text: async () => 'directory index, not a blob listing' + } as Response) + + expect( + await run.resolveLatestPatchViaListing(downloadBaseURL, '3', '14') + ).toBeNull() + }) + + 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..8bc4204e 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() @@ -62,16 +64,29 @@ export function getValidVersion(version: string): string { return 'v' + version } -// Matches a semantic version (major.minor.patch) with an optional leading 'v' -// and optional pre-release / build-metadata suffixes, e.g. '3.14.0', 'v3.14.0', -// '3.14.0-rc.1'. -const semVerShape = /^v?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/ +// Matches a complete semantic version (major.minor.patch with optional +// pre-release / build metadata). This is the official regex suggested at +// https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string +// with an added optional leading 'v' to accept Helm-style tags (e.g. 'v3.14.0'). +const semVerShape = + /^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/ // Returns true when version looks like a semantic version 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 +97,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 +136,130 @@ 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}` + ) +} + +// Attempts to resolve the latest patch in a single request via the Azure Blob +// container-listing API that backs get.helm.sh. Returns the newest stable patch +// version, or null when the host does not support listing (any non-listing +// response, empty result, or error) so the caller can fall back to probing. +// Only 'major.minor.patch-' names are matched, so prereleases and +// sidecar files (.sha256, ...) are ignored. +export async function resolveLatestPatchViaListing( + baseURL: string, + major: string, + minor: string +): Promise { + let body: string + try { + const listURL = new URL(baseURL) + listURL.searchParams.set('restype', 'container') + listURL.searchParams.set('comp', 'list') + listURL.searchParams.set('prefix', `helm-v${major}.${minor}.`) + const response = await fetch(listURL.toString()) + if (!response.ok) { + return null + } + body = await response.text() + } catch { + return null + } + + if (!body.includes(' latestPatch) { + latestPatch = patch + } + } + + if (latestPatch < 0) { + return null + } + return `v${major}.${minor}.${latestPatch}` +} + +// Resolves a major.minor value (e.g. '3.14' or 'v3.14') to the newest available +// patch (e.g. 'v3.14.4'). Fast path: a single container-listing request (which +// get.helm.sh supports). When the host does not support listing, it falls back +// to probing the download host for sequential patches. Only 'major.minor.n' +// artifacts are considered, so prereleases are never selected. +export async function resolveLatestPatchVersion( + baseURL: string, + version: string +): Promise { + const [major, minor] = ( + version[0] === 'v' ? version.slice(1) : version + ).split('.') + + const listed = await resolveLatestPatchViaListing(baseURL, major, minor) + if (listed) { + return listed + } + + 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() }