diff --git a/.github/workflows/artifact-tests.yml b/.github/workflows/artifact-tests.yml index 94053d08..b907cbe4 100644 --- a/.github/workflows/artifact-tests.yml +++ b/.github/workflows/artifact-tests.yml @@ -71,10 +71,143 @@ jobs: } catch (err) { console.log('Successfully blocked second artifact upload') } + + upload-single-file: + name: Upload Single File (no zip) + + strategy: + matrix: + runs-on: [ubuntu-latest, windows-latest, macos-latest] + fail-fast: false + + runs-on: ${{ matrix.runs-on }} + + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Set Node.js 24.x + uses: actions/setup-node@v5 + with: + node-version: 24.x + + - name: Install root npm packages + run: npm ci + + - name: Compile artifact package + run: | + npm ci + npm run tsc + working-directory: packages/artifact + + - name: Create file that will be uploaded + run: | + echo -n 'hello from single file upload' > single-file-${{ matrix.runs-on }}.txt + + - name: Upload Single File Artifact (skipArchive) + uses: actions/github-script@v8 + with: + script: | + const {default: artifact} = require('./packages/artifact/lib/artifact') + + const artifactName = 'my-single-file-${{ matrix.runs-on }}' + console.log('artifactName: ' + artifactName) + + const uploadResult = await artifact.uploadArtifact( + artifactName, + ['single-file-${{ matrix.runs-on }}.txt'], + './', + {skipArchive: true} + ) + console.log(uploadResult) + + const size = uploadResult.size + const id = uploadResult.id + + if (!id) { + throw new Error('Artifact ID is missing from upload result') + } + + console.log(`Successfully uploaded single file artifact ${id}`) + + upload-html-file: + name: Upload HTML File (no zip) + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Set Node.js 24.x + uses: actions/setup-node@v5 + with: + node-version: 24.x + + - name: Install root npm packages + run: npm ci + + - name: Compile artifact package + run: | + npm ci + npm run tsc + working-directory: packages/artifact + + - name: Create HTML file + run: | + cat > report.html << 'EOF' + + + + + + Artifact Upload Test Report + + + +

Artifact Upload Test Report

+
+ This HTML file was uploaded as a single un-zipped artifact. + If you can see this in the browser, the feature is working correctly! +
+ + + + + +
PropertyValue
Upload methodskipArchive: true
Content-Typetext/html
Filereport.html
+

✔ Single file upload is working!

