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()
}