From b62d4c91b60dda013224ff324dead43a4f0bc21b Mon Sep 17 00:00:00 2001 From: Rob Herley Date: Wed, 17 Jan 2024 16:18:49 -0500 Subject: [PATCH 1/6] 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 ++++++++++++++++++ packages/artifact/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 packages/artifact/__tests__/delete-artifacts.test.ts create mode 100644 packages/artifact/src/internal/delete/delete-artifact.ts diff --git a/packages/artifact/__tests__/delete-artifacts.test.ts b/packages/artifact/__tests__/delete-artifacts.test.ts new file mode 100644 index 00000000..98727d62 --- /dev/null +++ b/packages/artifact/__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/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..fc55236a 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 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/packages/artifact/src/internal/delete/delete-artifact.ts b/packages/artifact/src/internal/delete/delete-artifact.ts new file mode 100644 index 00000000..4183049b --- /dev/null +++ b/packages/artifact/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/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 +} From 7fd71a5e13092dd1a933e6d30e703a0f3f1d6726 Mon Sep 17 00:00:00 2001 From: Rob Herley Date: Wed, 17 Jan 2024 16:56:34 -0500 Subject: [PATCH 2/6] fix typo --- packages/artifact/src/internal/client.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/artifact/src/internal/client.ts b/packages/artifact/src/internal/client.ts index fc55236a..25565270 100644 --- a/packages/artifact/src/internal/client.ts +++ b/packages/artifact/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 2f5fb3f92b71b303a20d6b74fbc64dc64f3e32d8 Mon Sep 17 00:00:00 2001 From: Rob Herley Date: Wed, 17 Jan 2024 17:53:49 -0500 Subject: [PATCH 3/6] 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/packages/artifact/__tests__/delete-artifacts.test.ts b/packages/artifact/__tests__/delete-artifacts.test.ts index 98727d62..70a39412 100644 --- a/packages/artifact/__tests__/delete-artifacts.test.ts +++ b/packages/artifact/__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/packages/artifact/src/internal/delete/delete-artifact.ts b/packages/artifact/src/internal/delete/delete-artifact.ts index 4183049b..785b4035 100644 --- a/packages/artifact/src/internal/delete/delete-artifact.ts +++ b/packages/artifact/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 2ad687a32e07b6a6864773bc61602cfacc19476a Mon Sep 17 00:00:00 2001 From: Rob Herley Date: Wed, 17 Jan 2024 17:54:10 -0500 Subject: [PATCH 4/6] add integration test for delete --- .github/workflows/artifact-tests.yml | 30 +++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) 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.` + ) + } + From abe0bd98df90ce012351fb61591597bbb4a4d1f6 Mon Sep 17 00:00:00 2001 From: Rob Herley Date: Wed, 17 Jan 2024 18:21:25 -0500 Subject: [PATCH 5/6] delete example --- packages/artifact/README.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/packages/artifact/README.md b/packages/artifact/README.md index 1e03ecdf..9de4e154 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,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 1852eb2115645114d47975ca9eef48d4e56b17b0 Mon Sep 17 00:00:00 2001 From: Rob Herley Date: Wed, 17 Jan 2024 18:58:58 -0500 Subject: [PATCH 6/6] more delete examples --- packages/artifact/README.md | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/packages/artifact/README.md b/packages/artifact/README.md index 9de4e154..5a98da0e 100644 --- a/packages/artifact/README.md +++ b/packages/artifact/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