Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
182 changes: 176 additions & 6 deletions src/run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
Expand Down Expand Up @@ -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)
Expand All @@ -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 = () => {
Expand Down Expand Up @@ -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')
Expand Down
98 changes: 90 additions & 8 deletions src/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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)) {
Expand All @@ -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')`
)
Comment thread
ejaifeobuks marked this conversation as resolved.
}
return version
Expand Down Expand Up @@ -121,6 +134,75 @@ export async function getLatestHelmVersion(): Promise<string> {
}
}

// 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}`
)
}

// 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<string> {
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}`)
}
Comment thread
ejaifeobuks marked this conversation as resolved.

let latestPatch = 0
let consecutiveMisses = 0
for (
let patch = 1;
patch <= maxPatch && consecutiveMisses < patchLookahead;
patch++
Comment thread
ejaifeobuks marked this conversation as resolved.
) {
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()
}
Expand Down
Loading