From f0a2a2d8983748ceec003fd6833e42e1f99f1b10 Mon Sep 17 00:00:00 2001 From: Daniel Kennedy Date: Fri, 30 Jan 2026 15:12:08 -0500 Subject: [PATCH] Add a setting to specify what to do on hash mismatch and default it to `error` --- __tests__/download.test.ts | 82 +++++++++++++++++++++++++++++++++++++- action.yml | 5 +++ dist/index.js | 41 ++++++++++++++++++- src/constants.ts | 10 ++++- src/download-artifact.ts | 48 +++++++++++++++++++--- 5 files changed, 177 insertions(+), 9 deletions(-) diff --git a/__tests__/download.test.ts b/__tests__/download.test.ts index 4a14761..3525be1 100644 --- a/__tests__/download.test.ts +++ b/__tests__/download.test.ts @@ -234,7 +234,7 @@ describe('download', () => { ) }) - test('warns when digest validation fails', async () => { + test('errors when digest validation fails (default behavior)', async () => { const mockArtifact = { id: 123, name: 'corrupted-artifact', @@ -242,6 +242,31 @@ describe('download', () => { digest: 'abc123' } + jest + .spyOn(artifact.default, 'getArtifact') + .mockImplementation(() => Promise.resolve({artifact: mockArtifact})) + + jest + .spyOn(artifact.default, 'downloadArtifact') + .mockImplementation(() => Promise.resolve({digestMismatch: true})) + + await expect(run()).rejects.toThrow( + "Digest validation failed for artifact(s): corrupted-artifact" + ) + }) + + test('warns when digest validation fails with digest-mismatch set to warn', async () => { + const mockArtifact = { + id: 123, + name: 'corrupted-artifact', + size: 1024, + digest: 'abc123' + } + + mockInputs({ + [Inputs.DigestMismatch]: 'warn' + }) + jest .spyOn(artifact.default, 'getArtifact') .mockImplementation(() => Promise.resolve({artifact: mockArtifact})) @@ -257,6 +282,61 @@ describe('download', () => { ) }) + test('logs info when digest validation fails with digest-mismatch set to info', async () => { + const mockArtifact = { + id: 123, + name: 'corrupted-artifact', + size: 1024, + digest: 'abc123' + } + + mockInputs({ + [Inputs.DigestMismatch]: 'info' + }) + + jest + .spyOn(artifact.default, 'getArtifact') + .mockImplementation(() => Promise.resolve({artifact: mockArtifact})) + + jest + .spyOn(artifact.default, 'downloadArtifact') + .mockImplementation(() => Promise.resolve({digestMismatch: true})) + + await run() + + expect(core.info).toHaveBeenCalledWith( + expect.stringContaining('digest validation failed') + ) + }) + + test('silently continues when digest validation fails with digest-mismatch set to ignore', async () => { + const mockArtifact = { + id: 123, + name: 'corrupted-artifact', + size: 1024, + digest: 'abc123' + } + + mockInputs({ + [Inputs.DigestMismatch]: 'ignore' + }) + + jest + .spyOn(artifact.default, 'getArtifact') + .mockImplementation(() => Promise.resolve({artifact: mockArtifact})) + + jest + .spyOn(artifact.default, 'downloadArtifact') + .mockImplementation(() => Promise.resolve({digestMismatch: true})) + + await run() + + expect(core.warning).not.toHaveBeenCalledWith( + expect.stringContaining('digest validation failed') + ) + expect(core.info).toHaveBeenCalledWith('Total of 1 artifact(s) downloaded') + }) + test('downloads a single artifact by ID', async () => { const mockArtifact = { id: 456, diff --git a/action.yml b/action.yml index 93206b7..8b8c650 100644 --- a/action.yml +++ b/action.yml @@ -40,6 +40,11 @@ inputs: This is useful when you want to handle the artifact as-is without extraction.' required: false default: 'false' + digest-mismatch: + description: 'The behavior when a downloaded artifact''s digest does not match the expected digest. + Options: ignore, info, warn, error. Default is error which will fail the action.' + required: false + default: 'error' outputs: download-path: description: 'Path of artifact download' diff --git a/dist/index.js b/dist/index.js index 7dde87c..90916c1 100644 --- a/dist/index.js +++ b/dist/index.js @@ -129353,7 +129353,15 @@ var Inputs; Inputs["MergeMultiple"] = "merge-multiple"; Inputs["ArtifactIds"] = "artifact-ids"; Inputs["SkipDecompress"] = "skip-decompress"; + Inputs["DigestMismatch"] = "digest-mismatch"; })(Inputs || (Inputs = {})); +var DigestMismatchBehavior; +(function (DigestMismatchBehavior) { + DigestMismatchBehavior["Ignore"] = "ignore"; + DigestMismatchBehavior["Info"] = "info"; + DigestMismatchBehavior["Warn"] = "warn"; + DigestMismatchBehavior["Error"] = "error"; +})(DigestMismatchBehavior || (DigestMismatchBehavior = {})); var Outputs; (function (Outputs) { Outputs["DownloadPath"] = "download-path"; @@ -129386,8 +129394,15 @@ async function run() { artifactIds: getInput(Inputs.ArtifactIds, { required: false }), skipDecompress: getBooleanInput(Inputs.SkipDecompress, { required: false - }) + }), + digestMismatch: (getInput(Inputs.DigestMismatch, { required: false }) || + DigestMismatchBehavior.Error) }; + // Validate digest-mismatch input + const validBehaviors = Object.values(DigestMismatchBehavior); + if (!validBehaviors.includes(inputs.digestMismatch)) { + throw new Error(`Invalid value for 'digest-mismatch': '${inputs.digestMismatch}'. Valid options are: ${validBehaviors.join(', ')}`); + } if (!inputs.path) { inputs.path = process.env['GITHUB_WORKSPACE'] || process.cwd(); } @@ -129500,6 +129515,7 @@ async function run() { }) })); const chunkedPromises = chunk(downloadPromises, PARALLEL_DOWNLOADS); + const digestMismatches = []; for (const chunk of chunkedPromises) { const chunkPromises = chunk.map(item => item.promise); const results = await Promise.all(chunkPromises); @@ -129507,10 +129523,31 @@ async function run() { const outcome = results[i]; const artifactName = chunk[i].name; if (outcome.digestMismatch) { - warning(`Artifact '${artifactName}' digest validation failed. Please verify the integrity of the artifact.`); + digestMismatches.push(artifactName); + const message = `Artifact '${artifactName}' digest validation failed. Please verify the integrity of the artifact.`; + switch (inputs.digestMismatch) { + case DigestMismatchBehavior.Ignore: + // Do nothing + break; + case DigestMismatchBehavior.Info: + info(message); + break; + case DigestMismatchBehavior.Warn: + warning(message); + break; + case DigestMismatchBehavior.Error: + // Collect all errors and fail at the end + break; + } } } } + // If there were digest mismatches and behavior is 'error', fail the action + if (digestMismatches.length > 0 && + inputs.digestMismatch === DigestMismatchBehavior.Error) { + throw new Error(`Digest validation failed for artifact(s): ${digestMismatches.join(', ')}. ` + + `Use 'digest-mismatch: warn' to continue on mismatch.`); + } info(`Total of ${artifacts.length} artifact(s) downloaded`); setOutput(Outputs.DownloadPath, resolvedPath); info('Download artifact has finished successfully'); diff --git a/src/constants.ts b/src/constants.ts index 1dc68c6..9e09aea 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -7,7 +7,15 @@ export enum Inputs { Pattern = 'pattern', MergeMultiple = 'merge-multiple', ArtifactIds = 'artifact-ids', - SkipDecompress = 'skip-decompress' + SkipDecompress = 'skip-decompress', + DigestMismatch = 'digest-mismatch' +} + +export enum DigestMismatchBehavior { + Ignore = 'ignore', + Info = 'info', + Warn = 'warn', + Error = 'error' } export enum Outputs { diff --git a/src/download-artifact.ts b/src/download-artifact.ts index 1b3ade0..0e89600 100644 --- a/src/download-artifact.ts +++ b/src/download-artifact.ts @@ -4,7 +4,7 @@ import * as core from '@actions/core' import artifactClient from '@actions/artifact' import type {Artifact, FindOptions} from '@actions/artifact' import {Minimatch} from 'minimatch' -import {Inputs, Outputs} from './constants.js' +import {Inputs, Outputs, DigestMismatchBehavior} from './constants.js' const PARALLEL_DOWNLOADS = 5 @@ -29,7 +29,17 @@ export async function run(): Promise { artifactIds: core.getInput(Inputs.ArtifactIds, {required: false}), skipDecompress: core.getBooleanInput(Inputs.SkipDecompress, { required: false - }) + }), + digestMismatch: (core.getInput(Inputs.DigestMismatch, {required: false}) || + DigestMismatchBehavior.Error) as DigestMismatchBehavior + } + + // Validate digest-mismatch input + const validBehaviors = Object.values(DigestMismatchBehavior) + if (!validBehaviors.includes(inputs.digestMismatch)) { + throw new Error( + `Invalid value for 'digest-mismatch': '${inputs.digestMismatch}'. Valid options are: ${validBehaviors.join(', ')}` + ) } if (!inputs.path) { @@ -188,6 +198,8 @@ export async function run(): Promise { })) const chunkedPromises = chunk(downloadPromises, PARALLEL_DOWNLOADS) + const digestMismatches: string[] = [] + for (const chunk of chunkedPromises) { const chunkPromises = chunk.map(item => item.promise) const results = await Promise.all(chunkPromises) @@ -197,12 +209,38 @@ export async function run(): Promise { const artifactName = chunk[i].name if (outcome.digestMismatch) { - core.warning( - `Artifact '${artifactName}' digest validation failed. Please verify the integrity of the artifact.` - ) + digestMismatches.push(artifactName) + const message = `Artifact '${artifactName}' digest validation failed. Please verify the integrity of the artifact.` + + switch (inputs.digestMismatch) { + case DigestMismatchBehavior.Ignore: + // Do nothing + break + case DigestMismatchBehavior.Info: + core.info(message) + break + case DigestMismatchBehavior.Warn: + core.warning(message) + break + case DigestMismatchBehavior.Error: + // Collect all errors and fail at the end + break + } } } } + + // If there were digest mismatches and behavior is 'error', fail the action + if ( + digestMismatches.length > 0 && + inputs.digestMismatch === DigestMismatchBehavior.Error + ) { + throw new Error( + `Digest validation failed for artifact(s): ${digestMismatches.join(', ')}. ` + + `Use 'digest-mismatch: warn' to continue on mismatch.` + ) + } + core.info(`Total of ${artifacts.length} artifact(s) downloaded`) core.setOutput(Outputs.DownloadPath, resolvedPath) core.info('Download artifact has finished successfully')