From 2b881783d4bd101f3331065e5f01cf85689ea2e3 Mon Sep 17 00:00:00 2001 From: Daniel Kennedy Date: Mon, 26 Jan 2026 16:40:05 -0500 Subject: [PATCH 1/3] Artifact upload: support uploading single un-zipped files --- .../__tests__/upload-artifact.test.ts | 281 +++++++++++++++- .../src/generated/results/api/v1/artifact.ts | 309 +----------------- .../results/api/v1/artifact.twirp-client.ts | 2 +- .../src/internal/shared/interfaces.ts | 6 + .../src/internal/upload/blob-upload.ts | 21 +- .../artifact/src/internal/upload/stream.ts | 49 +++ .../artifact/src/internal/upload/types.ts | 82 +++++ .../src/internal/upload/upload-artifact.ts | 57 +++- packages/artifact/src/internal/upload/zip.ts | 20 +- 9 files changed, 484 insertions(+), 343 deletions(-) create mode 100644 packages/artifact/src/internal/upload/stream.ts create mode 100644 packages/artifact/src/internal/upload/types.ts diff --git a/packages/artifact/__tests__/upload-artifact.test.ts b/packages/artifact/__tests__/upload-artifact.test.ts index 30abab6e23..8045b71d03 100644 --- a/packages/artifact/__tests__/upload-artifact.test.ts +++ b/packages/artifact/__tests__/upload-artifact.test.ts @@ -1,5 +1,6 @@ import * as uploadZipSpecification from '../src/internal/upload/upload-zip-specification' import * as zip from '../src/internal/upload/zip' +import * as stream from '../src/internal/upload/stream' import * as util from '../src/internal/shared/util' import * as config from '../src/internal/shared/config' import {ArtifactServiceClientJSON} from '../src/generated' @@ -150,7 +151,7 @@ describe('upload-artifact', () => { it('should return false if the creation request fails', async () => { jest .spyOn(zip, 'createZipUploadStream') - .mockReturnValue(Promise.resolve(new zip.ZipUploadStream(1))) + .mockReturnValue(Promise.resolve(new stream.WaterMarkedUploadStream(1))) jest .spyOn(ArtifactServiceClientJSON.prototype, 'CreateArtifact') .mockReturnValue(Promise.resolve({ok: false, signedUploadUrl: ''})) @@ -167,7 +168,7 @@ describe('upload-artifact', () => { it('should return false if blob storage upload is unsuccessful', async () => { jest .spyOn(zip, 'createZipUploadStream') - .mockReturnValue(Promise.resolve(new zip.ZipUploadStream(1))) + .mockReturnValue(Promise.resolve(new stream.WaterMarkedUploadStream(1))) jest .spyOn(ArtifactServiceClientJSON.prototype, 'CreateArtifact') .mockReturnValue( @@ -177,7 +178,7 @@ describe('upload-artifact', () => { }) ) jest - .spyOn(blobUpload, 'uploadZipToBlobStorage') + .spyOn(blobUpload, 'uploadToBlobStorage') .mockReturnValue(Promise.reject(new Error('boom'))) const uploadResp = uploadArtifact( @@ -192,7 +193,7 @@ describe('upload-artifact', () => { it('should reject if finalize artifact fails', async () => { jest .spyOn(zip, 'createZipUploadStream') - .mockReturnValue(Promise.resolve(new zip.ZipUploadStream(1))) + .mockReturnValue(Promise.resolve(new stream.WaterMarkedUploadStream(1))) jest .spyOn(ArtifactServiceClientJSON.prototype, 'CreateArtifact') .mockReturnValue( @@ -201,7 +202,7 @@ describe('upload-artifact', () => { signedUploadUrl: 'https://signed-upload-url.com' }) ) - jest.spyOn(blobUpload, 'uploadZipToBlobStorage').mockReturnValue( + jest.spyOn(blobUpload, 'uploadToBlobStorage').mockReturnValue( Promise.resolve({ uploadSize: 1234, sha256Hash: 'test-sha256-hash' @@ -370,4 +371,274 @@ describe('upload-artifact', () => { await expect(uploadResp).rejects.toThrow('Upload progress stalled.') }) + + describe('skipArchive option', () => { + it('should throw an error if skipArchive is true and multiple files are provided', async () => { + const uploadResp = uploadArtifact( + fixtures.inputs.artifactName, + fixtures.inputs.files, + fixtures.inputs.rootDirectory, + {skipArchive: true} + ) + + await expect(uploadResp).rejects.toThrow( + 'skipArchive option is only supported when uploading a single file' + ) + }) + + it('should upload a single file without archiving when skipArchive is true', async () => { + jest + .spyOn(uploadZipSpecification, 'getUploadZipSpecification') + .mockRestore() + + const singleFile = path.join(fixtures.uploadDirectory, 'file1.txt') + const expectedFileName = 'file1.txt' + const expectedContent = 'test 1 file content' + + jest + .spyOn(ArtifactServiceClientJSON.prototype, 'CreateArtifact') + .mockReturnValue( + Promise.resolve({ + ok: true, + signedUploadUrl: 'https://signed-upload-url.local' + }) + ) + jest + .spyOn(ArtifactServiceClientJSON.prototype, 'FinalizeArtifact') + .mockReturnValue( + Promise.resolve({ + ok: true, + artifactId: '1' + }) + ) + + let uploadedContent = '' + let loadedBytes = 0 + uploadStreamMock.mockImplementation( + async ( + stream: NodeJS.ReadableStream, + bufferSize?: number, + maxConcurrency?: number, + options?: BlockBlobUploadStreamOptions + ) => { + const {onProgress} = options || {} + + onProgress?.({loadedBytes: 0}) + return new Promise((resolve, reject) => { + stream.on('data', chunk => { + loadedBytes += chunk.length + uploadedContent += chunk.toString() + onProgress?.({loadedBytes}) + }) + stream.on('end', () => { + onProgress?.({loadedBytes}) + resolve({}) + }) + stream.on('error', err => { + reject(err) + }) + }) + } + ) + + const {id, size, digest} = await uploadArtifact( + fixtures.inputs.artifactName, + [singleFile], + fixtures.uploadDirectory, + {skipArchive: true} + ) + + expect(id).toBe(1) + expect(size).toBe(loadedBytes) + expect(digest).toBeDefined() + expect(digest).toHaveLength(64) + // Verify the uploaded content is the raw file, not a zip + expect(uploadedContent).toBe(expectedContent) + }) + + it('should use the correct MIME type when skipArchive is true', async () => { + jest + .spyOn(uploadZipSpecification, 'getUploadZipSpecification') + .mockRestore() + + const singleFile = path.join(fixtures.uploadDirectory, 'file1.txt') + + const createArtifactSpy = jest + .spyOn(ArtifactServiceClientJSON.prototype, 'CreateArtifact') + .mockReturnValue( + Promise.resolve({ + ok: true, + signedUploadUrl: 'https://signed-upload-url.local' + }) + ) + jest + .spyOn(ArtifactServiceClientJSON.prototype, 'FinalizeArtifact') + .mockReturnValue( + Promise.resolve({ + ok: true, + artifactId: '1' + }) + ) + + uploadStreamMock.mockImplementation( + async ( + stream: NodeJS.ReadableStream, + bufferSize?: number, + maxConcurrency?: number, + options?: BlockBlobUploadStreamOptions + ) => { + const {onProgress} = options || {} + onProgress?.({loadedBytes: 0}) + return new Promise((resolve, reject) => { + stream.on('data', chunk => { + onProgress?.({loadedBytes: chunk.length}) + }) + stream.on('end', () => { + resolve({}) + }) + stream.on('error', err => { + reject(err) + }) + }) + } + ) + + await uploadArtifact( + fixtures.inputs.artifactName, + [singleFile], + fixtures.uploadDirectory, + {skipArchive: true} + ) + + // Verify CreateArtifact was called with the correct MIME type for .txt file + expect(createArtifactSpy).toHaveBeenCalledWith( + expect.objectContaining({ + mimeType: expect.objectContaining({value: 'text/plain'}) + }) + ) + }) + + it('should use application/zip MIME type when skipArchive is false', async () => { + jest + .spyOn(uploadZipSpecification, 'getUploadZipSpecification') + .mockRestore() + + const createArtifactSpy = jest + .spyOn(ArtifactServiceClientJSON.prototype, 'CreateArtifact') + .mockReturnValue( + Promise.resolve({ + ok: true, + signedUploadUrl: 'https://signed-upload-url.local' + }) + ) + jest + .spyOn(ArtifactServiceClientJSON.prototype, 'FinalizeArtifact') + .mockReturnValue( + Promise.resolve({ + ok: true, + artifactId: '1' + }) + ) + + uploadStreamMock.mockImplementation( + async ( + stream: NodeJS.ReadableStream, + bufferSize?: number, + maxConcurrency?: number, + options?: BlockBlobUploadStreamOptions + ) => { + const {onProgress} = options || {} + onProgress?.({loadedBytes: 0}) + return new Promise((resolve, reject) => { + stream.on('data', chunk => { + onProgress?.({loadedBytes: chunk.length}) + }) + stream.on('end', () => { + resolve({}) + }) + stream.on('error', err => { + reject(err) + }) + }) + } + ) + + await uploadArtifact( + fixtures.inputs.artifactName, + fixtures.files.map(file => + path.join(fixtures.uploadDirectory, file.name) + ), + fixtures.uploadDirectory + ) + + // Verify CreateArtifact was called with application/zip MIME type + expect(createArtifactSpy).toHaveBeenCalledWith( + expect.objectContaining({ + mimeType: expect.objectContaining({value: 'application/zip'}) + }) + ) + }) + + it('should use the file basename as artifact name when skipArchive is true', async () => { + jest + .spyOn(uploadZipSpecification, 'getUploadZipSpecification') + .mockRestore() + + const singleFile = path.join(fixtures.uploadDirectory, 'file1.txt') + + const createArtifactSpy = jest + .spyOn(ArtifactServiceClientJSON.prototype, 'CreateArtifact') + .mockReturnValue( + Promise.resolve({ + ok: true, + signedUploadUrl: 'https://signed-upload-url.local' + }) + ) + jest + .spyOn(ArtifactServiceClientJSON.prototype, 'FinalizeArtifact') + .mockReturnValue( + Promise.resolve({ + ok: true, + artifactId: '1' + }) + ) + + uploadStreamMock.mockImplementation( + async ( + stream: NodeJS.ReadableStream, + bufferSize?: number, + maxConcurrency?: number, + options?: BlockBlobUploadStreamOptions + ) => { + const {onProgress} = options || {} + onProgress?.({loadedBytes: 0}) + return new Promise((resolve, reject) => { + stream.on('data', chunk => { + onProgress?.({loadedBytes: chunk.length}) + }) + stream.on('end', () => { + resolve({}) + }) + stream.on('error', err => { + reject(err) + }) + }) + } + ) + + await uploadArtifact( + 'original-name', + [singleFile], + fixtures.uploadDirectory, + {skipArchive: true} + ) + + // Verify CreateArtifact was called with the file basename, not the original name + expect(createArtifactSpy).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'file1.txt' + }) + ) + }) + }) }) diff --git a/packages/artifact/src/generated/results/api/v1/artifact.ts b/packages/artifact/src/generated/results/api/v1/artifact.ts index 31ae4e0117..134b8a59fa 100644 --- a/packages/artifact/src/generated/results/api/v1/artifact.ts +++ b/packages/artifact/src/generated/results/api/v1/artifact.ts @@ -15,66 +15,6 @@ import { MessageType } from "@protobuf-ts/runtime"; import { Int64Value } from "../../../google/protobuf/wrappers"; import { StringValue } from "../../../google/protobuf/wrappers"; import { Timestamp } from "../../../google/protobuf/timestamp"; -/** - * @generated from protobuf message github.actions.results.api.v1.MigrateArtifactRequest - */ -export interface MigrateArtifactRequest { - /** - * @generated from protobuf field: string workflow_run_backend_id = 1; - */ - workflowRunBackendId: string; - /** - * @generated from protobuf field: string name = 2; - */ - name: string; - /** - * @generated from protobuf field: google.protobuf.Timestamp expires_at = 3; - */ - expiresAt?: Timestamp; -} -/** - * @generated from protobuf message github.actions.results.api.v1.MigrateArtifactResponse - */ -export interface MigrateArtifactResponse { - /** - * @generated from protobuf field: bool ok = 1; - */ - ok: boolean; - /** - * @generated from protobuf field: string signed_upload_url = 2; - */ - signedUploadUrl: string; -} -/** - * @generated from protobuf message github.actions.results.api.v1.FinalizeMigratedArtifactRequest - */ -export interface FinalizeMigratedArtifactRequest { - /** - * @generated from protobuf field: string workflow_run_backend_id = 1; - */ - workflowRunBackendId: string; - /** - * @generated from protobuf field: string name = 2; - */ - name: string; - /** - * @generated from protobuf field: int64 size = 3; - */ - size: string; -} -/** - * @generated from protobuf message github.actions.results.api.v1.FinalizeMigratedArtifactResponse - */ -export interface FinalizeMigratedArtifactResponse { - /** - * @generated from protobuf field: bool ok = 1; - */ - ok: boolean; - /** - * @generated from protobuf field: int64 artifact_id = 2; - */ - artifactId: string; -} /** * @generated from protobuf message github.actions.results.api.v1.CreateArtifactRequest */ @@ -99,6 +39,10 @@ export interface CreateArtifactRequest { * @generated from protobuf field: int32 version = 5; */ version: number; + /** + * @generated from protobuf field: google.protobuf.StringValue mime_type = 6; + */ + mimeType?: StringValue; // optional } /** * @generated from protobuf message github.actions.results.api.v1.CreateArtifactResponse @@ -293,236 +237,6 @@ export interface DeleteArtifactResponse { artifactId: string; } // @generated message type with reflection information, may provide speed optimized methods -class MigrateArtifactRequest$Type extends MessageType { - constructor() { - super("github.actions.results.api.v1.MigrateArtifactRequest", [ - { no: 1, name: "workflow_run_backend_id", kind: "scalar", T: 9 /*ScalarType.STRING*/ }, - { no: 2, name: "name", kind: "scalar", T: 9 /*ScalarType.STRING*/ }, - { no: 3, name: "expires_at", kind: "message", T: () => Timestamp } - ]); - } - create(value?: PartialMessage): MigrateArtifactRequest { - const message = { workflowRunBackendId: "", 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?: MigrateArtifactRequest): MigrateArtifactRequest { - 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 name */ 2: - message.name = reader.string(); - break; - case /* google.protobuf.Timestamp expires_at */ 3: - message.expiresAt = Timestamp.internalBinaryRead(reader, reader.uint32(), options, message.expiresAt); - 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: MigrateArtifactRequest, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter { - /* string workflow_run_backend_id = 1; */ - if (message.workflowRunBackendId !== "") - writer.tag(1, WireType.LengthDelimited).string(message.workflowRunBackendId); - /* string name = 2; */ - if (message.name !== "") - writer.tag(2, WireType.LengthDelimited).string(message.name); - /* google.protobuf.Timestamp expires_at = 3; */ - if (message.expiresAt) - Timestamp.internalBinaryWrite(message.expiresAt, writer.tag(3, WireType.LengthDelimited).fork(), options).join(); - 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.MigrateArtifactRequest - */ -export const MigrateArtifactRequest = new MigrateArtifactRequest$Type(); -// @generated message type with reflection information, may provide speed optimized methods -class MigrateArtifactResponse$Type extends MessageType { - constructor() { - super("github.actions.results.api.v1.MigrateArtifactResponse", [ - { no: 1, name: "ok", kind: "scalar", T: 8 /*ScalarType.BOOL*/ }, - { no: 2, name: "signed_upload_url", kind: "scalar", T: 9 /*ScalarType.STRING*/ } - ]); - } - create(value?: PartialMessage): MigrateArtifactResponse { - const message = { ok: false, signedUploadUrl: "" }; - 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?: MigrateArtifactResponse): MigrateArtifactResponse { - 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 /* string signed_upload_url */ 2: - message.signedUploadUrl = 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: MigrateArtifactResponse, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter { - /* bool ok = 1; */ - if (message.ok !== false) - writer.tag(1, WireType.Varint).bool(message.ok); - /* string signed_upload_url = 2; */ - if (message.signedUploadUrl !== "") - writer.tag(2, WireType.LengthDelimited).string(message.signedUploadUrl); - 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.MigrateArtifactResponse - */ -export const MigrateArtifactResponse = new MigrateArtifactResponse$Type(); -// @generated message type with reflection information, may provide speed optimized methods -class FinalizeMigratedArtifactRequest$Type extends MessageType { - constructor() { - super("github.actions.results.api.v1.FinalizeMigratedArtifactRequest", [ - { no: 1, name: "workflow_run_backend_id", kind: "scalar", T: 9 /*ScalarType.STRING*/ }, - { no: 2, name: "name", kind: "scalar", T: 9 /*ScalarType.STRING*/ }, - { no: 3, name: "size", kind: "scalar", T: 3 /*ScalarType.INT64*/ } - ]); - } - create(value?: PartialMessage): FinalizeMigratedArtifactRequest { - const message = { workflowRunBackendId: "", name: "", size: "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?: FinalizeMigratedArtifactRequest): FinalizeMigratedArtifactRequest { - 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 name */ 2: - message.name = reader.string(); - break; - case /* int64 size */ 3: - message.size = 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: FinalizeMigratedArtifactRequest, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter { - /* string workflow_run_backend_id = 1; */ - if (message.workflowRunBackendId !== "") - writer.tag(1, WireType.LengthDelimited).string(message.workflowRunBackendId); - /* string name = 2; */ - if (message.name !== "") - writer.tag(2, WireType.LengthDelimited).string(message.name); - /* int64 size = 3; */ - if (message.size !== "0") - writer.tag(3, WireType.Varint).int64(message.size); - 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.FinalizeMigratedArtifactRequest - */ -export const FinalizeMigratedArtifactRequest = new FinalizeMigratedArtifactRequest$Type(); -// @generated message type with reflection information, may provide speed optimized methods -class FinalizeMigratedArtifactResponse$Type extends MessageType { - constructor() { - super("github.actions.results.api.v1.FinalizeMigratedArtifactResponse", [ - { no: 1, name: "ok", kind: "scalar", T: 8 /*ScalarType.BOOL*/ }, - { no: 2, name: "artifact_id", kind: "scalar", T: 3 /*ScalarType.INT64*/ } - ]); - } - create(value?: PartialMessage): FinalizeMigratedArtifactResponse { - 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?: FinalizeMigratedArtifactResponse): FinalizeMigratedArtifactResponse { - 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: FinalizeMigratedArtifactResponse, 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.FinalizeMigratedArtifactResponse - */ -export const FinalizeMigratedArtifactResponse = new FinalizeMigratedArtifactResponse$Type(); -// @generated message type with reflection information, may provide speed optimized methods class CreateArtifactRequest$Type extends MessageType { constructor() { super("github.actions.results.api.v1.CreateArtifactRequest", [ @@ -530,7 +244,8 @@ class CreateArtifactRequest$Type extends MessageType { { no: 2, name: "workflow_job_run_backend_id", kind: "scalar", T: 9 /*ScalarType.STRING*/ }, { no: 3, name: "name", kind: "scalar", T: 9 /*ScalarType.STRING*/ }, { no: 4, name: "expires_at", kind: "message", T: () => Timestamp }, - { no: 5, name: "version", kind: "scalar", T: 5 /*ScalarType.INT32*/ } + { no: 5, name: "version", kind: "scalar", T: 5 /*ScalarType.INT32*/ }, + { no: 6, name: "mime_type", kind: "message", T: () => StringValue } ]); } create(value?: PartialMessage): CreateArtifactRequest { @@ -560,6 +275,9 @@ class CreateArtifactRequest$Type extends MessageType { case /* int32 version */ 5: message.version = reader.int32(); break; + case /* google.protobuf.StringValue mime_type */ 6: + message.mimeType = StringValue.internalBinaryRead(reader, reader.uint32(), options, message.mimeType); + break; default: let u = options.readUnknownField; if (u === "throw") @@ -587,6 +305,9 @@ class CreateArtifactRequest$Type extends MessageType { /* int32 version = 5; */ if (message.version !== 0) writer.tag(5, WireType.Varint).int32(message.version); + /* google.protobuf.StringValue mime_type = 6; */ + if (message.mimeType) + StringValue.internalBinaryWrite(message.mimeType, writer.tag(6, WireType.LengthDelimited).fork(), options).join(); let u = options.writeUnknownFields; if (u !== false) (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); @@ -852,7 +573,7 @@ export const ListArtifactsRequest = new ListArtifactsRequest$Type(); class ListArtifactsResponse$Type extends MessageType { constructor() { super("github.actions.results.api.v1.ListArtifactsResponse", [ - { no: 1, name: "artifacts", kind: "message", repeat: 1 /*RepeatType.PACKED*/, T: () => ListArtifactsResponse_MonolithArtifact } + { no: 1, name: "artifacts", kind: "message", repeat: 2 /*RepeatType.UNPACKED*/, T: () => ListArtifactsResponse_MonolithArtifact } ]); } create(value?: PartialMessage): ListArtifactsResponse { @@ -1215,7 +936,5 @@ export const ArtifactService = new ServiceType("github.actions.results.api.v1.Ar { name: "FinalizeArtifact", options: {}, I: FinalizeArtifactRequest, O: FinalizeArtifactResponse }, { name: "ListArtifacts", options: {}, I: ListArtifactsRequest, O: ListArtifactsResponse }, { name: "GetSignedArtifactURL", options: {}, I: GetSignedArtifactURLRequest, O: GetSignedArtifactURLResponse }, - { name: "DeleteArtifact", options: {}, I: DeleteArtifactRequest, O: DeleteArtifactResponse }, - { name: "MigrateArtifact", options: {}, I: MigrateArtifactRequest, O: MigrateArtifactResponse }, - { name: "FinalizeMigratedArtifact", options: {}, I: FinalizeMigratedArtifactRequest, O: FinalizeMigratedArtifactResponse } + { name: "DeleteArtifact", options: {}, I: DeleteArtifactRequest, O: DeleteArtifactResponse } ]); \ No newline at end of file diff --git a/packages/artifact/src/generated/results/api/v1/artifact.twirp-client.ts b/packages/artifact/src/generated/results/api/v1/artifact.twirp-client.ts index eeca4f5807..d68b1b8b2c 100644 --- a/packages/artifact/src/generated/results/api/v1/artifact.twirp-client.ts +++ b/packages/artifact/src/generated/results/api/v1/artifact.twirp-client.ts @@ -229,4 +229,4 @@ export class ArtifactServiceClientProtobuf implements ArtifactServiceClient { DeleteArtifactResponse.fromBinary(data as Uint8Array) ); } -} +} \ No newline at end of file diff --git a/packages/artifact/src/internal/shared/interfaces.ts b/packages/artifact/src/internal/shared/interfaces.ts index 9675d39a3e..02ea98e5f1 100644 --- a/packages/artifact/src/internal/shared/interfaces.ts +++ b/packages/artifact/src/internal/shared/interfaces.ts @@ -50,6 +50,12 @@ export interface UploadArtifactOptions { * For large files that are not easily compressed, a value of 0 is recommended for significantly faster uploads. */ compressionLevel?: number + /** + * If true, the artifact will be uploaded without being archived (zipped). + * This is only supported when uploading a single file. + * When using this option, the artifact will not be compressed. + */ + skipArchive?: boolean } /** diff --git a/packages/artifact/src/internal/upload/blob-upload.ts b/packages/artifact/src/internal/upload/blob-upload.ts index 225708c23b..47008b6356 100644 --- a/packages/artifact/src/internal/upload/blob-upload.ts +++ b/packages/artifact/src/internal/upload/blob-upload.ts @@ -1,6 +1,6 @@ import {BlobClient, BlockBlobUploadStreamOptions} from '@azure/storage-blob' import {TransferProgressEvent} from '@azure/core-http-compat' -import {ZipUploadStream} from './zip' +import {WaterMarkedUploadStream} from './stream' import { getUploadChunkSize, getConcurrency, @@ -23,9 +23,10 @@ export interface BlobUploadResponse { sha256Hash?: string } -export async function uploadZipToBlobStorage( +export async function uploadToBlobStorage( authenticatedUploadURL: string, - zipUploadStream: ZipUploadStream + uploadStream: WaterMarkedUploadStream, + contentType: string ): Promise { let uploadByteCount = 0 let lastProgressTime = Date.now() @@ -51,7 +52,7 @@ export async function uploadZipToBlobStorage( const blockBlobClient = blobClient.getBlockBlobClient() core.debug( - `Uploading artifact zip to blob storage with maxConcurrency: ${maxConcurrency}, bufferSize: ${bufferSize}` + `Uploading artifact to blob storage with maxConcurrency: ${maxConcurrency}, bufferSize: ${bufferSize}, contentType: ${contentType}` ) const uploadCallback = (progress: TransferProgressEvent): void => { @@ -61,24 +62,24 @@ export async function uploadZipToBlobStorage( } const options: BlockBlobUploadStreamOptions = { - blobHTTPHeaders: {blobContentType: 'zip'}, + blobHTTPHeaders: {blobContentType: contentType}, onProgress: uploadCallback, abortSignal: abortController.signal } let sha256Hash: string | undefined = undefined - const uploadStream = new stream.PassThrough() + const blobUploadStream = new stream.PassThrough() const hashStream = crypto.createHash('sha256') - zipUploadStream.pipe(uploadStream) // This stream is used for the upload - zipUploadStream.pipe(hashStream).setEncoding('hex') // This stream is used to compute a hash of the zip content that gets used. Integrity check + uploadStream.pipe(blobUploadStream) // This stream is used for the upload + uploadStream.pipe(hashStream).setEncoding('hex') // This stream is used to compute a hash of the content for integrity check core.info('Beginning upload of artifact content to blob storage') try { await Promise.race([ blockBlobClient.uploadStream( - uploadStream, + blobUploadStream, bufferSize, maxConcurrency, options @@ -98,7 +99,7 @@ export async function uploadZipToBlobStorage( hashStream.end() sha256Hash = hashStream.read() as string - core.info(`SHA256 digest of uploaded artifact zip is ${sha256Hash}`) + core.info(`SHA256 digest of uploaded artifact is ${sha256Hash}`) if (uploadByteCount === 0) { core.warning( diff --git a/packages/artifact/src/internal/upload/stream.ts b/packages/artifact/src/internal/upload/stream.ts new file mode 100644 index 0000000000..b8c44da715 --- /dev/null +++ b/packages/artifact/src/internal/upload/stream.ts @@ -0,0 +1,49 @@ +import * as stream from 'stream' +import * as fs from 'fs' +import {realpath} from 'fs/promises' +import * as core from '@actions/core' +import {getUploadChunkSize} from '../shared/config' + +// Custom stream transformer so we can set the highWaterMark property +// See https://github.com/nodejs/node/issues/8855 +export class WaterMarkedUploadStream extends stream.Transform { + constructor(bufferSize: number) { + super({ + highWaterMark: bufferSize + }) + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + _transform(chunk: any, enc: any, cb: any): void { + cb(null, chunk) + } +} + +export async function createRawFileUploadStream( + filePath: string +): Promise { + core.debug(`Creating raw file upload stream for: ${filePath}`) + + const bufferSize = getUploadChunkSize() + const uploadStream = new WaterMarkedUploadStream(bufferSize) + + // Check if symlink and resolve the source path + let sourcePath = filePath + const stats = await fs.promises.lstat(filePath) + if (stats.isSymbolicLink()) { + sourcePath = await realpath(filePath) + } + + // Create a read stream from the file and pipe it to the upload stream + const fileStream = fs.createReadStream(sourcePath, {highWaterMark: bufferSize}) + + fileStream.on('error', error => { + core.error('An error has occurred while reading the file for upload') + core.info(String(error)) + throw new Error('An error has occurred during file read for the artifact') + }) + + fileStream.pipe(uploadStream) + + return uploadStream +} diff --git a/packages/artifact/src/internal/upload/types.ts b/packages/artifact/src/internal/upload/types.ts new file mode 100644 index 0000000000..1ebbe8bcb0 --- /dev/null +++ b/packages/artifact/src/internal/upload/types.ts @@ -0,0 +1,82 @@ +import * as path from 'path' + +/** + * Maps file extensions to MIME types + */ +const mimeTypes: Record = { + // Text + '.txt': 'text/plain', + '.html': 'text/html', + '.htm': 'text/html', + '.css': 'text/css', + '.csv': 'text/csv', + '.xml': 'text/xml', + '.md': 'text/markdown', + + // JavaScript/JSON + '.js': 'application/javascript', + '.mjs': 'application/javascript', + '.json': 'application/json', + + // Images + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.svg': 'image/svg+xml', + '.webp': 'image/webp', + '.ico': 'image/x-icon', + '.bmp': 'image/bmp', + '.tiff': 'image/tiff', + '.tif': 'image/tiff', + + // Audio + '.mp3': 'audio/mpeg', + '.wav': 'audio/wav', + '.ogg': 'audio/ogg', + '.flac': 'audio/flac', + + // Video + '.mp4': 'video/mp4', + '.webm': 'video/webm', + '.avi': 'video/x-msvideo', + '.mov': 'video/quicktime', + + // Documents + '.pdf': 'application/pdf', + '.doc': 'application/msword', + '.docx': + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + '.xls': 'application/vnd.ms-excel', + '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + '.ppt': 'application/vnd.ms-powerpoint', + '.pptx': + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + + // Archives + '.zip': 'application/zip', + '.tar': 'application/x-tar', + '.gz': 'application/gzip', + '.rar': 'application/vnd.rar', + '.7z': 'application/x-7z-compressed', + + // Code/Data + '.wasm': 'application/wasm', + '.yaml': 'application/x-yaml', + '.yml': 'application/x-yaml', + + // Fonts + '.woff': 'font/woff', + '.woff2': 'font/woff2', + '.ttf': 'font/ttf', + '.otf': 'font/otf', + '.eot': 'application/vnd.ms-fontobject' +} + +/** + * Gets the MIME type for a file based on its extension + */ +export function getMimeType(filePath: string): string { + const ext = path.extname(filePath).toLowerCase() + return mimeTypes[ext] || 'application/octet-stream' +} diff --git a/packages/artifact/src/internal/upload/upload-artifact.ts b/packages/artifact/src/internal/upload/upload-artifact.ts index 81be322c75..fdbbf649a3 100644 --- a/packages/artifact/src/internal/upload/upload-artifact.ts +++ b/packages/artifact/src/internal/upload/upload-artifact.ts @@ -1,4 +1,5 @@ import * as core from '@actions/core' +import * as path from 'path' import { UploadArtifactOptions, UploadArtifactResponse @@ -12,14 +13,16 @@ import { validateRootDirectory } from './upload-zip-specification' import {getBackendIdsFromToken} from '../shared/util' -import {uploadZipToBlobStorage} from './blob-upload' +import {uploadToBlobStorage} from './blob-upload' import {createZipUploadStream} from './zip' +import {createRawFileUploadStream, WaterMarkedUploadStream} from './stream' import { CreateArtifactRequest, FinalizeArtifactRequest, StringValue } from '../../generated' import {FilesNotFoundError, InvalidResponseError} from '../shared/errors' +import {getMimeType} from './types' export async function uploadArtifact( name: string, @@ -27,6 +30,18 @@ export async function uploadArtifact( rootDirectory: string, options?: UploadArtifactOptions | undefined ): Promise { + let artifactFileName = `${name}.zip` + if (options?.skipArchive) { + if (files.length > 1){ + throw new Error( + 'skipArchive option is only supported when uploading a single file' + ) + } + + artifactFileName = path.basename(files[0]) + name = artifactFileName + } + validateArtifactName(name) validateRootDirectory(rootDirectory) @@ -34,11 +49,13 @@ export async function uploadArtifact( files, rootDirectory ) - if (zipSpecification.length === 0) { + + if (!options?.skipArchive && zipSpecification.length === 0) { throw new FilesNotFoundError( zipSpecification.flatMap(s => (s.sourcePath ? [s.sourcePath] : [])) ) } + const contentType = getMimeType(artifactFileName) // get the IDs needed for the artifact creation const backendIds = getBackendIdsFromToken() @@ -50,8 +67,9 @@ export async function uploadArtifact( const createArtifactReq: CreateArtifactRequest = { workflowRunBackendId: backendIds.workflowRunBackendId, workflowJobRunBackendId: backendIds.workflowJobRunBackendId, - name, - version: 4 + name: name, + mimeType: StringValue.create({value: contentType}), + version: 7 } // if there is a retention period, add it to the request @@ -68,22 +86,31 @@ export async function uploadArtifact( ) } - const zipUploadStream = await createZipUploadStream( - zipSpecification, - options?.compressionLevel - ) + let stream : WaterMarkedUploadStream - // Upload zip to blob storage - const uploadResult = await uploadZipToBlobStorage( - createArtifactResp.signedUploadUrl, - zipUploadStream - ) + if (options?.skipArchive) { + // Upload raw file without archiving + stream = await createRawFileUploadStream(files[0]) + } else { + // Create and upload zip archive + stream = await createZipUploadStream( + zipSpecification, + options?.compressionLevel + ) + } + + core.info(`Uploading artifact: ${artifactFileName}`) + const uploadResult = await uploadToBlobStorage( + createArtifactResp.signedUploadUrl, + stream, + contentType + ) // finalize the artifact const finalizeArtifactReq: FinalizeArtifactRequest = { workflowRunBackendId: backendIds.workflowRunBackendId, workflowJobRunBackendId: backendIds.workflowJobRunBackendId, - name, + name: name, size: uploadResult.uploadSize ? uploadResult.uploadSize.toString() : '0' } @@ -105,7 +132,7 @@ export async function uploadArtifact( const artifactId = BigInt(finalizeArtifactResp.artifactId) core.info( - `Artifact ${name}.zip successfully finalized. Artifact ID ${artifactId}` + `Artifact ${name} successfully finalized. Artifact ID ${artifactId}` ) return { diff --git a/packages/artifact/src/internal/upload/zip.ts b/packages/artifact/src/internal/upload/zip.ts index 5ea4403462..dae90b6f5b 100644 --- a/packages/artifact/src/internal/upload/zip.ts +++ b/packages/artifact/src/internal/upload/zip.ts @@ -4,28 +4,14 @@ import * as archiver from 'archiver' import * as core from '@actions/core' import {UploadZipSpecification} from './upload-zip-specification' import {getUploadChunkSize} from '../shared/config' +import {WaterMarkedUploadStream} from './stream' export const DEFAULT_COMPRESSION_LEVEL = 6 -// Custom stream transformer so we can set the highWaterMark property -// See https://github.com/nodejs/node/issues/8855 -export class ZipUploadStream extends stream.Transform { - constructor(bufferSize: number) { - super({ - highWaterMark: bufferSize - }) - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - _transform(chunk: any, enc: any, cb: any): void { - cb(null, chunk) - } -} - export async function createZipUploadStream( uploadSpecification: UploadZipSpecification[], compressionLevel: number = DEFAULT_COMPRESSION_LEVEL -): Promise { +): Promise { core.debug( `Creating Artifact archive with compressionLevel: ${compressionLevel}` ) @@ -60,7 +46,7 @@ export async function createZipUploadStream( } const bufferSize = getUploadChunkSize() - const zipUploadStream = new ZipUploadStream(bufferSize) + const zipUploadStream = new WaterMarkedUploadStream(bufferSize) core.debug( `Zip write high watermark value ${zipUploadStream.writableHighWaterMark}` From 7f545a31ce1d2f6f702368192bf3c1d4792d79fd Mon Sep 17 00:00:00 2001 From: Daniel Kennedy Date: Mon, 26 Jan 2026 16:56:06 -0500 Subject: [PATCH 2/3] Fix linters --- packages/artifact/__tests__/upload-artifact.test.ts | 1 - packages/artifact/src/internal/upload/stream.ts | 4 +++- .../artifact/src/internal/upload/upload-artifact.ts | 12 ++++++------ packages/artifact/src/internal/upload/zip.ts | 1 - 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/artifact/__tests__/upload-artifact.test.ts b/packages/artifact/__tests__/upload-artifact.test.ts index 8045b71d03..180b860c80 100644 --- a/packages/artifact/__tests__/upload-artifact.test.ts +++ b/packages/artifact/__tests__/upload-artifact.test.ts @@ -392,7 +392,6 @@ describe('upload-artifact', () => { .mockRestore() const singleFile = path.join(fixtures.uploadDirectory, 'file1.txt') - const expectedFileName = 'file1.txt' const expectedContent = 'test 1 file content' jest diff --git a/packages/artifact/src/internal/upload/stream.ts b/packages/artifact/src/internal/upload/stream.ts index b8c44da715..8894c8b6d6 100644 --- a/packages/artifact/src/internal/upload/stream.ts +++ b/packages/artifact/src/internal/upload/stream.ts @@ -35,7 +35,9 @@ export async function createRawFileUploadStream( } // Create a read stream from the file and pipe it to the upload stream - const fileStream = fs.createReadStream(sourcePath, {highWaterMark: bufferSize}) + const fileStream = fs.createReadStream(sourcePath, { + highWaterMark: bufferSize + }) fileStream.on('error', error => { core.error('An error has occurred while reading the file for upload') diff --git a/packages/artifact/src/internal/upload/upload-artifact.ts b/packages/artifact/src/internal/upload/upload-artifact.ts index fdbbf649a3..f1594bf91c 100644 --- a/packages/artifact/src/internal/upload/upload-artifact.ts +++ b/packages/artifact/src/internal/upload/upload-artifact.ts @@ -32,7 +32,7 @@ export async function uploadArtifact( ): Promise { let artifactFileName = `${name}.zip` if (options?.skipArchive) { - if (files.length > 1){ + if (files.length > 1) { throw new Error( 'skipArchive option is only supported when uploading a single file' ) @@ -86,7 +86,7 @@ export async function uploadArtifact( ) } - let stream : WaterMarkedUploadStream + let stream: WaterMarkedUploadStream if (options?.skipArchive) { // Upload raw file without archiving @@ -101,10 +101,10 @@ export async function uploadArtifact( core.info(`Uploading artifact: ${artifactFileName}`) const uploadResult = await uploadToBlobStorage( - createArtifactResp.signedUploadUrl, - stream, - contentType - ) + createArtifactResp.signedUploadUrl, + stream, + contentType + ) // finalize the artifact const finalizeArtifactReq: FinalizeArtifactRequest = { diff --git a/packages/artifact/src/internal/upload/zip.ts b/packages/artifact/src/internal/upload/zip.ts index dae90b6f5b..014a0b85f3 100644 --- a/packages/artifact/src/internal/upload/zip.ts +++ b/packages/artifact/src/internal/upload/zip.ts @@ -1,4 +1,3 @@ -import * as stream from 'stream' import {realpath} from 'fs/promises' import * as archiver from 'archiver' import * as core from '@actions/core' From 15fbe0f27275c78b0fd7f6aa3a9913b9c45afa5c Mon Sep 17 00:00:00 2001 From: Daniel Kennedy Date: Mon, 26 Jan 2026 17:04:41 -0500 Subject: [PATCH 3/3] Fix lint again --- packages/artifact/src/internal/upload/upload-artifact.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/artifact/src/internal/upload/upload-artifact.ts b/packages/artifact/src/internal/upload/upload-artifact.ts index f1594bf91c..8963f473da 100644 --- a/packages/artifact/src/internal/upload/upload-artifact.ts +++ b/packages/artifact/src/internal/upload/upload-artifact.ts @@ -67,7 +67,7 @@ export async function uploadArtifact( const createArtifactReq: CreateArtifactRequest = { workflowRunBackendId: backendIds.workflowRunBackendId, workflowJobRunBackendId: backendIds.workflowJobRunBackendId, - name: name, + name, mimeType: StringValue.create({value: contentType}), version: 7 } @@ -110,7 +110,7 @@ export async function uploadArtifact( const finalizeArtifactReq: FinalizeArtifactRequest = { workflowRunBackendId: backendIds.workflowRunBackendId, workflowJobRunBackendId: backendIds.workflowJobRunBackendId, - name: name, + name, size: uploadResult.uploadSize ? uploadResult.uploadSize.toString() : '0' }