Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0389dcd5e4 | |||
| 64b2775394 | |||
| 439cd9b37e | |||
| c1ded1dc4d | |||
| f37c445bc5 | |||
| e95bcfe359 | |||
| 7549d1b218 | |||
| 2124ef2413 | |||
| d617670abc | |||
| 47157e5ade | |||
| 8a6aae0a16 | |||
| 58ec2bdcc9 | |||
| e19b629130 | |||
| d63a8c4d3f | |||
| 67d2d582dc | |||
| 9d70b8a9fb | |||
| 7f47ffaee2 | |||
| 98e1a813db | |||
| 0d39975814 | |||
| f482643a6e | |||
| ff2c524611 | |||
| ecb4df89bf | |||
| 03319fcffa | |||
| c33724abbd | |||
| d6f3ee93b8 | |||
| 34a411f3c0 | |||
| 2d6ba67518 | |||
| 5430c5d848 | |||
| bc68ce94ea | |||
| 78ed49ff88 | |||
| c119fcd773 | |||
| 73babeabef | |||
| bf93b54558 | |||
| 0c0770ce57 | |||
| 571bf222ee | |||
| 68f22927e7 | |||
| 11a2dd3117 | |||
| 43c63eef65 | |||
| 6a9034d692 | |||
| eff198be5b | |||
| 16b786a545 | |||
| 18ce228b82 |
@@ -1,5 +1,3 @@
|
|||||||
# Temporarily disabled while v2.0.0 of @actions/artifact is under development
|
|
||||||
|
|
||||||
name: artifact-unit-tests
|
name: artifact-unit-tests
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -12,8 +10,8 @@ on:
|
|||||||
- '**.md'
|
- '**.md'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
upload:
|
||||||
name: Build
|
name: Upload
|
||||||
|
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
@@ -42,19 +40,13 @@ jobs:
|
|||||||
npm run tsc
|
npm run tsc
|
||||||
working-directory: packages/artifact
|
working-directory: packages/artifact
|
||||||
|
|
||||||
- name: Set artifact file contents
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
echo "file1=hello from file 1" >> $GITHUB_ENV
|
|
||||||
echo "file2=hello from file 2" >> $GITHUB_ENV
|
|
||||||
|
|
||||||
- name: Create files that will be uploaded
|
- name: Create files that will be uploaded
|
||||||
run: |
|
run: |
|
||||||
mkdir artifact-path
|
mkdir artifact-path
|
||||||
echo '${{ env.file1 }}' > artifact-path/first.txt
|
echo -n 'hello from file 1' > artifact-path/first.txt
|
||||||
echo '${{ env.file2 }}' > artifact-path/second.txt
|
echo -n 'hello from file 2' > artifact-path/second.txt
|
||||||
|
|
||||||
- name: Upload Artifacts using actions/github-script@v7
|
- name: Upload Artifacts
|
||||||
uses: actions/github-script@v7
|
uses: actions/github-script@v7
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
@@ -73,9 +65,16 @@ jobs:
|
|||||||
|
|
||||||
console.log(`Successfully uploaded artifact ${id}`)
|
console.log(`Successfully uploaded artifact ${id}`)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await artifact.uploadArtifact(artifactName, fileContents, './')
|
||||||
|
throw new Error('should have failed second upload')
|
||||||
|
} catch (err) {
|
||||||
|
console.log('Successfully blocked second artifact upload')
|
||||||
|
}
|
||||||
verify:
|
verify:
|
||||||
|
name: Verify
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [build]
|
needs: [upload]
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -96,35 +95,72 @@ jobs:
|
|||||||
npm run tsc
|
npm run tsc
|
||||||
working-directory: packages/artifact
|
working-directory: packages/artifact
|
||||||
|
|
||||||
- name: List artifacts using actions/github-script@v7
|
- name: List and Download Artifacts
|
||||||
uses: actions/github-script@v7
|
uses: actions/github-script@v7
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
const {default: artifact} = require('./packages/artifact/lib/artifact')
|
const {default: artifactClient} = require('./packages/artifact/lib/artifact')
|
||||||
|
|
||||||
const workflowRunId = process.env.GITHUB_RUN_ID
|
const {readFile} = require('fs/promises')
|
||||||
const repository = process.env.GITHUB_REPOSITORY
|
const path = require('path')
|
||||||
const repositoryOwner = repository.split('/')[0]
|
|
||||||
const repositoryName = repository.split('/')[1]
|
|
||||||
|
|
||||||
const listResult = await artifact.listArtifacts(workflowRunId, repositoryOwner, repositoryName, '${{ secrets.GITHUB_TOKEN }}')
|
const findBy = {
|
||||||
|
repositoryOwner: process.env.GITHUB_REPOSITORY.split('/')[0],
|
||||||
|
repositoryName: process.env.GITHUB_REPOSITORY.split('/')[1],
|
||||||
|
token: '${{ secrets.GITHUB_TOKEN }}',
|
||||||
|
workflowRunId: process.env.GITHUB_RUN_ID
|
||||||
|
}
|
||||||
|
|
||||||
|
const listResult = await artifactClient.listArtifacts({latest: true, findBy})
|
||||||
console.log(listResult)
|
console.log(listResult)
|
||||||
|
|
||||||
const artifacts = listResult.artifacts
|
const artifacts = listResult.artifacts
|
||||||
|
const expected = [
|
||||||
|
'my-artifact-ubuntu-latest',
|
||||||
|
'my-artifact-windows-latest',
|
||||||
|
'my-artifact-macos-latest'
|
||||||
|
]
|
||||||
|
|
||||||
if (artifacts.length !== 3) {
|
const foundArtifacts = artifacts.filter(artifact =>
|
||||||
throw new Error('Expected 3 artifacts but only found ' + artifacts.length + ' artifacts')
|
expected.includes(artifact.name)
|
||||||
}
|
)
|
||||||
|
|
||||||
const artifactNames = artifacts.map(artifact => artifact.name)
|
if (foundArtifacts.length !== 3) {
|
||||||
if (!artifactNames.includes('my-artifact-ubuntu-latest')){
|
console.log('Unexpected length of found artifacts', foundArtifacts)
|
||||||
throw new Error("Expected artifact list to contain an artifact named my-artifact-ubuntu-latest but it's missing")
|
throw new Error(
|
||||||
}
|
`Expected 3 artifacts but found ${foundArtifacts.length} artifacts.`
|
||||||
if (!artifactNames.includes('my-artifact-windows-latest')){
|
)
|
||||||
throw new Error("Expected artifact list to contain an artifact named my-artifact-windows-latest but it's missing")
|
|
||||||
}
|
|
||||||
if (!artifactNames.includes('my-artifact-macos-latest')){
|
|
||||||
throw new Error("Expected artifact list to contain an artifact named my-artifact-macos-latest but it's missing")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Successfully listed artifacts that were uploaded')
|
console.log('Successfully listed artifacts that were uploaded')
|
||||||
|
|
||||||
|
const files = [
|
||||||
|
{name: 'artifact-path/first.txt', content: 'hello from file 1'},
|
||||||
|
{name: 'artifact-path/second.txt', content: 'hello from file 2'}
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const artifact of foundArtifacts) {
|
||||||
|
const {downloadPath} = await artifactClient.downloadArtifact(artifact.id, {
|
||||||
|
path: artifact.name,
|
||||||
|
findBy
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('Downloaded artifact to:', downloadPath)
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const filepath = path.join(
|
||||||
|
process.env.GITHUB_WORKSPACE,
|
||||||
|
downloadPath,
|
||||||
|
file.name
|
||||||
|
)
|
||||||
|
|
||||||
|
console.log('Checking file:', filepath)
|
||||||
|
|
||||||
|
const content = await readFile(filepath, 'utf8')
|
||||||
|
if (content.trim() !== file.content.trim()) {
|
||||||
|
throw new Error(
|
||||||
|
`Expected file '${file.name}' to contain '${file.content}' but found '${content}'`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Generated
+3
-3
@@ -5781,9 +5781,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/follow-redirects": {
|
"node_modules/follow-redirects": {
|
||||||
"version": "1.15.2",
|
"version": "1.15.4",
|
||||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz",
|
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.4.tgz",
|
||||||
"integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==",
|
"integrity": "sha512-Cr4D/5wlrb0z9dgERpUL3LrmPKVDsETIJhaCMeDfuFYcqa5bldGV6wBsAN6X/vxlXQtFBMrXdXxdL8CbDTGniw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
|
|||||||
+1
-1
@@ -13,7 +13,7 @@
|
|||||||
"lint": "eslint packages/**/*.ts",
|
"lint": "eslint packages/**/*.ts",
|
||||||
"lint-fix": "eslint packages/**/*.ts --fix",
|
"lint-fix": "eslint packages/**/*.ts --fix",
|
||||||
"new-package": "scripts/create-package",
|
"new-package": "scripts/create-package",
|
||||||
"test": "jest --testTimeout 10000"
|
"test": "jest --testTimeout 60000"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/jest": "^29.5.4",
|
"@types/jest": "^29.5.4",
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ This is the core library that powers the [`@actions/upload-artifact`](https://gi
|
|||||||
## v2 - What's New
|
## v2 - What's New
|
||||||
|
|
||||||
> [!IMPORTANT]
|
> [!IMPORTANT]
|
||||||
> @actions/artifact v2+, upload-artifact@v4+ download-artifact@v4+ are not currently supported on GHES yet. The previous version of this package can be found at [this tag](https://github.com/actions/toolkit/tree/@actions/artifact@1.1.2/packages/artifact) and [on npm](https://www.npmjs.com/package/@actions/artifact/v/1.1.2).
|
> @actions/artifact v2+, upload-artifact@v4+, and download-artifact@v4+ are not currently supported on GHES yet. The previous version of this package can be found at [this tag](https://github.com/actions/toolkit/tree/@actions/artifact@1.1.2/packages/artifact) and [on npm](https://www.npmjs.com/package/@actions/artifact/v/1.1.2).
|
||||||
|
|
||||||
The release of `@actions/artifact@v2` (including `upload-artifact@v4` and `download-artifact@v4`) are major changes to the backend architecture of Artifacts. They have numerous performance and behavioral improvements.
|
The release of `@actions/artifact@v2` (including `upload-artifact@v4` and `download-artifact@v4`) are major changes to the backend architecture of Artifacts. They have numerous performance and behavioral improvements.
|
||||||
|
|
||||||
@@ -63,10 +63,16 @@ Import the module:
|
|||||||
|
|
||||||
```js
|
```js
|
||||||
// ES6 module
|
// ES6 module
|
||||||
import artifact from '@actions/artifact'
|
import {DefaultArtifactClient} from '@actions/artifact'
|
||||||
|
|
||||||
// CommonJS
|
// CommonJS
|
||||||
const {default: artifact} = require('@actions/artifact')
|
const {DefaultArtifactClient} = require('@actions/artifact')
|
||||||
|
```
|
||||||
|
|
||||||
|
Then instantiate:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const artifact = new DefaultArtifactClient()
|
||||||
```
|
```
|
||||||
|
|
||||||
ℹ️ For a comprehensive list of classes, interfaces, functions and more, see the [generated documentation](./docs/generated/README.md).
|
ℹ️ For a comprehensive list of classes, interfaces, functions and more, see the [generated documentation](./docs/generated/README.md).
|
||||||
|
|||||||
@@ -97,7 +97,11 @@
|
|||||||
|
|
||||||
### 2.0.0
|
### 2.0.0
|
||||||
|
|
||||||
Major release. Supports new Artifact backend for improved speed, reliability and behavior.
|
- Major release. Supports new Artifact backend for improved speed, reliability and behavior.
|
||||||
Numerous API changes, [some breaking](./README.md#breaking-changes).
|
- Numerous API changes, [some breaking](./README.md#breaking-changes).
|
||||||
|
|
||||||
Blog post with more info: TBD
|
- Blog post with more info: TBD
|
||||||
|
|
||||||
|
### 2.0.1
|
||||||
|
|
||||||
|
- Patch to fix transient request timeouts https://github.com/actions/download-artifact/issues/249
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {HttpClient} from '@actions/http-client'
|
|||||||
import * as config from '../src/internal/shared/config'
|
import * as config from '../src/internal/shared/config'
|
||||||
import {internalArtifactTwirpClient} from '../src/internal/shared/artifact-twirp-client'
|
import {internalArtifactTwirpClient} from '../src/internal/shared/artifact-twirp-client'
|
||||||
import {noopLogs} from './common'
|
import {noopLogs} from './common'
|
||||||
|
import {NetworkError, UsageError} from '../src/internal/shared/errors'
|
||||||
|
|
||||||
jest.mock('@actions/http-client')
|
jest.mock('@actions/http-client')
|
||||||
|
|
||||||
@@ -257,9 +258,42 @@ describe('artifact-http-client', () => {
|
|||||||
name: 'artifact',
|
name: 'artifact',
|
||||||
version: 4
|
version: 4
|
||||||
})
|
})
|
||||||
}).rejects.toThrowError(
|
}).rejects.toThrowError(new NetworkError('ENOTFOUND').message)
|
||||||
'Failed to CreateArtifact: Unable to make request: ENOTFOUND\nIf you are using self-hosted runners, please make sure your runner has access to all GitHub endpoints: https://docs.github.com/en/actions/hosting-your-own-runners/managing-self-hosted-runners/about-self-hosted-runners#communication-between-self-hosted-runners-and-github'
|
expect(mockHttpClient).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockPost).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should properly describe a usage error', async () => {
|
||||||
|
const mockPost = jest.fn(() => {
|
||||||
|
const msgFailed = new http.IncomingMessage(new net.Socket())
|
||||||
|
msgFailed.statusCode = 403
|
||||||
|
msgFailed.statusMessage = 'Forbidden'
|
||||||
|
return {
|
||||||
|
message: msgFailed,
|
||||||
|
readBody: async () => {
|
||||||
|
return Promise.resolve(
|
||||||
|
`{"msg": "insufficient usage to create artifact"}`
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const mockHttpClient = (
|
||||||
|
HttpClient as unknown as jest.Mock
|
||||||
|
).mockImplementation(() => {
|
||||||
|
return {
|
||||||
|
post: mockPost
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const client = internalArtifactTwirpClient()
|
||||||
|
await expect(async () => {
|
||||||
|
await client.CreateArtifact({
|
||||||
|
workflowRunBackendId: '1234',
|
||||||
|
workflowJobRunBackendId: '5678',
|
||||||
|
name: 'artifact',
|
||||||
|
version: 4
|
||||||
|
})
|
||||||
|
}).rejects.toThrowError(new UsageError().message)
|
||||||
expect(mockHttpClient).toHaveBeenCalledTimes(1)
|
expect(mockHttpClient).toHaveBeenCalledTimes(1)
|
||||||
expect(mockPost).toHaveBeenCalledTimes(1)
|
expect(mockPost).toHaveBeenCalledTimes(1)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ import archiver from 'archiver'
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
downloadArtifactInternal,
|
downloadArtifactInternal,
|
||||||
downloadArtifactPublic
|
downloadArtifactPublic,
|
||||||
|
streamExtractExternal
|
||||||
} from '../src/internal/download/download-artifact'
|
} from '../src/internal/download/download-artifact'
|
||||||
import {getUserAgentString} from '../src/internal/shared/user-agent'
|
import {getUserAgentString} from '../src/internal/shared/user-agent'
|
||||||
import {noopLogs} from './common'
|
import {noopLogs} from './common'
|
||||||
@@ -248,7 +249,44 @@ describe('download-artifact', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should fail if blob storage response is non-200', async () => {
|
it('should fail if blob storage storage chunk does not respond within 30s', async () => {
|
||||||
|
// mock http client to delay response data by 30s
|
||||||
|
const msg = new http.IncomingMessage(new net.Socket())
|
||||||
|
msg.statusCode = 200
|
||||||
|
|
||||||
|
const mockGet = jest.fn(async () => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
// Resolve with a 200 status code immediately
|
||||||
|
resolve({
|
||||||
|
message: msg,
|
||||||
|
readBody: async () => {
|
||||||
|
return Promise.resolve(`{"ok": true}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Reject with an error after 31 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
reject(new Error('Request timeout'))
|
||||||
|
}, 31000) // Timeout after 31 seconds
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const mockHttpClient = (HttpClient as jest.Mock).mockImplementation(
|
||||||
|
() => {
|
||||||
|
return {
|
||||||
|
get: mockGet
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
streamExtractExternal(fixtures.blobStorageUrl, fixtures.workspaceDir)
|
||||||
|
).rejects.toBeInstanceOf(Error)
|
||||||
|
|
||||||
|
expect(mockHttpClient).toHaveBeenCalledWith(getUserAgentString())
|
||||||
|
}, 35000) // add longer timeout to allow for timer to run out
|
||||||
|
|
||||||
|
it('should fail if blob storage response is non-200 after 5 retries', async () => {
|
||||||
const downloadArtifactMock = github.getOctokit(fixtures.token).rest
|
const downloadArtifactMock = github.getOctokit(fixtures.token).rest
|
||||||
.actions.downloadArtifact as MockedDownloadArtifact
|
.actions.downloadArtifact as MockedDownloadArtifact
|
||||||
downloadArtifactMock.mockResolvedValueOnce({
|
downloadArtifactMock.mockResolvedValueOnce({
|
||||||
@@ -290,7 +328,60 @@ describe('download-artifact', () => {
|
|||||||
expect(mockGetArtifactFailure).toHaveBeenCalledWith(
|
expect(mockGetArtifactFailure).toHaveBeenCalledWith(
|
||||||
fixtures.blobStorageUrl
|
fixtures.blobStorageUrl
|
||||||
)
|
)
|
||||||
|
expect(mockGetArtifactFailure).toHaveBeenCalledTimes(5)
|
||||||
|
}, 38000)
|
||||||
|
|
||||||
|
it('should retry if blob storage response is non-200 and then succeed with a 200', async () => {
|
||||||
|
const downloadArtifactMock = github.getOctokit(fixtures.token).rest
|
||||||
|
.actions.downloadArtifact as MockedDownloadArtifact
|
||||||
|
downloadArtifactMock.mockResolvedValueOnce({
|
||||||
|
headers: {
|
||||||
|
location: fixtures.blobStorageUrl
|
||||||
|
},
|
||||||
|
status: 302,
|
||||||
|
url: '',
|
||||||
|
data: Buffer.from('')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const mockGetArtifact = jest
|
||||||
|
.fn(mockGetArtifactSuccess)
|
||||||
|
.mockImplementationOnce(mockGetArtifactFailure)
|
||||||
|
|
||||||
|
const mockHttpClient = (HttpClient as jest.Mock).mockImplementation(
|
||||||
|
() => {
|
||||||
|
return {
|
||||||
|
get: mockGetArtifact
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const response = await downloadArtifactPublic(
|
||||||
|
fixtures.artifactID,
|
||||||
|
fixtures.repositoryOwner,
|
||||||
|
fixtures.repositoryName,
|
||||||
|
fixtures.token
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(downloadArtifactMock).toHaveBeenCalledWith({
|
||||||
|
owner: fixtures.repositoryOwner,
|
||||||
|
repo: fixtures.repositoryName,
|
||||||
|
artifact_id: fixtures.artifactID,
|
||||||
|
archive_format: 'zip',
|
||||||
|
request: {
|
||||||
|
redirect: 'manual'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
expect(mockHttpClient).toHaveBeenCalledWith(getUserAgentString())
|
||||||
|
expect(mockGetArtifactFailure).toHaveBeenCalledWith(
|
||||||
|
fixtures.blobStorageUrl
|
||||||
|
)
|
||||||
|
expect(mockGetArtifactFailure).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockGetArtifactSuccess).toHaveBeenCalledWith(
|
||||||
|
fixtures.blobStorageUrl
|
||||||
|
)
|
||||||
|
expect(mockGetArtifactSuccess).toHaveBeenCalledTimes(1)
|
||||||
|
expect(response.downloadPath).toBe(fixtures.workspaceDir)
|
||||||
|
}, 28000)
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('internal', () => {
|
describe('internal', () => {
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@actions/artifact",
|
"name": "@actions/artifact",
|
||||||
"version": "2.0.0",
|
"version": "2.0.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@actions/artifact",
|
"name": "@actions/artifact",
|
||||||
"version": "2.0.0",
|
"version": "2.0.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@actions/core": "^1.10.0",
|
"@actions/core": "^1.10.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@actions/artifact",
|
"name": "@actions/artifact",
|
||||||
"version": "2.0.0",
|
"version": "2.0.1",
|
||||||
"preview": true,
|
"preview": true,
|
||||||
"description": "Actions artifact lib",
|
"description": "Actions artifact lib",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
|
|||||||
@@ -38,20 +38,65 @@ async function exists(path: string): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function streamExtract(url: string, directory: string): Promise<void> {
|
async function streamExtract(url: string, directory: string): Promise<void> {
|
||||||
|
let retryCount = 0
|
||||||
|
while (retryCount < 5) {
|
||||||
|
try {
|
||||||
|
await streamExtractExternal(url, directory)
|
||||||
|
return
|
||||||
|
} catch (error) {
|
||||||
|
retryCount++
|
||||||
|
core.debug(
|
||||||
|
`Failed to download artifact after ${retryCount} retries due to ${error.message}. Retrying in 5 seconds...`
|
||||||
|
)
|
||||||
|
// wait 5 seconds before retrying
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 5000))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Artifact download failed after ${retryCount} retries.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function streamExtractExternal(
|
||||||
|
url: string,
|
||||||
|
directory: string
|
||||||
|
): Promise<void> {
|
||||||
const client = new httpClient.HttpClient(getUserAgentString())
|
const client = new httpClient.HttpClient(getUserAgentString())
|
||||||
const response = await client.get(url)
|
const response = await client.get(url)
|
||||||
|
|
||||||
if (response.message.statusCode !== 200) {
|
if (response.message.statusCode !== 200) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Unexpected HTTP response from blob storage: ${response.message.statusCode} ${response.message.statusMessage}`
|
`Unexpected HTTP response from blob storage: ${response.message.statusCode} ${response.message.statusMessage}`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const timeout = 30 * 1000 // 30 seconds
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
|
const timerFn = (): void => {
|
||||||
|
response.message.destroy(
|
||||||
|
new Error(`Blob storage chunk did not respond in ${timeout}ms`)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const timer = setTimeout(timerFn, timeout)
|
||||||
|
|
||||||
response.message
|
response.message
|
||||||
|
.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}))
|
.pipe(unzip.Extract({path: directory}))
|
||||||
.on('close', resolve)
|
.on('close', () => {
|
||||||
.on('error', reject)
|
clearTimeout(timer)
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
.on('error', (error: Error) => {
|
||||||
|
reject(error)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {info, debug} from '@actions/core'
|
|||||||
import {ArtifactServiceClientJSON} from '../../generated'
|
import {ArtifactServiceClientJSON} from '../../generated'
|
||||||
import {getResultsServiceUrl, getRuntimeToken} from './config'
|
import {getResultsServiceUrl, getRuntimeToken} from './config'
|
||||||
import {getUserAgentString} from './user-agent'
|
import {getUserAgentString} from './user-agent'
|
||||||
import {NetworkError} from './errors'
|
import {NetworkError, UsageError} from './errors'
|
||||||
|
|
||||||
// The twirp http client must implement this interface
|
// The twirp http client must implement this interface
|
||||||
interface Rpc {
|
interface Rpc {
|
||||||
@@ -64,7 +64,7 @@ class ArtifactHttpClient implements Rpc {
|
|||||||
this.httpClient.post(url, JSON.stringify(data), headers)
|
this.httpClient.post(url, JSON.stringify(data), headers)
|
||||||
)
|
)
|
||||||
|
|
||||||
return JSON.parse(body)
|
return body
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(`Failed to ${method}: ${error.message}`)
|
throw new Error(`Failed to ${method}: ${error.message}`)
|
||||||
}
|
}
|
||||||
@@ -72,34 +72,49 @@ class ArtifactHttpClient implements Rpc {
|
|||||||
|
|
||||||
async retryableRequest(
|
async retryableRequest(
|
||||||
operation: () => Promise<HttpClientResponse>
|
operation: () => Promise<HttpClientResponse>
|
||||||
): Promise<{response: HttpClientResponse; body: string}> {
|
): Promise<{response: HttpClientResponse; body: object}> {
|
||||||
let attempt = 0
|
let attempt = 0
|
||||||
let errorMessage = ''
|
let errorMessage = ''
|
||||||
|
let rawBody = ''
|
||||||
while (attempt < this.maxAttempts) {
|
while (attempt < this.maxAttempts) {
|
||||||
let isRetryable = false
|
let isRetryable = false
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await operation()
|
const response = await operation()
|
||||||
const statusCode = response.message.statusCode
|
const statusCode = response.message.statusCode
|
||||||
const body = await response.readBody()
|
rawBody = await response.readBody()
|
||||||
debug(`[Response] - ${response.message.statusCode}`)
|
debug(`[Response] - ${response.message.statusCode}`)
|
||||||
debug(`Headers: ${JSON.stringify(response.message.headers, null, 2)}`)
|
debug(`Headers: ${JSON.stringify(response.message.headers, null, 2)}`)
|
||||||
debug(`Body: ${body}`)
|
const body = JSON.parse(rawBody)
|
||||||
|
debug(`Body: ${JSON.stringify(body, null, 2)}`)
|
||||||
if (this.isSuccessStatusCode(statusCode)) {
|
if (this.isSuccessStatusCode(statusCode)) {
|
||||||
return {response, body}
|
return {response, body}
|
||||||
}
|
}
|
||||||
isRetryable = this.isRetryableHttpStatusCode(statusCode)
|
isRetryable = this.isRetryableHttpStatusCode(statusCode)
|
||||||
errorMessage = `Failed request: (${statusCode}) ${response.message.statusMessage}`
|
errorMessage = `Failed request: (${statusCode}) ${response.message.statusMessage}`
|
||||||
const responseMessage = JSON.parse(body).msg
|
if (body.msg) {
|
||||||
if (responseMessage) {
|
if (UsageError.isUsageErrorMessage(body.msg)) {
|
||||||
errorMessage = `${errorMessage}: ${responseMessage}`
|
throw new UsageError()
|
||||||
|
}
|
||||||
|
|
||||||
|
errorMessage = `${errorMessage}: ${body.msg}`
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
isRetryable = true
|
if (error instanceof SyntaxError) {
|
||||||
errorMessage = error.message
|
debug(`Raw Body: ${rawBody}`)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof UsageError) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
if (NetworkError.isNetworkErrorCode(error?.code)) {
|
if (NetworkError.isNetworkErrorCode(error?.code)) {
|
||||||
throw new NetworkError(error?.code)
|
throw new NetworkError(error?.code)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isRetryable = true
|
||||||
|
errorMessage = error.message
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isRetryable) {
|
if (!isRetryable) {
|
||||||
|
|||||||
@@ -57,3 +57,16 @@ export class NetworkError extends Error {
|
|||||||
].includes(code)
|
].includes(code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class UsageError extends Error {
|
||||||
|
constructor() {
|
||||||
|
const message = `Artifact storage quota has been hit. Unable to upload any new artifacts. Usage is recalculated every 6-12 hours.\nMore info on storage limits: https://docs.github.com/en/billing/managing-billing-for-github-actions/about-billing-for-github-actions#calculating-minute-and-storage-spending`
|
||||||
|
super(message)
|
||||||
|
this.name = 'UsageError'
|
||||||
|
}
|
||||||
|
|
||||||
|
static isUsageErrorMessage = (msg?: string): boolean => {
|
||||||
|
if (!msg) return false
|
||||||
|
return msg.includes('insufficient usage')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -358,3 +358,129 @@ const {
|
|||||||
version, // 10.0.22621
|
version, // 10.0.22621
|
||||||
} = await platform.getDetails()
|
} = await platform.getDetails()
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### Populating job summary
|
||||||
|
|
||||||
|
These methods can be used to populate a [job summary](https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#adding-a-job-summary). A job summary is a buffer that can be added to throughout your job via `core.summary` methods.
|
||||||
|
|
||||||
|
Job summaries when complete must be written to the summary buffer file via the `core.summary.write()` method.
|
||||||
|
|
||||||
|
All methods except `addRaw()` utilize the `addRaw()` method to append to the buffer, followed by an EOL using the `addEOL()` method.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
|
||||||
|
// Write raw text, optionally add an EOL after the content, defaults to false
|
||||||
|
core.summary.addRaw('Some content here :speech_balloon:', true)
|
||||||
|
// Output: Some content here :speech_balloon:\n
|
||||||
|
|
||||||
|
// Add an operating system-specific end-of-line marker
|
||||||
|
core.summary.addEOL()
|
||||||
|
// Output (POSIX): \n
|
||||||
|
// Output (Windows): \r\n
|
||||||
|
|
||||||
|
// Add a codeblock with an optional language for syntax highlighting
|
||||||
|
core.summary.addCodeBlock('console.log(\'hello world\')', 'javascript')
|
||||||
|
// Output: <pre lang="javascript"><code>console.log('hello world')</code></pre>
|
||||||
|
|
||||||
|
// Add a list, second parameter indicates if list is ordered, defaults to false
|
||||||
|
core.summary.addList(['item1','item2','item3'], true)
|
||||||
|
// Output: <ol><li>item1</li><li>item2</li><li>item3</li></ol>
|
||||||
|
|
||||||
|
// Add a collapsible HTML details element
|
||||||
|
core.summary.addDetails('Label', 'Some detail that will be collapsed')
|
||||||
|
// Output: <details><summary>Label</summary>Some detail that will be collapsed</details>
|
||||||
|
|
||||||
|
// Add an image, image options parameter is optional, you can supply one of or both width and height in pixels
|
||||||
|
core.summary.addImage('example.png', 'alt description of img', {width: '100', height: '100'})
|
||||||
|
// Output: <img src="example.png" alt="alt description of img" width="100" height="100">
|
||||||
|
|
||||||
|
// Add an HTML section heading element, optionally pass a level that translates to 'hX' ie. h2. Defaults to h1
|
||||||
|
core.summary.addHeading('My Heading', '2')
|
||||||
|
// Output: <h2>My Heading</h2>
|
||||||
|
|
||||||
|
// Add an HTML thematic break <hr>
|
||||||
|
core.summary.addSeparator()
|
||||||
|
// Output: <hr>
|
||||||
|
|
||||||
|
// Add an HTML line break <br>
|
||||||
|
core.summary.addBreak()
|
||||||
|
// Output: <br>
|
||||||
|
|
||||||
|
// Add an HTML blockquote with an optional citation
|
||||||
|
core.summary.addQuote('To be or not to be', 'Shakespeare')
|
||||||
|
// Output: <blockquote cite="Shakespeare">To be or not to be</blockquote>
|
||||||
|
|
||||||
|
// Add an HTML anchor tag
|
||||||
|
core.summary.addLink('click here', 'https://github.com')
|
||||||
|
// Output: <a href="https://github.com">click here</a>
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
Tables are added using the `addTable()` method, and an array of `SummaryTableRow`.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
|
||||||
|
export type SummaryTableRow = (SummaryTableCell | string)[]
|
||||||
|
|
||||||
|
export interface SummaryTableCell {
|
||||||
|
/**
|
||||||
|
* Cell content
|
||||||
|
*/
|
||||||
|
data: string
|
||||||
|
/**
|
||||||
|
* Render cell as header
|
||||||
|
* (optional) default: false
|
||||||
|
*/
|
||||||
|
header?: boolean
|
||||||
|
/**
|
||||||
|
* Number of columns the cell extends
|
||||||
|
* (optional) default: '1'
|
||||||
|
*/
|
||||||
|
colspan?: string
|
||||||
|
/**
|
||||||
|
* Number of rows the cell extends
|
||||||
|
* (optional) default: '1'
|
||||||
|
*/
|
||||||
|
rowspan?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
For example
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
|
||||||
|
const tableData = [
|
||||||
|
{data: 'Header1', header: true},
|
||||||
|
{data: 'Header2', header: true},
|
||||||
|
{data: 'Header3', header: true},
|
||||||
|
{data: 'MyData1'},
|
||||||
|
{data: 'MyData2'},
|
||||||
|
{data: 'MyData3'}
|
||||||
|
]
|
||||||
|
|
||||||
|
// Add an HTML table
|
||||||
|
core.summary.addTable([tableData])
|
||||||
|
// Output: <table><tr><th>Header1</th><th>Header2</th><th>Header3</th></tr><tr></tr><td>MyData1</td><td>MyData2</td><td>MyData3</td></tr></table>
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
In addition to job summary content, there are utility functions for interfacing with the buffer.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
|
||||||
|
// Empties the summary buffer AND wipes the summary file on disk
|
||||||
|
core.summary.clear()
|
||||||
|
|
||||||
|
// Returns the current summary buffer as a string
|
||||||
|
core.summary.stringify()
|
||||||
|
|
||||||
|
// If the summary buffer is empty
|
||||||
|
core.summary.isEmptyBuffer()
|
||||||
|
|
||||||
|
// Resets the summary buffer without writing to the summary file on disk
|
||||||
|
core.summary.emptyBuffer()
|
||||||
|
|
||||||
|
// Writes text in the buffer to the summary buffer file and empties the buffer, optionally overwriting all existing content in the summary file with buffer contents. Defaults to false.
|
||||||
|
core.summary.write({overwrite: true})
|
||||||
|
```
|
||||||
Reference in New Issue
Block a user