2021-06-24 19:26:50 +05:30
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
2019-10-11 16:24:26 +05:30
2022-06-27 15:27:44 -07:00
import * as os from 'os'
import * as path from 'path'
import * as util from 'util'
import * as fs from 'fs'
2021-06-24 19:26:50 +05:30
2022-06-27 15:27:44 -07:00
import * as toolCache from '@actions/tool-cache'
import * as core from '@actions/core'
2021-06-24 19:26:50 +05:30
2022-06-27 15:27:44 -07:00
const helmToolName = 'helm'
2025-08-12 12:18:06 -07:00
export const stableHelmVersion = 'v3.18.4'
2021-06-24 19:26:50 +05:30
2022-01-26 15:27:11 -05:00
export async function run() {
2026-06-24 05:07:39 +09:00
let version = core . getInput ( 'version' )
const versionFile = core . getInput ( 'version-file' )
if ( versionFile ) {
if ( version && version !== 'latest' ) {
core . warning (
` Both 'version' and 'version-file' inputs are specified, only 'version' will be used. `
)
} else {
version = getVersionFromToolVersionsFile ( versionFile )
core . info ( ` Resolved Helm version ' ${ version } ' from ' ${ versionFile } ' ` )
}
}
if ( ! version ) {
version = 'latest'
}
2022-06-27 15:27:44 -07:00
feat: resolve latest patch for a major.minor version (#293)
* 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.
* 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.
* ### feat: single-request listing fast-path + semver.org validation
**Listing fast-path (with fallback).** Following the discussion about enumeration, `resolveLatestPatchVersion` now first tries a single Azure Blob container-listing request against the download host (which `get.helm.sh` backs), and only falls back to the sequential HEAD-probe walk if the host doesn't return a valid listing. Enumeration and download stay on the **same host**, so there's no risk of resolving a version the host can't serve, and it still works on any mirror.
- Default host: resolution now takes **1 request** instead of ~8 probes (verified live: `3.9`→`v3.9.4`, `3.12`→`v3.12.3`, `3.14`→`v3.14.4`, `3.16`→`v3.16.4`, `2.17`→`v2.17.0`).
- Non-listing hosts fall back to probing; prereleases and `.sha256` sidecars are excluded from the listing parse.
**semver.org regex.** Per review feedback, `isSemVerShaped` now uses the official regex from semver.org (with an added optional leading `v` for Helm tags) instead of the hand-rolled one.
Added tests for the listing fast-path, the fallback, and a non-listing response; full suite is green (`npm test`, `typecheck`, `format-check`).
2026-07-20 12:27:29 -07:00
const downloadBaseURL = core . getInput ( 'downloadBaseURL' , { required : false } )
2022-06-27 15:27:44 -07:00
if ( version . toLocaleLowerCase ( ) === 'latest' ) {
version = await getLatestHelmVersion ( )
feat: resolve latest patch for a major.minor version (#293)
* 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.
* 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.
* ### feat: single-request listing fast-path + semver.org validation
**Listing fast-path (with fallback).** Following the discussion about enumeration, `resolveLatestPatchVersion` now first tries a single Azure Blob container-listing request against the download host (which `get.helm.sh` backs), and only falls back to the sequential HEAD-probe walk if the host doesn't return a valid listing. Enumeration and download stay on the **same host**, so there's no risk of resolving a version the host can't serve, and it still works on any mirror.
- Default host: resolution now takes **1 request** instead of ~8 probes (verified live: `3.9`→`v3.9.4`, `3.12`→`v3.12.3`, `3.14`→`v3.14.4`, `3.16`→`v3.16.4`, `2.17`→`v2.17.0`).
- Non-listing hosts fall back to probing; prereleases and `.sha256` sidecars are excluded from the listing parse.
**semver.org regex.** Per review feedback, `isSemVerShaped` now uses the official regex from semver.org (with an added optional leading `v` for Helm tags) instead of the hand-rolled one.
Added tests for the listing fast-path, the fallback, and a non-listing response; full suite is green (`npm test`, `typecheck`, `format-check`).
2026-07-20 12:27:29 -07:00
} 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 } ' ` )
2022-06-27 15:27:44 -07:00
}
2025-02-14 16:39:40 -06:00
core . startGroup ( ` Installing ${ version } ` )
2024-01-02 15:30:48 +01:00
const cachedPath = await downloadHelm ( downloadBaseURL , version )
2022-07-11 10:12:11 -04:00
core . endGroup ( )
2022-06-27 15:27:44 -07:00
try {
2026-05-05 14:18:11 -04:00
if ( ! process . env [ 'PATH' ] ? . startsWith ( path . dirname ( cachedPath ) ) ) {
2022-06-27 15:27:44 -07:00
core . addPath ( path . dirname ( cachedPath ) )
}
} catch {
//do nothing, set as output variable
}
2022-07-11 10:12:11 -04:00
core . info ( ` Helm tool version ' ${ version } ' has been cached at ${ cachedPath } ` )
2022-06-27 15:27:44 -07:00
core . setOutput ( 'helm-path' , cachedPath )
2022-01-26 15:27:11 -05:00
}
2022-07-11 10:12:11 -04:00
// Prefixes version with v
2022-02-08 17:07:21 -05:00
export function getValidVersion ( version : string ) : string {
2022-06-27 15:27:44 -07:00
return 'v' + version
}
2022-02-08 17:07:21 -05:00
feat: resolve latest patch for a major.minor version (#293)
* 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.
* 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.
* ### feat: single-request listing fast-path + semver.org validation
**Listing fast-path (with fallback).** Following the discussion about enumeration, `resolveLatestPatchVersion` now first tries a single Azure Blob container-listing request against the download host (which `get.helm.sh` backs), and only falls back to the sequential HEAD-probe walk if the host doesn't return a valid listing. Enumeration and download stay on the **same host**, so there's no risk of resolving a version the host can't serve, and it still works on any mirror.
- Default host: resolution now takes **1 request** instead of ~8 probes (verified live: `3.9`→`v3.9.4`, `3.12`→`v3.12.3`, `3.14`→`v3.14.4`, `3.16`→`v3.16.4`, `2.17`→`v2.17.0`).
- Non-listing hosts fall back to probing; prereleases and `.sha256` sidecars are excluded from the listing parse.
**semver.org regex.** Per review feedback, `isSemVerShaped` now uses the official regex from semver.org (with an added optional leading `v` for Helm tags) instead of the hand-rolled one.
Added tests for the listing fast-path, the fallback, and a non-listing response; full suite is green (`npm test`, `typecheck`, `format-check`).
2026-07-20 12:27:29 -07:00
// 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-]+)*))?$/
2026-06-24 05:07:39 +09:00
// Returns true when version looks like a semantic version
export function isSemVerShaped ( version : string ) : boolean {
return semVerShape . test ( version )
}
feat: resolve latest patch for a major.minor version (#293)
* 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.
* 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.
* ### feat: single-request listing fast-path + semver.org validation
**Listing fast-path (with fallback).** Following the discussion about enumeration, `resolveLatestPatchVersion` now first tries a single Azure Blob container-listing request against the download host (which `get.helm.sh` backs), and only falls back to the sequential HEAD-probe walk if the host doesn't return a valid listing. Enumeration and download stay on the **same host**, so there's no risk of resolving a version the host can't serve, and it still works on any mirror.
- Default host: resolution now takes **1 request** instead of ~8 probes (verified live: `3.9`→`v3.9.4`, `3.12`→`v3.12.3`, `3.14`→`v3.14.4`, `3.16`→`v3.16.4`, `2.17`→`v2.17.0`).
- Non-listing hosts fall back to probing; prereleases and `.sha256` sidecars are excluded from the listing parse.
**semver.org regex.** Per review feedback, `isSemVerShaped` now uses the official regex from semver.org (with an added optional leading `v` for Helm tags) instead of the hand-rolled one.
Added tests for the listing fast-path, the fallback, and a non-listing response; full suite is green (`npm test`, `typecheck`, `format-check`).
2026-07-20 12:27:29 -07:00
// 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 )
}
2026-06-24 05:07:39 +09:00
// Reads a .tool-versions file and returns the helm version declared in it
export function getVersionFromToolVersionsFile ( filePath : string ) : string {
if ( ! fs . existsSync ( filePath ) ) {
throw new Error ( ` The version-file ' ${ filePath } ' does not exist ` )
}
const content = fs . readFileSync ( filePath , 'utf8' )
const version = parseToolVersions ( content )
if ( ! version ) {
throw new Error ( ` No helm version found in ' ${ filePath } ' ` )
}
feat: resolve latest patch for a major.minor version (#293)
* 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.
* 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.
* ### feat: single-request listing fast-path + semver.org validation
**Listing fast-path (with fallback).** Following the discussion about enumeration, `resolveLatestPatchVersion` now first tries a single Azure Blob container-listing request against the download host (which `get.helm.sh` backs), and only falls back to the sequential HEAD-probe walk if the host doesn't return a valid listing. Enumeration and download stay on the **same host**, so there's no risk of resolving a version the host can't serve, and it still works on any mirror.
- Default host: resolution now takes **1 request** instead of ~8 probes (verified live: `3.9`→`v3.9.4`, `3.12`→`v3.12.3`, `3.14`→`v3.14.4`, `3.16`→`v3.16.4`, `2.17`→`v2.17.0`).
- Non-listing hosts fall back to probing; prereleases and `.sha256` sidecars are excluded from the listing parse.
**semver.org regex.** Per review feedback, `isSemVerShaped` now uses the official regex from semver.org (with an added optional leading `v` for Helm tags) instead of the hand-rolled one.
Added tests for the listing fast-path, the fallback, and a non-listing response; full suite is green (`npm test`, `typecheck`, `format-check`).
2026-07-20 12:27:29 -07:00
if ( ! isSemVerShaped ( version ) && ! isMajorMinorShaped ( version ) ) {
2026-06-24 05:07:39 +09:00
throw new Error (
feat: resolve latest patch for a major.minor version (#293)
* 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.
* 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.
* ### feat: single-request listing fast-path + semver.org validation
**Listing fast-path (with fallback).** Following the discussion about enumeration, `resolveLatestPatchVersion` now first tries a single Azure Blob container-listing request against the download host (which `get.helm.sh` backs), and only falls back to the sequential HEAD-probe walk if the host doesn't return a valid listing. Enumeration and download stay on the **same host**, so there's no risk of resolving a version the host can't serve, and it still works on any mirror.
- Default host: resolution now takes **1 request** instead of ~8 probes (verified live: `3.9`→`v3.9.4`, `3.12`→`v3.12.3`, `3.14`→`v3.14.4`, `3.16`→`v3.16.4`, `2.17`→`v2.17.0`).
- Non-listing hosts fall back to probing; prereleases and `.sha256` sidecars are excluded from the listing parse.
**semver.org regex.** Per review feedback, `isSemVerShaped` now uses the official regex from semver.org (with an added optional leading `v` for Helm tags) instead of the hand-rolled one.
Added tests for the listing fast-path, the fallback, and a non-listing response; full suite is green (`npm test`, `typecheck`, `format-check`).
2026-07-20 12:27:29 -07:00
` 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') `
2026-06-24 05:07:39 +09:00
)
}
return version
}
// Parses .tool-versions content (asdf/mise format) and returns the first
// helm version, or an empty string when none is declared. Lines look like
// `helm 3.14.0`; comments (#) and blank lines are ignored.
export function parseToolVersions ( content : string ) : string {
for ( const line of content . split ( /\r?\n/ ) ) {
const trimmed = line . trim ( )
if ( ! trimmed || trimmed . startsWith ( '#' ) ) {
continue
}
const [ tool , version ] = trimmed . split ( /\s+/ )
if ( tool === helmToolName && version ) {
return version
}
}
return ''
}
2022-07-11 10:12:11 -04:00
// Gets the latest helm version or returns a default stable if getting latest fails
2022-01-26 15:27:11 -05:00
export async function getLatestHelmVersion ( ) : Promise < string > {
2022-06-27 15:27:44 -07:00
try {
2024-03-01 17:15:56 +01:00
const response = await fetch ( 'https://get.helm.sh/helm-latest-version' )
const release = ( await response . text ( ) ) . trim ( )
return release
2022-06-27 15:27:44 -07:00
} catch ( err ) {
core . warning (
2026-05-05 14:18:11 -04:00
` Error while fetching latest Helm release: ${ err instanceof Error ? err.message : String ( err ) } . Using default version ${ stableHelmVersion } `
2022-02-04 09:45:03 -05:00
)
2022-06-27 15:27:44 -07:00
return stableHelmVersion
}
2022-01-26 15:27:11 -05:00
}
feat: resolve latest patch for a major.minor version (#293)
* 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.
* 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.
* ### feat: single-request listing fast-path + semver.org validation
**Listing fast-path (with fallback).** Following the discussion about enumeration, `resolveLatestPatchVersion` now first tries a single Azure Blob container-listing request against the download host (which `get.helm.sh` backs), and only falls back to the sequential HEAD-probe walk if the host doesn't return a valid listing. Enumeration and download stay on the **same host**, so there's no risk of resolving a version the host can't serve, and it still works on any mirror.
- Default host: resolution now takes **1 request** instead of ~8 probes (verified live: `3.9`→`v3.9.4`, `3.12`→`v3.12.3`, `3.14`→`v3.14.4`, `3.16`→`v3.16.4`, `2.17`→`v2.17.0`).
- Non-listing hosts fall back to probing; prereleases and `.sha256` sidecars are excluded from the listing parse.
**semver.org regex.** Per review feedback, `isSemVerShaped` now uses the official regex from semver.org (with an added optional leading `v` for Helm tags) instead of the hand-rolled one.
Added tests for the listing fast-path, the fallback, and a non-listing response; full suite is green (`npm test`, `typecheck`, `format-check`).
2026-07-20 12:27:29 -07:00
// 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 < boolean > {
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-<platform>' names are matched, so prereleases and
// sidecar files (.sha256, ...) are ignored.
export async function resolveLatestPatchViaListing (
baseURL : string ,
major : string ,
minor : string
) : Promise < string | null > {
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 ( '<EnumerationResults' ) ) {
return null
}
const patchPattern = new RegExp (
` helm-v ${ major } \\ . ${ minor } \\ .( \\ d+)-(?:darwin|linux|windows) ` ,
'g'
)
let latestPatch = - 1
for ( const match of body . matchAll ( patchPattern ) ) {
const patch = Number ( match [ 1 ] )
if ( patch > 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 < string > {
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 } `
}
2024-04-12 21:46:47 +02:00
export function getArch ( ) : string {
return os . arch ( ) === 'x64' ? 'amd64' : os . arch ( )
}
export function getPlatform ( ) : string {
return os . platform ( ) === 'win32' ? 'windows' : os . platform ( )
}
export function getArchiveExtension ( ) : string {
return os . platform ( ) === 'win32' ? 'zip' : 'tar.gz'
}
2021-06-24 19:26:50 +05:30
export function getExecutableExtension ( ) : string {
2024-04-12 21:46:47 +02:00
return os . platform ( ) === 'win32' ? '.exe' : ''
2021-06-24 19:26:50 +05:30
}
2024-01-02 15:30:48 +01:00
export function getHelmDownloadURL ( baseURL : string , version : string ) : string {
2024-04-12 21:46:47 +02:00
const urlPath = ` helm- ${ version } - ${ getPlatform ( ) } - ${ getArch ( ) } . ${ getArchiveExtension ( ) } `
2026-07-15 12:45:05 -07:00
// Ensure the base ends with '/' so a subpath mirror (e.g.
// 'https://example/kubernetes/helm') is preserved; otherwise URL resolution
// replaces the last path segment and points at the wrong location.
const base = baseURL . endsWith ( '/' ) ? baseURL : ` ${ baseURL } / `
const url = new URL ( urlPath , base )
2024-01-02 15:30:48 +01:00
return url . toString ( )
2021-06-24 19:26:50 +05:30
}
2024-01-02 15:30:48 +01:00
export async function downloadHelm (
baseURL : string ,
version : string
) : Promise < string > {
2022-06-27 15:27:44 -07:00
let cachedToolpath = toolCache . find ( helmToolName , version )
2025-02-14 16:39:40 -06:00
if ( cachedToolpath ) {
core . info ( ` Restoring ' ${ version } ' from cache ` )
} else {
core . info ( ` Downloading ' ${ version } ' from ' ${ baseURL } ' ` )
2022-06-27 15:27:44 -07:00
let helmDownloadPath
try {
helmDownloadPath = await toolCache . downloadTool (
2024-01-02 15:30:48 +01:00
getHelmDownloadURL ( baseURL , version )
2022-06-27 15:27:44 -07:00
)
} catch ( exception ) {
throw new Error (
2022-07-11 10:12:11 -04:00
` Failed to download Helm from location ${ getHelmDownloadURL (
2024-01-02 15:30:48 +01:00
baseURL ,
2022-07-11 10:12:11 -04:00
version
) } `
2022-06-27 15:27:44 -07:00
)
}
2026-06-04 19:25:36 -04:00
fs . chmodSync ( helmDownloadPath , '755' )
2024-04-12 21:46:47 +02:00
const extractedPath =
getPlatform ( ) === 'windows'
? await toolCache . extractZip ( helmDownloadPath )
: await toolCache . extractTar ( helmDownloadPath )
2022-06-27 15:27:44 -07:00
cachedToolpath = await toolCache . cacheDir (
2024-04-12 21:46:47 +02:00
extractedPath ,
2022-06-27 15:27:44 -07:00
helmToolName ,
version
)
}
const helmpath = findHelm ( cachedToolpath )
if ( ! helmpath ) {
2022-02-04 09:45:03 -05:00
throw new Error (
2022-06-27 15:27:44 -07:00
util . format ( 'Helm executable not found in path' , cachedToolpath )
)
}
2026-06-04 19:25:36 -04:00
fs . chmodSync ( helmpath , '755' )
2022-06-27 15:27:44 -07:00
return helmpath
2021-06-24 19:26:50 +05:30
}
export function findHelm ( rootFolder : string ) : string {
2026-06-04 19:25:36 -04:00
fs . chmodSync ( rootFolder , '755' )
2026-03-24 13:46:19 -04:00
let filelist : string [ ] = [ ]
2022-06-27 15:27:44 -07:00
walkSync ( rootFolder , filelist , helmToolName + getExecutableExtension ( ) )
if ( ! filelist || filelist . length == 0 ) {
throw new Error (
util . format ( 'Helm executable not found in path' , rootFolder )
)
} else {
return filelist [ 0 ]
}
2021-06-24 19:26:50 +05:30
}
2026-03-24 13:46:19 -04:00
export function walkSync ( dir , filelist , fileToFind ) {
const files = fs . readdirSync ( dir )
2022-06-27 15:27:44 -07:00
filelist = filelist || [ ]
files . forEach ( function ( file ) {
if ( fs . statSync ( path . join ( dir , file ) ) . isDirectory ( ) ) {
filelist = walkSync ( path . join ( dir , file ) , filelist , fileToFind )
} else {
core . debug ( file )
if ( file == fileToFind ) {
filelist . push ( path . join ( dir , file ) )
}
2022-02-04 09:45:03 -05:00
}
2022-06-27 15:27:44 -07:00
} )
return filelist
}