Compare commits

..
24 Commits
v7 .. v8
Author SHA1 Message Date
Daniel Kennedy 3e5f45b2cf Add regression tests for CJK characters (#471)
* Add regression tests for CJK characters

* Dedupe the names and remove the ignored `name` param

* Bump @actions/artifact to v6.2.1

* Run `npm run release`

* Update licenses
2026-03-11 11:35:54 -04:00
Daniel Kennedy e6d03f6737 Add a regression test for artifact name + content-type mismatches (#472)
* Add a regression test for artifact name + content-type mismatches

* Specify that we want v4 in a comment

* Fix the expected type
2026-03-11 08:57:07 -04:00
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
Daniel Kennedy 7127277c52 Fixup the minimatch license 2026-01-30 15:53:50 -05:00
Daniel Kennedy 41a8b730f4 Re-add minimatch.dep.yml 2026-01-30 15:51:28 -05:00
Daniel Kennedy 6533bb2df3 Revert "Updated the licensed version"
This reverts commit e6afc5e70e.
2026-01-30 15:45:48 -05:00
Daniel Kennedy e6afc5e70e Updated the licensed version 2026-01-30 15:44:23 -05:00
Daniel Kennedy 87db0e9f47 Cache licenses 2026-01-30 15:34:43 -05:00
Daniel Kennedy c9ff097e79 Update the integration test 2026-01-30 14:45:33 -05:00
Daniel Kennedy 882c7072ac Add an integration test for skip-decompress 2026-01-30 14:33:56 -05:00
Daniel Kennedy 6d4a0de04f Add tests 2026-01-30 14:19:20 -05:00
Daniel Kennedy f45b79f03c Don't attempt to un-zip non-zipped downloads 2026-01-30 14:16:38 -05:00
Daniel Kennedy 1d636af56d Upgrade jest and typescript 2026-01-30 14:15:49 -05:00
Daniel Kennedy 43c2394920 Migrate eslint to v9 2026-01-30 14:15:20 -05:00
Daniel Kennedy 3d65dc0e0b Update .gitignore 2026-01-30 14:14:33 -05:00
58 changed files with 70984 additions and 70308 deletions
-3
View File
@@ -1,3 +0,0 @@
node_modules/
lib/
dist/
-15
View File
@@ -1,15 +0,0 @@
{
"env": { "node": true, "jest": true },
"parser": "@typescript-eslint/parser",
"parserOptions": { "ecmaVersion": 9, "sourceType": "module" },
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended",
"plugin:import/errors",
"plugin:import/warnings",
"plugin:import/typescript",
"plugin:prettier/recommended"
],
"plugins": ["@typescript-eslint"]
}
+191 -2
View File
@@ -63,14 +63,14 @@ jobs:
path: path/to/artifact-B
# Test downloading a single artifact
- name: Download artifact A
- name: Download artifact A (absolute path)
uses: ./
with:
name: Artifact-A-${{ matrix.runs-on }}
path: some/new/path
# Test downloading an artifact using tilde expansion
- name: Download artifact A
- name: Download artifact A (tilde expansion)
uses: ./
with:
name: Artifact-A-${{ matrix.runs-on }}
@@ -131,3 +131,192 @@ jobs:
Write-Error "File contents of downloaded artifacts are incorrect"
}
shell: pwsh
# Test downloading artifact without decompressing (skip-decompress)
- name: Download artifact A without decompressing
uses: ./
with:
name: Artifact-A-${{ matrix.runs-on }}
path: skip-decompress-test
skip-decompress: true
- name: Verify skip-decompress download
run: |
$rawFile = "skip-decompress-test/Artifact-A-${{ matrix.runs-on }}.zip"
if(!(Test-Path -path $rawFile))
{
Write-Error "Expected raw artifact file does not exist at $rawFile"
}
$fileInfo = Get-Item $rawFile
if($fileInfo.Length -eq 0)
{
Write-Error "Downloaded artifact file is empty"
}
Write-Host "Successfully downloaded artifact without decompressing: $rawFile (size: $($fileInfo.Length) bytes)"
shell: pwsh
# Regression test for artifact filename vs content-type mismatch
# When an archived artifact has a name with a file extension that doesn't
# match the blob type (e.g. "report.txt" but blob is zip), the server
# should append .zip to the content-disposition filename.
- name: Create and upload archived artifact with misleading extension
shell: bash
run: |
mkdir -p path/to/extension-test
echo '{"key": "value"}' > path/to/extension-test/data.json
- uses: actions/upload-artifact@v4 # V4 is important here to ensure we're supporting older versions correctly
with:
name: report.txt-${{ matrix.runs-on }}.json
path: path/to/extension-test/data.json
- name: Download misleading-extension artifact without decompressing
uses: ./
with:
name: report.txt-${{ matrix.runs-on }}.json
path: ext-test/raw
skip-decompress: true
- name: Verify downloaded file has .zip extension appended
shell: bash
run: |
expected="ext-test/raw/report.txt-${{ matrix.runs-on }}.json.zip"
if [ -f "$expected" ]; then
echo "PASS: Downloaded file has .zip appended: $expected"
else
echo "FAIL: Expected $expected but got:"
ls -al ext-test/raw/
exit 1
fi
# Test uploading and downloading artifacts with CJK (Chinese, Japanese, Korean) characters
# Regression test: certain non-ASCII chars (e.g. U+571F 土) caused 400 errors from
# Azure Blob Storage due to encoding issues in the Content-Disposition / rscd parameter
- name: Create artifacts with CJK names
shell: bash
run: |
mkdir -p path/to/cjk-artifacts
# Chinese - 土 (U+571F) known to fail, 日 (U+65E5) known to work
echo "Content for 土" > "path/to/cjk-artifacts/file-土-${{ matrix.runs-on }}.txt"
echo "Content for 中文测试" > "path/to/cjk-artifacts/file-中文测试-${{ matrix.runs-on }}.txt"
# Japanese - katakana and kanji
echo "Content for テスト" > "path/to/cjk-artifacts/file-テスト-${{ matrix.runs-on }}.txt"
echo "Content for 東京タワー" > "path/to/cjk-artifacts/file-東京タワー-${{ matrix.runs-on }}.txt"
# Korean - Hangul
echo "Content for 테스트" > "path/to/cjk-artifacts/file-테스트-${{ matrix.runs-on }}.txt"
echo "Content for 서울시" > "path/to/cjk-artifacts/file-서울시-${{ matrix.runs-on }}.txt"
- name: Upload CJK artifact - Chinese 土
uses: actions/upload-artifact@v7
with:
path: path/to/cjk-artifacts/file-土-${{ matrix.runs-on }}.txt
archive: false
- name: Upload CJK artifact - Chinese 中文测试
uses: actions/upload-artifact@v7
with:
path: path/to/cjk-artifacts/file-中文测试-${{ matrix.runs-on }}.txt
archive: false
- name: Upload CJK artifact - Japanese テスト
uses: actions/upload-artifact@v7
with:
path: path/to/cjk-artifacts/file-テスト-${{ matrix.runs-on }}.txt
archive: false
- name: Upload CJK artifact - Japanese 東京タワー
uses: actions/upload-artifact@v7
with:
path: path/to/cjk-artifacts/file-東京タワー-${{ matrix.runs-on }}.txt
archive: false
- name: Upload CJK artifact - Korean 테스트
uses: actions/upload-artifact@v7
with:
path: path/to/cjk-artifacts/file-테스트-${{ matrix.runs-on }}.txt
archive: false
- name: Upload CJK artifact - Korean 서울시
uses: actions/upload-artifact@v7
with:
path: path/to/cjk-artifacts/file-서울시-${{ matrix.runs-on }}.txt
archive: false
- name: Download CJK artifact - Chinese 土
uses: ./
with:
name: file-土-${{ matrix.runs-on }}.txt
path: cjk-download/土
- name: Download CJK artifact - Chinese 中文测试
uses: ./
with:
name: file-中文测试-${{ matrix.runs-on }}.txt
path: cjk-download/中文测试
- name: Download CJK artifact - Japanese テスト
uses: ./
with:
name: file-テスト-${{ matrix.runs-on }}.txt
path: cjk-download/テスト
- name: Download CJK artifact - Japanese 東京タワー
uses: ./
with:
name: file-東京タワー-${{ matrix.runs-on }}.txt
path: cjk-download/東京タワー
- name: Download CJK artifact - Korean 테스트
uses: ./
with:
name: file-테스트-${{ matrix.runs-on }}.txt
path: cjk-download/테스트
- name: Download CJK artifact - Korean 서울시
uses: ./
with:
name: file-서울시-${{ matrix.runs-on }}.txt
path: cjk-download/서울시
- name: Verify CJK artifact downloads
shell: bash
run: |
set -e
fail=0
check_file() {
local file="$1"
local expected="$2"
if [ ! -f "$file" ]; then
echo "FAIL: Missing file: $file"
fail=1
return
fi
actual=$(cat "$file")
if [ "$actual" != "$expected" ]; then
echo "FAIL: Content mismatch in $file"
echo " Expected: '$expected'"
echo " Got: '$actual'"
fail=1
return
fi
echo "PASS: $file"
}
echo "=== Chinese ==="
check_file "cjk-download/土/file-土-${{ matrix.runs-on }}.txt" "Content for 土"
check_file "cjk-download/中文测试/file-中文测试-${{ matrix.runs-on }}.txt" "Content for 中文测试"
echo "=== Japanese ==="
check_file "cjk-download/テスト/file-テスト-${{ matrix.runs-on }}.txt" "Content for テスト"
check_file "cjk-download/東京タワー/file-東京タワー-${{ matrix.runs-on }}.txt" "Content for 東京タワー"
echo "=== Korean ==="
check_file "cjk-download/테스트/file-테스트-${{ matrix.runs-on }}.txt" "Content for 테스트"
check_file "cjk-download/서울시/file-서울시-${{ matrix.runs-on }}.txt" "Content for 서울시"
if [ "$fail" -ne 0 ]; then
echo "Some CJK artifact checks failed"
ls -alR cjk-download/ || true
exit 1
fi
echo "All CJK artifact downloads verified successfully"
+3 -1
View File
@@ -2,4 +2,6 @@
node_modules/
# Ignore js files that are transpiled from ts files in src/
lib/
lib/
.DS_Store
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
+24 -5
View File
@@ -23,6 +23,25 @@ See also [upload-artifact](https://github.com/actions/upload-artifact).
- [Limitations](#limitations)
- [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.
### v8.0.1
- Support for CJK (Chinese/Japanese/Korean) characters in the file name.
## v7 - What's new
> [!IMPORTANT]
@@ -69,7 +88,7 @@ For assistance with breaking changes, see [MIGRATION.md](docs/MIGRATION.md).
## Note
Thank you for your interest in this GitHub repo, however, right now we are not taking contributions.
Thank you for your interest in this GitHub repo, however, right now we are not taking contributions.
We continue to focus our resources on strategic areas that help our customers be successful while making developers' lives easier. While GitHub Actions remains a key part of this vision, we are allocating resources towards other areas of Actions and are not taking contributions to this repository at this time. The GitHub public roadmap is the best place to follow along for any updates on features we’re working on and what stage they’re in.
@@ -77,7 +96,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)
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).
@@ -216,7 +235,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:
```
```bash
etc/usr/artifacts/
Artifact-A/
... contents of Artifact-A
@@ -258,7 +277,7 @@ steps:
Which will result in:
```
```bash
path/to/artifacts/
... contents of Artifact-A
... contents of Artifact-B
@@ -298,7 +317,7 @@ jobs:
This results in a directory like so:
```
```bash
my-artifact/
file-macos-latest.txt
file-ubuntu-latest.txt
+238 -41
View File
@@ -1,10 +1,8 @@
import * as core from '@actions/core'
import {jest, describe, test, expect, beforeEach} from '@jest/globals'
import * as path from 'path'
import artifact, {ArtifactNotFoundError} from '@actions/artifact'
import {run} from '../src/download-artifact'
import {Inputs} from '../src/constants'
jest.mock('@actions/github', () => ({
// Mock @actions/github before importing modules that use it
jest.unstable_mockModule('@actions/github', () => ({
context: {
repo: {
owner: 'actions',
@@ -12,14 +10,46 @@ jest.mock('@actions/github', () => ({
},
runId: 123,
serverUrl: 'https://github.com'
}
},
getOctokit: jest.fn()
}))
jest.mock('@actions/core')
// Mock @actions/core
jest.unstable_mockModule('@actions/core', () => ({
getInput: jest.fn(),
getBooleanInput: jest.fn(),
setOutput: jest.fn(),
setFailed: jest.fn(),
setSecret: jest.fn(),
info: jest.fn(),
warning: jest.fn(),
debug: jest.fn(),
error: jest.fn(),
notice: jest.fn(),
startGroup: jest.fn(),
endGroup: jest.fn(),
isDebug: jest.fn(() => false),
getState: jest.fn(),
saveState: jest.fn(),
exportVariable: jest.fn(),
addPath: jest.fn(),
group: jest.fn((name: string, fn: () => Promise<unknown>) => fn()),
toPlatformPath: jest.fn(p => p),
toWin32Path: jest.fn(p => p),
toPosixPath: jest.fn(p => p)
}))
/* eslint-disable no-unused-vars */ /* eslint-disable @typescript-eslint/no-explicit-any */
const mockInputs = (overrides?: Partial<{[K in Inputs]?: any}>) => {
const inputs = {
// Dynamic imports after mocking
const core = await import('@actions/core')
const artifact = await import('@actions/artifact')
const {run} = await import('../src/download-artifact.js')
const {Inputs} = await import('../src/constants.js')
const {ArtifactNotFoundError} = artifact
const mockInputs = (
overrides?: Partial<{[K in (typeof Inputs)[keyof typeof Inputs]]?: any}>
) => {
const inputs: Record<string, any> = {
[Inputs.Name]: 'artifact-name',
[Inputs.Path]: '/some/artifact/path',
[Inputs.GitHubToken]: 'warn',
@@ -29,10 +59,14 @@ const mockInputs = (overrides?: Partial<{[K in Inputs]?: any}>) => {
...overrides
}
;(core.getInput as jest.Mock).mockImplementation((name: string) => {
return inputs[name]
})
;(core.getBooleanInput as jest.Mock).mockImplementation((name: string) => {
;(core.getInput as jest.Mock<typeof core.getInput>).mockImplementation(
(name: string) => {
return inputs[name]
}
)
;(
core.getBooleanInput as jest.Mock<typeof core.getBooleanInput>
).mockImplementation((name: string) => {
return inputs[name]
})
@@ -46,13 +80,13 @@ describe('download', () => {
// Mock artifact client methods
jest
.spyOn(artifact, 'listArtifacts')
.spyOn(artifact.default, 'listArtifacts')
.mockImplementation(() => Promise.resolve({artifacts: []}))
jest.spyOn(artifact, 'getArtifact').mockImplementation(name => {
jest.spyOn(artifact.default, 'getArtifact').mockImplementation(name => {
throw new ArtifactNotFoundError(`Artifact '${name}' not found`)
})
jest
.spyOn(artifact, 'downloadArtifact')
.spyOn(artifact.default, 'downloadArtifact')
.mockImplementation(() => Promise.resolve({digestMismatch: false}))
})
@@ -65,12 +99,12 @@ describe('download', () => {
}
jest
.spyOn(artifact, 'getArtifact')
.spyOn(artifact.default, 'getArtifact')
.mockImplementation(() => Promise.resolve({artifact: mockArtifact}))
await run()
expect(artifact.downloadArtifact).toHaveBeenCalledWith(
expect(artifact.default.downloadArtifact).toHaveBeenCalledWith(
mockArtifact.id,
expect.objectContaining({
expectedHash: mockArtifact.digest
@@ -102,12 +136,12 @@ describe('download', () => {
// Set up artifact mock after clearing mocks
jest
.spyOn(artifact, 'listArtifacts')
.spyOn(artifact.default, 'listArtifacts')
.mockImplementation(() => Promise.resolve({artifacts: mockArtifacts}))
// Reset downloadArtifact mock as well
jest
.spyOn(artifact, 'downloadArtifact')
.spyOn(artifact.default, 'downloadArtifact')
.mockImplementation(() => Promise.resolve({digestMismatch: false}))
await run()
@@ -117,7 +151,7 @@ describe('download', () => {
)
expect(core.info).toHaveBeenCalledWith('Total of 2 artifact(s) downloaded')
expect(artifact.downloadArtifact).toHaveBeenCalledTimes(2)
expect(artifact.default.downloadArtifact).toHaveBeenCalledTimes(2)
})
test('sets download path output even when no artifacts are found', async () => {
@@ -144,7 +178,7 @@ describe('download', () => {
]
jest
.spyOn(artifact, 'listArtifacts')
.spyOn(artifact.default, 'listArtifacts')
.mockImplementation(() => Promise.resolve({artifacts: mockArtifacts}))
mockInputs({
@@ -154,8 +188,8 @@ describe('download', () => {
await run()
expect(artifact.downloadArtifact).toHaveBeenCalledTimes(1)
expect(artifact.downloadArtifact).toHaveBeenCalledWith(
expect(artifact.default.downloadArtifact).toHaveBeenCalledTimes(1)
expect(artifact.default.downloadArtifact).toHaveBeenCalledWith(
123,
expect.anything()
)
@@ -172,12 +206,12 @@ describe('download', () => {
})
jest
.spyOn(artifact, 'listArtifacts')
.spyOn(artifact.default, 'listArtifacts')
.mockImplementation(() => Promise.resolve({artifacts: []}))
await run()
expect(artifact.listArtifacts).toHaveBeenCalledWith(
expect(artifact.default.listArtifacts).toHaveBeenCalledWith(
expect.objectContaining({
findBy: {
token,
@@ -200,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',
@@ -209,11 +243,36 @@ describe('download', () => {
}
jest
.spyOn(artifact, 'getArtifact')
.spyOn(artifact.default, 'getArtifact')
.mockImplementation(() => Promise.resolve({artifact: mockArtifact}))
jest
.spyOn(artifact, 'downloadArtifact')
.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}))
jest
.spyOn(artifact.default, 'downloadArtifact')
.mockImplementation(() => Promise.resolve({digestMismatch: true}))
await run()
@@ -223,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,
@@ -237,7 +351,7 @@ describe('download', () => {
[Inputs.ArtifactIds]: '456'
})
jest.spyOn(artifact, 'listArtifacts').mockImplementation(() =>
jest.spyOn(artifact.default, 'listArtifacts').mockImplementation(() =>
Promise.resolve({
artifacts: [mockArtifact]
})
@@ -247,8 +361,8 @@ describe('download', () => {
expect(core.info).toHaveBeenCalledWith('Downloading artifacts by ID')
expect(core.debug).toHaveBeenCalledWith('Parsed artifact IDs: ["456"]')
expect(artifact.downloadArtifact).toHaveBeenCalledTimes(1)
expect(artifact.downloadArtifact).toHaveBeenCalledWith(
expect(artifact.default.downloadArtifact).toHaveBeenCalledTimes(1)
expect(artifact.default.downloadArtifact).toHaveBeenCalledWith(
456,
expect.objectContaining({
expectedHash: mockArtifact.digest
@@ -270,7 +384,7 @@ describe('download', () => {
[Inputs.ArtifactIds]: '123, 456, 789'
})
jest.spyOn(artifact, 'listArtifacts').mockImplementation(() =>
jest.spyOn(artifact.default, 'listArtifacts').mockImplementation(() =>
Promise.resolve({
artifacts: mockArtifacts
})
@@ -282,9 +396,9 @@ describe('download', () => {
expect(core.debug).toHaveBeenCalledWith(
'Parsed artifact IDs: ["123","456","789"]'
)
expect(artifact.downloadArtifact).toHaveBeenCalledTimes(3)
expect(artifact.default.downloadArtifact).toHaveBeenCalledTimes(3)
mockArtifacts.forEach(mockArtifact => {
expect(artifact.downloadArtifact).toHaveBeenCalledWith(
expect(artifact.default.downloadArtifact).toHaveBeenCalledWith(
mockArtifact.id,
expect.objectContaining({
expectedHash: mockArtifact.digest
@@ -305,7 +419,7 @@ describe('download', () => {
[Inputs.ArtifactIds]: '123, 456, 789'
})
jest.spyOn(artifact, 'listArtifacts').mockImplementation(() =>
jest.spyOn(artifact.default, 'listArtifacts').mockImplementation(() =>
Promise.resolve({
artifacts: mockArtifacts
})
@@ -317,7 +431,7 @@ describe('download', () => {
'Could not find the following artifact IDs: 456, 789'
)
expect(core.debug).toHaveBeenCalledWith('Found 1 artifacts by ID')
expect(artifact.downloadArtifact).toHaveBeenCalledTimes(1)
expect(artifact.default.downloadArtifact).toHaveBeenCalledTimes(1)
})
test('throws error when no artifacts with requested IDs are found', async () => {
@@ -327,7 +441,7 @@ describe('download', () => {
[Inputs.ArtifactIds]: '123, 456'
})
jest.spyOn(artifact, 'listArtifacts').mockImplementation(() =>
jest.spyOn(artifact.default, 'listArtifacts').mockImplementation(() =>
Promise.resolve({
artifacts: []
})
@@ -389,7 +503,7 @@ describe('download', () => {
[Inputs.Path]: testPath
})
jest.spyOn(artifact, 'listArtifacts').mockImplementation(() =>
jest.spyOn(artifact.default, 'listArtifacts').mockImplementation(() =>
Promise.resolve({
artifacts: [mockArtifact]
})
@@ -398,7 +512,7 @@ describe('download', () => {
await run()
// Verify it downloads directly to the specified path (not nested in artifact name subdirectory)
expect(artifact.downloadArtifact).toHaveBeenCalledWith(
expect(artifact.default.downloadArtifact).toHaveBeenCalledWith(
456,
expect.objectContaining({
path: path.resolve(testPath), // Should be the resolved path directly, not nested
@@ -406,4 +520,87 @@ describe('download', () => {
})
)
})
test('passes skipDecompress option when skip-decompress input is true', async () => {
const mockArtifact = {
id: 123,
name: 'artifact-name',
size: 1024,
digest: 'abc123'
}
mockInputs({
[Inputs.SkipDecompress]: true
})
jest
.spyOn(artifact.default, 'getArtifact')
.mockImplementation(() => Promise.resolve({artifact: mockArtifact}))
await run()
expect(artifact.default.downloadArtifact).toHaveBeenCalledWith(
mockArtifact.id,
expect.objectContaining({
skipDecompress: true,
expectedHash: mockArtifact.digest
})
)
})
test('does not pass skipDecompress when skip-decompress input is false', async () => {
const mockArtifact = {
id: 123,
name: 'artifact-name',
size: 1024,
digest: 'abc123'
}
mockInputs({
[Inputs.SkipDecompress]: false
})
jest
.spyOn(artifact.default, 'getArtifact')
.mockImplementation(() => Promise.resolve({artifact: mockArtifact}))
await run()
expect(artifact.default.downloadArtifact).toHaveBeenCalledWith(
mockArtifact.id,
expect.objectContaining({
skipDecompress: false,
expectedHash: mockArtifact.digest
})
)
})
test('passes skipDecompress for multiple artifact downloads', async () => {
mockInputs({
[Inputs.Name]: '',
[Inputs.Pattern]: '',
[Inputs.SkipDecompress]: true
})
const mockArtifacts = [
{id: 123, name: 'artifact1', size: 1024, digest: 'abc123'},
{id: 456, name: 'artifact2', size: 2048, digest: 'def456'}
]
jest
.spyOn(artifact.default, 'listArtifacts')
.mockImplementation(() => Promise.resolve({artifacts: mockArtifacts}))
await run()
expect(artifact.default.downloadArtifact).toHaveBeenCalledTimes(2)
expect(artifact.default.downloadArtifact).toHaveBeenCalledWith(
123,
expect.objectContaining({skipDecompress: true})
)
expect(artifact.default.downloadArtifact).toHaveBeenCalledWith(
456,
expect.objectContaining({skipDecompress: true})
)
})
})
+10
View File
@@ -35,6 +35,16 @@ inputs:
If github-token is specified, this is the run that artifacts will be downloaded from.'
required: false
default: ${{ github.run_id }}
skip-decompress:
description: 'If true, the downloaded artifact will not be automatically extracted/decompressed.
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'
+68502 -66191
View File
File diff suppressed because one or more lines are too long
+3
View File
@@ -0,0 +1,3 @@
{
"type": "module"
}
+58
View File
@@ -0,0 +1,58 @@
import github from 'eslint-plugin-github'
import jest from 'eslint-plugin-jest'
import prettier from 'eslint-plugin-prettier/recommended'
const githubConfigs = github.getFlatConfigs()
export default [
{
ignores: ['**/node_modules/**', '**/lib/**', '**/dist/**']
},
githubConfigs.recommended,
...githubConfigs.typescript,
prettier,
{
files: ['**/*.ts'],
languageOptions: {
parserOptions: {
project: './tsconfig.eslint.json'
}
},
rules: {
// Prettier
'prettier/prettier': ['error', {endOfLine: 'auto'}],
// Disable rules that conflict with project style
'eslint-comments/no-use': 'off',
'github/no-then': 'off',
'github/filenames-match-regex': 'off',
'github/array-foreach': 'off',
'import/no-namespace': 'off',
'import/no-commonjs': 'off',
'import/named': 'off',
'import/no-unresolved': 'off',
'i18n-text/no-en': 'off',
'filenames/match-regex': 'off',
'no-shadow': 'off',
'no-unused-vars': 'off',
'no-undef': 'off',
camelcase: 'off',
// TypeScript rules
'@typescript-eslint/no-unused-vars': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-shadow': 'off',
'@typescript-eslint/array-type': 'off',
'@typescript-eslint/no-require-imports': 'off'
}
},
{
files: ['**/__tests__/**/*.ts'],
...jest.configs['flat/recommended'],
rules: {
...jest.configs['flat/recommended'].rules,
'jest/expect-expect': 'off',
'jest/no-conditional-expect': 'off'
}
}
]
+15 -3
View File
@@ -1,12 +1,24 @@
module.exports = {
export default {
clearMocks: true,
moduleFileExtensions: ['js', 'ts'],
roots: ['<rootDir>'],
testEnvironment: 'node',
testMatch: ['**/*.test.ts'],
testRunner: 'jest-circus/runner',
transform: {
'^.+\\.ts$': 'ts-jest'
'^.+\\.ts$': [
'ts-jest',
{
useESM: true,
diagnostics: {
ignoreCodes: [151002]
}
}
]
},
extensionsToTreatAsEsm: ['.ts'],
transformIgnorePatterns: ['node_modules/(?!(@actions)/)'],
moduleNameMapper: {
'^(\\.{1,2}/.*)\\.js$': '$1'
},
verbose: true
}
+1852 -4021
View File
File diff suppressed because it is too large Load Diff
+19 -16
View File
@@ -1,7 +1,8 @@
{
"name": "download-artifact",
"version": "7.0.0",
"version": "8.0.1",
"description": "Download an Actions Artifact from a workflow run",
"type": "module",
"engines": {
"node": ">=24"
},
@@ -13,7 +14,7 @@
"format": "prettier --write **/*.ts",
"format-check": "prettier --check **/*.ts",
"lint": "eslint **/*.ts",
"test": "jest"
"test": "node --experimental-vm-modules node_modules/jest/bin/jest.js"
},
"repository": {
"type": "git",
@@ -32,22 +33,24 @@
},
"homepage": "https://github.com/actions/download-artifact#readme",
"dependencies": {
"@actions/artifact": "^5.0.0",
"@actions/core": "^2.0.0",
"@actions/github": "^6.0.1",
"minimatch": "^9.0.3"
"@actions/artifact": "^6.2.1",
"@actions/core": "^3.0.0",
"minimatch": "^10.1.1"
},
"devDependencies": {
"@types/jest": "^29.5.14",
"@types/node": "^24.1.0",
"@typescript-eslint/eslint-plugin": "^6.14.0",
"@vercel/ncc": "^0.33.4",
"concurrently": "^5.2.0",
"eslint": "^8.55.0",
"eslint-plugin-github": "^4.10.1",
"eslint-plugin-prettier": "^5.0.1",
"jest": "^29.7.0",
"prettier": "^3.1.1",
"@actions/github": "^9.0.0",
"@types/jest": "^30.0.0",
"@types/node": "^25.1.0",
"@typescript-eslint/eslint-plugin": "^8.54.0",
"@typescript-eslint/parser": "^8.54.0",
"@vercel/ncc": "^0.38.4",
"concurrently": "^9.2.1",
"eslint": "^9.39.2",
"eslint-plugin-github": "^6.0.0",
"eslint-plugin-jest": "^29.12.1",
"eslint-plugin-prettier": "^5.5.5",
"jest": "^30.2.0",
"prettier": "^3.8.1",
"ts-jest": "^29.2.6",
"ts-node": "^10.9.2",
"typescript": "^5.3.3"
+10 -1
View File
@@ -6,7 +6,16 @@ export enum Inputs {
RunID = 'run-id',
Pattern = 'pattern',
MergeMultiple = 'merge-multiple',
ArtifactIds = 'artifact-ids'
ArtifactIds = 'artifact-ids',
SkipDecompress = 'skip-decompress',
DigestMismatch = 'digest-mismatch'
}
export enum DigestMismatchBehavior {
Ignore = 'ignore',
Info = 'info',
Warn = 'warn',
Error = 'error'
}
export enum Outputs {
+48 -6
View File
@@ -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'
import {Inputs, Outputs, DigestMismatchBehavior} from './constants.js'
const PARALLEL_DOWNLOADS = 5
@@ -26,7 +26,20 @@ export async function run(): Promise<void> {
mergeMultiple: core.getBooleanInput(Inputs.MergeMultiple, {
required: false
}),
artifactIds: core.getInput(Inputs.ArtifactIds, {required: false})
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) {
@@ -179,11 +192,14 @@ export async function run(): Promise<void> {
artifacts.length === 1
? resolvedPath
: path.join(resolvedPath, artifact.name),
expectedHash: artifact.digest
expectedHash: artifact.digest,
skipDecompress: inputs.skipDecompress
})
}))
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)
@@ -193,12 +209,38 @@ export async function run(): Promise<void> {
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')
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": "."
},
"include": ["src/**/*.ts", "__tests__/**/*.ts", "*.ts"],
"exclude": ["node_modules", "lib", "dist"]
}
+3 -3
View File
@@ -1,12 +1,12 @@
{
"compilerOptions": {
"target": "es6",
"module": "commonjs",
"target": "ES2022",
"module": "NodeNext",
"outDir": "./lib",
"rootDir": "./src",
"strict": true,
"noImplicitAny": false,
"moduleResolution": "node",
"moduleResolution": "NodeNext",
"esModuleInterop": true
},
"exclude": ["node_modules", "**/*.test.ts", "jest.config.ts", "__tests__"]