Skip to content
Merged
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
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,31 @@ If both `version` and `version-file` are set, an explicitly requested `version`
>
> If you rely on a certain version, you should explicitly request that version instead of `latest`.

Freshly downloaded Helm archives are verified against the SHA-256 checksum published beside the archive before extraction. With the default `checksum-policy: auto`, the action verifies a matching checksum when one is available as a bare 64-character hexadecimal digest at `<archive-url>.sha256`; when the checksum is not found, the action warns and continues downloading. Existing tool-cache entries continue to be reused without downloading or re-verifying the archive.

Set `checksum-policy` to control this behavior:

- `auto` (default): verify checksums when available and warn when they are absent.
- `required`: require a valid matching checksum from every download location.
- `if-available`: explicitly select the same verify-or-warn behavior as `auto`.
- `disabled`: skip checksum verification and emit a warning.

Malformed or mismatched checksums always fail unless verification is explicitly `disabled`. In `if-available` and `auto` modes, only a checksum response of HTTP 404 or 410 is treated as absent; other download errors fail closed.

Because each checksum is retrieved from the same mirror as its archive, verification detects corruption or an incomplete mirror sync; it does not protect against a mirror that has been compromised together with its checksum files.

An optional fallback mirror can be used when version resolution, archive download, or checksum verification fails against the primary location:

```yaml
- uses: azure/setup-helm@v5
with:
version: 'v4.3.1'
downloadBaseURL: 'https://primary.example/helm'
downloadBaseURLFallback: 'https://fallback.example/helm'
```

