Compare commits

..

10 Commits

Author SHA1 Message Date
Daniel Kennedy
70fc10c6e5 Merge pull request #461 from actions/danwkennedy/digest-mismatch-behavior
Add a setting to specify what to do on hash mismatch and default it to `error`
2026-02-23 15:27:01 -05:00
Daniel Kennedy
f258da9a50 Add change docs 2026-02-23 15:12:17 -05:00
Daniel Kennedy
ccc058e5fb Fix linting issues 2026-02-23 15:07:11 -05:00
Daniel Kennedy
bd7976ba57 Add a setting to specify what to do on hash mismatch and default it to error 2026-02-23 15:07:11 -05:00
Daniel Kennedy
ac21fcf45e Merge pull request #460 from actions/danwkennedy/download-no-unzip
Don't attempt to un-zip non-zipped downloads
2026-02-23 15:05:25 -05:00
Daniel Kennedy
15999bff51 Add note about package bumps 2026-02-23 12:32:38 -05:00
Daniel Kennedy
974686ed50 Bump the version to v8 and add release notes 2026-02-23 12:31:19 -05:00
Daniel Kennedy
fbe48b1d27 Update test names to make it clearer what they do 2026-02-23 12:21:41 -05:00
Daniel Kennedy
96bf374a61 One more test fix 2026-02-23 12:18:06 -05:00
Daniel Kennedy
b8c4819ef5 Fix skip decompress test 2026-02-23 12:13:23 -05:00
8 changed files with 201 additions and 18 deletions

View File

