From 1400ae6d4a1b13ff7bda9b40f240854710cb5bef Mon Sep 17 00:00:00 2001 From: Rob Herley Date: Wed, 17 Jan 2024 16:18:49 -0500 Subject: [PATCH 1/5] add public and internal methods to delete artifacts --- __tests__/delete-artifacts.test.ts | 170 +++++++++++++++++ src/generated/results/api/v1/artifact.ts | 148 ++++++++++++++- .../results/api/v1/artifact.twirp.ts | 177 ++++++++++++++++++ src/internal/client.ts | 60 +++++- src/internal/delete/delete-artifact.ts | 80 ++++++++ src/internal/shared/interfaces.ts | 10 + 6 files changed, 642 insertions(+), 3 deletions(-) create mode 100644 __tests__/delete-artifacts.test.ts create mode 100644 src/internal/delete/delete-artifact.ts diff --git a/__tests__/delete-artifacts.test.ts b/__tests__/delete-artifacts.test.ts new file mode 100644 index 0000000..98727d6 --- /dev/null +++ b/__tests__/delete-artifacts.test.ts @@ -0,0 +1,170 @@ +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} 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 return a list of artifacts', async () => { + 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, 'DeleteArtifact') + .mockRejectedValue(new Error('boom')) + await expect( + deleteArtifactInternal(fixtures.artifacts[0].id) + ).rejects.toThrow('boom') + }) + }) +}) diff --git a/src/generated/results/api/v1/artifact.ts b/src/generated/results/api/v1/artifact.ts index acce303..7bb7f4b 100644 --- a/src/generated/results/api/v1/artifact.ts +++ b/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/src/generated/results/api/v1/artifact.twirp.ts b/src/generated/results/api/v1/artifact.twirp.ts index 4871eb6..bc09211 100644 --- a/src/generated/results/api/v1/artifact.twirp.ts +++ b/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/src/internal/client.ts b/src/internal/client.ts index c4971d9..fc55236 100644 --- a/src/internal/client.ts +++ b/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 Download 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/src/internal/delete/delete-artifact.ts b/src/internal/delete/delete-artifact.ts new file mode 100644 index 0000000..4183049 --- /dev/null +++ b/src/internal/delete/delete-artifact.ts @@ -0,0 +1,80 @@ +import {info} 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} from '../../generated' +import {getArtifactPublic} from '../find/get-artifact' +import {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 req: DeleteArtifactRequest = { + workflowRunBackendId, + workflowJobRunBackendId, + name: artifactName + } + + const res = await artifactClient.DeleteArtifact(req) + info(`Artifact '${artifactName}' (ID: ${res.artifactId}) deleted`) + + return { + id: Number(res.artifactId) + } +} diff --git a/src/internal/shared/interfaces.ts b/src/internal/shared/interfaces.ts index c661721..eb55ae8 100644 --- a/src/internal/shared/interfaces.ts +++ b/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 +} From 4f1fa7feb820ed5a5f63310151a99cd9b702261e Mon Sep 17 00:00:00 2001 From: Rob Herley Date: Wed, 17 Jan 2024 16:56:34 -0500 Subject: [PATCH 2/5] fix typo --- src/internal/client.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/internal/client.ts b/src/internal/client.ts index fc55236..2556527 100644 --- a/src/internal/client.ts +++ b/src/internal/client.ts @@ -94,7 +94,7 @@ export interface ArtifactClient { /** * Delete an Artifact * - * 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#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 From 4edb7c1b870710f6c9ee5bda307affa2335c0d86 Mon Sep 17 00:00:00 2001 From: Rob Herley Date: Wed, 17 Jan 2024 17:53:49 -0500 Subject: [PATCH 3/5] list for correct backend ids in internal delete --- __tests__/delete-artifacts.test.ts | 26 +++++++++++++++-- src/internal/delete/delete-artifact.ts | 39 ++++++++++++++++++++++---- 2 files changed, 58 insertions(+), 7 deletions(-) diff --git a/__tests__/delete-artifacts.test.ts b/__tests__/delete-artifacts.test.ts index 98727d6..70a3941 100644 --- a/__tests__/delete-artifacts.test.ts +++ b/__tests__/delete-artifacts.test.ts @@ -6,7 +6,7 @@ import { deleteArtifactPublic } from '../src/internal/delete/delete-artifact' import * as config from '../src/internal/shared/config' -import {ArtifactServiceClientJSON} from '../src/generated' +import {ArtifactServiceClientJSON, Timestamp} from '../src/generated' import * as util from '../src/internal/shared/util' import {noopLogs} from './common' @@ -145,7 +145,18 @@ describe('delete-artifact', () => { .mockReturnValue('https://results.local') }) - it('should return a list of artifacts', async () => { + 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({ @@ -159,6 +170,17 @@ describe('delete-artifact', () => { }) 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')) diff --git a/src/internal/delete/delete-artifact.ts b/src/internal/delete/delete-artifact.ts index 4183049..785b403 100644 --- a/src/internal/delete/delete-artifact.ts +++ b/src/internal/delete/delete-artifact.ts @@ -1,4 +1,4 @@ -import {info} from '@actions/core' +import {info, debug} from '@actions/core' import {getOctokit} from '@actions/github' import {DeleteArtifactResponse} from '../shared/interfaces' import {getUserAgentString} from '../shared/user-agent' @@ -9,9 +9,13 @@ 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} from '../../generated' +import { + DeleteArtifactRequest, + ListArtifactsRequest, + StringValue +} from '../../generated' import {getArtifactPublic} from '../find/get-artifact' -import {InvalidResponseError} from '../shared/errors' +import {ArtifactNotFoundError, InvalidResponseError} from '../shared/errors' export async function deleteArtifactPublic( artifactName: string, @@ -65,10 +69,35 @@ export async function deleteArtifactInternal( const {workflowRunBackendId, workflowJobRunBackendId} = getBackendIdsFromToken() - const req: DeleteArtifactRequest = { + const listReq: ListArtifactsRequest = { workflowRunBackendId, workflowJobRunBackendId, - name: artifactName + 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) From c0ebc50ab58d80370e68ddcc3f0cd6e59f2c9746 Mon Sep 17 00:00:00 2001 From: Rob Herley Date: Wed, 17 Jan 2024 18:21:25 -0500 Subject: [PATCH 4/5] delete example --- README.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/README.md b/README.md index 1e03ecd..9de4e15 100644 --- a/README.md +++ b/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,19 @@ 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. Also supports options to delete from other repos/runs given a token with proper permissions is supplied. + +```js +const {id} = await artifact.deleteArtifact( + // name of the artifact + 'my-artifact' +) + +console.log(`Deleted Artifact ID: ${id}`) +``` + ### 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`. From ef583c5c39bc9d253614e1bf82f0ff4de0801c80 Mon Sep 17 00:00:00 2001 From: Rob Herley Date: Wed, 17 Jan 2024 18:58:58 -0500 Subject: [PATCH 5/5] more delete examples --- README.md | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 9de4e15..5a98da0 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,7 @@ console.log(`Downloaded artifact ${id} to: ${downloadPath}`) ### Delete an Artifact -To delete an artifact, all you need is the name. Also supports options to delete from other repos/runs given a token with proper permissions is supplied. +To delete an artifact, all you need is the name. ```js const {id} = await artifact.deleteArtifact( @@ -117,7 +117,29 @@ const {id} = await artifact.deleteArtifact( 'my-artifact' ) -console.log(`Deleted Artifact ID: ${id}`) +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