2023-08-21 21:23:54 +00:00
import fs from 'fs/promises'
2026-01-26 13:48:31 -05:00
import * as fsSync from 'fs'
2025-03-05 11:29:44 +00:00
import * as crypto from 'crypto'
import * as stream from 'stream'
2026-01-26 13:48:31 -05:00
import * as path from 'path'
2025-03-05 11:29:44 +00:00
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'
2023-08-17 14:40:33 -04:00
import {
DownloadArtifactOptions ,
2025-03-05 11:29:44 +00:00
DownloadArtifactResponse ,
StreamExtractResponse
2026-01-29 09:52:09 -05:00
} from '../shared/interfaces.js'
import { getUserAgentString } from '../shared/user-agent.js'
import { getGitHubWorkspaceDir } from '../shared/config.js'
import { internalArtifactTwirpClient } from '../shared/artifact-twirp-client.js'
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
2026-01-29 09:52:09 -05:00
} from '../../generated/index.js'
import { getBackendIdsFromToken } from '../shared/util.js'
import { ArtifactNotFoundError } from '../shared/errors.js'
2023-12-18 17:11:14 -05:00
2023-08-21 21:23:54 +00: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
}
}
}
2025-03-05 11:29:44 +00:00
async function streamExtract (
url : string ,
2026-01-26 14:35:09 -05:00
directory : string ,
skipDecompress? : boolean
2025-03-05 11:29:44 +00:00
) : 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 {
2026-01-26 14:35:09 -05:00
return await streamExtractExternal ( url , directory , { skipDecompress })
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.` )
}
2024-01-09 16:44:12 +00:00
export async function streamExtractExternal (
2023-12-21 09:25:34 -05:00
url : string ,
2025-09-24 14:05:45 -04:00
directory : string ,
2026-01-26 14:35:09 -05:00
opts : { timeout? : number ; skipDecompress? : boolean } = {}
2025-03-05 11:29:44 +00:00
) : Promise < StreamExtractResponse > {
2026-01-26 14:35:09 -05:00
const { timeout = 30 * 1000 , skipDecompress = false } = opts
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
2026-01-26 13:48:31 -05:00
const contentType = response . message . headers [ 'content-type' ] || ''
const isZip =
contentType === 'application/zip' ||
contentType === 'application/x-zip-compressed' ||
contentType === 'zip'
// 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 ]) {
2026-01-26 14:48:39 -05:00
// Sanitize fileName to prevent path traversal attacks
// Use path.basename to extract only the filename component
fileName = path . basename ( decodeURIComponent ( filenameMatch [ 1 ]. trim ()))
2026-01-26 13:48:31 -05:00
}
2026-01-26 14:35:09 -05:00
core . debug ( `Content-Type: ${ contentType } , isZip: ${ isZip } , skipDecompress: ${ skipDecompress } ` )
2026-01-26 13:48:31 -05:00
core . debug ( `Content-Disposition: ${ contentDisposition } , fileName: ${ fileName } ` )
2025-03-05 11:29:44 +00: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 (
2026-01-26 14:35:09 -05:00
`Blob storage chunk did not respond in ${ 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
}
2026-01-26 14:35:09 -05:00
const timer = setTimeout ( timerFn , timeout )
2023-12-20 13:59:31 -05:00
2026-01-26 13:48:31 -05:00
const onError = ( error : Error ) : void => {
core . debug (
`response.message: Artifact download failed: ${ error . message } `
)
clearTimeout ( timer )
reject ( error )
}
2025-03-05 11:29:44 +00:00
const hashStream = crypto . createHash ( 'sha256' ). setEncoding ( 'hex' )
2026-01-26 13:48:31 -05:00
const passThrough = new stream . PassThrough ()
2026-01-26 14:48:46 -05:00
. on ( 'data' , () => {
timer . refresh ()
})
. on ( 'error' , onError )
2025-03-05 11:29:44 +00:00
response . message . pipe ( passThrough )
passThrough . pipe ( hashStream )
2026-01-26 13:48:31 -05:00
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 } ` })
}
2026-01-26 14:35:09 -05:00
if ( isZip && ! skipDecompress ) {
2026-01-26 13:48:31 -05:00
// 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 )
}
2023-12-11 12:15:40 -05:00
})
2023-08-21 21:23:54 +00:00
}
2023-08-17 14:40:33 -04:00
2023-11-30 03:47:04 +00:00
export async function downloadArtifactPublic (
2023-08-17 14:40:33 -04:00
artifactId : number ,
repositoryOwner : string ,
repositoryName : string ,
token : string ,
options? : DownloadArtifactOptions
) : Promise < DownloadArtifactResponse > {
2023-11-30 03:47:04 +00:00
const downloadPath = await resolveOrCreateDirectory ( options ? . path )
2023-08-21 21:23:54 +00:00
const api = github . getOctokit ( token )
2025-03-05 11:29:44 +00:00
let digestMismatch = false
2023-08-21 21:23:54 +00:00
core . info (
2023-08-21 17:47:17 -04:00
`Downloading artifact ' ${ artifactId } ' from ' ${ repositoryOwner } / ${ repositoryName } '`
2023-08-21 21:23:54 +00:00
)
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 } ` )
2026-01-26 14:35:09 -05:00
const extractResponse = await streamExtract (
location ,
downloadPath ,
options ? . skipDecompress
)
2023-08-21 21:23:54 +00:00
core . info ( `Artifact download completed successfully.` )
2025-03-05 11:29:44 +00:00
if ( options ? . expectedHash ) {
if ( options ? . expectedHash !== extractResponse . sha256Digest ) {
digestMismatch = true
core . debug ( `Computed digest: ${ extractResponse . sha256Digest } ` )
core . debug ( `Expected digest: ${ options . expectedHash } ` )
}
}
2023-08-21 21:23:54 +00:00
} catch ( error ) {
throw new Error ( `Unable to download and extract artifact: ${ error . message } ` )
}
2025-03-05 11:29:44 +00:00
return { downloadPath , digestMismatch }
2023-08-17 14:40:33 -04:00
}
2023-11-30 03:47:04 +00:00
export async function downloadArtifactInternal (
artifactId : number ,
options? : DownloadArtifactOptions
) : Promise < DownloadArtifactResponse > {
const downloadPath = await resolveOrCreateDirectory ( options ? . path )
const artifactClient = internalArtifactTwirpClient ()
2025-03-05 11:29:44 +00:00
let digestMismatch = false
2023-11-30 03:47:04 +00:00
const { workflowRunBackendId , workflowJobRunBackendId } =
getBackendIdsFromToken ()
const listReq : ListArtifactsRequest = {
workflowRunBackendId ,
2023-12-01 09:05:46 -05:00
workflowJobRunBackendId ,
idFilter : Int64Value.create ({ value : artifactId.toString ()})
2023-11-30 03:47:04 +00:00
}
const { artifacts } = await artifactClient . ListArtifacts ( listReq )
if ( artifacts . length === 0 ) {
2023-12-05 18:35:26 +00:00
throw new ArtifactNotFoundError (
2023-11-30 03:47:04 +00:00
`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 } ` )
2026-01-26 14:35:09 -05:00
const extractResponse = await streamExtract (
signedUrl ,
downloadPath ,
options ? . skipDecompress
)
2023-11-30 03:47:04 +00:00
core . info ( `Artifact download completed successfully.` )
2025-03-05 11:29:44 +00:00
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 } ` )
2025-03-05 11:29:44 +00:00
}
}
2023-11-30 03:47:04 +00:00
} catch ( error ) {
throw new Error ( `Unable to download and extract artifact: ${ error . message } ` )
}
2025-03-05 11:29:44 +00:00
return { downloadPath , digestMismatch }
2023-11-30 03:47:04 +00:00
}
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
}