@@ -63,14 +63,14 @@ jobs:
path: path/to/artifact-B path: path/to/artifact-B
# Test downloading a single artifact # Test downloading a single artifact
- name: Download artifact A - name: Download artifact A (absolute path)
uses: ./ uses: ./
with: with:
name: Artifact-A-${{ matrix.runs-on }} name: Artifact-A-${{ matrix.runs-on }}
path: some/new/path path: some/new/path
# Test downloading an artifact using tilde expansion # Test downloading an artifact using tilde expansion
- name: Download artifact A - name: Download artifact A (tilde expansion)
uses: ./ uses: ./
with: with:
name: Artifact-A-${{ matrix.runs-on }} name: Artifact-A-${{ matrix.runs-on }}
@@ -142,7 +142,7 @@ jobs:
- name: Verify skip-decompress download - name: Verify skip-decompress download
run: | run: |
$rawFile = "skip-decompress-test/artifact" $rawFile = "skip-decompress-test/Artifact-A-${{ matrix.runs-on }}.zip"
if(!(Test-Path -path $rawFile)) if(!(Test-Path -path $rawFile))
{ {
Write-Error "Expected raw artifact file does not exist at $rawFile" Write-Error "Expected raw artifact file does not exist at $rawFile"

View File

@@ -23,6 +23,21 @@ See also [upload-artifact](https://github.com/actions/upload-artifact).
- [Limitations](#limitations) - [Limitations](#limitations)
- [Permission Loss](#permission-loss) - [Permission Loss](#permission-loss)
## v8 - What's new
> [!IMPORTANT]
> actions/download-artifact@v8 has been migrated to an ESM module. This should be transparent to the caller but forks might need to make significant changes.
> [!IMPORTANT]
> Hash mismatches will now error by default. Users can override this behavior with a setting change (see below).
- Downloads will check the content-type returned to determine if a file can be decompressed and skip the decompression stage if so. This removes previous failures where we were trying to decompress a non-zip file. Since this is making a big change to the default behavior, we're making it opt-in via a version bump.
- Users can also download a zip file without decompressing it with the new `skip-decompress` flag.
- Introduces a new parameter `digest-mismatch` that allows callers to specify what to do when the downloaded hash doesn't match the expected hash (`ignore`, `info`, `warn`, `error`). To ensure security by default, the default value is `error`.
- Chore: we've bumped versions on a lot of our dev packages to get them up to date with the latest bugfixes/security patches.
## v7 - What's new ## v7 - What's new
> [!IMPORTANT] > [!IMPORTANT]
@@ -77,7 +92,7 @@ We are taking the following steps to better direct requests related to GitHub Ac
1. We will be directing questions and support requests to our [Community Discussions area](https://github.com/orgs/community/discussions/categories/actions) 1. We will be directing questions and support requests to our [Community Discussions area](https://github.com/orgs/community/discussions/categories/actions)
2. High Priority bugs can be reported through Community Discussions or you can report these to our support team https://support.github.com/contact/bug-report. 2. High Priority bugs can be reported through Community Discussions or you can report these to our support team <https://support.github.com/contact/bug-report>.
3. Security Issues should be handled as per our [security.md](SECURITY.md). 3. Security Issues should be handled as per our [security.md](SECURITY.md).
@@ -216,7 +231,7 @@ If the `name` input parameter is not provided, all artifacts will be downloaded.
Example, if there are two artifacts `Artifact-A` and `Artifact-B`, and the directory is `etc/usr/artifacts/`, the directory structure will look like this: Example, if there are two artifacts `Artifact-A` and `Artifact-B`, and the directory is `etc/usr/artifacts/`, the directory structure will look like this:
``` ```bash
etc/usr/artifacts/ etc/usr/artifacts/
Artifact-A/ Artifact-A/
... contents of Artifact-A ... contents of Artifact-A
@@ -258,7 +273,7 @@ steps:
Which will result in: Which will result in:
``` ```bash
path/to/artifacts/ path/to/artifacts/
... contents of Artifact-A ... contents of Artifact-A
... contents of Artifact-B ... contents of Artifact-B
@@ -298,7 +313,7 @@ jobs:
This results in a directory like so: This results in a directory like so:
``` ```bash
my-artifact/ my-artifact/
file-macos-latest.txt file-macos-latest.txt
file-ubuntu-latest.txt file-ubuntu-latest.txt

View File

@@ -234,7 +234,7 @@ describe('download', () => {
) )
}) })
test('warns when digest validation fails', async () => { test('errors when digest validation fails (default behavior)', async () => {
const mockArtifact = { const mockArtifact = {
id: 123, id: 123,
name: 'corrupted-artifact', name: 'corrupted-artifact',
@@ -242,6 +242,31 @@ describe('download', () => {
digest: 'abc123' 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 jest
.spyOn(artifact.default, 'getArtifact') .spyOn(artifact.default, 'getArtifact')
.mockImplementation(() => Promise.resolve({artifact: mockArtifact})) .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 () => { test('downloads a single artifact by ID', async () => {
const mockArtifact = { const mockArtifact = {
id: 456, id: 456,

View File

@@ -40,6 +40,11 @@ inputs:
This is useful when you want to handle the artifact as-is without extraction.' This is useful when you want to handle the artifact as-is without extraction.'
required: false required: false
default: '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: outputs:
download-path: download-path:
description: 'Path of artifact download' description: 'Path of artifact download'

41
dist/index.js vendored
View File

@@ -129353,7 +129353,15 @@ var Inputs;
Inputs["MergeMultiple"] = "merge-multiple"; Inputs["MergeMultiple"] = "merge-multiple";
Inputs["ArtifactIds"] = "artifact-ids"; Inputs["ArtifactIds"] = "artifact-ids";
Inputs["SkipDecompress"] = "skip-decompress"; Inputs["SkipDecompress"] = "skip-decompress";
Inputs["DigestMismatch"] = "digest-mismatch";
})(Inputs || (Inputs = {})); })(Inputs || (Inputs = {}));
var DigestMismatchBehavior;
(function (DigestMismatchBehavior) {
DigestMismatchBehavior["Ignore"] = "ignore";
DigestMismatchBehavior["Info"] = "info";
DigestMismatchBehavior["Warn"] = "warn";
DigestMismatchBehavior["Error"] = "error";
})(DigestMismatchBehavior || (DigestMismatchBehavior = {}));
var Outputs; var Outputs;
(function (Outputs) { (function (Outputs) {
Outputs["DownloadPath"] = "download-path"; Outputs["DownloadPath"] = "download-path";
@@ -129386,8 +129394,15 @@ async function run() {
artifactIds: getInput(Inputs.ArtifactIds, { required: false }), artifactIds: getInput(Inputs.ArtifactIds, { required: false }),
skipDecompress: getBooleanInput(Inputs.SkipDecompress, { skipDecompress: getBooleanInput(Inputs.SkipDecompress, {
required: false 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) { if (!inputs.path) {
inputs.path = process.env['GITHUB_WORKSPACE'] || process.cwd(); inputs.path = process.env['GITHUB_WORKSPACE'] || process.cwd();
} }
@@ -129500,6 +129515,7 @@ async function run() {
}) })
})); }));
const chunkedPromises = chunk(downloadPromises, PARALLEL_DOWNLOADS); const chunkedPromises = chunk(downloadPromises, PARALLEL_DOWNLOADS);
const digestMismatches = [];
for (const chunk of chunkedPromises) { for (const chunk of chunkedPromises) {
const chunkPromises = chunk.map(item => item.promise); const chunkPromises = chunk.map(item => item.promise);
const results = await Promise.all(chunkPromises); const results = await Promise.all(chunkPromises);
@@ -129507,10 +129523,31 @@ async function run() {
const outcome = results[i]; const outcome = results[i];
const artifactName = chunk[i].name; const artifactName = chunk[i].name;
if (outcome.digestMismatch) { 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`); info(`Total of ${artifacts.length} artifact(s) downloaded`);
setOutput(Outputs.DownloadPath, resolvedPath); setOutput(Outputs.DownloadPath, resolvedPath);
info('Download artifact has finished successfully'); info('Download artifact has finished successfully');

View File

@@ -1,6 +1,6 @@
{ {
"name": "download-artifact", "name": "download-artifact",
"version": "7.0.0", "version": "8.0.0",
"description": "Download an Actions Artifact from a workflow run", "description": "Download an Actions Artifact from a workflow run",
"type": "module", "type": "module",
"engines": { "engines": {

View File

@@ -7,7 +7,15 @@ export enum Inputs {
Pattern = 'pattern', Pattern = 'pattern',
MergeMultiple = 'merge-multiple', MergeMultiple = 'merge-multiple',
ArtifactIds = 'artifact-ids', 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 { export enum Outputs {

View File

@@ -4,7 +4,7 @@ import * as core from '@actions/core'
import artifactClient from '@actions/artifact' import artifactClient from '@actions/artifact'
import type {Artifact, FindOptions} from '@actions/artifact' import type {Artifact, FindOptions} from '@actions/artifact'
import {Minimatch} from 'minimatch' import {Minimatch} from 'minimatch'
import {Inputs, Outputs} from './constants.js' import {Inputs, Outputs, DigestMismatchBehavior} from './constants.js'
const PARALLEL_DOWNLOADS = 5 const PARALLEL_DOWNLOADS = 5
@@ -29,7 +29,17 @@ export async function run(): Promise<void> {
artifactIds: core.getInput(Inputs.ArtifactIds, {required: false}), artifactIds: core.getInput(Inputs.ArtifactIds, {required: false}),
skipDecompress: core.getBooleanInput(Inputs.SkipDecompress, { skipDecompress: core.getBooleanInput(Inputs.SkipDecompress, {
required: false 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) { if (!inputs.path) {
@@ -188,6 +198,8 @@ export async function run(): Promise<void> {
})) }))
const chunkedPromises = chunk(downloadPromises, PARALLEL_DOWNLOADS) const chunkedPromises = chunk(downloadPromises, PARALLEL_DOWNLOADS)
const digestMismatches: string[] = []
for (const chunk of chunkedPromises) { for (const chunk of chunkedPromises) {
const chunkPromises = chunk.map(item => item.promise) const chunkPromises = chunk.map(item => item.promise)
const results = await Promise.all(chunkPromises) const results = await Promise.all(chunkPromises)
@@ -197,12 +209,38 @@ export async function run(): Promise<void> {
const artifactName = chunk[i].name const artifactName = chunk[i].name
if (outcome.digestMismatch) { if (outcome.digestMismatch) {
core.warning( digestMismatches.push(artifactName)
`Artifact '${artifactName}' digest validation failed. Please verify the integrity of the artifact.` 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.info(`Total of ${artifacts.length} artifact(s) downloaded`)
core.setOutput(Outputs.DownloadPath, resolvedPath) core.setOutput(Outputs.DownloadPath, resolvedPath)
core.info('Download artifact has finished successfully') core.info('Download artifact has finished successfully')