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!
+
+
+ | Property | Value |
+ | Upload method | skipArchive: true |
+ | Content-Type | text/html |
+ | File | report.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}`