Artifact upload: support uploading single un-zipped files (#2256)

* Artifact upload: support uploading single un-zipped files

* Fix linters

* Fix lint again

* Fix tests

* Check for 0 sized artifact lists

* Add some more stream tests and handle an upload failure gracefully

* Add CI tests for non-zipped artifacts

* Add an html report to test rendering in the browser

* Fix linting issue

* Artifact: bump the version and add release notes

* Fix Windows tests

* Fix linting

* stream: switch the error details to error type

* Refactor the validation logic in `uploadArtifact` a bit

* Added more details about how the name parameter is handled
This commit is contained in:
Daniel Kennedy
2026-02-25 11:01:38 -05:00
committed by GitHub
parent 8c90e2297a
commit 6fe3c0f3e6
14 changed files with 788 additions and 353 deletions
+209 -3
View File
@@ -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'
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Artifact Upload Test Report</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; max-width: 800px; margin: 40px auto; padding: 0 20px; color: #24292f; }
h1 { border-bottom: 1px solid #d0d7de; padding-bottom: 8px; }
.success { color: #1a7f37; }
.info { background: #ddf4ff; border: 1px solid #54aeff; border-radius: 6px; padding: 12px 16px; margin: 16px 0; }
table { border-collapse: collapse; width: 100%; margin: 16px 0; }
th, td { border: 1px solid #d0d7de; padding: 8px 12px; text-align: left; }
th { background: #f6f8fa; }
</style>
</head>
<body>
<h1>Artifact Upload Test Report</h1>
<div class="info">
<strong>This HTML file was uploaded as a single un-zipped artifact.</strong>
If you can see this in the browser, the feature is working correctly!
</div>
<table>
<tr><th>Property</th><th>Value</th></tr>
<tr><td>Upload method</td><td><code>skipArchive: true</code></td></tr>
<tr><td>Content-Type</td><td><code>text/html</code></td></tr>
<tr><td>File</td><td><code>report.html</code></td></tr>
</table>
<p class="success">&#10004; Single file upload is working!</p>
</body>
</html>
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})
+4
View File
@@ -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.
@@ -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<string>((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')
})
})
@@ -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'
})
)
})
})
})
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@actions/artifact",
"version": "6.1.0",
"version": "6.2.0",
"preview": true,
"description": "Actions artifact lib",
"keywords": [
@@ -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<MigrateArtifactRequest> {
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>): MigrateArtifactRequest {
const message = { workflowRunBackendId: "", name: "" };
globalThis.Object.defineProperty(message, MESSAGE_TYPE, { enumerable: false, value: this });
if (value !== undefined)
reflectionMergePartial<MigrateArtifactRequest>(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<MigrateArtifactResponse> {
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>): MigrateArtifactResponse {
const message = { ok: false, signedUploadUrl: "" };
globalThis.Object.defineProperty(message, MESSAGE_TYPE, { enumerable: false, value: this });
if (value !== undefined)
reflectionMergePartial<MigrateArtifactResponse>(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<FinalizeMigratedArtifactRequest> {
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>): FinalizeMigratedArtifactRequest {
const message = { workflowRunBackendId: "", name: "", size: "0" };
globalThis.Object.defineProperty(message, MESSAGE_TYPE, { enumerable: false, value: this });
if (value !== undefined)
reflectionMergePartial<FinalizeMigratedArtifactRequest>(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<FinalizeMigratedArtifactResponse> {
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>): FinalizeMigratedArtifactResponse {
const message = { ok: false, artifactId: "0" };
globalThis.Object.defineProperty(message, MESSAGE_TYPE, { enumerable: false, value: this });
if (value !== undefined)
reflectionMergePartial<FinalizeMigratedArtifactResponse>(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<CreateArtifactRequest> {
constructor() {
super("github.actions.results.api.v1.CreateArtifactRequest", [
@@ -530,7 +244,8 @@ class CreateArtifactRequest$Type extends MessageType<CreateArtifactRequest> {
{ 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>): CreateArtifactRequest {
@@ -560,6 +275,9 @@ class CreateArtifactRequest$Type extends MessageType<CreateArtifactRequest> {
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<CreateArtifactRequest> {
/* 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<ListArtifactsResponse> {
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>): 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 }
]);
@@ -229,4 +229,4 @@ export class ArtifactServiceClientProtobuf implements ArtifactServiceClient {
DeleteArtifactResponse.fromBinary(data as Uint8Array)
);
}
}
}
@@ -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
}
/**
@@ -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<BlobUploadResponse> {
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(
@@ -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<WaterMarkedUploadStream> {
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
}
@@ -0,0 +1,82 @@
import * as path from 'path'
/**
* Maps file extensions to MIME types
*/
const mimeTypes: Record<string, string> = {
// 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'
}
@@ -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<UploadArtifactResponse> {
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 {
+3 -18
View File
@@ -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<ZipUploadStream> {
): Promise<WaterMarkedUploadStream> {
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}`