+ + + EOF + + - name: Upload HTML Artifact (skipArchive) + uses: actions/github-script@v8 + with: + script: | + const {default: artifact} = require('./packages/artifact/lib/artifact') + + const uploadResult = await artifact.uploadArtifact( + 'test-report', + ['report.html'], + './', + {skipArchive: true} + ) + console.log(uploadResult) + console.log(`Successfully uploaded HTML artifact ${uploadResult.id}`) + console.log('This artifact is intentionally kept for manual browser verification') + verify: name: Verify and Delete runs-on: ubuntu-latest - needs: [upload] + needs: [upload, upload-single-file, upload-html-file] steps: - name: Checkout uses: actions/checkout@v5 @@ -164,6 +297,71 @@ jobs: } } } + + - name: Download and Verify Single File Artifacts + uses: actions/github-script@v8 + with: + script: | + const {default: artifactClient} = require('./packages/artifact/lib/artifact') + + const {readFile} = require('fs/promises') + const path = require('path') + + const findBy = { + repositoryOwner: process.env.GITHUB_REPOSITORY.split('/')[0], + repositoryName: process.env.GITHUB_REPOSITORY.split('/')[1], + token: '${{ secrets.GITHUB_TOKEN }}', + workflowRunId: process.env.GITHUB_RUN_ID + } + + const listResult = await artifactClient.listArtifacts({latest: true, findBy}) + const expectedSingleFiles = [ + 'single-file-ubuntu-latest.txt', + 'single-file-windows-latest.txt', + 'single-file-macos-latest.txt' + ] + + // Single file artifacts are named after the file basename + const singleFileArtifacts = listResult.artifacts.filter(a => + expectedSingleFiles.includes(a.name) + ) + + console.log('Found single file artifacts:', singleFileArtifacts.length) + + if (singleFileArtifacts.length !== 3) { + console.log('Unexpected single file artifacts:', singleFileArtifacts) + throw new Error( + `Expected 3 single-file artifacts but found ${singleFileArtifacts.length}` + ) + } + + for (const artifact of singleFileArtifacts) { + const downloadDir = `single-file-download-${artifact.id}` + const {downloadPath} = await artifactClient.downloadArtifact(artifact.id, { + path: downloadDir, + findBy + }) + + console.log('Downloaded single file artifact to:', downloadPath) + + const filePath = path.join( + process.env.GITHUB_WORKSPACE, + downloadPath, + artifact.name + ) + + console.log('Checking file:', filePath) + + const content = await readFile(filePath, 'utf8') + if (content.trim() !== 'hello from single file upload') { + throw new Error( + `Expected single file to contain 'hello from single file upload' but found '${content}'` + ) + } + + console.log(`Successfully verified single file artifact ${artifact.id}`) + } + - name: Delete Artifacts uses: actions/github-script@v8 with: @@ -173,11 +371,19 @@ jobs: const artifactsToDelete = [ 'my-artifact-ubuntu-latest', 'my-artifact-windows-latest', - 'my-artifact-macos-latest' + 'my-artifact-macos-latest', + 'single-file-ubuntu-latest.txt', + 'single-file-windows-latest.txt', + 'single-file-macos-latest.txt' ] for (const artifactName of artifactsToDelete) { - const {id} = await artifactClient.deleteArtifact(artifactName) + try { + const {id} = await artifactClient.deleteArtifact(artifactName) + console.log(`Deleted artifact '${artifactName}' (ID: ${id})`) + } catch (err) { + console.log(`Could not delete artifact '${artifactName}': ${err.message}`) + } } const {artifacts} = await artifactClient.listArtifacts({latest: true}) diff --git a/packages/artifact/RELEASES.md b/packages/artifact/RELEASES.md index 3290ce62..9b368e35 100644 --- a/packages/artifact/RELEASES.md +++ b/packages/artifact/RELEASES.md @@ -1,5 +1,9 @@ # @actions/artifact Releases +## 6.2.0 + +- Support uploading single un-archived files (not zipped). Direct uploads are only supported for artifacts version 7+ (based on the major version of `actions/upload-artifact`). Callers must pass the `skipArchive` option to `uploadArtifact`. Only single files can be uploaded at a time right now. Default behavior should remain unchanged if `skipArchive = false`. When `skipArchive = true`, the name of the file is used as the name of the artifact for consistency with the downloads: you upload `artifact.txt`, you download `artifact.txt`. + ## 6.1.0 - Support downloading non-zip artifacts. Zipped artifacts will be decompressed automatically (with an optional override). Un-zipped artifacts will be downloaded as-is. diff --git a/packages/artifact/__tests__/stream.test.ts b/packages/artifact/__tests__/stream.test.ts new file mode 100644 index 00000000..a8411540 --- /dev/null +++ b/packages/artifact/__tests__/stream.test.ts @@ -0,0 +1,59 @@ +import * as fs from 'fs' +import * as path from 'path' +import {createRawFileUploadStream} from '../src/internal/upload/stream.js' +import {noopLogs} from './common.js' + +const fixtures = { + testDirectory: path.join(__dirname, '_temp', 'stream-test'), + testFile: path.join(__dirname, '_temp', 'stream-test', 'test-file.txt'), + testContent: 'hello stream test' +} + +describe('createRawFileUploadStream', () => { + beforeAll(() => { + fs.mkdirSync(fixtures.testDirectory, {recursive: true}) + fs.writeFileSync(fixtures.testFile, fixtures.testContent) + }) + + beforeEach(() => { + noopLogs() + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + it('should stream file contents through the upload stream', async () => { + const uploadStream = await createRawFileUploadStream(fixtures.testFile) + + const chunks: Buffer[] = [] + const result = await new Promise((resolve, reject) => { + uploadStream.on('data', chunk => chunks.push(Buffer.from(chunk))) + uploadStream.on('end', () => + resolve(Buffer.concat(chunks).toString('utf-8')) + ) + uploadStream.on('error', reject) + }) + + expect(result).toBe(fixtures.testContent) + }) + + it('should propagate file read errors through the upload stream', async () => { + // Use a directory path — createReadStream on a directory fails cross-platform + // Mock lstat to return a non-symlink result so we reach createReadStream + const dirPath = fixtures.testDirectory + jest + .spyOn(fs.promises, 'lstat') + .mockResolvedValue({isSymbolicLink: () => false} as fs.Stats) + + const uploadStream = await createRawFileUploadStream(dirPath) + + await expect( + new Promise((resolve, reject) => { + uploadStream.on('data', resolve) + uploadStream.on('end', resolve) + uploadStream.on('error', reject) + }) + ).rejects.toThrow('An error has occurred during file read for the artifact') + }) +}) diff --git a/packages/artifact/__tests__/upload-artifact.test.ts b/packages/artifact/__tests__/upload-artifact.test.ts index 20a42881..9865815d 100644 --- a/packages/artifact/__tests__/upload-artifact.test.ts +++ b/packages/artifact/__tests__/upload-artifact.test.ts @@ -7,6 +7,7 @@ import * as blobUpload from '../src/internal/upload/blob-upload.js' import {uploadArtifact} from '../src/internal/upload/upload-artifact.js' import {noopLogs} from './common.js' import {FilesNotFoundError} from '../src/internal/shared/errors.js' +import * as stream from '../src/internal/upload/stream.js' import {BlockBlobUploadStreamOptions} from '@azure/storage-blob' import * as fs from 'fs' import * as path from 'path' @@ -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,284 @@ describe('upload-artifact', () => { await expect(uploadResp).rejects.toThrow('Upload progress stalled.') }) + + describe('skipArchive option', () => { + it('should throw an error if skipArchive is true and files array is empty', async () => { + const uploadResp = uploadArtifact( + fixtures.inputs.artifactName, + [], + fixtures.inputs.rootDirectory, + {skipArchive: true} + ) + + await expect(uploadResp).rejects.toThrow(FilesNotFoundError) + }) + + 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 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/package-lock.json b/packages/artifact/package-lock.json index 47e6ffb0..e3b01744 100644 --- a/packages/artifact/package-lock.json +++ b/packages/artifact/package-lock.json @@ -1,12 +1,12 @@ { "name": "@actions/artifact", - "version": "6.1.0", + "version": "6.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@actions/artifact", - "version": "6.1.0", + "version": "6.2.0", "license": "MIT", "dependencies": { "@actions/core": "^3.0.0", diff --git a/packages/artifact/package.json b/packages/artifact/package.json index 705b9948..9197d1d9 100644 --- a/packages/artifact/package.json +++ b/packages/artifact/package.json @@ -1,6 +1,6 @@ { "name": "@actions/artifact", - "version": "6.1.0", + "version": "6.2.0", "preview": true, "description": "Actions artifact lib", "keywords": [ diff --git a/packages/artifact/src/generated/results/api/v1/artifact.ts b/packages/artifact/src/generated/results/api/v1/artifact.ts index e1c43c50..dbdd7bbb 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.js"; import { StringValue } from "../../../google/protobuf/wrappers.js"; import { Timestamp } from "../../../google/protobuf/timestamp.js"; -/** - * @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 141135db..278e2cd4 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 784ff98d..4ca5b262 100644 --- a/packages/artifact/src/internal/shared/interfaces.ts +++ b/packages/artifact/src/internal/shared/interfaces.ts @@ -50,6 +50,13 @@ 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. + * When using this option, the name parameter passed to the upload is ignored. Instead, the name of the file is used as the name of the artifact. + */ + skipArchive?: boolean } /** diff --git a/packages/artifact/src/internal/upload/blob-upload.ts b/packages/artifact/src/internal/upload/blob-upload.ts index 66251185..be6fcef5 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.js' +import {WaterMarkedUploadStream} from './stream.js' 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 00000000..2501290a --- /dev/null +++ b/packages/artifact/src/internal/upload/stream.ts @@ -0,0 +1,53 @@ +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.js' + +// 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.error(String(error)) + uploadStream.destroy( + 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 00000000..1ebbe8bc --- /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 bb125cb9..c7a6e60b 100644 --- a/packages/artifact/src/internal/upload/upload-artifact.ts +++ b/packages/artifact/src/internal/upload/upload-artifact.ts @@ -1,4 +1,6 @@ import * as core from '@actions/core' +import * as fs from 'fs' +import * as path from 'path' import { UploadArtifactOptions, UploadArtifactResponse @@ -12,14 +14,16 @@ import { validateRootDirectory } from './upload-zip-specification.js' import {getBackendIdsFromToken} from '../shared/util.js' -import {uploadZipToBlobStorage} from './blob-upload.js' +import {uploadToBlobStorage} from './blob-upload.js' import {createZipUploadStream} from './zip.js' +import {createRawFileUploadStream, WaterMarkedUploadStream} from './stream.js' import { CreateArtifactRequest, FinalizeArtifactRequest, StringValue } from '../../generated/index.js' import {FilesNotFoundError, InvalidResponseError} from '../shared/errors.js' +import {getMimeType} from './types.js' export async function uploadArtifact( name: string, @@ -27,19 +31,43 @@ export async function uploadArtifact( rootDirectory: string, options?: UploadArtifactOptions | undefined ): Promise { + let artifactFileName = `${name}.zip` + if (options?.skipArchive) { + if (files.length === 0) { + throw new FilesNotFoundError([]) + } + + if (files.length > 1) { + throw new Error( + 'skipArchive option is only supported when uploading a single file' + ) + } + + if (!fs.existsSync(files[0])) { + throw new FilesNotFoundError(files) + } + + artifactFileName = path.basename(files[0]) + name = artifactFileName + } + validateArtifactName(name) validateRootDirectory(rootDirectory) - const zipSpecification: UploadZipSpecification[] = getUploadZipSpecification( - files, - rootDirectory - ) - if (zipSpecification.length === 0) { - throw new FilesNotFoundError( - zipSpecification.flatMap(s => (s.sourcePath ? [s.sourcePath] : [])) - ) + let zipSpecification: UploadZipSpecification[] = [] + + if (!options?.skipArchive) { + zipSpecification = getUploadZipSpecification(files, rootDirectory) + + if (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() @@ -51,7 +79,8 @@ export async function uploadArtifact( workflowRunBackendId: backendIds.workflowRunBackendId, workflowJobRunBackendId: backendIds.workflowJobRunBackendId, name, - version: 4 + mimeType: StringValue.create({value: contentType}), + version: 7 } // if there is a retention period, add it to the request @@ -68,15 +97,24 @@ export async function uploadArtifact( ) } - const zipUploadStream = await createZipUploadStream( - zipSpecification, - options?.compressionLevel - ) + let stream: WaterMarkedUploadStream - // Upload zip to blob storage - const uploadResult = await uploadZipToBlobStorage( + 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, - zipUploadStream + stream, + contentType ) // finalize the artifact @@ -105,7 +143,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 dd2a32e5..45969bc9 100644 --- a/packages/artifact/src/internal/upload/zip.ts +++ b/packages/artifact/src/internal/upload/zip.ts @@ -1,31 +1,16 @@ -import * as stream from 'stream' import {realpath} from 'fs/promises' import archiver from 'archiver' import * as core from '@actions/core' import {UploadZipSpecification} from './upload-zip-specification.js' import {getUploadChunkSize} from '../shared/config.js' +import {WaterMarkedUploadStream} from './stream.js' 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 +45,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}`