2023-08-21 21:23:54 +00:00
import fs from 'fs/promises'
2025-03-05 11:29:44 +00:00
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'
2023-08-17 14:40:33 -04:00
import {
DownloadArtifactOptions ,
2025-03-05 11:29:44 +00:00
DownloadArtifactResponse ,
StreamExtractResponse
2023-08-17 14:40:33 -04:00
} 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'
2023-11-30 03:47:04 +00:00
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'
2023-11-30 03:47:04 +00:00
import { getBackendIdsFromToken } from '../shared/util'
2023-12-05 18:35:26 +00:00
import { ArtifactNotFoundError } from '../shared/errors'
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 ,
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 {
2025-03-05 11:29:44 +00:00
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.` )
}
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 ,
2025-09-25 09:26:13 -04:00
opts : { timeout : number } = { timeout : 30 * 1000 }
2025-03-05 11:29:44 +00:00
) : 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
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 (
2025-09-24 14:05:45 -04:00
`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
}
2025-09-24 14:05:45 -04:00
const timer = setTimeout ( timerFn , opts . timeout )
2023-12-20 13:59:31 -05:00
2025-03-05 11:29:44 +00: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 )
2025-03-05 11:29:44 +00:00
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 } ` )
2025-03-05 11:29:44 +00:00
}
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
})
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 } ` )
2025-03-05 11:29:44 +00:00
const extractResponse = await streamExtract ( location , downloadPath )
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 } ` )
2025-03-05 11:29:44 +00:00
const extractResponse = await streamExtract ( signedUrl , downloadPath )
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
}