The fallback archive follows the same checksum policy as the primary mirror. This capability follows the use case proposed by @mvtavares in [#227](https://github.com/Azure/setup-helm/pull/227).

The cached helm binary path is prepended to the PATH environment variable as well as stored in the helm-path output variable.
Refer to the action metadata file for details about all the inputs https://github.com/Azure/setup-helm/blob/master/action.yml

Expand Down
8 changes: 8 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,14 @@ inputs:
description: 'Set the download base URL'
required: false
default: 'https://get.helm.sh'
downloadBaseURLFallback:
description: 'Fallback base URL used when version resolution, download, or checksum verification fails against downloadBaseURL'
required: false
default: ''
checksum-policy:
description: "Checksum verification policy: 'auto' verifies checksums when available and warns when absent; 'required' always requires them; 'if-available' is an explicit alias for auto behavior; 'disabled' skips verification"
required: false
default: 'auto'
latest-fallback:
description: "When 'version' is 'latest' and the latest version cannot be determined after retrying, install the built-in default version instead of failing. Set to 'false' to fail the step instead, since the built-in default can be a major version behind what 'latest' resolves to."
required: false
Expand Down
253 changes: 244 additions & 9 deletions src/run.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import {vi, describe, test, expect, afterEach} from 'vitest'
import * as path from 'path'
import {createHash} from 'crypto'
import {Readable} from 'stream'

// Mock os module
vi.mock('os', async (importOriginal) => {
Expand All @@ -20,6 +22,7 @@ vi.mock('fs', async (importOriginal) => {
statSync: vi.fn(),
chmodSync: vi.fn(),
readFileSync: vi.fn(),
createReadStream: vi.fn(),
existsSync: vi.fn()
}
})
Expand Down Expand Up @@ -62,6 +65,18 @@ import * as core from '@actions/core'

describe('run.ts', () => {
const downloadBaseURL = 'https://test.tld'
const archive = Buffer.from('helm archive')
const archiveChecksum = createHash('sha256').update(archive).digest('hex')

const mockVerifiedDownload = () => {
vi.mocked(toolCache.downloadTool)
.mockResolvedValueOnce('pathToChecksum')
.mockResolvedValueOnce('pathToTool')
vi.mocked(fs.readFileSync).mockReturnValue(archiveChecksum)
vi.mocked(fs.createReadStream).mockReturnValue(
Readable.from([archive]) as fs.ReadStream
)
}

// Cleanup mocks after each test to ensure that subsequent tests are not affected by the mocks.
afterEach(() => {
Expand All @@ -83,6 +98,19 @@ describe('run.ts', () => {
expect(os.platform).toHaveBeenCalled()
})

test('getChecksumPolicy() - default to auto and reject invalid values', () => {
expect(run.getChecksumPolicy('')).toBe('auto')
expect(run.getChecksumPolicy(' REQUIRED ')).toBe('required')
expect(() => run.getChecksumPolicy('warn')).toThrow(
"Invalid checksum-policy 'warn'"
)
})

test('resolveChecksumPolicy() - auto verifies checksums when available', () => {
expect(run.resolveChecksumPolicy('auto')).toBe('if-available')
expect(run.resolveChecksumPolicy('required')).toBe('required')
})

test('getHelmDownloadURL() - return the URL to download helm for Linux amd64', () => {
vi.mocked(os.platform).mockReturnValue('linux')
vi.mocked(os.arch).mockReturnValue('x64')
Expand Down Expand Up @@ -365,12 +393,14 @@ describe('run.ts', () => {
const inputs = (
version: string,
versionFile: string,
latestFallback: string = ''
latestFallback: string = '',
downloadFallback: string = ''
) =>
vi.mocked(core.getInput).mockImplementation((name: string) => {
if (name === 'version') return version
if (name === 'version-file') return versionFile
if (name === 'downloadBaseURL') return downloadBaseURL
if (name === 'downloadBaseURLFallback') return downloadFallback
if (name === 'latest-fallback') return latestFallback
return ''
})
Expand Down Expand Up @@ -630,6 +660,29 @@ describe('run.ts', () => {
expect(toolCache.find).toHaveBeenCalledWith('helm', 'v3.14.4')
})

test('run() - resolve a major.minor version from the fallback mirror', async () => {
stubDownloadChain()
inputs('3.14', '', '', 'https://fallback.tld')
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
const url = String(input)
if (url.startsWith(downloadBaseURL)) {
throw new Error('Primary unavailable')
}
return {
ok: true,
text: async () =>
'<EnumerationResults><Blob><Name>helm-v3.14.4-linux-amd64.tar.gz</Name></Blob></EnumerationResults>'
} as Response
})

await run.run()

expect(toolCache.find).toHaveBeenCalledWith('helm', 'v3.14.4')
expect(core.warning).toHaveBeenCalledWith(
expect.stringContaining("Trying fallback 'https://fallback.tld'")
)
})

test('run() - resolve the latest patch for a wildcard patch version input', async () => {
stubDownloadChain()
inputs('v3.12.x', '')
Expand Down Expand Up @@ -755,9 +808,7 @@ describe('run.ts', () => {

test('downloadHelm() - download helm and return path to it', async () => {
vi.mocked(toolCache.find).mockReturnValue('')
vi.mocked(toolCache.downloadTool).mockResolvedValue('pathToTool')
const response = JSON.stringify([{tag_name: 'v4.0.0'}])
vi.mocked(fs.readFileSync).mockReturnValue(response)
mockVerifiedDownload()
vi.mocked(os.platform).mockReturnValue('win32')
vi.mocked(os.arch).mockReturnValue('x64')
vi.mocked(fs.chmodSync).mockImplementation(() => {})
Expand All @@ -776,7 +827,12 @@ describe('run.ts', () => {
path.join('pathToCachedDir', 'helm.exe')
)
expect(toolCache.find).toHaveBeenCalledWith('helm', 'v4.0.0')
expect(toolCache.downloadTool).toHaveBeenCalledWith(
expect(toolCache.downloadTool).toHaveBeenNthCalledWith(
1,
'https://test.tld/helm-v4.0.0-windows-amd64.zip.sha256'
)
expect(toolCache.downloadTool).toHaveBeenNthCalledWith(
2,
'https://test.tld/helm-v4.0.0-windows-amd64.zip'
)
expect(fs.chmodSync).toHaveBeenCalledWith('pathToTool', '755')
Expand All @@ -797,10 +853,12 @@ describe('run.ts', () => {

const downloadUrl = 'https://test.tld/helm-v3.2.1-windows-amd64.zip'
await expect(run.downloadHelm(downloadBaseURL, 'v3.2.1')).rejects.toThrow(
`Failed to download Helm from location ${downloadUrl}`
`Failed to download and verify Helm from location ${downloadUrl}`
)
expect(toolCache.find).toHaveBeenCalledWith('helm', 'v3.2.1')
expect(toolCache.downloadTool).toHaveBeenCalledWith(`${downloadUrl}`)
expect(toolCache.downloadTool).toHaveBeenCalledWith(
`${downloadUrl}.sha256`
)
})

test('downloadHelm() - return path to helm tool with same version from toolCache', async () => {
Expand Down Expand Up @@ -832,9 +890,8 @@ describe('run.ts', () => {

test('downloadHelm() - throw error is helm is not found in path', async () => {
vi.mocked(toolCache.find).mockReturnValue('')
vi.mocked(toolCache.downloadTool).mockResolvedValue('pathToTool')
mockVerifiedDownload()
vi.mocked(toolCache.cacheDir).mockResolvedValue('pathToCachedDir')
vi.mocked(toolCache.downloadTool).mockResolvedValue('pathToTool')
vi.mocked(toolCache.extractZip).mockResolvedValue('extractedPath')
vi.mocked(os.platform).mockReturnValue('win32')
vi.mocked(os.arch).mockReturnValue('x64')
Expand All @@ -856,4 +913,182 @@ describe('run.ts', () => {
expect(fs.chmodSync).toHaveBeenCalledWith('pathToTool', '755')
expect(toolCache.extractZip).toHaveBeenCalledWith('pathToTool')
})

test('downloadAndVerifyHelm() - reject a checksum mismatch', async () => {
vi.mocked(os.platform).mockReturnValue('linux')
vi.mocked(os.arch).mockReturnValue('x64')
vi.mocked(toolCache.downloadTool)
.mockResolvedValueOnce('pathToChecksum')
.mockResolvedValueOnce('pathToTool')
vi.mocked(fs.readFileSync).mockReturnValue('0'.repeat(64))
vi.mocked(fs.createReadStream).mockReturnValue(
Readable.from([archive]) as fs.ReadStream
)

await expect(
run.downloadAndVerifyHelm(downloadBaseURL, 'v3.18.4')
).rejects.toThrow('SHA-256 checksum mismatch')
})

test('downloadAndVerifyHelm() - reject an invalid checksum file', async () => {
vi.mocked(os.platform).mockReturnValue('linux')
vi.mocked(os.arch).mockReturnValue('x64')
vi.mocked(toolCache.downloadTool).mockResolvedValueOnce('pathToChecksum')
vi.mocked(fs.readFileSync).mockReturnValue('not-a-checksum')

await expect(
run.downloadAndVerifyHelm(downloadBaseURL, 'v3.18.4')
).rejects.toThrow('Invalid SHA-256 checksum')
})

test('downloadAndVerifyHelm() - auto mode warns and continues when a custom mirror has no checksum', async () => {
vi.mocked(os.platform).mockReturnValue('linux')
vi.mocked(os.arch).mockReturnValue('x64')
vi.mocked(toolCache.downloadTool)
.mockRejectedValueOnce(new toolCache.HTTPError(404))
.mockResolvedValueOnce('pathToTool')

await expect(
run.downloadAndVerifyHelm(downloadBaseURL, 'v3.18.4')
).resolves.toBe('pathToTool')
expect(toolCache.downloadTool).toHaveBeenNthCalledWith(
1,
'https://test.tld/helm-v3.18.4-linux-amd64.tar.gz.sha256'
)
expect(toolCache.downloadTool).toHaveBeenNthCalledWith(
2,
'https://test.tld/helm-v3.18.4-linux-amd64.tar.gz'
)
expect(core.warning).toHaveBeenCalledWith(
expect.stringContaining('continuing without verification')
)
})

test('downloadAndVerifyHelm() - auto mode warns when get.helm.sh has no checksum', async () => {
vi.mocked(os.platform).mockReturnValue('linux')
vi.mocked(os.arch).mockReturnValue('x64')
vi.mocked(toolCache.downloadTool)
.mockRejectedValueOnce(new toolCache.HTTPError(404))
.mockResolvedValueOnce('pathToTool')

await expect(
run.downloadAndVerifyHelm('https://get.helm.sh', 'v3.18.4')
).resolves.toBe('pathToTool')
expect(toolCache.downloadTool).toHaveBeenCalledTimes(2)
expect(core.warning).toHaveBeenCalledWith(
expect.stringContaining('continuing without verification')
)
})

test('downloadAndVerifyHelm() - required mode blocks a missing checksum', async () => {
vi.mocked(os.platform).mockReturnValue('linux')
vi.mocked(os.arch).mockReturnValue('x64')
vi.mocked(toolCache.downloadTool).mockRejectedValueOnce(
new toolCache.HTTPError(404)
)

await expect(
run.downloadAndVerifyHelm(downloadBaseURL, 'v3.18.4', 'required')
).rejects.toThrow('Unexpected HTTP response: 404')
expect(toolCache.downloadTool).toHaveBeenCalledTimes(1)
expect(core.warning).not.toHaveBeenCalled()
})

test('downloadAndVerifyHelm() - if-available mode does not ignore checksum server errors', async () => {
vi.mocked(os.platform).mockReturnValue('linux')
vi.mocked(os.arch).mockReturnValue('x64')
vi.mocked(toolCache.downloadTool).mockRejectedValueOnce(
new toolCache.HTTPError(503)
)

await expect(
run.downloadAndVerifyHelm(downloadBaseURL, 'v3.18.4', 'if-available')
).rejects.toThrow('Unexpected HTTP response: 503')
expect(toolCache.downloadTool).toHaveBeenCalledTimes(1)
})

test('downloadAndVerifyHelm() - disabled mode downloads without requesting a checksum', async () => {
vi.mocked(os.platform).mockReturnValue('linux')
vi.mocked(os.arch).mockReturnValue('x64')
vi.mocked(toolCache.downloadTool).mockResolvedValueOnce('pathToTool')

await expect(
run.downloadAndVerifyHelm(downloadBaseURL, 'v3.18.4', 'disabled')
).resolves.toBe('pathToTool')
expect(toolCache.downloadTool).toHaveBeenCalledWith(
'https://test.tld/helm-v3.18.4-linux-amd64.tar.gz'
)
expect(toolCache.downloadTool).toHaveBeenCalledTimes(1)
expect(core.warning).toHaveBeenCalledWith(
expect.stringContaining('Checksum verification is disabled')
)
})

test('downloadHelm() - use a verified fallback after a primary checksum mismatch', async () => {
vi.mocked(toolCache.find).mockReturnValue('')
vi.mocked(os.platform).mockReturnValue('win32')
vi.mocked(os.arch).mockReturnValue('x64')
vi.mocked(toolCache.downloadTool)
.mockResolvedValueOnce('primaryChecksum')
.mockResolvedValueOnce('primaryTool')
.mockResolvedValueOnce('fallbackChecksum')
.mockResolvedValueOnce('fallbackTool')
vi.mocked(fs.readFileSync).mockImplementation((file) =>
file === 'primaryChecksum' ? '0'.repeat(64) : archiveChecksum
)
vi.mocked(fs.createReadStream).mockImplementation(
() => Readable.from([archive]) as fs.ReadStream
)
vi.mocked(fs.chmodSync).mockImplementation(() => {})
vi.mocked(toolCache.extractZip).mockResolvedValue('extractedPath')
vi.mocked(toolCache.cacheDir).mockResolvedValue('pathToCachedDir')
vi.mocked(fs.readdirSync).mockReturnValue([
'helm.exe' as unknown as fs.Dirent<NonSharedBuffer>
])
vi.mocked(fs.statSync).mockReturnValue({
isDirectory: () => false
} as fs.Stats)

expect(
await run.downloadHelm(
downloadBaseURL,
'v3.18.4',
'https://fallback.tld'
)
).toBe(path.join('pathToCachedDir', 'helm.exe'))
expect(toolCache.downloadTool).toHaveBeenNthCalledWith(
3,
'https://fallback.tld/helm-v3.18.4-windows-amd64.zip.sha256'
)
expect(toolCache.downloadTool).toHaveBeenNthCalledWith(
4,
'https://fallback.tld/helm-v3.18.4-windows-amd64.zip'
)
expect(toolCache.extractZip).toHaveBeenCalledWith('fallbackTool')
expect(core.warning).toHaveBeenCalledWith(
expect.stringContaining('Trying fallback')
)
})

test('downloadHelm() - fail when primary and fallback verification fail', async () => {
vi.mocked(toolCache.find).mockReturnValue('')
vi.mocked(os.platform).mockReturnValue('linux')
vi.mocked(os.arch).mockReturnValue('x64')
vi.mocked(toolCache.downloadTool)
.mockResolvedValueOnce('primaryChecksum')
.mockResolvedValueOnce('primaryTool')
.mockResolvedValueOnce('fallbackChecksum')
.mockResolvedValueOnce('fallbackTool')
vi.mocked(fs.readFileSync).mockReturnValue('0'.repeat(64))
vi.mocked(fs.createReadStream).mockImplementation(
() => Readable.from([archive]) as fs.ReadStream
)

await expect(
run.downloadHelm(downloadBaseURL, 'v3.18.4', 'https://fallback.tld')
).rejects.toThrow(
"Failed to download and verify Helm from 'https://test.tld' and fallback 'https://fallback.tld'"
)
expect(toolCache.extractTar).not.toHaveBeenCalled()
})
})
Loading
Loading