Files
toolkit/packages/artifact/src/internal/download/download-artifact.ts
T

257 lines
7.4 KiB
TypeScript
Raw Normal View History

import fs from 'fs/promises'
import * as crypto from 'crypto'
import * as stream from 'stream'
2023-08-21 17:47:17 -04:00
import * as github from '@actions/github'
import * as core from '@actions/core'
2023-12-20 13:11:04 -05:00
import * as httpClient from '@actions/http-client'
2023-12-11 12:15:40 -05:00
import unzip from 'unzip-stream'
import {
DownloadArtifactOptions,
DownloadArtifactResponse,
StreamExtractResponse
} from '../shared/interfaces'
2023-12-20 13:11:04 -05:00
import {getUserAgentString} from '../shared/user-agent'
2023-08-22 11:47:14 -07:00
import {getGitHubWorkspaceDir} from '../shared/config'
import {internalArtifactTwirpClient} from '../shared/artifact-twirp-client'
2023-12-01 00:31:27 +00:00
import {
GetSignedArtifactURLRequest,
2023-12-01 09:05:46 -05:00
Int64Value,
2023-12-01 00:31:27 +00:00
ListArtifactsRequest
} from '../../generated'
import {getBackendIdsFromToken} from '../shared/util'
import {ArtifactNotFoundError} from '../shared/errors'
2023-12-18 17:11:14 -05:00
const scrubQueryParameters = (url: string): string => {
const parsed = new URL(url)
parsed.search = ''
return parsed.toString()
}
async function exists(path: string): Promise<boolean> {
try {
await fs.access(path)
return true
} catch (error) {
if (error.code === 'ENOENT') {
return false
} else {
throw error
}
}
}
async function streamExtract(
url: string,
directory: string
): Promise<StreamExtractResponse> {
2023-12-20 13:11:04 -05:00
let retryCount = 0
2023-12-20 15:45:19 -05:00
while (retryCount < 5) {
2023-12-20 13:11:04 -05:00
try {
return await streamExtractExternal(url, directory)
2023-12-20 13:11:04 -05:00
} catch (error) {
retryCount++
2024-01-09 19:36:26 +00:00
core.debug(
2023-12-21 09:25:34 -05:00
`Failed to download artifact after ${retryCount} retries due to ${error.message}. Retrying in 5 seconds...`
)
2023-12-20 15:45:19 -05:00
// wait 5 seconds before retrying
await new Promise(resolve => setTimeout(resolve, 5000))
2023-12-20 13:11:04 -05:00
}
2023-12-19 11:49:39 -05:00
}
2023-12-20 13:11:04 -05:00
throw new Error(`Artifact download failed after ${retryCount} retries.`)
}
export async function streamExtractExternal(
2023-12-21 09:25:34 -05:00
url: string,
directory: string,
2025-09-25 09:26:13 -04:00
opts: {timeout: number} = {timeout: 30 * 1000}
): Promise<StreamExtractResponse> {
2023-12-20 13:11:04 -05:00
const client = new httpClient.HttpClient(getUserAgentString())
const response = await client.get(url)
if (response.message.statusCode !== 200) {
throw new Error(
`Unexpected HTTP response from blob storage: ${response.message.statusCode} ${response.message.statusMessage}`
)
}
2023-12-19 11:49:39 -05:00
let sha256Digest: string | undefined = undefined
2023-12-20 13:59:31 -05:00
2024-02-22 22:06:32 -05:00
return new Promise((resolve, reject) => {
2023-12-20 13:59:31 -05:00
const timerFn = (): void => {
2025-09-25 10:53:34 +02:00
const timeoutError = new Error(
`Blob storage chunk did not respond in ${opts.timeout}ms`
2025-09-25 10:53:34 +02:00
)
2025-09-24 17:05:25 +02:00
response.message.destroy(timeoutError)
reject(timeoutError)
2023-12-20 13:59:31 -05:00
}
const timer = setTimeout(timerFn, opts.timeout)
2023-12-20 13:59:31 -05:00
const hashStream = crypto.createHash('sha256').setEncoding('hex')
const passThrough = new stream.PassThrough()
response.message.pipe(passThrough)
passThrough.pipe(hashStream)
const extractStream = passThrough
extractStream
2023-12-20 18:08:00 -05:00
.on('data', () => {
timer.refresh()
})
.on('error', (error: Error) => {
2024-01-09 19:23:57 +00:00
core.debug(
2023-12-21 09:25:34 -05:00
`response.message: Artifact download failed: ${error.message}`
)
2023-12-20 18:08:00 -05:00
clearTimeout(timer)
reject(error)
})
2024-04-23 15:54:54 -04:00
.pipe(unzip.Extract({path: directory}))
2024-04-23 16:06:02 -04:00
.on('close', () => {
2023-12-20 18:08:00 -05:00
clearTimeout(timer)
if (hashStream) {
hashStream.end()
sha256Digest = hashStream.read() as string
2025-03-05 14:44:58 +00:00
core.info(`SHA256 digest of downloaded artifact is ${sha256Digest}`)
}
resolve({sha256Digest: `sha256:${sha256Digest}`})
2023-12-20 18:08:00 -05:00
})
.on('error', (error: Error) => {
reject(error)
})
2023-12-11 12:15:40 -05:00
})
}
export async function downloadArtifactPublic(
artifactId: number,
repositoryOwner: string,
repositoryName: string,
token: string,
options?: DownloadArtifactOptions
): Promise<DownloadArtifactResponse> {
const downloadPath = await resolveOrCreateDirectory(options?.path)
const api = github.getOctokit(token)
let digestMismatch = false
core.info(
2023-08-21 17:47:17 -04:00
`Downloading artifact '${artifactId}' from '${repositoryOwner}/${repositoryName}'`
)
const {headers, status} = await api.rest.actions.downloadArtifact({
owner: repositoryOwner,
repo: repositoryName,
artifact_id: artifactId,
archive_format: 'zip',
request: {
redirect: 'manual'
}
})
if (status !== 302) {
throw new Error(`Unable to download artifact. Unexpected status: ${status}`)
}
const {location} = headers
if (!location) {
throw new Error(`Unable to redirect to artifact download url`)
}
core.info(
`Redirecting to blob download url: ${scrubQueryParameters(location)}`
)
try {
core.info(`Starting download of artifact to: ${downloadPath}`)
const extractResponse = await streamExtract(location, downloadPath)
core.info(`Artifact download completed successfully.`)
if (options?.expectedHash) {
if (options?.expectedHash !== extractResponse.sha256Digest) {
digestMismatch = true
core.debug(`Computed digest: ${extractResponse.sha256Digest}`)
core.debug(`Expected digest: ${options.expectedHash}`)
}
}
} catch (error) {
throw new Error(`Unable to download and extract artifact: ${error.message}`)
}
return {downloadPath, digestMismatch}
}
export async function downloadArtifactInternal(
artifactId: number,
options?: DownloadArtifactOptions
): Promise<DownloadArtifactResponse> {
const downloadPath = await resolveOrCreateDirectory(options?.path)
const artifactClient = internalArtifactTwirpClient()
let digestMismatch = false
const {workflowRunBackendId, workflowJobRunBackendId} =
getBackendIdsFromToken()
const listReq: ListArtifactsRequest = {
workflowRunBackendId,
2023-12-01 09:05:46 -05:00
workflowJobRunBackendId,
idFilter: Int64Value.create({value: artifactId.toString()})
}
const {artifacts} = await artifactClient.ListArtifacts(listReq)
if (artifacts.length === 0) {
throw new ArtifactNotFoundError(
`No artifacts found for ID: ${artifactId}\nAre you trying to download from a different run? Try specifying a github-token with \`actions:read\` scope.`
)
}
if (artifacts.length > 1) {
core.warning('Multiple artifacts found, defaulting to first.')
}
const signedReq: GetSignedArtifactURLRequest = {
workflowRunBackendId: artifacts[0].workflowRunBackendId,
workflowJobRunBackendId: artifacts[0].workflowJobRunBackendId,
name: artifacts[0].name
}
const {signedUrl} = await artifactClient.GetSignedArtifactURL(signedReq)
core.info(
`Redirecting to blob download url: ${scrubQueryParameters(signedUrl)}`
)
try {
core.info(`Starting download of artifact to: ${downloadPath}`)
const extractResponse = await streamExtract(signedUrl, downloadPath)
core.info(`Artifact download completed successfully.`)
if (options?.expectedHash) {
if (options?.expectedHash !== extractResponse.sha256Digest) {
digestMismatch = true
2025-03-07 09:38:33 +00:00
core.debug(`Computed digest: ${extractResponse.sha256Digest}`)
core.debug(`Expected digest: ${options.expectedHash}`)
}
}
} catch (error) {
throw new Error(`Unable to download and extract artifact: ${error.message}`)
}
return {downloadPath, digestMismatch}
}
async function resolveOrCreateDirectory(
downloadPath = getGitHubWorkspaceDir()
): Promise<string> {
if (!(await exists(downloadPath))) {
core.debug(
`Artifact destination folder does not exist, creating: ${downloadPath}`
)
await fs.mkdir(downloadPath, {recursive: true})
} else {
core.debug(`Artifact destination folder already exists: ${downloadPath}`)
}
return downloadPath
}