Compare commits

..
Author SHA1 Message Date
Daniel Kennedy b95dd85265 Releases: use ubuntu-latest instead of macos-latest-large 2026-01-30 15:16:40 -05:00
975fcbd402 Artifact download: don't unzip non-zip artifacts (#2253)
* Download artifact: don't extract the downloaded file if the content-type isn't a zip

* Remove unused `import`

* Add support for specifying whether to skip decompressing

* Prevent path traversal attacks

* Fix indenting

* Update packages/artifact/__tests__/download-artifact.test.ts

Co-authored-by: Copilot <[email protected]>

* Parse the mime type out of the content-type header

Co-authored-by: Copilot <[email protected]>

* Fix some linting issues

* Swap `zip` for `application/zip-compressed`

* Test: negative check for malicious paths

* Increase the timeout on one of the tests

* Check the URL path for `.zip` to see if we can auto-decompress

* Fix linting issue

* Bump the package version and add release notes

* Remove `launch.json`

---------

Co-authored-by: Copilot <[email protected]>
2026-01-30 12:18:38 -05:00
23 changed files with 1140 additions and 1268 deletions
+2 -2
View File
@@ -24,7 +24,7 @@ on:
jobs:
test:
runs-on: macos-latest-large
runs-on: ubuntu-latest
steps:
- name: setup repo
@@ -61,7 +61,7 @@ jobs:
path: packages/${{ github.event.inputs.package }}/*.tgz
publish:
runs-on: macos-latest-large
runs-on: ubuntu-slim
needs: test
environment: npm-publish
permissions:
+653 -1178
View File
File diff suppressed because it is too large Load Diff
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "root",
"private": true,
"private": true,
"scripts": {
"audit-all": "lerna run audit-moderate",
"bootstrap": "lerna exec -- npm install",
@@ -16,7 +16,7 @@
"test": "jest --testTimeout 70000"
},
"devDependencies": {
"@types/jest": "^30.0.0",
"@types/jest": "^29.5.4",
"@types/node": "^24.1.0",
"@types/signale": "^1.4.1",
"concurrently": "^6.1.0",
@@ -26,11 +26,11 @@
"eslint-plugin-jest": "^27.2.3",
"eslint-plugin-prettier": "^5.0.0",
"flow-bin": "^0.115.0",
"jest": "^30.2.0",
"jest": "^29.6.4",
"lerna": "^6.4.1",
"nx": "16.6.0",
"prettier": "^3.0.0",
"ts-jest": "^29.4.6",
"ts-jest": "^29.1.1",
"typescript": "^5.2.2"
},
"overrides": {
@@ -47,4 +47,4 @@
"uri-js": "npm:uri-js-replace@^1.0.1",
"node-fetch": "^3.3.2"
}
}
}
+9
View File
@@ -1,5 +1,14 @@
# @actions/artifact Releases
## 6.1.0
- Support downloading non-zip artifacts. Zipped artifacts will be decompressed automatically (with an optional override). Un-zipped artifacts will be downloaded as-is.
## 6.0.0
- **Breaking change**: Package is now ESM-only
- CommonJS consumers must use dynamic `import()` instead of `require()`
## 5.0.3
- Bump `@actions/http-client` to `3.0.2`
@@ -192,7 +192,7 @@ describe('artifact-http-client', () => {
name: 'artifact',
version: 4
})
}).rejects.toThrow(
}).rejects.toThrowError(
'Failed to make request after 5 attempts: Failed request: (500) Internal Server Error'
)
expect(mockHttpClient).toHaveBeenCalledTimes(1)
@@ -227,7 +227,7 @@ describe('artifact-http-client', () => {
name: 'artifact',
version: 4
})
}).rejects.toThrow(
}).rejects.toThrowError(
'Received non-retryable error: Failed request: (401) Unauthorized'
)
expect(mockHttpClient).toHaveBeenCalledTimes(1)
@@ -271,7 +271,7 @@ describe('artifact-http-client', () => {
name: 'artifact',
version: 4
})
}).rejects.toThrow(
}).rejects.toThrowError(
'Failed to CreateArtifact: Received non-retryable error: Failed request: (409) Conflict: an artifact with this name already exists on the workflow run'
)
expect(mockHttpClient).toHaveBeenCalledTimes(1)
@@ -306,7 +306,7 @@ describe('artifact-http-client', () => {
name: 'artifact',
version: 4
})
}).rejects.toThrow(new NetworkError('ENOTFOUND').message)
}).rejects.toThrowError(new NetworkError('ENOTFOUND').message)
expect(mockHttpClient).toHaveBeenCalledTimes(1)
expect(mockPost).toHaveBeenCalledTimes(1)
})
@@ -341,7 +341,7 @@ describe('artifact-http-client', () => {
name: 'artifact',
version: 4
})
}).rejects.toThrow(new UsageError().message)
}).rejects.toThrowError(new UsageError().message)
expect(mockHttpClient).toHaveBeenCalledTimes(1)
expect(mockPost).toHaveBeenCalledTimes(1)
})
@@ -104,6 +104,7 @@ const cleanup = async (): Promise<void> => {
const mockGetArtifactSuccess = jest.fn(() => {
const message = new http.IncomingMessage(new net.Socket())
message.statusCode = 200
message.headers['content-type'] = 'application/zip'
message.push(fs.readFileSync(fixtures.exampleArtifact.path))
message.push(null)
return {
@@ -114,6 +115,7 @@ const mockGetArtifactSuccess = jest.fn(() => {
const mockGetArtifactHung = jest.fn(() => {
const message = new http.IncomingMessage(new net.Socket())
message.statusCode = 200
message.headers['content-type'] = 'application/zip'
// Don't push any data or call push(null) to end the stream
// This creates a stream that hangs and never completes
return {
@@ -134,6 +136,7 @@ const mockGetArtifactFailure = jest.fn(() => {
const mockGetArtifactMalicious = jest.fn(() => {
const message = new http.IncomingMessage(new net.Socket())
message.statusCode = 200
message.headers['content-type'] = 'application/zip'
message.push(fs.readFileSync(path.join(__dirname, 'fixtures', 'evil.zip'))) // evil.zip contains files that are formatted x/../../etc/hosts
message.push(null)
return {
@@ -619,10 +622,17 @@ describe('download-artifact', () => {
...fixtures.backendIds,
name: fixtures.artifactName
})
})
}, 38000)
})
describe('streamExtractExternal', () => {
beforeEach(async () => {
await setup()
// Create workspace directory for streamExtractExternal tests
await fs.promises.mkdir(fixtures.workspaceDir, {recursive: true})
})
afterEach(cleanup)
it('should fail if the timeout is exceeded', async () => {
const mockSlowGetArtifact = jest.fn(mockGetArtifactHung)
@@ -641,12 +651,331 @@ describe('download-artifact', () => {
{timeout: 2}
)
expect(true).toBe(false) // should not be called
} catch (e) {
} catch (error: unknown) {
const e = error as Error
expect(e).toBeInstanceOf(Error)
expect(e.message).toContain('did not respond in 2ms')
expect(mockHttpClient).toHaveBeenCalledWith(getUserAgentString())
expect(mockSlowGetArtifact).toHaveBeenCalledTimes(1)
}
})
it('should extract zip file when content-type is application/zip', async () => {
const mockHttpClient = (HttpClient as jest.Mock).mockImplementation(
() => {
return {
get: mockGetArtifactSuccess
}
}
)
await streamExtractExternal(
fixtures.blobStorageUrl,
fixtures.workspaceDir
)
expect(mockHttpClient).toHaveBeenCalledWith(getUserAgentString())
// Verify files were extracted (not saved as a single file)
await expectExtractedArchive(fixtures.workspaceDir)
})
it('should save raw file without extracting when content-type is not a zip', async () => {
const rawFileContent = 'This is a raw text file, not a zip'
const rawFileName = 'my-artifact.txt'
const mockGetRawFile = jest.fn(() => {
const message = new http.IncomingMessage(new net.Socket())
message.statusCode = 200
message.headers['content-type'] = 'text/plain'
message.headers['content-disposition'] =
`attachment; filename="${rawFileName}"`
message.push(Buffer.from(rawFileContent))
message.push(null)
return {
message
}
})
const mockHttpClient = (HttpClient as jest.Mock).mockImplementation(
() => {
return {
get: mockGetRawFile
}
}
)
await streamExtractExternal(
fixtures.blobStorageUrl,
fixtures.workspaceDir
)
expect(mockHttpClient).toHaveBeenCalledWith(getUserAgentString())
// Verify file was saved as-is, not extracted
const savedFilePath = path.join(fixtures.workspaceDir, rawFileName)
expect(fs.existsSync(savedFilePath)).toBe(true)
expect(fs.readFileSync(savedFilePath, 'utf8')).toBe(rawFileContent)
})
it('should save raw file with default name when content-disposition is missing', async () => {
const rawFileContent = 'Binary content here'
const mockGetRawFileNoDisposition = jest.fn(() => {
const message = new http.IncomingMessage(new net.Socket())
message.statusCode = 200
message.headers['content-type'] = 'application/octet-stream'
// No content-disposition header
message.push(Buffer.from(rawFileContent))
message.push(null)
return {
message
}
})
const mockHttpClient = (HttpClient as jest.Mock).mockImplementation(
() => {
return {
get: mockGetRawFileNoDisposition
}
}
)
await streamExtractExternal(
fixtures.blobStorageUrl,
fixtures.workspaceDir
)
expect(mockHttpClient).toHaveBeenCalledWith(getUserAgentString())
// Verify file was saved with default name 'artifact'
const savedFilePath = path.join(fixtures.workspaceDir, 'artifact')
expect(fs.existsSync(savedFilePath)).toBe(true)
expect(fs.readFileSync(savedFilePath, 'utf8')).toBe(rawFileContent)
})
it('should not attempt to unzip when content-type is image/png', async () => {
const pngFileName = 'screenshot.png'
// Simple PNG header bytes for testing
const pngContent = Buffer.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a
])
const mockGetPngFile = jest.fn(() => {
const message = new http.IncomingMessage(new net.Socket())
message.statusCode = 200
message.headers['content-type'] = 'image/png'
message.headers['content-disposition'] =
`attachment; filename="${pngFileName}"`
message.push(pngContent)
message.push(null)
return {
message
}
})
const mockHttpClient = (HttpClient as jest.Mock).mockImplementation(
() => {
return {
get: mockGetPngFile
}
}
)
await streamExtractExternal(
fixtures.blobStorageUrl,
fixtures.workspaceDir
)
expect(mockHttpClient).toHaveBeenCalledWith(getUserAgentString())
// Verify PNG was saved as-is
const savedFilePath = path.join(fixtures.workspaceDir, pngFileName)
expect(fs.existsSync(savedFilePath)).toBe(true)
expect(fs.readFileSync(savedFilePath)).toEqual(pngContent)
})
it('should extract when content-type is application/x-zip-compressed', async () => {
const mockGetZipCompressed = jest.fn(() => {
const message = new http.IncomingMessage(new net.Socket())
message.statusCode = 200
message.headers['content-type'] = 'application/x-zip-compressed'
message.push(fs.readFileSync(fixtures.exampleArtifact.path))
message.push(null)
return {
message
}
})
const mockHttpClient = (HttpClient as jest.Mock).mockImplementation(
() => {
return {
get: mockGetZipCompressed
}
}
)
await streamExtractExternal(
fixtures.blobStorageUrl,
fixtures.workspaceDir
)
expect(mockHttpClient).toHaveBeenCalledWith(getUserAgentString())
// Verify files were extracted
await expectExtractedArchive(fixtures.workspaceDir)
})
it('should extract zip when URL ends with .zip even if content-type is not application/zip', async () => {
const blobUrlWithZipExtension =
'https://blob-storage.local/artifact.zip?sig=abc123'
const mockGetZipByUrl = jest.fn(() => {
const message = new http.IncomingMessage(new net.Socket())
message.statusCode = 200
// Azure Blob Storage may return a generic content-type
message.headers['content-type'] = 'application/octet-stream'
message.push(fs.readFileSync(fixtures.exampleArtifact.path))
message.push(null)
return {
message
}
})
const mockHttpClient = (HttpClient as jest.Mock).mockImplementation(
() => {
return {
get: mockGetZipByUrl
}
}
)
await streamExtractExternal(
blobUrlWithZipExtension,
fixtures.workspaceDir
)
expect(mockHttpClient).toHaveBeenCalledWith(getUserAgentString())
// Verify files were extracted based on URL .zip extension
await expectExtractedArchive(fixtures.workspaceDir)
})
it('should skip decompression when skipDecompress option is true even for zip content-type', async () => {
const mockHttpClient = (HttpClient as jest.Mock).mockImplementation(
() => {
return {
get: mockGetArtifactSuccess
}
}
)
await streamExtractExternal(
fixtures.blobStorageUrl,
fixtures.workspaceDir,
{skipDecompress: true}
)
expect(mockHttpClient).toHaveBeenCalledWith(getUserAgentString())
// Verify zip was saved as-is, not extracted
// When skipDecompress is true, the file should be saved with default name 'artifact'
const savedFilePath = path.join(fixtures.workspaceDir, 'artifact')
expect(fs.existsSync(savedFilePath)).toBe(true)
// The saved file should be the raw zip content
const savedContent = fs.readFileSync(savedFilePath)
const originalZipContent = fs.readFileSync(fixtures.exampleArtifact.path)
expect(savedContent).toEqual(originalZipContent)
})
it('should sanitize path traversal attempts in Content-Disposition filename', async () => {
const rawFileContent = 'malicious content'
const maliciousFileName = '../../../etc/passwd'
const mockGetMaliciousFile = jest.fn(() => {
const message = new http.IncomingMessage(new net.Socket())
message.statusCode = 200
message.headers['content-type'] = 'text/plain'
message.headers['content-disposition'] =
`attachment; filename="${maliciousFileName}"`
message.push(Buffer.from(rawFileContent))
message.push(null)
return {
message
}
})
const mockHttpClient = (HttpClient as jest.Mock).mockImplementation(
() => {
return {
get: mockGetMaliciousFile
}
}
)
await streamExtractExternal(
fixtures.blobStorageUrl,
fixtures.workspaceDir
)
expect(mockHttpClient).toHaveBeenCalledWith(getUserAgentString())
// Verify file was saved with sanitized name (just 'passwd', not the full path)
const sanitizedFileName = 'passwd'
const savedFilePath = path.join(fixtures.workspaceDir, sanitizedFileName)
expect(fs.existsSync(savedFilePath)).toBe(true)
expect(fs.readFileSync(savedFilePath, 'utf8')).toBe(rawFileContent)
// Verify the file was NOT written outside the workspace directory
const maliciousPath = path.resolve(
fixtures.workspaceDir,
maliciousFileName
)
expect(fs.existsSync(maliciousPath)).toBe(false)
})
it('should handle encoded path traversal attempts in Content-Disposition filename', async () => {
const rawFileContent = 'encoded malicious content'
// URL encoded version of ../../../etc/passwd
const encodedMaliciousFileName = '..%2F..%2F..%2Fetc%2Fpasswd'
const mockGetEncodedMaliciousFile = jest.fn(() => {
const message = new http.IncomingMessage(new net.Socket())
message.statusCode = 200
message.headers['content-type'] = 'application/octet-stream'
message.headers['content-disposition'] =
`attachment; filename="${encodedMaliciousFileName}"`
message.push(Buffer.from(rawFileContent))
message.push(null)
return {
message
}
})
const mockHttpClient = (HttpClient as jest.Mock).mockImplementation(
() => {
return {
get: mockGetEncodedMaliciousFile
}
}
)
await streamExtractExternal(
fixtures.blobStorageUrl,
fixtures.workspaceDir
)
expect(mockHttpClient).toHaveBeenCalledWith(getUserAgentString())
// After decoding and sanitizing, should just be 'passwd'
const sanitizedFileName = 'passwd'
const savedFilePath = path.join(fixtures.workspaceDir, sanitizedFileName)
expect(fs.existsSync(savedFilePath)).toBe(true)
expect(fs.readFileSync(savedFilePath, 'utf8')).toBe(rawFileContent)
// Verify the file was NOT written outside the workspace directory
const maliciousPathEncoded = path.resolve(
fixtures.workspaceDir,
encodedMaliciousFileName
)
expect(fs.existsSync(maliciousPathEncoded)).toBe(false)
const maliciousPath = path.resolve(
fixtures.workspaceDir,
'../../../etc/passwd'
)
expect(fs.existsSync(maliciousPath)).toBe(false)
})
})
})
@@ -134,7 +134,7 @@ describe('get-artifact', () => {
fixtures.token
)
expect(response).rejects.toThrow(ArtifactNotFoundError)
expect(response).rejects.toThrowError(ArtifactNotFoundError)
})
it('should fail if non-200 response', async () => {
@@ -155,7 +155,7 @@ describe('get-artifact', () => {
fixtures.token
)
expect(response).rejects.toThrow(InvalidResponseError)
expect(response).rejects.toThrowError(InvalidResponseError)
})
})
@@ -223,7 +223,7 @@ describe('get-artifact', () => {
const response = getArtifactInternal(fixtures.artifacts[0].name)
expect(response).rejects.toThrow(ArtifactNotFoundError)
expect(response).rejects.toThrowError(ArtifactNotFoundError)
})
it('should fail if non-200 response', async () => {
@@ -132,7 +132,7 @@ describe('upload-artifact', () => {
fixtures.inputs.files,
fixtures.inputs.rootDirectory
)
await expect(uploadResp).rejects.toThrow(FilesNotFoundError)
await expect(uploadResp).rejects.toThrowError(FilesNotFoundError)
})
it('should reject if no backend IDs are found', async () => {
+4 -4
View File
@@ -26,7 +26,7 @@ describe('get-backend-ids-from-token', () => {
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwic2NwIjoiQWN0aW9ucy5FeGFtcGxlIEFjdGlvbnMuQW5vdGhlckV4YW1wbGU6dGVzdCIsImlhdCI6MTUxNjIzOTAyMn0.K0IEoULZteGevF38G94xiaA8zcZ5UlKWfGfqE6q3dhw'
)
expect(util.getBackendIdsFromToken).toThrow(
expect(util.getBackendIdsFromToken).toThrowError(
'Failed to get backend IDs: The provided JWT token is invalid'
)
})
@@ -38,7 +38,7 @@ describe('get-backend-ids-from-token', () => {
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwic2NwIjoiQWN0aW9ucy5FeGFtcGxlIEFjdGlvbnMuQW5vdGhlckV4YW1wbGU6dGVzdCBBY3Rpb25zLlJlc3VsdHM6Y2U3ZjU0YzctNjFjNy00YWFlLTg4N2YtMzBkYTQ3NWY1ZjFhIiwiaWF0IjoxNTE2MjM5MDIyfQ.7D0_LRfRFRZFImHQ7GxH2S6ZyFjjZ5U0ujjGCfle1XE'
)
expect(util.getBackendIdsFromToken).toThrow(
expect(util.getBackendIdsFromToken).toThrowError(
'Failed to get backend IDs: The provided JWT token is invalid'
)
})
@@ -46,7 +46,7 @@ describe('get-backend-ids-from-token', () => {
it('should throw an error when the token is in an invalid format', () => {
jest.spyOn(config, 'getRuntimeToken').mockReturnValue('token')
expect(util.getBackendIdsFromToken).toThrow('Invalid token specified')
expect(util.getBackendIdsFromToken).toThrowError('Invalid token specified')
})
it("should throw an error when the token doesn't have the right field", () => {
@@ -56,7 +56,7 @@ describe('get-backend-ids-from-token', () => {
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c'
)
expect(util.getBackendIdsFromToken).toThrow(
expect(util.getBackendIdsFromToken).toThrowError(
'Failed to get backend IDs: The provided JWT token is invalid'
)
})
+2 -3
View File
@@ -1,12 +1,12 @@
{
"name": "@actions/artifact",
"version": "6.0.0",
"version": "6.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@actions/artifact",
"version": "6.0.0",
"version": "6.1.0",
"license": "MIT",
"dependencies": {
"@actions/core": "^3.0.0",
@@ -1795,7 +1795,6 @@
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@actions/artifact",
"version": "6.0.0",
"version": "6.1.0",
"preview": true,
"description": "Actions artifact lib",
"keywords": [
@@ -1,6 +1,8 @@
import fs from 'fs/promises'
import * as fsSync from 'fs'
import * as crypto from 'crypto'
import * as stream from 'stream'
import * as path from 'path'
import * as github from '@actions/github'
import * as core from '@actions/core'
@@ -43,12 +45,13 @@ async function exists(path: string): Promise<boolean> {
async function streamExtract(
url: string,
directory: string
directory: string,
skipDecompress?: boolean
): Promise<StreamExtractResponse> {
let retryCount = 0
while (retryCount < 5) {
try {
return await streamExtractExternal(url, directory)
return await streamExtractExternal(url, directory, {skipDecompress})
} catch (error) {
retryCount++
core.debug(
@@ -65,8 +68,9 @@ async function streamExtract(
export async function streamExtractExternal(
url: string,
directory: string,
opts: {timeout: number} = {timeout: 30 * 1000}
opts: {timeout?: number; skipDecompress?: boolean} = {}
): Promise<StreamExtractResponse> {
const {timeout = 30 * 1000, skipDecompress = false} = opts
const client = new httpClient.HttpClient(getUserAgentString())
const response = await client.get(url)
if (response.message.statusCode !== 200) {
@@ -75,49 +79,91 @@ export async function streamExtractExternal(
)
}
const contentType = response.message.headers['content-type'] || ''
const mimeType = contentType.split(';', 1)[0].trim().toLowerCase()
// Check if the URL path ends with .zip (ignoring query parameters)
const urlPath = new URL(url).pathname.toLowerCase()
const urlEndsWithZip = urlPath.endsWith('.zip')
const isZip =
mimeType === 'application/zip' ||
mimeType === 'application/x-zip-compressed' ||
mimeType === 'application/zip-compressed' ||
urlEndsWithZip
// Extract filename from Content-Disposition header
const contentDisposition =
response.message.headers['content-disposition'] || ''
let fileName = 'artifact'
const filenameMatch = contentDisposition.match(
/filename\*?=['"]?(?:UTF-\d['"]*)?([^;\r\n"']*)['"]?/i
)
if (filenameMatch && filenameMatch[1]) {
// Sanitize fileName to prevent path traversal attacks
// Use path.basename to extract only the filename component
fileName = path.basename(decodeURIComponent(filenameMatch[1].trim()))
}
core.debug(
`Content-Type: ${contentType}, mimeType: ${mimeType}, urlEndsWithZip: ${urlEndsWithZip}, isZip: ${isZip}, skipDecompress: ${skipDecompress}`
)
core.debug(
`Content-Disposition: ${contentDisposition}, fileName: ${fileName}`
)
let sha256Digest: string | undefined = undefined
return new Promise((resolve, reject) => {
const timerFn = (): void => {
const timeoutError = new Error(
`Blob storage chunk did not respond in ${opts.timeout}ms`
`Blob storage chunk did not respond in ${timeout}ms`
)
response.message.destroy(timeoutError)
reject(timeoutError)
}
const timer = setTimeout(timerFn, opts.timeout)
const timer = setTimeout(timerFn, timeout)
const onError = (error: Error): void => {
core.debug(`response.message: Artifact download failed: ${error.message}`)
clearTimeout(timer)
reject(error)
}
const hashStream = crypto.createHash('sha256').setEncoding('hex')
const passThrough = new stream.PassThrough()
response.message.pipe(passThrough)
passThrough.pipe(hashStream)
const extractStream = passThrough
extractStream
.on('data', () => {
timer.refresh()
})
.on('error', (error: Error) => {
core.debug(
`response.message: Artifact download failed: ${error.message}`
)
clearTimeout(timer)
reject(error)
})
.pipe(unzip.Extract({path: directory}))
.on('close', () => {
clearTimeout(timer)
if (hashStream) {
hashStream.end()
sha256Digest = hashStream.read() as string
core.info(`SHA256 digest of downloaded artifact is ${sha256Digest}`)
}
resolve({sha256Digest: `sha256:${sha256Digest}`})
})
.on('error', (error: Error) => {
reject(error)
})
.on('error', onError)
response.message.pipe(passThrough)
passThrough.pipe(hashStream)
const onClose = (): void => {
clearTimeout(timer)
if (hashStream) {
hashStream.end()
sha256Digest = hashStream.read() as string
core.info(`SHA256 digest of downloaded artifact is ${sha256Digest}`)
}
resolve({sha256Digest: `sha256:${sha256Digest}`})
}
if (isZip && !skipDecompress) {
// Extract zip file
passThrough
.pipe(unzip.Extract({path: directory}))
.on('close', onClose)
.on('error', onError)
} else {
// Save raw file without extracting
const filePath = path.join(directory, fileName)
const writeStream = fsSync.createWriteStream(filePath)
core.info(`Downloading raw file (non-zip) to: ${filePath}`)
passThrough.pipe(writeStream).on('close', onClose).on('error', onError)
}
})
}
@@ -163,7 +209,11 @@ export async function downloadArtifactPublic(
try {
core.info(`Starting download of artifact to: ${downloadPath}`)
const extractResponse = await streamExtract(location, downloadPath)
const extractResponse = await streamExtract(
location,
downloadPath,
options?.skipDecompress
)
core.info(`Artifact download completed successfully.`)
if (options?.expectedHash) {
if (options?.expectedHash !== extractResponse.sha256Digest) {
@@ -224,7 +274,11 @@ export async function downloadArtifactInternal(
try {
core.info(`Starting download of artifact to: ${downloadPath}`)
const extractResponse = await streamExtract(signedUrl, downloadPath)
const extractResponse = await streamExtract(
signedUrl,
downloadPath,
options?.skipDecompress
)
core.info(`Artifact download completed successfully.`)
if (options?.expectedHash) {
if (options?.expectedHash !== extractResponse.sha256Digest) {
@@ -113,6 +113,12 @@ export interface DownloadArtifactOptions {
* matches the expected hash.
*/
expectedHash?: string
/**
* If true, the downloaded artifact will not be automatically extracted/decompressed.
* The artifact will be saved as-is to the destination path.
*/
skipDecompress?: boolean
}
export interface StreamExtractResponse {
@@ -1,4 +1,4 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`buildIntotoStatement returns an intoto statement 1`] = `
{
@@ -1,4 +1,4 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`provenance functions buildSLSAProvenancePredicate returns a provenance hydrated from an OIDC token 1`] = `
{
+1 -1
View File
@@ -8,7 +8,7 @@ describe('attest', () => {
predicate: {bar: 'baz'},
token: 'token'
}
expect(attest(options)).rejects.toThrow(
expect(attest(options)).rejects.toThrowError(
'Must provide either subjectName and subjectDigest or subjects'
)
})
+1 -1
View File
@@ -30,7 +30,7 @@ test('unlinkFile unlinks file', async () => {
})
test('assertDefined throws if undefined', () => {
expect(() => cacheUtils.assertDefined('test', undefined)).toThrow()
expect(() => cacheUtils.assertDefined('test', undefined)).toThrowError()
})
test('assertDefined returns value', () => {
+4 -4
View File
@@ -29,7 +29,7 @@ beforeAll(() => {
test('restore with no path should fail', async () => {
const paths: string[] = []
const key = 'node-test'
await expect(restoreCache(paths, key)).rejects.toThrow(
await expect(restoreCache(paths, key)).rejects.toThrowError(
`Path Validation Error: At least one directory or file path is required`
)
})
@@ -38,7 +38,7 @@ test('restore with too many keys should fail', async () => {
const paths = ['node_modules']
const key = 'node-test'
const restoreKeys = [...Array(20).keys()].map(x => x.toString())
await expect(restoreCache(paths, key, restoreKeys)).rejects.toThrow(
await expect(restoreCache(paths, key, restoreKeys)).rejects.toThrowError(
`Key Validation Error: Keys are limited to a maximum of 10.`
)
})
@@ -46,7 +46,7 @@ test('restore with too many keys should fail', async () => {
test('restore with large key should fail', async () => {
const paths = ['node_modules']
const key = 'foo'.repeat(512) // Over the 512 character limit
await expect(restoreCache(paths, key)).rejects.toThrow(
await expect(restoreCache(paths, key)).rejects.toThrowError(
`Key Validation Error: ${key} cannot be larger than 512 characters.`
)
})
@@ -54,7 +54,7 @@ test('restore with large key should fail', async () => {
test('restore with invalid key should fail', async () => {
const paths = ['node_modules']
const key = 'comma,comma'
await expect(restoreCache(paths, key)).rejects.toThrow(
await expect(restoreCache(paths, key)).rejects.toThrowError(
`Key Validation Error: ${key} cannot contain commas.`
)
})
+4 -4
View File
@@ -44,7 +44,7 @@ afterEach(() => {
test('restore with no path should fail', async () => {
const paths: string[] = []
const key = 'node-test'
await expect(restoreCache(paths, key)).rejects.toThrow(
await expect(restoreCache(paths, key)).rejects.toThrowError(
`Path Validation Error: At least one directory or file path is required`
)
})
@@ -53,7 +53,7 @@ test('restore with too many keys should fail', async () => {
const paths = ['node_modules']
const key = 'node-test'
const restoreKeys = [...Array(20).keys()].map(x => x.toString())
await expect(restoreCache(paths, key, restoreKeys)).rejects.toThrow(
await expect(restoreCache(paths, key, restoreKeys)).rejects.toThrowError(
`Key Validation Error: Keys are limited to a maximum of 10.`
)
})
@@ -61,7 +61,7 @@ test('restore with too many keys should fail', async () => {
test('restore with large key should fail', async () => {
const paths = ['node_modules']
const key = 'foo'.repeat(512) // Over the 512 character limit
await expect(restoreCache(paths, key)).rejects.toThrow(
await expect(restoreCache(paths, key)).rejects.toThrowError(
`Key Validation Error: ${key} cannot be larger than 512 characters.`
)
})
@@ -69,7 +69,7 @@ test('restore with large key should fail', async () => {
test('restore with invalid key should fail', async () => {
const paths = ['node_modules']
const key = 'comma,comma'
await expect(restoreCache(paths, key)).rejects.toThrow(
await expect(restoreCache(paths, key)).rejects.toThrowError(
`Key Validation Error: ${key} cannot contain commas.`
)
})
+2 -2
View File
@@ -40,7 +40,7 @@ beforeAll(() => {
test('save with missing input should fail', async () => {
const paths: string[] = []
const primaryKey = 'Linux-node-bb828da54c148048dd17899ba9fda624811cfb43'
await expect(saveCache(paths, primaryKey)).rejects.toThrow(
await expect(saveCache(paths, primaryKey)).rejects.toThrowError(
`Path Validation Error: At least one directory or file path is required`
)
})
@@ -342,7 +342,7 @@ test('save with non existing path should not save cache', async () => {
jest.spyOn(cacheUtils, 'resolvePaths').mockImplementation(async () => {
return []
})
await expect(saveCache([path], primaryKey)).rejects.toThrow(
await expect(saveCache([path], primaryKey)).rejects.toThrowError(
`Path Validation Error: Path(s) specified in the action for caching do(es) not exist, hence no cache is being saved.`
)
})
+2 -2
View File
@@ -54,7 +54,7 @@ test('save with missing input should fail', async () => {
const paths: string[] = []
const key = 'Linux-node-bb828da54c148048dd17899ba9fda624811cfb43'
await expect(saveCache(paths, key)).rejects.toThrow(
await expect(saveCache(paths, key)).rejects.toThrowError(
`Path Validation Error: At least one directory or file path is required`
)
})
@@ -581,7 +581,7 @@ test('save with non existing path should not save cache using v2 saveCache', asy
jest.spyOn(cacheUtils, 'resolvePaths').mockImplementation(async () => {
return []
})
await expect(saveCache([path], key)).rejects.toThrow(
await expect(saveCache([path], key)).rejects.toThrowError(
`Path Validation Error: Path(s) specified in the action for caching do(es) not exist, hence no cache is being saved.`
)
})
+14 -14
View File
@@ -55,12 +55,12 @@ describe('@actions/exec', () => {
expect(exitCode).toBe(0)
if (IS_WINDOWS) {
expect(outstream.write).toHaveBeenCalledWith(
expect(outstream.write).toBeCalledWith(
`[command]${toolpath} /c echo hello${os.EOL}`
)
expect(outstream.write).toHaveBeenCalledWith(Buffer.from(`hello${os.EOL}`))
expect(outstream.write).toBeCalledWith(Buffer.from(`hello${os.EOL}`))
} else {
expect(outstream.write).toHaveBeenCalledWith(
expect(outstream.write).toBeCalledWith(
`[command]${toolpath} -l -a${os.EOL}`
)
}
@@ -85,12 +85,12 @@ describe('@actions/exec', () => {
expect(exitCode).toBe(0)
if (IS_WINDOWS) {
expect(outstream.write).toHaveBeenCalledWith(
expect(outstream.write).toBeCalledWith(
`[command]${toolpath} /c echo hello${os.EOL}`
)
expect(outstream.write).toHaveBeenCalledWith(Buffer.from(`hello${os.EOL}`))
expect(outstream.write).toBeCalledWith(Buffer.from(`hello${os.EOL}`))
} else {
expect(outstream.write).toHaveBeenCalledWith(
expect(outstream.write).toBeCalledWith(
`[command]${toolpath} -l -a${os.EOL}`
)
}
@@ -115,12 +115,12 @@ describe('@actions/exec', () => {
expect(exitCode).toBe(0)
if (IS_WINDOWS) {
expect(outstream.write).toHaveBeenCalledWith(
expect(outstream.write).toBeCalledWith(
`[command]${toolpath} /c echo hello${os.EOL}`
)
expect(outstream.write).toHaveBeenCalledWith(Buffer.from(`hello${os.EOL}`))
expect(outstream.write).toBeCalledWith(Buffer.from(`hello${os.EOL}`))
} else {
expect(outstream.write).toHaveBeenCalledWith(
expect(outstream.write).toBeCalledWith(
`[command]${toolpath} -l -a${os.EOL}`
)
}
@@ -182,11 +182,11 @@ describe('@actions/exec', () => {
expect(failed).toBe(true)
if (IS_WINDOWS) {
expect(outstream.write).toHaveBeenCalledWith(
expect(outstream.write).toBeCalledWith(
`[command]${toolpath} /c non-existent${os.EOL}`
)
} else {
expect(outstream.write).toHaveBeenCalledWith(
expect(outstream.write).toBeCalledWith(
`[command]${toolpath} -l non-existent${os.EOL}`
)
}
@@ -209,7 +209,7 @@ describe('@actions/exec', () => {
)
expect(exitCode).toBe(0)
expect(outstream.write).toHaveBeenCalledWith(
expect(outstream.write).toBeCalledWith(
Buffer.from('this is output to stderr')
)
})
@@ -233,7 +233,7 @@ describe('@actions/exec', () => {
})
expect(failed).toBe(true)
expect(errstream.write).toHaveBeenCalledWith(
expect(errstream.write).toBeCalledWith(
Buffer.from('this is output to stderr')
)
})
@@ -524,7 +524,7 @@ describe('@actions/exec', () => {
const execOptions = getExecOptions()
execOptions.cwd = 'nonexistent/path'
await expect(exec.exec('ls', ['-all'], execOptions)).rejects.toThrow(
await expect(exec.exec('ls', ['-all'], execOptions)).rejects.toThrowError(
`The cwd: ${execOptions.cwd} does not exist!`
)
})
@@ -1,3 +1,3 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`@actions/context return error for context.repo when repository doesn't exist 1`] = `"context.repo requires a GITHUB_REPOSITORY environment variable like 'owner/repo'"`;