Compare commits

...

17 Commits

Author SHA1 Message Date
Rob Herley 5f152b798e Update artifact-tests.yml 2023-12-05 13:54:14 -05:00
Rob Herley c390199be6 Update artifact-tests.yml 2023-12-05 13:51:51 -05:00
Rob Herley a3053b5cc2 fix typo 2023-12-05 18:47:37 +00:00
Rob Herley b9872153b8 update GHES warning behavior 2023-12-05 18:42:36 +00:00
Rob Herley ce9eae0785 consistent promise behavior for download artifact 2023-12-05 18:35:26 +00:00
Rob Herley d3c5f358d1 consistent promise behavior for get artifact 2023-12-05 17:56:18 +00:00
Rob Herley 75a3586061 consistent promise behavior for upload artifact 2023-12-05 17:35:46 +00:00
Rob Herley 8ac8bf1d3d Merge pull request #1592 from actions/robherley/get-list-artifact-updates
Additional get/list artifact updates
2023-12-04 12:40:59 -05:00
Rob Herley 141b3509e4 update import 2023-12-03 21:13:55 +00:00
Rob Herley 790e6f7194 more docs 2023-12-03 20:52:36 +00:00
Rob Herley ef454f0991 add tests for list-artifacts 2023-12-03 20:48:33 +00:00
Rob Herley 86ce0b159a get artifact tests 2023-12-03 19:43:37 +00:00
Rob Herley c11a7cdeac wip 2023-12-03 06:24:49 +00:00
Rob Herley c94ca49c9c ability to filter artifacts by latest 2023-12-03 05:01:20 +00:00
Rob Herley fa7657714a fix import 2023-12-02 21:34:07 -05:00
Rob Herley c1f9d37323 updates to get/list artifacts 2023-12-02 21:18:22 -05:00
Rob Herley 8f1c589e25 Merge pull request #1591 from actions/robherley/artifact-internal-apis
Implement internal APIs for list/get/download artifacts
2023-12-01 16:17:26 -05:00
15 changed files with 719 additions and 203 deletions
+9 -14
View File
@@ -24,10 +24,10 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Set Node.js 20.x
uses: actions/setup-node@v3
uses: actions/setup-node@v4
with:
node-version: 20.x
@@ -54,8 +54,8 @@ jobs:
echo '${{ env.file1 }}' > artifact-path/first.txt
echo '${{ env.file2 }}' > artifact-path/second.txt
- name: Upload Artifacts using actions/github-script@v6
uses: actions/github-script@v6
- name: Upload Artifacts using actions/github-script@v7
uses: actions/github-script@v7
with:
script: |
const artifact = require('./packages/artifact/lib/artifact')
@@ -68,25 +68,20 @@ jobs:
const uploadResult = await artifact.create().uploadArtifact(artifactName, fileContents, './')
console.log(uploadResult)
const success = uploadResult.success
const size = uploadResult.size
const id = uploadResult.id
if (!success) {
throw new Error('Failed to upload artifact')
} else {
console.log(`Successfully uploaded artifact ${id}`)
}
console.log(`Successfully uploaded artifact ${id}`)
verify:
runs-on: ubuntu-latest
needs: [build]
steps:
- name: Checkout
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Set Node.js 20.x
uses: actions/setup-node@v3
uses: actions/setup-node@v4
with:
node-version: 20.x
@@ -101,8 +96,8 @@ jobs:
npm run tsc
working-directory: packages/artifact
- name: List artifacts using actions/github-script@v6
uses: actions/github-script@v6
- name: List artifacts using actions/github-script@v7
uses: actions/github-script@v7
with:
script: |
const artifact = require('./packages/artifact/lib/artifact')
@@ -164,7 +164,6 @@ describe('download-artifact', () => {
fixtures.blobStorageUrl
)
expectExtractedArchive(fixtures.workspaceDir)
expect(response.success).toBe(true)
expect(response.downloadPath).toBe(fixtures.workspaceDir)
})
@@ -214,7 +213,6 @@ describe('download-artifact', () => {
fixtures.blobStorageUrl
)
expectExtractedArchive(customPath)
expect(response.success).toBe(true)
expect(response.downloadPath).toBe(customPath)
})
@@ -344,7 +342,6 @@ describe('download-artifact', () => {
const response = await downloadArtifactInternal(fixtures.artifactID)
expectExtractedArchive(fixtures.workspaceDir)
expect(response.success).toBe(true)
expect(response.downloadPath).toBe(fixtures.workspaceDir)
expect(mockHttpClient).toHaveBeenCalledWith(getUserAgentString())
expect(mockListArtifacts).toHaveBeenCalledWith({
@@ -396,7 +393,6 @@ describe('download-artifact', () => {
})
expectExtractedArchive(customPath)
expect(response.success).toBe(true)
expect(response.downloadPath).toBe(customPath)
expect(mockHttpClient).toHaveBeenCalledWith(getUserAgentString())
expect(mockListArtifacts).toHaveBeenCalledWith({
@@ -0,0 +1,239 @@
import * as github from '@actions/github'
import type {RequestInterface} from '@octokit/types'
import {
getArtifactInternal,
getArtifactPublic
} from '../src/internal/find/get-artifact'
import * as config from '../src/internal/shared/config'
import {ArtifactServiceClientJSON, Timestamp} from '../src/generated'
import * as util from '../src/internal/shared/util'
import {noopLogs} from './common'
import {
ArtifactNotFoundError,
InvalidResponseError
} from '../src/internal/shared/errors'
type MockedRequest = jest.MockedFunction<RequestInterface<object>>
jest.mock('@actions/github', () => ({
getOctokit: jest.fn().mockReturnValue({
request: jest.fn()
})
}))
const fixtures = {
repo: 'toolkit',
owner: 'actions',
token: 'ghp_1234567890',
runId: 123,
backendIds: {
workflowRunBackendId: 'c4d7c21f-ba3f-4ddc-a8c8-6f2f626f8422',
workflowJobRunBackendId: '760803a1-f890-4d25-9a6e-a3fc01a0c7cf'
},
artifacts: [
{
id: 1,
name: 'my-artifact',
size: 456,
createdAt: new Date('2023-12-01')
},
{
id: 2,
name: 'my-artifact',
size: 456,
createdAt: new Date('2023-12-02')
}
]
}
describe('get-artifact', () => {
beforeAll(() => {
noopLogs()
})
describe('public', () => {
it('should return the artifact if it is found', async () => {
const mockRequest = github.getOctokit(fixtures.token)
.request as MockedRequest
mockRequest.mockResolvedValueOnce({
status: 200,
headers: {},
url: '',
data: {
artifacts: [
{
name: fixtures.artifacts[0].name,
id: fixtures.artifacts[0].id,
size_in_bytes: fixtures.artifacts[0].size,
created_at: fixtures.artifacts[0].createdAt.toISOString()
}
]
}
})
const response = await getArtifactPublic(
fixtures.artifacts[0].name,
fixtures.runId,
fixtures.owner,
fixtures.repo,
fixtures.token
)
expect(response).toEqual({
artifact: fixtures.artifacts[0]
})
})
it('should return the latest artifact if multiple are found', async () => {
const mockRequest = github.getOctokit(fixtures.token)
.request as MockedRequest
mockRequest.mockResolvedValueOnce({
status: 200,
headers: {},
url: '',
data: {
artifacts: fixtures.artifacts.map(artifact => ({
name: artifact.name,
id: artifact.id,
size_in_bytes: artifact.size,
created_at: artifact.createdAt.toISOString()
}))
}
})
const response = await getArtifactPublic(
fixtures.artifacts[0].name,
fixtures.runId,
fixtures.owner,
fixtures.repo,
fixtures.token
)
expect(response).toEqual({
artifact: fixtures.artifacts[1]
})
})
it('should fail if no artifacts are found', async () => {
const mockRequest = github.getOctokit(fixtures.token)
.request as MockedRequest
mockRequest.mockResolvedValueOnce({
status: 200,
headers: {},
url: '',
data: {
artifacts: []
}
})
const response = getArtifactPublic(
fixtures.artifacts[0].name,
fixtures.runId,
fixtures.owner,
fixtures.repo,
fixtures.token
)
expect(response).rejects.toThrowError(ArtifactNotFoundError)
})
it('should fail if non-200 response', async () => {
const mockRequest = github.getOctokit(fixtures.token)
.request as MockedRequest
mockRequest.mockResolvedValueOnce({
status: 404,
headers: {},
url: '',
data: {}
})
const response = getArtifactPublic(
fixtures.artifacts[0].name,
fixtures.runId,
fixtures.owner,
fixtures.repo,
fixtures.token
)
expect(response).rejects.toThrowError(InvalidResponseError)
})
})
describe('internal', () => {
beforeEach(() => {
jest.spyOn(config, 'getRuntimeToken').mockReturnValue('test-token')
jest
.spyOn(util, 'getBackendIdsFromToken')
.mockReturnValue(fixtures.backendIds)
jest
.spyOn(config, 'getResultsServiceUrl')
.mockReturnValue('https://results.local')
})
it('should return the artifact if it is found', async () => {
jest
.spyOn(ArtifactServiceClientJSON.prototype, 'ListArtifacts')
.mockResolvedValue({
artifacts: [
{
...fixtures.backendIds,
databaseId: fixtures.artifacts[0].id.toString(),
name: fixtures.artifacts[0].name,
size: fixtures.artifacts[0].size.toString(),
createdAt: Timestamp.fromDate(fixtures.artifacts[0].createdAt)
}
]
})
const response = await getArtifactInternal(fixtures.artifacts[0].name)
expect(response).toEqual({
artifact: fixtures.artifacts[0]
})
})
it('should return the latest artifact if multiple are found', async () => {
jest
.spyOn(ArtifactServiceClientJSON.prototype, 'ListArtifacts')
.mockResolvedValue({
artifacts: fixtures.artifacts.map(artifact => ({
...fixtures.backendIds,
databaseId: artifact.id.toString(),
name: artifact.name,
size: artifact.size.toString(),
createdAt: Timestamp.fromDate(artifact.createdAt)
}))
})
const response = await getArtifactInternal(fixtures.artifacts[0].name)
expect(response).toEqual({
artifact: fixtures.artifacts[1]
})
})
it('should fail if no artifacts are found', async () => {
jest
.spyOn(ArtifactServiceClientJSON.prototype, 'ListArtifacts')
.mockResolvedValue({
artifacts: []
})
const response = getArtifactInternal(fixtures.artifacts[0].name)
expect(response).rejects.toThrowError(ArtifactNotFoundError)
})
it('should fail if non-200 response', async () => {
jest
.spyOn(ArtifactServiceClientJSON.prototype, 'ListArtifacts')
.mockRejectedValue(new Error('boom'))
const response = getArtifactInternal(fixtures.artifacts[0].name)
expect(response).rejects.toThrow()
})
})
})
@@ -0,0 +1,242 @@
import * as github from '@actions/github'
import type {RestEndpointMethods} from '@octokit/plugin-rest-endpoint-methods/dist-types/generated/method-types'
import type {RestEndpointMethodTypes} from '@octokit/plugin-rest-endpoint-methods/dist-types/generated/parameters-and-response-types'
import {
listArtifactsInternal,
listArtifactsPublic
} from '../src/internal/find/list-artifacts'
import * as config from '../src/internal/shared/config'
import {ArtifactServiceClientJSON, Timestamp} from '../src/generated'
import * as util from '../src/internal/shared/util'
import {noopLogs} from './common'
import {Artifact} from '../src/internal/shared/interfaces'
type MockedListWorkflowRunArtifacts = jest.MockedFunction<
RestEndpointMethods['actions']['listWorkflowRunArtifacts']
>
jest.mock('@actions/github', () => ({
getOctokit: jest.fn().mockReturnValue({
rest: {
actions: {
listWorkflowRunArtifacts: jest.fn()
}
}
})
}))
const artifactsToListResponse = (
artifacts: Artifact[]
): RestEndpointMethodTypes['actions']['listWorkflowRunArtifacts']['response']['data'] => {
return {
total_count: artifacts.length,
artifacts: artifacts.map(artifact => ({
name: artifact.name,
id: artifact.id,
size_in_bytes: artifact.size,
created_at: artifact.createdAt?.toISOString() || '',
run_id: fixtures.runId,
// unused fields for tests
url: '',
archive_download_url: '',
expired: false,
expires_at: '',
node_id: '',
run_url: '',
type: '',
updated_at: ''
}))
}
}
const fixtures = {
repo: 'toolkit',
owner: 'actions',
token: 'ghp_1234567890',
runId: 123,
backendIds: {
workflowRunBackendId: 'c4d7c21f-ba3f-4ddc-a8c8-6f2f626f8422',
workflowJobRunBackendId: '760803a1-f890-4d25-9a6e-a3fc01a0c7cf'
},
artifacts: [
{
id: 1,
name: 'my-artifact',
size: 456,
createdAt: new Date('2023-12-01')
},
{
id: 2,
name: 'my-artifact',
size: 456,
createdAt: new Date('2023-12-02')
}
]
}
describe('list-artifact', () => {
beforeAll(() => {
noopLogs()
})
describe('public', () => {
it('should return a list of artifacts', async () => {
const mockListArtifacts = github.getOctokit(fixtures.token).rest.actions
.listWorkflowRunArtifacts as MockedListWorkflowRunArtifacts
mockListArtifacts.mockResolvedValueOnce({
status: 200,
headers: {},
url: '',
data: artifactsToListResponse(fixtures.artifacts)
})
const response = await listArtifactsPublic(
fixtures.runId,
fixtures.owner,
fixtures.repo,
fixtures.token,
false
)
expect(response).toEqual({
artifacts: fixtures.artifacts
})
})
it('should return the latest artifact when latest is specified', async () => {
const mockListArtifacts = github.getOctokit(fixtures.token).rest.actions
.listWorkflowRunArtifacts as MockedListWorkflowRunArtifacts
mockListArtifacts.mockResolvedValueOnce({
status: 200,
headers: {},
url: '',
data: artifactsToListResponse(fixtures.artifacts)
})
const response = await listArtifactsPublic(
fixtures.runId,
fixtures.owner,
fixtures.repo,
fixtures.token,
true
)
expect(response).toEqual({
artifacts: [fixtures.artifacts[1]]
})
})
it('can return empty artifacts', async () => {
const mockListArtifacts = github.getOctokit(fixtures.token).rest.actions
.listWorkflowRunArtifacts as MockedListWorkflowRunArtifacts
mockListArtifacts.mockResolvedValueOnce({
status: 200,
headers: {},
url: '',
data: {
total_count: 0,
artifacts: []
}
})
const response = await listArtifactsPublic(
fixtures.runId,
fixtures.owner,
fixtures.repo,
fixtures.token,
true
)
expect(response).toEqual({
artifacts: []
})
})
it('should fail if non-200 response', async () => {
const mockListArtifacts = github.getOctokit(fixtures.token).rest.actions
.listWorkflowRunArtifacts as MockedListWorkflowRunArtifacts
mockListArtifacts.mockRejectedValue(new Error('boom'))
await expect(
listArtifactsPublic(
fixtures.runId,
fixtures.owner,
fixtures.repo,
fixtures.token,
false
)
).rejects.toThrow('boom')
})
})
describe('internal', () => {
beforeEach(() => {
jest.spyOn(config, 'getRuntimeToken').mockReturnValue('test-token')
jest
.spyOn(util, 'getBackendIdsFromToken')
.mockReturnValue(fixtures.backendIds)
jest
.spyOn(config, 'getResultsServiceUrl')
.mockReturnValue('https://results.local')
})
it('should return a list of artifacts', async () => {
jest
.spyOn(ArtifactServiceClientJSON.prototype, 'ListArtifacts')
.mockResolvedValue({
artifacts: fixtures.artifacts.map(artifact => ({
...fixtures.backendIds,
databaseId: artifact.id.toString(),
name: artifact.name,
size: artifact.size.toString(),
createdAt: Timestamp.fromDate(artifact.createdAt)
}))
})
const response = await listArtifactsInternal(false)
expect(response).toEqual({
artifacts: fixtures.artifacts
})
})
it('should return the latest artifact when latest is specified', async () => {
jest
.spyOn(ArtifactServiceClientJSON.prototype, 'ListArtifacts')
.mockResolvedValue({
artifacts: fixtures.artifacts.map(artifact => ({
...fixtures.backendIds,
databaseId: artifact.id.toString(),
name: artifact.name,
size: artifact.size.toString(),
createdAt: Timestamp.fromDate(artifact.createdAt)
}))
})
const response = await listArtifactsInternal(true)
expect(response).toEqual({
artifacts: [fixtures.artifacts[1]]
})
})
it('can return empty artifacts', async () => {
jest
.spyOn(ArtifactServiceClientJSON.prototype, 'ListArtifacts')
.mockResolvedValue({
artifacts: []
})
const response = await listArtifactsInternal(false)
expect(response).toEqual({
artifacts: []
})
})
it('should fail if non-200 response', async () => {
jest
.spyOn(ArtifactServiceClientJSON.prototype, 'ListArtifacts')
.mockRejectedValue(new Error('boom'))
await expect(listArtifactsInternal(false)).rejects.toThrow('boom')
})
})
})
@@ -7,6 +7,7 @@ import {Timestamp, ArtifactServiceClientJSON} from '../src/generated'
import * as blobUpload from '../src/internal/upload/blob-upload'
import {uploadArtifact} from '../src/internal/upload/upload-artifact'
import {noopLogs} from './common'
import {FilesNotFoundError} from '../src/internal/shared/errors'
describe('upload-artifact', () => {
beforeEach(() => {
@@ -59,7 +60,6 @@ describe('upload-artifact', () => {
)
jest.spyOn(blobUpload, 'uploadZipToBlobStorage').mockReturnValue(
Promise.resolve({
isSuccess: true,
uploadSize: 1234,
sha256Hash: 'test-sha256-hash'
})
@@ -84,7 +84,7 @@ describe('upload-artifact', () => {
'/home/user/files/plz-upload'
)
expect(uploadResp).resolves.toEqual({success: true, size: 1234, id: 1})
expect(uploadResp).resolves.toEqual({size: 1234, id: 1})
})
it('should throw an error if the root directory is invalid', () => {
@@ -107,7 +107,7 @@ describe('upload-artifact', () => {
expect(uploadResp).rejects.toThrow('Invalid root directory')
})
it('should return false if there are no files to upload', () => {
it('should reject if there are no files to upload', () => {
jest
.spyOn(uploadZipSpecification, 'validateRootDirectory')
.mockReturnValue()
@@ -124,7 +124,7 @@ describe('upload-artifact', () => {
],
'/home/user/files/plz-upload'
)
expect(uploadResp).resolves.toEqual({success: false})
expect(uploadResp).rejects.toThrowError(FilesNotFoundError)
})
it('should reject if no backend IDs are found', () => {
@@ -217,7 +217,7 @@ describe('upload-artifact', () => {
'/home/user/files/plz-upload'
)
expect(uploadResp).resolves.toEqual({success: false})
expect(uploadResp).rejects.toThrow()
})
it('should return false if blob storage upload is unsuccessful', () => {
@@ -262,7 +262,7 @@ describe('upload-artifact', () => {
)
jest
.spyOn(blobUpload, 'uploadZipToBlobStorage')
.mockReturnValue(Promise.resolve({isSuccess: false}))
.mockReturnValue(Promise.reject(new Error('boom')))
// ArtifactHttpClient mocks
jest.spyOn(config, 'getRuntimeToken').mockReturnValue('test-token')
@@ -280,10 +280,10 @@ describe('upload-artifact', () => {
'/home/user/files/plz-upload'
)
expect(uploadResp).resolves.toEqual({success: false})
expect(uploadResp).rejects.toThrow()
})
it('should return false if finalize artifact fails', () => {
it('should reject if finalize artifact fails', () => {
const mockDate = new Date('2020-01-01')
jest
.spyOn(uploadZipSpecification, 'validateRootDirectory')
@@ -325,7 +325,6 @@ describe('upload-artifact', () => {
)
jest.spyOn(blobUpload, 'uploadZipToBlobStorage').mockReturnValue(
Promise.resolve({
isSuccess: true,
uploadSize: 1234,
sha256Hash: 'test-sha256-hash'
})
@@ -350,6 +349,6 @@ describe('upload-artifact', () => {
'/home/user/files/plz-upload'
)
expect(uploadResp).resolves.toEqual({success: false})
expect(uploadResp).rejects.toThrow()
})
})
+1
View File
@@ -4,6 +4,7 @@ import {ArtifactClient, Client} from './internal/client'
* Exported functionality that we want to expose for any users of @actions/artifact
*/
export * from './internal/shared/interfaces'
export * from './internal/shared/errors'
export {ArtifactClient}
export function create(): ArtifactClient {
@@ -163,6 +163,12 @@ export interface ListArtifactsResponse_MonolithArtifact {
* @generated from protobuf field: int64 size = 5;
*/
size: string;
/**
* When the artifact was created in the monolith
*
* @generated from protobuf field: google.protobuf.Timestamp created_at = 6;
*/
createdAt?: Timestamp;
}
/**
* @generated from protobuf message github.actions.results.api.v1.GetSignedArtifactURLRequest
@@ -571,7 +577,8 @@ class ListArtifactsResponse_MonolithArtifact$Type extends MessageType<ListArtifa
{ no: 2, name: "workflow_job_run_backend_id", kind: "scalar", T: 9 /*ScalarType.STRING*/ },
{ no: 3, name: "database_id", kind: "scalar", T: 3 /*ScalarType.INT64*/ },
{ no: 4, name: "name", kind: "scalar", T: 9 /*ScalarType.STRING*/ },
{ no: 5, name: "size", kind: "scalar", T: 3 /*ScalarType.INT64*/ }
{ no: 5, name: "size", kind: "scalar", T: 3 /*ScalarType.INT64*/ },
{ no: 6, name: "created_at", kind: "message", T: () => Timestamp }
]);
}
create(value?: PartialMessage<ListArtifactsResponse_MonolithArtifact>): ListArtifactsResponse_MonolithArtifact {
@@ -601,6 +608,9 @@ class ListArtifactsResponse_MonolithArtifact$Type extends MessageType<ListArtifa
case /* int64 size */ 5:
message.size = reader.int64().toString();
break;
case /* google.protobuf.Timestamp created_at */ 6:
message.createdAt = Timestamp.internalBinaryRead(reader, reader.uint32(), options, message.createdAt);
break;
default:
let u = options.readUnknownField;
if (u === "throw")
@@ -628,6 +638,9 @@ class ListArtifactsResponse_MonolithArtifact$Type extends MessageType<ListArtifa
/* int64 size = 5; */
if (message.size !== "0")
writer.tag(5, WireType.Varint).int64(message.size);
/* google.protobuf.Timestamp created_at = 6; */
if (message.createdAt)
Timestamp.internalBinaryWrite(message.createdAt, writer.tag(6, WireType.LengthDelimited).fork(), options).join();
let u = options.writeUnknownFields;
if (u !== false)
(u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer);
+43 -61
View File
@@ -1,10 +1,11 @@
import {warning} from '@actions/core'
import {isGhes} from './shared/config'
import {
UploadOptions,
UploadResponse,
UploadArtifactOptions,
UploadArtifactResponse,
DownloadArtifactOptions,
GetArtifactResponse,
ListArtifactsOptions,
ListArtifactsResponse,
DownloadArtifactResponse,
FindOptions
@@ -16,6 +17,7 @@ import {
} from './download/download-artifact'
import {getArtifactPublic, getArtifactInternal} from './find/get-artifact'
import {listArtifactsPublic, listArtifactsInternal} from './find/list-artifacts'
import {GHESNotSupportedError} from './shared/errors'
export interface ArtifactClient {
/**
@@ -25,14 +27,14 @@ export interface ArtifactClient {
* @param files A list of absolute or relative paths that denote what files should be uploaded
* @param rootDirectory An absolute or relative file path that denotes the root parent directory of the files being uploaded
* @param options Extra options for customizing the upload behavior
* @returns single UploadResponse object
* @returns single UploadArtifactResponse object
*/
uploadArtifact(
name: string,
files: string[],
rootDirectory: string,
options?: UploadOptions
): Promise<UploadResponse>
options?: UploadArtifactOptions
): Promise<UploadArtifactResponse>
/**
* Lists all artifacts that are part of the current workflow run.
@@ -44,10 +46,14 @@ export interface ArtifactClient {
* @param options Extra options that allow for the customization of the list behavior
* @returns ListArtifactResponse object
*/
listArtifacts(options?: FindOptions): Promise<ListArtifactsResponse>
listArtifacts(
options?: ListArtifactsOptions & FindOptions
): Promise<ListArtifactsResponse>
/**
* Finds an artifact by name.
* If there are multiple artifacts with the same name in the same workflow run, this will return the latest.
* If the artifact is not found, it will throw.
*
* If options.findBy is specified, this will use the public List Artifacts API with a name filter which can get artifacts from other runs.
* https://docs.github.com/en/rest/actions/artifacts?apiVersion=2022-11-28#list-workflow-run-artifacts
@@ -93,18 +99,13 @@ export class Client implements ArtifactClient {
name: string,
files: string[],
rootDirectory: string,
options?: UploadOptions
): Promise<UploadResponse> {
if (isGhes()) {
warning(
`@actions/artifact v2.0.0+ and upload-artifact@v4+ are not currently supported on GHES.`
)
return {
success: false
}
}
options?: UploadArtifactOptions
): Promise<UploadArtifactResponse> {
try {
if (isGhes()) {
throw new GHESNotSupportedError()
}
return uploadArtifact(name, files, rootDirectory, options)
} catch (error) {
warning(
@@ -114,9 +115,8 @@ Errors can be temporary, so please try again and optionally run the action with
If the error persists, please check whether Actions is operating normally at [https://githubstatus.com](https://www.githubstatus.com).`
)
return {
success: false
}
throw error
}
}
@@ -127,16 +127,11 @@ If the error persists, please check whether Actions is operating normally at [ht
artifactId: number,
options?: DownloadArtifactOptions & FindOptions
): Promise<DownloadArtifactResponse> {
if (isGhes()) {
warning(
`@actions/artifact v2.0.0+ and download-artifact@v4+ are not currently supported on GHES.`
)
return {
success: false
}
}
try {
if (isGhes()) {
throw new GHESNotSupportedError()
}
if (options?.findBy) {
const {
findBy: {repositoryOwner, repositoryName, token},
@@ -155,33 +150,28 @@ If the error persists, please check whether Actions is operating normally at [ht
return downloadArtifactInternal(artifactId, options)
} catch (error) {
warning(
`Artifact download failed with error: ${error}.
`Download Artifact failed with error: ${error}.
Errors can be temporary, so please try again and optionally run the action with debug mode enabled for more information.
If the error persists, please check whether Actions and API requests are operating normally at [https://githubstatus.com](https://www.githubstatus.com).`
)
return {
success: false
}
throw error
}
}
/**
* List Artifacts
*/
async listArtifacts(options?: FindOptions): Promise<ListArtifactsResponse> {
if (isGhes()) {
warning(
`@actions/artifact v2.0.0+ and download-artifact@v4+ are not currently supported on GHES.`
)
return {
artifacts: []
}
}
async listArtifacts(
options?: ListArtifactsOptions & FindOptions
): Promise<ListArtifactsResponse> {
try {
if (isGhes()) {
throw new GHESNotSupportedError()
}
if (options?.findBy) {
const {
findBy: {workflowRunId, repositoryOwner, repositoryName, token}
@@ -191,11 +181,12 @@ If the error persists, please check whether Actions and API requests are operati
workflowRunId,
repositoryOwner,
repositoryName,
token
token,
options?.latest
)
}
return listArtifactsInternal()
return listArtifactsInternal(options?.latest)
} catch (error: unknown) {
warning(
`Listing Artifacts failed with error: ${error}.
@@ -205,9 +196,7 @@ Errors can be temporary, so please try again and optionally run the action with
If the error persists, please check whether Actions and API requests are operating normally at [https://githubstatus.com](https://www.githubstatus.com).`
)
return {
artifacts: []
}
throw error
}
}
@@ -218,16 +207,11 @@ If the error persists, please check whether Actions and API requests are operati
artifactName: string,
options?: FindOptions
): Promise<GetArtifactResponse> {
if (isGhes()) {
warning(
`@actions/artifact v2.0.0+ and download-artifact@v4+ are not currently supported on GHES.`
)
return {
success: false
}
}
try {
if (isGhes()) {
throw new GHESNotSupportedError()
}
if (options?.findBy) {
const {
findBy: {workflowRunId, repositoryOwner, repositoryName, token}
@@ -245,15 +229,13 @@ If the error persists, please check whether Actions and API requests are operati
return getArtifactInternal(artifactName)
} catch (error: unknown) {
warning(
`Fetching Artifact failed with error: ${error}.
`Get Artifact failed with error: ${error}.
Errors can be temporary, so please try again and optionally run the action with debug mode enabled for more information.
If the error persists, please check whether Actions and API requests are operating normally at [https://githubstatus.com](https://www.githubstatus.com).`
)
return {
success: false
}
throw error
}
}
}
@@ -16,6 +16,7 @@ import {
ListArtifactsRequest
} from '../../generated'
import {getBackendIdsFromToken} from '../shared/util'
import {ArtifactNotFoundError} from '../shared/errors'
const scrubQueryParameters = (url: string): string => {
const parsed = new URL(url)
@@ -95,7 +96,7 @@ export async function downloadArtifactPublic(
throw new Error(`Unable to download and extract artifact: ${error.message}`)
}
return {success: true, downloadPath}
return {downloadPath}
}
export async function downloadArtifactInternal(
@@ -118,10 +119,9 @@ export async function downloadArtifactInternal(
const {artifacts} = await artifactClient.ListArtifacts(listReq)
if (artifacts.length === 0) {
core.warning(
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.`
)
return {success: false}
}
if (artifacts.length > 1) {
@@ -148,7 +148,7 @@ export async function downloadArtifactInternal(
throw new Error(`Unable to download and extract artifact: ${error.message}`)
}
return {success: true, downloadPath}
return {downloadPath}
}
async function resolveOrCreateDirectory(
@@ -9,7 +9,8 @@ import {GetArtifactResponse} from '../shared/interfaces'
import {getBackendIdsFromToken} from '../shared/util'
import {getUserAgentString} from '../shared/user-agent'
import {internalArtifactTwirpClient} from '../shared/artifact-twirp-client'
import {ListArtifactsRequest, StringValue} from '../../generated'
import {ListArtifactsRequest, StringValue, Timestamp} from '../../generated'
import {ArtifactNotFoundError, InvalidResponseError} from '../shared/errors'
export async function getArtifactPublic(
artifactName: string,
@@ -41,31 +42,31 @@ export async function getArtifactPublic(
)
if (getArtifactResp.status !== 200) {
core.warning(`non-200 response from GitHub API: ${getArtifactResp.status}`)
return {
success: false
}
throw new InvalidResponseError(
`Invalid response from GitHub API: ${getArtifactResp.status} (${getArtifactResp?.headers?.['x-github-request-id']})`
)
}
if (getArtifactResp.data.artifacts.length === 0) {
core.warning('no artifacts found')
return {
success: false
}
throw new ArtifactNotFoundError(
`Artifact not found for name: ${artifactName}`
)
}
let artifact = getArtifactResp.data.artifacts[0]
if (getArtifactResp.data.artifacts.length > 1) {
core.warning(
'more than one artifact found for a single name, returning first'
artifact = getArtifactResp.data.artifacts.sort((a, b) => b.id - a.id)[0]
core.debug(
`More than one artifact found for a single name, returning newest (id: ${artifact.id})`
)
}
return {
success: true,
artifact: {
name: getArtifactResp.data.artifacts[0].name,
id: getArtifactResp.data.artifacts[0].id,
size: getArtifactResp.data.artifacts[0].size_in_bytes
name: artifact.name,
id: artifact.id,
size: artifact.size_in_bytes,
createdAt: artifact.created_at ? new Date(artifact.created_at) : undefined
}
}
}
@@ -87,32 +88,30 @@ export async function getArtifactInternal(
const res = await artifactClient.ListArtifacts(req)
if (res.artifacts.length === 0) {
core.warning('no artifacts found')
return {
success: false
}
}
if (res.artifacts.length > 1) {
core.warning(
'more than one artifact found for a single name, returning first'
throw new ArtifactNotFoundError(
`Artifact not found for name: ${artifactName}`
)
}
// In the case of reruns, we may have artifacts with the same name scoped under the same workflow run.
// Let's prefer the artifact closest scoped to this run.
// If it doesn't exist (e.g. partial rerun) we'll use the first match.
const artifact =
res.artifacts.find(
artifact => artifact.workflowRunBackendId === workflowRunBackendId
) || res.artifacts[0]
let artifact = res.artifacts[0]
if (res.artifacts.length > 1) {
artifact = res.artifacts.sort(
(a, b) => Number(b.databaseId) - Number(a.databaseId)
)[0]
core.debug(
`More than one artifact found for a single name, returning newest (id: ${artifact.databaseId})`
)
}
return {
success: true,
artifact: {
name: artifact.name,
id: Number(artifact.databaseId),
size: Number(artifact.size)
size: Number(artifact.size),
createdAt: artifact.createdAt
? Timestamp.toDate(artifact.createdAt)
: undefined
}
}
}
@@ -9,7 +9,7 @@ import {retry} from '@octokit/plugin-retry'
import {OctokitOptions} from '@octokit/core/dist-types/types'
import {internalArtifactTwirpClient} from '../shared/artifact-twirp-client'
import {getBackendIdsFromToken} from '../shared/util'
import {ListArtifactsRequest} from 'src/generated'
import {ListArtifactsRequest, Timestamp} from '../../generated'
// Limiting to 1000 for perf reasons
const maximumArtifactCount = 1000
@@ -20,13 +20,14 @@ export async function listArtifactsPublic(
workflowRunId: number,
repositoryOwner: string,
repositoryName: string,
token: string
token: string,
latest = false
): Promise<ListArtifactsResponse> {
info(
`Fetching artifact list for workflow run ${workflowRunId} in repository ${repositoryOwner}/${repositoryName}`
)
const artifacts: Artifact[] = []
let artifacts: Artifact[] = []
const [retryOpts, requestOpts] = getRetryOptions(defaultGitHubOptions)
const opts: OctokitOptions = {
@@ -65,7 +66,8 @@ export async function listArtifactsPublic(
artifacts.push({
name: artifact.name,
id: artifact.id,
size: artifact.size_in_bytes
size: artifact.size_in_bytes,
createdAt: artifact.created_at ? new Date(artifact.created_at) : undefined
})
}
@@ -91,11 +93,18 @@ export async function listArtifactsPublic(
artifacts.push({
name: artifact.name,
id: artifact.id,
size: artifact.size_in_bytes
size: artifact.size_in_bytes,
createdAt: artifact.created_at
? new Date(artifact.created_at)
: undefined
})
}
}
if (latest) {
artifacts = filterLatest(artifacts)
}
info(`Found ${artifacts.length} artifact(s)`)
return {
@@ -103,7 +112,9 @@ export async function listArtifactsPublic(
}
}
export async function listArtifactsInternal(): Promise<ListArtifactsResponse> {
export async function listArtifactsInternal(
latest = false
): Promise<ListArtifactsResponse> {
const artifactClient = internalArtifactTwirpClient()
const {workflowRunBackendId, workflowJobRunBackendId} =
@@ -115,15 +126,40 @@ export async function listArtifactsInternal(): Promise<ListArtifactsResponse> {
}
const res = await artifactClient.ListArtifacts(req)
const artifacts = res.artifacts.map(artifact => ({
let artifacts: Artifact[] = res.artifacts.map(artifact => ({
name: artifact.name,
id: Number(artifact.databaseId),
size: Number(artifact.size)
size: Number(artifact.size),
createdAt: artifact.createdAt
? Timestamp.toDate(artifact.createdAt)
: undefined
}))
if (latest) {
artifacts = filterLatest(artifacts)
}
info(`Found ${artifacts.length} artifact(s)`)
return {
artifacts
}
}
/**
* Filters a list of artifacts to only include the latest artifact for each name
* @param artifacts The artifacts to filter
* @returns The filtered list of artifacts
*/
function filterLatest(artifacts: Artifact[]): Artifact[] {
artifacts.sort((a, b) => b.id - a.id)
const latestArtifacts: Artifact[] = []
const seenArtifactNames = new Set<string>()
for (const artifact of artifacts) {
if (!seenArtifactNames.has(artifact.name)) {
latestArtifacts.push(artifact)
seenArtifactNames.add(artifact.name)
}
}
return latestArtifacts
}
@@ -0,0 +1,37 @@
export class FilesNotFoundError extends Error {
files: string[]
constructor(files: string[] = []) {
let message = 'No files were found to upload'
if (files.length > 0) {
message += `: ${files.join(', ')}`
}
super(message)
this.files = files
this.name = 'FilesNotFoundError'
}
}
export class InvalidResponseError extends Error {
constructor(message: string) {
super(message)
this.name = 'InvalidResponseError'
}
}
export class ArtifactNotFoundError extends Error {
constructor(message = 'Artifact not found') {
super(message)
this.name = 'ArtifactNotFoundError'
}
}
export class GHESNotSupportedError extends Error {
constructor(
message = '@actions/artifact v2.0.0+ and download-artifact@v4+ are not currently supported on GHES.'
) {
super(message)
this.name = 'GHESNotSupportedError'
}
}
@@ -3,12 +3,7 @@
* UploadArtifact *
* *
*****************************************************************************/
export interface UploadResponse {
/**
* Denotes if an artifact was successfully uploaded
*/
success: boolean
export interface UploadArtifactResponse {
/**
* Total size of the artifact in bytes. Not provided if no artifact was uploaded
*/
@@ -21,7 +16,7 @@ export interface UploadResponse {
id?: number
}
export interface UploadOptions {
export interface UploadArtifactOptions {
/**
* Duration after which artifact will expire in days.
*
@@ -58,15 +53,10 @@ export interface UploadOptions {
*****************************************************************************/
export interface GetArtifactResponse {
/**
* If an artifact was found
*/
success: boolean
/**
* Metadata about the artifact that was found
*/
artifact?: Artifact
artifact: Artifact
}
/*****************************************************************************
@@ -74,6 +64,15 @@ export interface GetArtifactResponse {
* ListArtifact *
* *
*****************************************************************************/
export interface ListArtifactsOptions {
/**
* Filter the workflow run's artifacts to the latest by name
* In the case of reruns, this can be useful to avoid duplicates
*/
latest?: boolean
}
export interface ListArtifactsResponse {
/**
* A list of artifacts that were found
@@ -87,10 +86,6 @@ export interface ListArtifactsResponse {
* *
*****************************************************************************/
export interface DownloadArtifactResponse {
/**
* If the artifact download was successful
*/
success: boolean
/**
* The path where the artifact was downloaded to
*/
@@ -124,6 +119,11 @@ export interface Artifact {
* The size of the artifact in bytes
*/
size: number
/**
* The time when the artifact was created
*/
createdAt?: Date
}
// FindOptions are for fetching Artifact(s) out of the scope of the current run.
@@ -7,11 +7,6 @@ import * as crypto from 'crypto'
import * as stream from 'stream'
export interface BlobUploadResponse {
/**
* If the upload was successful or not
*/
isSuccess: boolean
/**
* The total reported upload size in bytes. Empty if the upload failed
*/
@@ -55,41 +50,28 @@ export async function uploadZipToBlobStorage(
zipUploadStream.pipe(uploadStream) // This stream is used for the upload
zipUploadStream.pipe(hashStream).setEncoding('hex') // This stream is used to compute a hash of the zip content that gets used. Integrity check
try {
core.info('Beginning upload of artifact content to blob storage')
core.info('Beginning upload of artifact content to blob storage')
await blockBlobClient.uploadStream(
uploadStream,
bufferSize,
maxConcurrency,
options
)
await blockBlobClient.uploadStream(
uploadStream,
bufferSize,
maxConcurrency,
options
)
core.info('Finished uploading artifact content to blob storage!')
core.info('Finished uploading artifact content to blob storage!')
hashStream.end()
sha256Hash = hashStream.read() as string
core.info(`SHA256 hash of uploaded artifact zip is ${sha256Hash}`)
} catch (error) {
core.warning(
`Failed to upload artifact zip to blob storage, error: ${error}`
)
return {
isSuccess: false
}
}
hashStream.end()
sha256Hash = hashStream.read() as string
core.info(`SHA256 hash of uploaded artifact zip is ${sha256Hash}`)
if (uploadByteCount === 0) {
core.warning(
`No data was uploaded to blob storage. Reported upload byte count is 0`
`No data was uploaded to blob storage. Reported upload byte count is 0.`
)
return {
isSuccess: false
}
}
return {
isSuccess: true,
uploadSize: uploadByteCount,
sha256Hash
}
@@ -1,5 +1,8 @@
import * as core from '@actions/core'
import {UploadOptions, UploadResponse} from '../shared/interfaces'
import {
UploadArtifactOptions,
UploadArtifactResponse
} from '../shared/interfaces'
import {getExpiration} from './retention'
import {validateArtifactName} from './path-and-artifact-name-validation'
import {internalArtifactTwirpClient} from '../shared/artifact-twirp-client'
@@ -16,13 +19,14 @@ import {
FinalizeArtifactRequest,
StringValue
} from '../../generated'
import {FilesNotFoundError, InvalidResponseError} from '../shared/errors'
export async function uploadArtifact(
name: string,
files: string[],
rootDirectory: string,
options?: UploadOptions | undefined
): Promise<UploadResponse> {
options?: UploadArtifactOptions | undefined
): Promise<UploadArtifactResponse> {
validateArtifactName(name)
validateRootDirectory(rootDirectory)
@@ -31,10 +35,9 @@ export async function uploadArtifact(
rootDirectory
)
if (zipSpecification.length === 0) {
core.warning(`No files were found to upload`)
return {
success: false
}
throw new FilesNotFoundError(
zipSpecification.flatMap(s => (s.sourcePath ? [s.sourcePath] : []))
)
}
const zipUploadStream = await createZipUploadStream(
@@ -65,10 +68,9 @@ export async function uploadArtifact(
const createArtifactResp =
await artifactClient.CreateArtifact(createArtifactReq)
if (!createArtifactResp.ok) {
core.warning(`Failed to create artifact`)
return {
success: false
}
throw new InvalidResponseError(
'CreateArtifact: response from backend was not ok'
)
}
// Upload zip to blob storage
@@ -76,11 +78,6 @@ export async function uploadArtifact(
createArtifactResp.signedUploadUrl,
zipUploadStream
)
if (uploadResult.isSuccess === false) {
return {
success: false
}
}
// finalize the artifact
const finalizeArtifactReq: FinalizeArtifactRequest = {
@@ -101,10 +98,9 @@ export async function uploadArtifact(
const finalizeArtifactResp =
await artifactClient.FinalizeArtifact(finalizeArtifactReq)
if (!finalizeArtifactResp.ok) {
core.warning(`Failed to finalize artifact`)
return {
success: false
}
throw new InvalidResponseError(
'FinalizeArtifact: response from backend was not ok'
)
}
const artifactId = BigInt(finalizeArtifactResp.artifactId)
@@ -113,7 +109,6 @@ export async function uploadArtifact(
)
return {
success: true,
size: uploadResult.uploadSize,
id: Number(artifactId)
}