From f1156a72674320d7b709295d49d2c161707fac1e Mon Sep 17 00:00:00 2001 From: David Gamero Date: Wed, 4 Nov 2026 19:39:06 +0000 Subject: [PATCH 1/5] feat: verify Helm downloads and support fallback mirrors --- README.md | 14 ++++++ action.yml | 4 ++ src/run.test.ts | 124 +++++++++++++++++++++++++++++++++++++++++++++--- src/run.ts | 72 ++++++++++++++++++++++++---- 4 files changed, 197 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 865023cb..3b22f696 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,20 @@ 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`. +Downloaded Helm archives are verified against the SHA-256 checksum published beside the archive before extraction. Custom download mirrors must provide the matching checksum at `.sha256`. + +An optional fallback mirror can be used when the archive or checksum cannot be downloaded or verified from the primary location: + +```yaml +- uses: azure/setup-helm@v5 + with: + version: 'v3.18.4' + downloadBaseURL: 'https://primary.example/helm' + downloadBaseURLFallback: 'https://fallback.example/helm' +``` + +The fallback archive is also verified against the checksum from the fallback 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 diff --git a/action.yml b/action.yml index f9198e0c..49115651 100644 --- a/action.yml +++ b/action.yml @@ -17,6 +17,10 @@ inputs: description: 'Set the download base URL' required: false default: 'https://get.helm.sh' + downloadBaseURLFallback: + description: 'Fallback base URL used when the archive or checksum cannot be downloaded or verified from downloadBaseURL' + required: false + default: '' 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 diff --git a/src/run.test.ts b/src/run.test.ts index fffbc935..5bfd9e22 100644 --- a/src/run.test.ts +++ b/src/run.test.ts @@ -1,5 +1,6 @@ import {vi, describe, test, expect, afterEach} from 'vitest' import * as path from 'path' +import {createHash} from 'crypto' // Mock os module vi.mock('os', async (importOriginal) => { @@ -62,6 +63,17 @@ 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('pathToTool') + .mockResolvedValueOnce('pathToChecksum') + vi.mocked(fs.readFileSync).mockImplementation((file) => + file === 'pathToChecksum' ? archiveChecksum : archive + ) + } // Cleanup mocks after each test to ensure that subsequent tests are not affected by the mocks. afterEach(() => { @@ -755,9 +767,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(() => {}) @@ -776,9 +786,14 @@ 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' ) + expect(toolCache.downloadTool).toHaveBeenNthCalledWith( + 2, + 'https://test.tld/helm-v4.0.0-windows-amd64.zip.sha256' + ) expect(fs.chmodSync).toHaveBeenCalledWith('pathToTool', '755') expect(toolCache.extractZip).toHaveBeenCalledWith('pathToTool') expect(fs.chmodSync).toHaveBeenCalledWith( @@ -797,7 +812,7 @@ 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}`) @@ -832,9 +847,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') @@ -856,4 +870,100 @@ 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('pathToTool') + .mockResolvedValueOnce('pathToChecksum') + vi.mocked(fs.readFileSync).mockImplementation((file) => + file === 'pathToChecksum' ? '0'.repeat(64) : archive + ) + + 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('pathToTool') + .mockResolvedValueOnce('pathToChecksum') + vi.mocked(fs.readFileSync).mockImplementation((file) => + file === 'pathToChecksum' ? 'not-a-checksum' : archive + ) + + await expect( + run.downloadAndVerifyHelm(downloadBaseURL, 'v3.18.4') + ).rejects.toThrow('Invalid SHA-256 checksum') + }) + + 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('primaryTool') + .mockResolvedValueOnce('primaryChecksum') + .mockResolvedValueOnce('fallbackTool') + .mockResolvedValueOnce('fallbackChecksum') + vi.mocked(fs.readFileSync).mockImplementation((file) => { + if (file === 'primaryChecksum') return '0'.repeat(64) + if (file === 'fallbackChecksum') return archiveChecksum + return archive + }) + 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 + ]) + 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' + ) + expect(toolCache.downloadTool).toHaveBeenNthCalledWith( + 4, + 'https://fallback.tld/helm-v3.18.4-windows-amd64.zip.sha256' + ) + 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('primaryTool') + .mockResolvedValueOnce('primaryChecksum') + .mockResolvedValueOnce('fallbackTool') + .mockResolvedValueOnce('fallbackChecksum') + vi.mocked(fs.readFileSync).mockImplementation((file) => + String(file).endsWith('Checksum') ? '0'.repeat(64) : archive + ) + + 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() + }) }) diff --git a/src/run.ts b/src/run.ts index cf575aaf..983f33c8 100644 --- a/src/run.ts +++ b/src/run.ts @@ -5,6 +5,7 @@ import * as os from 'os' import * as path from 'path' import * as util from 'util' import * as fs from 'fs' +import {createHash} from 'crypto' import * as toolCache from '@actions/tool-cache' import * as core from '@actions/core' @@ -32,6 +33,9 @@ export async function run() { } const downloadBaseURL = core.getInput('downloadBaseURL', {required: false}) + const downloadBaseURLFallback = core.getInput('downloadBaseURLFallback', { + required: false + }) if (version.toLocaleLowerCase() === 'latest') { // Only an explicit 'false' turns the fallback off, matching the @@ -48,7 +52,11 @@ export async function run() { } core.startGroup(`Installing ${version}`) - const cachedPath = await downloadHelm(downloadBaseURL, version) + const cachedPath = await downloadHelm( + downloadBaseURL, + version, + downloadBaseURLFallback + ) core.endGroup() try { @@ -336,9 +344,33 @@ export function getHelmDownloadURL(baseURL: string, version: string): string { return url.toString() } -export async function downloadHelm( +export async function downloadAndVerifyHelm( baseURL: string, version: string +): Promise { + const downloadURL = getHelmDownloadURL(baseURL, version) + const archivePath = await toolCache.downloadTool(downloadURL) + const checksumPath = await toolCache.downloadTool(`${downloadURL}.sha256`) + const checksum = fs.readFileSync(checksumPath, 'utf8').trim() + + if (!/^[a-fA-F0-9]{64}$/.test(checksum)) { + throw new Error(`Invalid SHA-256 checksum from ${downloadURL}.sha256`) + } + + const actual = createHash('sha256') + .update(fs.readFileSync(archivePath)) + .digest('hex') + if (actual.toLowerCase() !== checksum.toLowerCase()) { + throw new Error(`SHA-256 checksum mismatch for ${downloadURL}`) + } + + return archivePath +} + +export async function downloadHelm( + baseURL: string, + version: string, + fallbackBaseURL?: string ): Promise { let cachedToolpath = toolCache.find(helmToolName, version) if (cachedToolpath) { @@ -347,15 +379,35 @@ export async function downloadHelm( core.info(`Downloading '${version}' from '${baseURL}'`) let helmDownloadPath try { - helmDownloadPath = await toolCache.downloadTool( - getHelmDownloadURL(baseURL, version) - ) - } catch (exception) { + helmDownloadPath = await downloadAndVerifyHelm(baseURL, version) + } catch (error) { + if (fallbackBaseURL) { + core.warning( + `Unable to download and verify Helm from '${baseURL}': ${error instanceof Error ? error.message : String(error)}. Trying fallback '${fallbackBaseURL}'` + ) + try { + helmDownloadPath = await downloadAndVerifyHelm( + fallbackBaseURL, + version + ) + } catch (fallbackError) { + throw new Error( + `Failed to download and verify Helm from '${baseURL}' and fallback '${fallbackBaseURL}': ${fallbackError instanceof Error ? fallbackError.message : String(fallbackError)}` + ) + } + } else { + throw new Error( + `Failed to download and verify Helm from location ${getHelmDownloadURL( + baseURL, + version + )}: ${error instanceof Error ? error.message : String(error)}` + ) + } + } + + if (!helmDownloadPath) { throw new Error( - `Failed to download Helm from location ${getHelmDownloadURL( - baseURL, - version - )}` + `Failed to download and verify Helm version '${version}'` ) } From 2c958a19600ed67827c3bc7e15bdbb689ba9c985 Mon Sep 17 00:00:00 2001 From: David Gamero Date: Thu, 5 Nov 2026 02:48:30 +0000 Subject: [PATCH 2/5] fix: strengthen Helm download fallback --- README.md | 6 ++-- action.yml | 2 +- src/run.test.ts | 87 ++++++++++++++++++++++++++++++++----------------- src/run.ts | 28 +++++++++++++--- 4 files changed, 86 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 3b22f696..98beba41 100644 --- a/README.md +++ b/README.md @@ -42,9 +42,11 @@ 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`. -Downloaded Helm archives are verified against the SHA-256 checksum published beside the archive before extraction. Custom download mirrors must provide the matching checksum at `.sha256`. +Freshly downloaded Helm archives are verified against the SHA-256 checksum published beside the archive before extraction. Custom download mirrors must provide the matching checksum as a bare 64-character hexadecimal digest at `.sha256`. Existing tool-cache entries continue to be reused without downloading or re-verifying the archive. -An optional fallback mirror can be used when the archive or checksum cannot be downloaded or verified from the primary location: +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 diff --git a/action.yml b/action.yml index 49115651..26dcd9b4 100644 --- a/action.yml +++ b/action.yml @@ -18,7 +18,7 @@ inputs: required: false default: 'https://get.helm.sh' downloadBaseURLFallback: - description: 'Fallback base URL used when the archive or checksum cannot be downloaded or verified from downloadBaseURL' + description: 'Fallback base URL used when version resolution, download, or checksum verification fails against downloadBaseURL' required: false default: '' latest-fallback: diff --git a/src/run.test.ts b/src/run.test.ts index 5bfd9e22..f0bcc8dc 100644 --- a/src/run.test.ts +++ b/src/run.test.ts @@ -1,6 +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) => { @@ -21,6 +22,7 @@ vi.mock('fs', async (importOriginal) => { statSync: vi.fn(), chmodSync: vi.fn(), readFileSync: vi.fn(), + createReadStream: vi.fn(), existsSync: vi.fn() } }) @@ -68,10 +70,11 @@ describe('run.ts', () => { const mockVerifiedDownload = () => { vi.mocked(toolCache.downloadTool) - .mockResolvedValueOnce('pathToTool') .mockResolvedValueOnce('pathToChecksum') - vi.mocked(fs.readFileSync).mockImplementation((file) => - file === 'pathToChecksum' ? archiveChecksum : archive + .mockResolvedValueOnce('pathToTool') + vi.mocked(fs.readFileSync).mockReturnValue(archiveChecksum) + vi.mocked(fs.createReadStream).mockReturnValue( + Readable.from([archive]) as fs.ReadStream ) } @@ -377,12 +380,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 '' }) @@ -642,6 +647,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 () => + 'helm-v3.14.4-linux-amd64.tar.gz' + } 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', '') @@ -788,11 +816,11 @@ describe('run.ts', () => { expect(toolCache.find).toHaveBeenCalledWith('helm', 'v4.0.0') expect(toolCache.downloadTool).toHaveBeenNthCalledWith( 1, - 'https://test.tld/helm-v4.0.0-windows-amd64.zip' + '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.sha256' + 'https://test.tld/helm-v4.0.0-windows-amd64.zip' ) expect(fs.chmodSync).toHaveBeenCalledWith('pathToTool', '755') expect(toolCache.extractZip).toHaveBeenCalledWith('pathToTool') @@ -815,7 +843,9 @@ describe('run.ts', () => { `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 () => { @@ -875,10 +905,11 @@ describe('run.ts', () => { vi.mocked(os.platform).mockReturnValue('linux') vi.mocked(os.arch).mockReturnValue('x64') vi.mocked(toolCache.downloadTool) - .mockResolvedValueOnce('pathToTool') .mockResolvedValueOnce('pathToChecksum') - vi.mocked(fs.readFileSync).mockImplementation((file) => - file === 'pathToChecksum' ? '0'.repeat(64) : archive + .mockResolvedValueOnce('pathToTool') + vi.mocked(fs.readFileSync).mockReturnValue('0'.repeat(64)) + vi.mocked(fs.createReadStream).mockReturnValue( + Readable.from([archive]) as fs.ReadStream ) await expect( @@ -889,12 +920,8 @@ describe('run.ts', () => { 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('pathToTool') - .mockResolvedValueOnce('pathToChecksum') - vi.mocked(fs.readFileSync).mockImplementation((file) => - file === 'pathToChecksum' ? 'not-a-checksum' : archive - ) + vi.mocked(toolCache.downloadTool).mockResolvedValueOnce('pathToChecksum') + vi.mocked(fs.readFileSync).mockReturnValue('not-a-checksum') await expect( run.downloadAndVerifyHelm(downloadBaseURL, 'v3.18.4') @@ -906,15 +933,16 @@ describe('run.ts', () => { vi.mocked(os.platform).mockReturnValue('win32') vi.mocked(os.arch).mockReturnValue('x64') vi.mocked(toolCache.downloadTool) - .mockResolvedValueOnce('primaryTool') .mockResolvedValueOnce('primaryChecksum') - .mockResolvedValueOnce('fallbackTool') + .mockResolvedValueOnce('primaryTool') .mockResolvedValueOnce('fallbackChecksum') - vi.mocked(fs.readFileSync).mockImplementation((file) => { - if (file === 'primaryChecksum') return '0'.repeat(64) - if (file === 'fallbackChecksum') return archiveChecksum - return archive - }) + .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') @@ -934,11 +962,11 @@ describe('run.ts', () => { ).toBe(path.join('pathToCachedDir', 'helm.exe')) expect(toolCache.downloadTool).toHaveBeenNthCalledWith( 3, - 'https://fallback.tld/helm-v3.18.4-windows-amd64.zip' + '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.sha256' + 'https://fallback.tld/helm-v3.18.4-windows-amd64.zip' ) expect(toolCache.extractZip).toHaveBeenCalledWith('fallbackTool') expect(core.warning).toHaveBeenCalledWith( @@ -951,12 +979,13 @@ describe('run.ts', () => { vi.mocked(os.platform).mockReturnValue('linux') vi.mocked(os.arch).mockReturnValue('x64') vi.mocked(toolCache.downloadTool) - .mockResolvedValueOnce('primaryTool') .mockResolvedValueOnce('primaryChecksum') - .mockResolvedValueOnce('fallbackTool') + .mockResolvedValueOnce('primaryTool') .mockResolvedValueOnce('fallbackChecksum') - vi.mocked(fs.readFileSync).mockImplementation((file) => - String(file).endsWith('Checksum') ? '0'.repeat(64) : archive + .mockResolvedValueOnce('fallbackTool') + vi.mocked(fs.readFileSync).mockReturnValue('0'.repeat(64)) + vi.mocked(fs.createReadStream).mockImplementation( + () => Readable.from([archive]) as fs.ReadStream ) await expect( diff --git a/src/run.ts b/src/run.ts index 983f33c8..ed542449 100644 --- a/src/run.ts +++ b/src/run.ts @@ -44,7 +44,20 @@ export async function run() { core.getInput('latest-fallback').toLowerCase() !== 'false' version = await getLatestHelmVersion(fallbackToDefault) } else if (isMajorMinorShaped(version)) { - version = await resolveLatestPatchVersion(downloadBaseURL, version) + try { + version = await resolveLatestPatchVersion(downloadBaseURL, version) + } catch (error) { + if (!downloadBaseURLFallback) { + throw error + } + core.warning( + `Unable to resolve the latest Helm patch from '${downloadBaseURL}': ${error instanceof Error ? error.message : String(error)}. Trying fallback '${downloadBaseURLFallback}'` + ) + version = await resolveLatestPatchVersion( + downloadBaseURLFallback, + version + ) + } core.info(`Resolved latest patch Helm version to '${version}'`) } else if (version[0] !== 'v') { version = getValidVersion(version) @@ -349,7 +362,6 @@ export async function downloadAndVerifyHelm( version: string ): Promise { const downloadURL = getHelmDownloadURL(baseURL, version) - const archivePath = await toolCache.downloadTool(downloadURL) const checksumPath = await toolCache.downloadTool(`${downloadURL}.sha256`) const checksum = fs.readFileSync(checksumPath, 'utf8').trim() @@ -357,9 +369,15 @@ export async function downloadAndVerifyHelm( throw new Error(`Invalid SHA-256 checksum from ${downloadURL}.sha256`) } - const actual = createHash('sha256') - .update(fs.readFileSync(archivePath)) - .digest('hex') + const archivePath = await toolCache.downloadTool(downloadURL) + const hash = createHash('sha256') + await new Promise((resolve, reject) => { + const archive = fs.createReadStream(archivePath) + archive.on('data', (chunk) => hash.update(chunk)) + archive.on('error', reject) + archive.on('end', resolve) + }) + const actual = hash.digest('hex') if (actual.toLowerCase() !== checksum.toLowerCase()) { throw new Error(`SHA-256 checksum mismatch for ${downloadURL}`) } From a7f13d2df0c56259bcc8b8c17a41423563bd532c Mon Sep 17 00:00:00 2001 From: David Gamero Date: Thu, 5 Nov 2026 17:37:22 +0000 Subject: [PATCH 3/5] docs: align mirror example with Helm fallback --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 98beba41..b8c57f66 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ An optional fallback mirror can be used when version resolution, archive downloa ```yaml - uses: azure/setup-helm@v5 with: - version: 'v3.18.4' + version: 'v4.3.1' downloadBaseURL: 'https://primary.example/helm' downloadBaseURLFallback: 'https://fallback.example/helm' ``` From 8c202158fede46b7e2000b9caea543df818c9352 Mon Sep 17 00:00:00 2001 From: David Gamero Date: Mon, 9 Nov 2026 18:17:28 +0000 Subject: [PATCH 4/5] fix: preserve custom mirror checksum compatibility --- README.md | 13 +++++-- action.yml | 4 +++ src/run.test.ts | 90 ++++++++++++++++++++++++++++++++++++++++++++++ src/run.ts | 94 +++++++++++++++++++++++++++++++++++++++++++++---- 4 files changed, 192 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index b8c57f66..da6a3c42 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,16 @@ 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. Custom download mirrors must provide the matching checksum as a bare 64-character hexadecimal digest at `.sha256`. Existing tool-cache entries continue to be reused without downloading or re-verifying the archive. +Freshly downloaded Helm archives from the default `get.helm.sh` endpoint are verified against the SHA-256 checksum published beside the archive before extraction. With the default `checksum-policy: auto`, custom mirrors are also verified when they provide a matching checksum as a bare 64-character hexadecimal digest at `.sha256`; when the checksum is not found, the action warns and continues downloading from the custom mirror. Existing tool-cache entries continue to be reused without downloading or re-verifying the archive. + +Set `checksum-policy` to control this behavior: + +- `auto` (default): require checksums from `get.helm.sh`; verify custom mirror checksums when available and warn when they are absent. +- `required`: require a valid matching checksum from every download location. +- `if-available`: verify checksums when available and warn when they are absent. +- `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. @@ -56,7 +65,7 @@ An optional fallback mirror can be used when version resolution, archive downloa downloadBaseURLFallback: 'https://fallback.example/helm' ``` -The fallback archive is also verified against the checksum from the fallback mirror. This capability follows the use case proposed by @mvtavares in [#227](https://github.com/Azure/setup-helm/pull/227). +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 diff --git a/action.yml b/action.yml index 26dcd9b4..01d7c07a 100644 --- a/action.yml +++ b/action.yml @@ -21,6 +21,10 @@ inputs: 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' requires checksums for get.helm.sh and uses them when available for custom mirrors; 'required' always requires them; 'if-available' verifies them when present; '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 diff --git a/src/run.test.ts b/src/run.test.ts index f0bcc8dc..255dde5f 100644 --- a/src/run.test.ts +++ b/src/run.test.ts @@ -98,6 +98,29 @@ 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() - require default endpoint checksums in auto mode', () => { + expect(run.resolveChecksumPolicy('https://get.helm.sh', 'auto')).toBe( + 'required' + ) + expect(run.resolveChecksumPolicy('https://get.helm.sh/', 'auto')).toBe( + 'required' + ) + expect( + run.resolveChecksumPolicy('https://mirror.example/helm', 'auto') + ).toBe('if-available') + expect( + run.resolveChecksumPolicy('https://mirror.example/helm', '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') @@ -928,6 +951,73 @@ describe('run.ts', () => { ).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 requires a checksum from get.helm.sh', 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('https://get.helm.sh', 'v3.18.4') + ).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') diff --git a/src/run.ts b/src/run.ts index ed542449..96b83f84 100644 --- a/src/run.ts +++ b/src/run.ts @@ -12,6 +12,9 @@ import * as core from '@actions/core' const helmToolName = 'helm' export const stableHelmVersion = 'v4.3.1' +const defaultDownloadBaseURL = 'https://get.helm.sh' + +export type ChecksumPolicy = 'auto' | 'required' | 'if-available' | 'disabled' export async function run() { let version = core.getInput('version') @@ -36,6 +39,9 @@ export async function run() { const downloadBaseURLFallback = core.getInput('downloadBaseURLFallback', { required: false }) + const checksumPolicy = getChecksumPolicy( + core.getInput('checksum-policy', {required: false}) + ) if (version.toLocaleLowerCase() === 'latest') { // Only an explicit 'false' turns the fallback off, matching the @@ -68,7 +74,8 @@ export async function run() { const cachedPath = await downloadHelm( downloadBaseURL, version, - downloadBaseURLFallback + downloadBaseURLFallback, + checksumPolicy ) core.endGroup() @@ -89,6 +96,21 @@ export function getValidVersion(version: string): string { return 'v' + version } +export function getChecksumPolicy(value: string): ChecksumPolicy { + const policy = value.trim().toLowerCase() || 'auto' + if ( + policy !== 'auto' && + policy !== 'required' && + policy !== 'if-available' && + policy !== 'disabled' + ) { + throw new Error( + `Invalid checksum-policy '${value}'. Expected auto, required, if-available, or disabled` + ) + } + return policy +} + // 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 @@ -359,11 +381,35 @@ export function getHelmDownloadURL(baseURL: string, version: string): string { export async function downloadAndVerifyHelm( baseURL: string, - version: string + version: string, + policy: ChecksumPolicy = 'auto' ): Promise { const downloadURL = getHelmDownloadURL(baseURL, version) - const checksumPath = await toolCache.downloadTool(`${downloadURL}.sha256`) - const checksum = fs.readFileSync(checksumPath, 'utf8').trim() + const effectivePolicy = resolveChecksumPolicy(baseURL, policy) + + if (effectivePolicy === 'disabled') { + core.warning( + `Checksum verification is disabled for Helm downloads from '${baseURL}'` + ) + return toolCache.downloadTool(downloadURL) + } + + let checksum: string | undefined + try { + const checksumPath = await toolCache.downloadTool(`${downloadURL}.sha256`) + checksum = fs.readFileSync(checksumPath, 'utf8').trim() + } catch (error) { + if ( + effectivePolicy === 'if-available' && + isChecksumNotFoundError(error) + ) { + core.warning( + `No SHA-256 checksum is available for ${downloadURL}; continuing without verification. Set checksum-policy to 'required' to fail instead.` + ) + return toolCache.downloadTool(downloadURL) + } + throw error + } if (!/^[a-fA-F0-9]{64}$/.test(checksum)) { throw new Error(`Invalid SHA-256 checksum from ${downloadURL}.sha256`) @@ -385,10 +431,39 @@ export async function downloadAndVerifyHelm( return archivePath } +export function resolveChecksumPolicy( + baseURL: string, + policy: ChecksumPolicy +): Exclude { + if (policy !== 'auto') { + return policy + } + + try { + const base = new URL(baseURL) + const defaultBase = new URL(defaultDownloadBaseURL) + const pathname = base.pathname.replace(/\/+$/, '') || '/' + const defaultPathname = defaultBase.pathname.replace(/\/+$/, '') || '/' + return base.origin === defaultBase.origin && pathname === defaultPathname + ? 'required' + : 'if-available' + } catch { + return 'if-available' + } +} + +function isChecksumNotFoundError(error: unknown): boolean { + return ( + error instanceof toolCache.HTTPError && + (error.httpStatusCode === 404 || error.httpStatusCode === 410) + ) +} + export async function downloadHelm( baseURL: string, version: string, - fallbackBaseURL?: string + fallbackBaseURL?: string, + checksumPolicy: ChecksumPolicy = 'auto' ): Promise { let cachedToolpath = toolCache.find(helmToolName, version) if (cachedToolpath) { @@ -397,7 +472,11 @@ export async function downloadHelm( core.info(`Downloading '${version}' from '${baseURL}'`) let helmDownloadPath try { - helmDownloadPath = await downloadAndVerifyHelm(baseURL, version) + helmDownloadPath = await downloadAndVerifyHelm( + baseURL, + version, + checksumPolicy + ) } catch (error) { if (fallbackBaseURL) { core.warning( @@ -406,7 +485,8 @@ export async function downloadHelm( try { helmDownloadPath = await downloadAndVerifyHelm( fallbackBaseURL, - version + version, + checksumPolicy ) } catch (fallbackError) { throw new Error( From bf8d2b71b43f891e1cd0b6aff0878274d034643b Mon Sep 17 00:00:00 2001 From: David Gamero Date: Mon, 9 Nov 2026 18:18:07 +0000 Subject: [PATCH 5/5] fix: warn for missing checksums in auto mode --- README.md | 6 +++--- action.yml | 2 +- src/run.test.ts | 36 +++++++++++++++++++++--------------- src/run.ts | 20 ++------------------ 4 files changed, 27 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index da6a3c42..4149d548 100644 --- a/README.md +++ b/README.md @@ -42,13 +42,13 @@ 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 from the default `get.helm.sh` endpoint are verified against the SHA-256 checksum published beside the archive before extraction. With the default `checksum-policy: auto`, custom mirrors are also verified when they provide a matching checksum as a bare 64-character hexadecimal digest at `.sha256`; when the checksum is not found, the action warns and continues downloading from the custom mirror. Existing tool-cache entries continue to be reused without downloading or re-verifying the archive. +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 `.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): require checksums from `get.helm.sh`; verify custom mirror checksums when available and warn when they are absent. +- `auto` (default): verify checksums when available and warn when they are absent. - `required`: require a valid matching checksum from every download location. -- `if-available`: verify checksums when available and warn when they are absent. +- `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. diff --git a/action.yml b/action.yml index 01d7c07a..cb08d022 100644 --- a/action.yml +++ b/action.yml @@ -22,7 +22,7 @@ inputs: required: false default: '' checksum-policy: - description: "Checksum verification policy: 'auto' requires checksums for get.helm.sh and uses them when available for custom mirrors; 'required' always requires them; 'if-available' verifies them when present; 'disabled' skips verification" + 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: diff --git a/src/run.test.ts b/src/run.test.ts index 255dde5f..51bca3f3 100644 --- a/src/run.test.ts +++ b/src/run.test.ts @@ -106,19 +106,9 @@ describe('run.ts', () => { ) }) - test('resolveChecksumPolicy() - require default endpoint checksums in auto mode', () => { - expect(run.resolveChecksumPolicy('https://get.helm.sh', 'auto')).toBe( - 'required' - ) - expect(run.resolveChecksumPolicy('https://get.helm.sh/', 'auto')).toBe( - 'required' - ) - expect( - run.resolveChecksumPolicy('https://mirror.example/helm', 'auto') - ).toBe('if-available') - expect( - run.resolveChecksumPolicy('https://mirror.example/helm', 'required') - ).toBe('required') + 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', () => { @@ -974,7 +964,23 @@ describe('run.ts', () => { ) }) - test('downloadAndVerifyHelm() - auto mode requires a checksum from get.helm.sh', async () => { + 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( @@ -982,7 +988,7 @@ describe('run.ts', () => { ) await expect( - run.downloadAndVerifyHelm('https://get.helm.sh', 'v3.18.4') + run.downloadAndVerifyHelm(downloadBaseURL, 'v3.18.4', 'required') ).rejects.toThrow('Unexpected HTTP response: 404') expect(toolCache.downloadTool).toHaveBeenCalledTimes(1) expect(core.warning).not.toHaveBeenCalled() diff --git a/src/run.ts b/src/run.ts index 96b83f84..594b98bc 100644 --- a/src/run.ts +++ b/src/run.ts @@ -12,7 +12,6 @@ import * as core from '@actions/core' const helmToolName = 'helm' export const stableHelmVersion = 'v4.3.1' -const defaultDownloadBaseURL = 'https://get.helm.sh' export type ChecksumPolicy = 'auto' | 'required' | 'if-available' | 'disabled' @@ -385,7 +384,7 @@ export async function downloadAndVerifyHelm( policy: ChecksumPolicy = 'auto' ): Promise { const downloadURL = getHelmDownloadURL(baseURL, version) - const effectivePolicy = resolveChecksumPolicy(baseURL, policy) + const effectivePolicy = resolveChecksumPolicy(policy) if (effectivePolicy === 'disabled') { core.warning( @@ -432,24 +431,9 @@ export async function downloadAndVerifyHelm( } export function resolveChecksumPolicy( - baseURL: string, policy: ChecksumPolicy ): Exclude { - if (policy !== 'auto') { - return policy - } - - try { - const base = new URL(baseURL) - const defaultBase = new URL(defaultDownloadBaseURL) - const pathname = base.pathname.replace(/\/+$/, '') || '/' - const defaultPathname = defaultBase.pathname.replace(/\/+$/, '') || '/' - return base.origin === defaultBase.origin && pathname === defaultPathname - ? 'required' - : 'if-available' - } catch { - return 'if-available' - } + return policy === 'auto' ? 'if-available' : policy } function isChecksumNotFoundError(error: unknown): boolean {