From 98cf174a353bdf6f1863d11b6fc31d8ae6ce0f38 Mon Sep 17 00:00:00 2001 From: Muhammad Ibragimov Date: Sat, 7 Mar 2026 14:17:36 +0000 Subject: [PATCH] fix(artifact): avoid zero-byte warning for empty raw-file uploads When uploadArtifact is called with skipArchive=true on an empty file, blob upload progress remains at 0 bytes by design. The generic zero-byte warning in uploadToBlobStorage incorrectly reports this as a potential upload problem, even though the upload is valid. Add an internal upload option to suppress the zero-byte warning for this specific expected case. uploadArtifact now detects empty raw files and passes suppressZeroByteWarning=true only for skipArchive empty-file uploads. Also add a regression test to verify that empty raw-file uploads do not emit the warning. Fixes actions/toolkit#2333 --- .../__tests__/upload-artifact.test.ts | 66 +++++++++++++++++++ .../src/internal/upload/blob-upload.ts | 13 ++-- .../src/internal/upload/upload-artifact.ts | 8 ++- 3 files changed, 82 insertions(+), 5 deletions(-) diff --git a/packages/artifact/__tests__/upload-artifact.test.ts b/packages/artifact/__tests__/upload-artifact.test.ts index 9865815dc7..de4c584751 100644 --- a/packages/artifact/__tests__/upload-artifact.test.ts +++ b/packages/artifact/__tests__/upload-artifact.test.ts @@ -12,6 +12,7 @@ import {BlockBlobUploadStreamOptions} from '@azure/storage-blob' import * as fs from 'fs' import * as path from 'path' import unzip from 'unzip-stream' +import * as core from '@actions/core' const uploadStreamMock = jest.fn() const blockBlobClientMock = jest.fn().mockImplementation(() => ({ @@ -466,6 +467,71 @@ describe('upload-artifact', () => { expect(uploadedContent).toBe(expectedContent) }) + it('should not warn when uploading an empty file with skipArchive enabled', async () => { + jest + .spyOn(uploadZipSpecification, 'getUploadZipSpecification') + .mockRestore() + + const emptyFile = path.join(fixtures.uploadDirectory, 'empty.txt') + fs.writeFileSync(emptyFile, '') + + 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', () => { + onProgress?.({loadedBytes: 0}) + }) + stream.on('end', () => { + onProgress?.({loadedBytes: 0}) + resolve({}) + }) + stream.on('error', err => { + reject(err) + }) + }) + } + ) + + const warningSpy = core.warning as jest.Mock + warningSpy.mockClear() + + await uploadArtifact( + fixtures.inputs.artifactName, + [emptyFile], + fixtures.uploadDirectory, + {skipArchive: true} + ) + + expect(warningSpy).not.toHaveBeenCalledWith( + 'No data was uploaded to blob storage. Reported upload byte count is 0.' + ) + }) + it('should use the correct MIME type when skipArchive is true', async () => { jest .spyOn(uploadZipSpecification, 'getUploadZipSpecification') diff --git a/packages/artifact/src/internal/upload/blob-upload.ts b/packages/artifact/src/internal/upload/blob-upload.ts index be6fcef59b..a7b4c65239 100644 --- a/packages/artifact/src/internal/upload/blob-upload.ts +++ b/packages/artifact/src/internal/upload/blob-upload.ts @@ -23,10 +23,15 @@ export interface BlobUploadResponse { sha256Hash?: string } +export interface BlobUploadOptions { + suppressZeroByteWarning?: boolean +} + export async function uploadToBlobStorage( authenticatedUploadURL: string, uploadStream: WaterMarkedUploadStream, - contentType: string + contentType: string, + options?: BlobUploadOptions ): Promise { let uploadByteCount = 0 let lastProgressTime = Date.now() @@ -61,7 +66,7 @@ export async function uploadToBlobStorage( lastProgressTime = Date.now() } - const options: BlockBlobUploadStreamOptions = { + const uploadOptions: BlockBlobUploadStreamOptions = { blobHTTPHeaders: {blobContentType: contentType}, onProgress: uploadCallback, abortSignal: abortController.signal @@ -82,7 +87,7 @@ export async function uploadToBlobStorage( blobUploadStream, bufferSize, maxConcurrency, - options + uploadOptions ), chunkTimer(getUploadChunkTimeout()) ]) @@ -101,7 +106,7 @@ export async function uploadToBlobStorage( sha256Hash = hashStream.read() as string core.info(`SHA256 digest of uploaded artifact is ${sha256Hash}`) - if (uploadByteCount === 0) { + if (uploadByteCount === 0 && !options?.suppressZeroByteWarning) { core.warning( `No data was uploaded to blob storage. Reported upload byte count is 0.` ) diff --git a/packages/artifact/src/internal/upload/upload-artifact.ts b/packages/artifact/src/internal/upload/upload-artifact.ts index c7a6e60b75..765daa4d35 100644 --- a/packages/artifact/src/internal/upload/upload-artifact.ts +++ b/packages/artifact/src/internal/upload/upload-artifact.ts @@ -32,6 +32,8 @@ export async function uploadArtifact( options?: UploadArtifactOptions | undefined ): Promise { let artifactFileName = `${name}.zip` + let suppressZeroByteWarning = false + if (options?.skipArchive) { if (files.length === 0) { throw new FilesNotFoundError([]) @@ -47,6 +49,7 @@ export async function uploadArtifact( throw new FilesNotFoundError(files) } + suppressZeroByteWarning = fs.statSync(files[0]).size === 0 artifactFileName = path.basename(files[0]) name = artifactFileName } @@ -114,7 +117,10 @@ export async function uploadArtifact( const uploadResult = await uploadToBlobStorage( createArtifactResp.signedUploadUrl, stream, - contentType + contentType, + { + suppressZeroByteWarning + } ) // finalize the artifact