diff --git a/.github/workflows/artifact-tests.yml b/.github/workflows/artifact-tests.yml index aa0ab371..a74db480 100644 --- a/.github/workflows/artifact-tests.yml +++ b/.github/workflows/artifact-tests.yml @@ -72,7 +72,7 @@ jobs: console.log('Successfully blocked second artifact upload') } verify: - name: Verify + name: Verify and Delete runs-on: ubuntu-latest needs: [upload] steps: @@ -164,3 +164,31 @@ jobs: } } } + - name: Delete Artifacts + uses: actions/github-script@v7 + with: + script: | + const {default: artifactClient} = require('./packages/artifact/lib/artifact') + + const artifactsToDelete = [ + 'my-artifact-ubuntu-latest', + 'my-artifact-windows-latest', + 'my-artifact-macos-latest' + ] + + for (const artifactName of artifactsToDelete) { + const {id} = await artifactClient.deleteArtifact(artifactName) + } + + const {artifacts} = await artifactClient.listArtifacts({latest: true}) + const foundArtifacts = artifacts.filter(artifact => + artifactsToDelete.includes(artifact.name) + ) + + if (foundArtifacts.length !== 0) { + console.log('Unexpected length of found artifacts:', foundArtifacts) + throw new Error( + `Expected 0 artifacts but found ${foundArtifacts.length} artifacts.` + ) + } + diff --git a/packages/artifact/README.md b/packages/artifact/README.md index 1e03ecdf..5a98da0e 100644 --- a/packages/artifact/README.md +++ b/packages/artifact/README.md @@ -12,6 +12,7 @@ This is the core library that powers the [`@actions/upload-artifact`](https://gi - [Quick Start](#quick-start) - [Examples](#examples) - [Upload and Download](#upload-and-download) + - [Delete an Artifact](#delete-an-artifact) - [Downloading from other workflow runs or repos](#downloading-from-other-workflow-runs-or-repos) - [Speeding up large uploads](#speeding-up-large-uploads) - [Additional Resources](#additional-resources) @@ -106,6 +107,41 @@ const {downloadPath} = await artifact.downloadArtifact(id, { console.log(`Downloaded artifact ${id} to: ${downloadPath}`) ``` +### Delete an Artifact + +To delete an artifact, all you need is the name. + +```js +const {id} = await artifact.deleteArtifact( + // name of the artifact + 'my-artifact' +) + +console.log(`Deleted Artifact ID '${id}'`) +``` + +It also supports options to delete from other repos/runs given a github token with `actions:write` permissions on the target repository is supplied. + +```js +const findBy = { + // must have actions:write permission on target repository + token: process.env['GITHUB_TOKEN'], + workflowRunId: 123, + repositoryOwner: 'actions', + repositoryName: 'toolkit' +} + + +const {id} = await artifact.deleteArtifact( + // name of the artifact + 'my-artifact', + // options to find by other repo/owner + { findBy } +) + +console.log(`Deleted Artifact ID '${id}' from ${findBy.repositoryOwner}/ ${findBy.repositoryName}`) +``` + ### Downloading from other workflow runs or repos It may be useful to download Artifacts from other workflow runs, or even other repositories. By default, the permissions are scoped so they can only download Artifacts within the current workflow run. To elevate permissions for this scenario, you must specify `options.findBy` to `downloadArtifact`. diff --git a/packages/artifact/__tests__/delete-artifacts.test.ts b/packages/artifact/__tests__/delete-artifacts.test.ts new file mode 100644 index 00000000..70a39412 --- /dev/null +++ b/packages/artifact/__tests__/delete-artifacts.test.ts @@ -0,0 +1,192 @@ +import * as github from '@actions/github' +import type {RestEndpointMethods} from '@octokit/plugin-rest-endpoint-methods/dist-types/generated/method-types' +import type {RequestInterface} from '@octokit/types' +import { + deleteArtifactInternal, + deleteArtifactPublic +} from '../src/internal/delete/delete-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' + +type MockedRequest = jest.MockedFunction> + +type MockedDeleteArtifact = jest.MockedFunction< + RestEndpointMethods['actions']['deleteArtifact'] +> + +jest.mock('@actions/github', () => ({ + getOctokit: jest.fn().mockReturnValue({ + request: jest.fn(), + rest: { + actions: { + deleteArtifact: 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('delete-artifact', () => { + beforeAll(() => { + noopLogs() + }) + + describe('public', () => { + it('should delete an artifact', 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 mockDeleteArtifact = github.getOctokit(fixtures.token).rest.actions + .deleteArtifact as MockedDeleteArtifact + mockDeleteArtifact.mockResolvedValueOnce({ + status: 204, + headers: {}, + url: '', + data: null as never + }) + + const response = await deleteArtifactPublic( + fixtures.artifacts[0].name, + fixtures.runId, + fixtures.owner, + fixtures.repo, + fixtures.token + ) + + expect(response).toEqual({ + id: fixtures.artifacts[0].id + }) + }) + + it('should fail if non-200 response', 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 mockDeleteArtifact = github.getOctokit(fixtures.token).rest.actions + .deleteArtifact as MockedDeleteArtifact + mockDeleteArtifact.mockRejectedValue(new Error('boom')) + + await expect( + deleteArtifactPublic( + fixtures.artifacts[0].name, + fixtures.runId, + fixtures.owner, + fixtures.repo, + fixtures.token + ) + ).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 delete an artifact', 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) + })) + }) + jest + .spyOn(ArtifactServiceClientJSON.prototype, 'DeleteArtifact') + .mockResolvedValue({ + ok: true, + artifactId: fixtures.artifacts[0].id.toString() + }) + const response = await deleteArtifactInternal(fixtures.artifacts[0].name) + expect(response).toEqual({ + id: fixtures.artifacts[0].id + }) + }) + + it('should fail if non-200 response', 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) + })) + }) + jest + .spyOn(ArtifactServiceClientJSON.prototype, 'DeleteArtifact') + .mockRejectedValue(new Error('boom')) + await expect( + deleteArtifactInternal(fixtures.artifacts[0].id) + ).rejects.toThrow('boom') + }) + }) +}) diff --git a/packages/artifact/src/generated/results/api/v1/artifact.ts b/packages/artifact/src/generated/results/api/v1/artifact.ts index acce3037..7bb7f4be 100644 --- a/packages/artifact/src/generated/results/api/v1/artifact.ts +++ b/packages/artifact/src/generated/results/api/v1/artifact.ts @@ -196,6 +196,36 @@ export interface GetSignedArtifactURLResponse { */ signedUrl: string; } +/** + * @generated from protobuf message github.actions.results.api.v1.DeleteArtifactRequest + */ +export interface DeleteArtifactRequest { + /** + * @generated from protobuf field: string workflow_run_backend_id = 1; + */ + workflowRunBackendId: string; + /** + * @generated from protobuf field: string workflow_job_run_backend_id = 2; + */ + workflowJobRunBackendId: string; + /** + * @generated from protobuf field: string name = 3; + */ + name: string; +} +/** + * @generated from protobuf message github.actions.results.api.v1.DeleteArtifactResponse + */ +export interface DeleteArtifactResponse { + /** + * @generated from protobuf field: bool ok = 1; + */ + ok: boolean; + /** + * @generated from protobuf field: int64 artifact_id = 2; + */ + artifactId: string; +} // @generated message type with reflection information, may provide speed optimized methods class CreateArtifactRequest$Type extends MessageType { constructor() { @@ -759,6 +789,121 @@ class GetSignedArtifactURLResponse$Type extends MessageType { + constructor() { + super("github.actions.results.api.v1.DeleteArtifactRequest", [ + { no: 1, name: "workflow_run_backend_id", kind: "scalar", T: 9 /*ScalarType.STRING*/ }, + { no: 2, name: "workflow_job_run_backend_id", kind: "scalar", T: 9 /*ScalarType.STRING*/ }, + { no: 3, name: "name", kind: "scalar", T: 9 /*ScalarType.STRING*/ } + ]); + } + create(value?: PartialMessage): DeleteArtifactRequest { + const message = { workflowRunBackendId: "", workflowJobRunBackendId: "", name: "" }; + globalThis.Object.defineProperty(message, MESSAGE_TYPE, { enumerable: false, value: this }); + if (value !== undefined) + reflectionMergePartial(this, message, value); + return message; + } + internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: DeleteArtifactRequest): DeleteArtifactRequest { + let message = target ?? this.create(), end = reader.pos + length; + while (reader.pos < end) { + let [fieldNo, wireType] = reader.tag(); + switch (fieldNo) { + case /* string workflow_run_backend_id */ 1: + message.workflowRunBackendId = reader.string(); + break; + case /* string workflow_job_run_backend_id */ 2: + message.workflowJobRunBackendId = reader.string(); + break; + case /* string name */ 3: + message.name = reader.string(); + break; + default: + let u = options.readUnknownField; + if (u === "throw") + throw new globalThis.Error(`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`); + let d = reader.skip(wireType); + if (u !== false) + (u === true ? UnknownFieldHandler.onRead : u)(this.typeName, message, fieldNo, wireType, d); + } + } + return message; + } + internalBinaryWrite(message: DeleteArtifactRequest, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter { + /* string workflow_run_backend_id = 1; */ + if (message.workflowRunBackendId !== "") + writer.tag(1, WireType.LengthDelimited).string(message.workflowRunBackendId); + /* string workflow_job_run_backend_id = 2; */ + if (message.workflowJobRunBackendId !== "") + writer.tag(2, WireType.LengthDelimited).string(message.workflowJobRunBackendId); + /* string name = 3; */ + if (message.name !== "") + writer.tag(3, WireType.LengthDelimited).string(message.name); + let u = options.writeUnknownFields; + if (u !== false) + (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); + return writer; + } +} +/** + * @generated MessageType for protobuf message github.actions.results.api.v1.DeleteArtifactRequest + */ +export const DeleteArtifactRequest = new DeleteArtifactRequest$Type(); +// @generated message type with reflection information, may provide speed optimized methods +class DeleteArtifactResponse$Type extends MessageType { + constructor() { + super("github.actions.results.api.v1.DeleteArtifactResponse", [ + { no: 1, name: "ok", kind: "scalar", T: 8 /*ScalarType.BOOL*/ }, + { no: 2, name: "artifact_id", kind: "scalar", T: 3 /*ScalarType.INT64*/ } + ]); + } + create(value?: PartialMessage): DeleteArtifactResponse { + const message = { ok: false, artifactId: "0" }; + globalThis.Object.defineProperty(message, MESSAGE_TYPE, { enumerable: false, value: this }); + if (value !== undefined) + reflectionMergePartial(this, message, value); + return message; + } + internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: DeleteArtifactResponse): DeleteArtifactResponse { + let message = target ?? this.create(), end = reader.pos + length; + while (reader.pos < end) { + let [fieldNo, wireType] = reader.tag(); + switch (fieldNo) { + case /* bool ok */ 1: + message.ok = reader.bool(); + break; + case /* int64 artifact_id */ 2: + message.artifactId = reader.int64().toString(); + break; + default: + let u = options.readUnknownField; + if (u === "throw") + throw new globalThis.Error(`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`); + let d = reader.skip(wireType); + if (u !== false) + (u === true ? UnknownFieldHandler.onRead : u)(this.typeName, message, fieldNo, wireType, d); + } + } + return message; + } + internalBinaryWrite(message: DeleteArtifactResponse, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter { + /* bool ok = 1; */ + if (message.ok !== false) + writer.tag(1, WireType.Varint).bool(message.ok); + /* int64 artifact_id = 2; */ + if (message.artifactId !== "0") + writer.tag(2, WireType.Varint).int64(message.artifactId); + let u = options.writeUnknownFields; + if (u !== false) + (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); + return writer; + } +} +/** + * @generated MessageType for protobuf message github.actions.results.api.v1.DeleteArtifactResponse + */ +export const DeleteArtifactResponse = new DeleteArtifactResponse$Type(); /** * @generated ServiceType for protobuf service github.actions.results.api.v1.ArtifactService */ @@ -766,5 +911,6 @@ export const ArtifactService = new ServiceType("github.actions.results.api.v1.Ar { name: "CreateArtifact", options: {}, I: CreateArtifactRequest, O: CreateArtifactResponse }, { name: "FinalizeArtifact", options: {}, I: FinalizeArtifactRequest, O: FinalizeArtifactResponse }, { name: "ListArtifacts", options: {}, I: ListArtifactsRequest, O: ListArtifactsResponse }, - { name: "GetSignedArtifactURL", options: {}, I: GetSignedArtifactURLRequest, O: GetSignedArtifactURLResponse } + { name: "GetSignedArtifactURL", options: {}, I: GetSignedArtifactURLRequest, O: GetSignedArtifactURLResponse }, + { name: "DeleteArtifact", options: {}, I: DeleteArtifactRequest, O: DeleteArtifactResponse } ]); diff --git a/packages/artifact/src/generated/results/api/v1/artifact.twirp.ts b/packages/artifact/src/generated/results/api/v1/artifact.twirp.ts index 4871eb6b..bc092117 100644 --- a/packages/artifact/src/generated/results/api/v1/artifact.twirp.ts +++ b/packages/artifact/src/generated/results/api/v1/artifact.twirp.ts @@ -17,6 +17,8 @@ import { ListArtifactsResponse, GetSignedArtifactURLRequest, GetSignedArtifactURLResponse, + DeleteArtifactRequest, + DeleteArtifactResponse, } from "./artifact"; //==================================// @@ -43,6 +45,9 @@ export interface ArtifactServiceClient { GetSignedArtifactURL( request: GetSignedArtifactURLRequest ): Promise; + DeleteArtifact( + request: DeleteArtifactRequest + ): Promise; } export class ArtifactServiceClientJSON implements ArtifactServiceClient { @@ -53,6 +58,7 @@ export class ArtifactServiceClientJSON implements ArtifactServiceClient { this.FinalizeArtifact.bind(this); this.ListArtifacts.bind(this); this.GetSignedArtifactURL.bind(this); + this.DeleteArtifact.bind(this); } CreateArtifact( request: CreateArtifactRequest @@ -129,6 +135,26 @@ export class ArtifactServiceClientJSON implements ArtifactServiceClient { }) ); } + + DeleteArtifact( + request: DeleteArtifactRequest + ): Promise { + const data = DeleteArtifactRequest.toJson(request, { + useProtoFieldName: true, + emitDefaultValues: false, + }); + const promise = this.rpc.request( + "github.actions.results.api.v1.ArtifactService", + "DeleteArtifact", + "application/json", + data as object + ); + return promise.then((data) => + DeleteArtifactResponse.fromJson(data as any, { + ignoreUnknownFields: true, + }) + ); + } } export class ArtifactServiceClientProtobuf implements ArtifactServiceClient { @@ -139,6 +165,7 @@ export class ArtifactServiceClientProtobuf implements ArtifactServiceClient { this.FinalizeArtifact.bind(this); this.ListArtifacts.bind(this); this.GetSignedArtifactURL.bind(this); + this.DeleteArtifact.bind(this); } CreateArtifact( request: CreateArtifactRequest @@ -197,6 +224,21 @@ export class ArtifactServiceClientProtobuf implements ArtifactServiceClient { GetSignedArtifactURLResponse.fromBinary(data as Uint8Array) ); } + + DeleteArtifact( + request: DeleteArtifactRequest + ): Promise { + const data = DeleteArtifactRequest.toBinary(request); + const promise = this.rpc.request( + "github.actions.results.api.v1.ArtifactService", + "DeleteArtifact", + "application/protobuf", + data + ); + return promise.then((data) => + DeleteArtifactResponse.fromBinary(data as Uint8Array) + ); + } } //==================================// @@ -220,6 +262,10 @@ export interface ArtifactServiceTwirp { ctx: T, request: GetSignedArtifactURLRequest ): Promise; + DeleteArtifact( + ctx: T, + request: DeleteArtifactRequest + ): Promise; } export enum ArtifactServiceMethod { @@ -227,6 +273,7 @@ export enum ArtifactServiceMethod { FinalizeArtifact = "FinalizeArtifact", ListArtifacts = "ListArtifacts", GetSignedArtifactURL = "GetSignedArtifactURL", + DeleteArtifact = "DeleteArtifact", } export const ArtifactServiceMethodList = [ @@ -234,6 +281,7 @@ export const ArtifactServiceMethodList = [ ArtifactServiceMethod.FinalizeArtifact, ArtifactServiceMethod.ListArtifacts, ArtifactServiceMethod.GetSignedArtifactURL, + ArtifactServiceMethod.DeleteArtifact, ]; export function createArtifactServiceServer< @@ -333,6 +381,26 @@ function matchArtifactServiceRoute( interceptors ); }; + case "DeleteArtifact": + return async ( + ctx: T, + service: ArtifactServiceTwirp, + data: Buffer, + interceptors?: Interceptor< + T, + DeleteArtifactRequest, + DeleteArtifactResponse + >[] + ) => { + ctx = { ...ctx, methodName: "DeleteArtifact" }; + await events.onMatch(ctx); + return handleArtifactServiceDeleteArtifactRequest( + ctx, + service, + data, + interceptors + ); + }; default: events.onNotFound(); const msg = `no handler found`; @@ -463,6 +531,35 @@ function handleArtifactServiceGetSignedArtifactURLRequest< throw new TwirpError(TwirpErrorCode.BadRoute, msg); } } + +function handleArtifactServiceDeleteArtifactRequest< + T extends TwirpContext = TwirpContext +>( + ctx: T, + service: ArtifactServiceTwirp, + data: Buffer, + interceptors?: Interceptor[] +): Promise { + switch (ctx.contentType) { + case TwirpContentType.JSON: + return handleArtifactServiceDeleteArtifactJSON( + ctx, + service, + data, + interceptors + ); + case TwirpContentType.Protobuf: + return handleArtifactServiceDeleteArtifactProtobuf( + ctx, + service, + data, + interceptors + ); + default: + const msg = "unexpected Content-Type"; + throw new TwirpError(TwirpErrorCode.BadRoute, msg); + } +} async function handleArtifactServiceCreateArtifactJSON< T extends TwirpContext = TwirpContext >( @@ -646,6 +743,50 @@ async function handleArtifactServiceGetSignedArtifactURLJSON< }) as string ); } + +async function handleArtifactServiceDeleteArtifactJSON< + T extends TwirpContext = TwirpContext +>( + ctx: T, + service: ArtifactServiceTwirp, + data: Buffer, + interceptors?: Interceptor[] +) { + let request: DeleteArtifactRequest; + let response: DeleteArtifactResponse; + + try { + const body = JSON.parse(data.toString() || "{}"); + request = DeleteArtifactRequest.fromJson(body, { + ignoreUnknownFields: true, + }); + } catch (e) { + if (e instanceof Error) { + const msg = "the json request could not be decoded"; + throw new TwirpError(TwirpErrorCode.Malformed, msg).withCause(e, true); + } + } + + if (interceptors && interceptors.length > 0) { + const interceptor = chainInterceptors(...interceptors) as Interceptor< + T, + DeleteArtifactRequest, + DeleteArtifactResponse + >; + response = await interceptor(ctx, request!, (ctx, inputReq) => { + return service.DeleteArtifact(ctx, inputReq); + }); + } else { + response = await service.DeleteArtifact(ctx, request!); + } + + return JSON.stringify( + DeleteArtifactResponse.toJson(response, { + useProtoFieldName: true, + emitDefaultValues: false, + }) as string + ); +} async function handleArtifactServiceCreateArtifactProtobuf< T extends TwirpContext = TwirpContext >( @@ -797,3 +938,39 @@ async function handleArtifactServiceGetSignedArtifactURLProtobuf< return Buffer.from(GetSignedArtifactURLResponse.toBinary(response)); } + +async function handleArtifactServiceDeleteArtifactProtobuf< + T extends TwirpContext = TwirpContext +>( + ctx: T, + service: ArtifactServiceTwirp, + data: Buffer, + interceptors?: Interceptor[] +) { + let request: DeleteArtifactRequest; + let response: DeleteArtifactResponse; + + try { + request = DeleteArtifactRequest.fromBinary(data); + } catch (e) { + if (e instanceof Error) { + const msg = "the protobuf request could not be decoded"; + throw new TwirpError(TwirpErrorCode.Malformed, msg).withCause(e, true); + } + } + + if (interceptors && interceptors.length > 0) { + const interceptor = chainInterceptors(...interceptors) as Interceptor< + T, + DeleteArtifactRequest, + DeleteArtifactResponse + >; + response = await interceptor(ctx, request!, (ctx, inputReq) => { + return service.DeleteArtifact(ctx, inputReq); + }); + } else { + response = await service.DeleteArtifact(ctx, request!); + } + + return Buffer.from(DeleteArtifactResponse.toBinary(response)); +} diff --git a/packages/artifact/src/internal/client.ts b/packages/artifact/src/internal/client.ts index c4971d9f..25565270 100644 --- a/packages/artifact/src/internal/client.ts +++ b/packages/artifact/src/internal/client.ts @@ -8,13 +8,18 @@ import { ListArtifactsOptions, ListArtifactsResponse, DownloadArtifactResponse, - FindOptions + FindOptions, + DeleteArtifactResponse } from './shared/interfaces' import {uploadArtifact} from './upload/upload-artifact' import { downloadArtifactPublic, downloadArtifactInternal } from './download/download-artifact' +import { + deleteArtifactPublic, + deleteArtifactInternal +} from './delete/delete-artifact' import {getArtifactPublic, getArtifactInternal} from './find/get-artifact' import {listArtifactsPublic, listArtifactsInternal} from './find/list-artifacts' import {GHESNotSupportedError} from './shared/errors' @@ -77,7 +82,7 @@ export interface ArtifactClient { * * If `options.findBy` is specified, this will use the public Download Artifact API https://docs.github.com/en/rest/actions/artifacts?apiVersion=2022-11-28#download-an-artifact * - * @param artifactId The name of the artifact to download + * @param artifactId The id of the artifact to download * @param options Extra options that allow for the customization of the download behavior * @returns single DownloadArtifactResponse object */ @@ -85,6 +90,20 @@ export interface ArtifactClient { artifactId: number, options?: DownloadArtifactOptions & FindOptions ): Promise + + /** + * Delete an Artifact + * + * If `options.findBy` is specified, this will use the public Delete Artifact API https://docs.github.com/en/rest/actions/artifacts?apiVersion=2022-11-28#delete-an-artifact + * + * @param artifactName The name of the artifact to delete + * @param options Extra options that allow for the customization of the delete behavior + * @returns single DeleteArtifactResponse object + */ + deleteArtifact( + artifactName: string, + options?: FindOptions + ): Promise } /** @@ -225,4 +244,41 @@ If the error persists, please check whether Actions and API requests are operati throw error } } + + async deleteArtifact( + artifactName: string, + options?: FindOptions + ): Promise { + try { + if (isGhes()) { + throw new GHESNotSupportedError() + } + + if (options?.findBy) { + const { + findBy: {repositoryOwner, repositoryName, workflowRunId, token} + } = options + + return deleteArtifactPublic( + artifactName, + workflowRunId, + repositoryOwner, + repositoryName, + token + ) + } + + return deleteArtifactInternal(artifactName) + } catch (error) { + warning( + `Delete 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).` + ) + + throw error + } + } } diff --git a/packages/artifact/src/internal/delete/delete-artifact.ts b/packages/artifact/src/internal/delete/delete-artifact.ts new file mode 100644 index 00000000..785b4035 --- /dev/null +++ b/packages/artifact/src/internal/delete/delete-artifact.ts @@ -0,0 +1,109 @@ +import {info, debug} from '@actions/core' +import {getOctokit} from '@actions/github' +import {DeleteArtifactResponse} from '../shared/interfaces' +import {getUserAgentString} from '../shared/user-agent' +import {getRetryOptions} from '../find/retry-options' +import {defaults as defaultGitHubOptions} from '@actions/github/lib/utils' +import {requestLog} from '@octokit/plugin-request-log' +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 { + DeleteArtifactRequest, + ListArtifactsRequest, + StringValue +} from '../../generated' +import {getArtifactPublic} from '../find/get-artifact' +import {ArtifactNotFoundError, InvalidResponseError} from '../shared/errors' + +export async function deleteArtifactPublic( + artifactName: string, + workflowRunId: number, + repositoryOwner: string, + repositoryName: string, + token: string +): Promise { + const [retryOpts, requestOpts] = getRetryOptions(defaultGitHubOptions) + + const opts: OctokitOptions = { + log: undefined, + userAgent: getUserAgentString(), + previews: undefined, + retry: retryOpts, + request: requestOpts + } + + const github = getOctokit(token, opts, retry, requestLog) + + const getArtifactResp = await getArtifactPublic( + artifactName, + workflowRunId, + repositoryOwner, + repositoryName, + token + ) + + const deleteArtifactResp = await github.rest.actions.deleteArtifact({ + owner: repositoryOwner, + repo: repositoryName, + artifact_id: getArtifactResp.artifact.id + }) + + if (deleteArtifactResp.status !== 204) { + throw new InvalidResponseError( + `Invalid response from GitHub API: ${deleteArtifactResp.status} (${deleteArtifactResp?.headers?.['x-github-request-id']})` + ) + } + + return { + id: getArtifactResp.artifact.id + } +} + +export async function deleteArtifactInternal( + artifactName +): Promise { + const artifactClient = internalArtifactTwirpClient() + + const {workflowRunBackendId, workflowJobRunBackendId} = + getBackendIdsFromToken() + + const listReq: ListArtifactsRequest = { + workflowRunBackendId, + workflowJobRunBackendId, + nameFilter: StringValue.create({value: artifactName}) + } + + const listRes = await artifactClient.ListArtifacts(listReq) + + if (listRes.artifacts.length === 0) { + throw new ArtifactNotFoundError( + `Artifact not found for name: ${artifactName}` + ) + } + + let artifact = listRes.artifacts[0] + if (listRes.artifacts.length > 1) { + artifact = listRes.artifacts.sort( + (a, b) => Number(b.databaseId) - Number(a.databaseId) + )[0] + + debug( + `More than one artifact found for a single name, returning newest (id: ${artifact.databaseId})` + ) + } + + const req: DeleteArtifactRequest = { + workflowRunBackendId: artifact.workflowRunBackendId, + workflowJobRunBackendId: artifact.workflowJobRunBackendId, + name: artifact.name + } + + const res = await artifactClient.DeleteArtifact(req) + info(`Artifact '${artifactName}' (ID: ${res.artifactId}) deleted`) + + return { + id: Number(res.artifactId) + } +} diff --git a/packages/artifact/src/internal/shared/interfaces.ts b/packages/artifact/src/internal/shared/interfaces.ts index c661721e..eb55ae8b 100644 --- a/packages/artifact/src/internal/shared/interfaces.ts +++ b/packages/artifact/src/internal/shared/interfaces.ts @@ -147,3 +147,13 @@ export interface FindOptions { repositoryName: string } } + +/** + * Response from the server when deleting an artifact + */ +export interface DeleteArtifactResponse { + /** + * The id of the artifact that was deleted + */ + id: number +}