1
0
mirror of https://github.com/actions/upload-artifact.git synced 2026-02-26 05:32:32 +00:00

Support direct file uploads (#764)

* Cache licenses

* Bump minimatch to 10.1.1

* Try fixing licenced issues

* More licensed fixes

* Support direct file uploads

* Add CI tests for direct uploads

* Use download-artifact@main temporarily

* CI: clean up artifacts on successful runs

* Use script v8

* Fix some issues with the cleanup

* Add unit tests

* Clarify naming
This commit is contained in:
Daniel Kennedy
2026-02-25 16:04:27 -05:00
committed by GitHub
parent 589182c5a4
commit bbbca2ddaa
10 changed files with 242 additions and 14 deletions

View File

@ -10,6 +10,10 @@ on:
paths-ignore: paths-ignore:
- '**.md' - '**.md'
permissions:
contents: read
actions: write
jobs: jobs:
build: build:
name: Build name: Build
@ -94,7 +98,7 @@ jobs:
# Download Artifact #1 and verify the correctness of the content # Download Artifact #1 and verify the correctness of the content
- name: 'Download artifact #1' - name: 'Download artifact #1'
uses: actions/download-artifact@v4 uses: actions/download-artifact@main
with: with:
name: 'Artifact-A-${{ matrix.runs-on }}' name: 'Artifact-A-${{ matrix.runs-on }}'
path: some/new/path path: some/new/path
@ -114,7 +118,7 @@ jobs:
# Download Artifact #2 and verify the correctness of the content # Download Artifact #2 and verify the correctness of the content
- name: 'Download artifact #2' - name: 'Download artifact #2'
uses: actions/download-artifact@v4 uses: actions/download-artifact@main
with: with:
name: 'Artifact-Wildcard-${{ matrix.runs-on }}' name: 'Artifact-Wildcard-${{ matrix.runs-on }}'
path: some/other/path path: some/other/path
@ -135,7 +139,7 @@ jobs:
# Download Artifact #4 and verify the correctness of the content # Download Artifact #4 and verify the correctness of the content
- name: 'Download artifact #4' - name: 'Download artifact #4'
uses: actions/download-artifact@v4 uses: actions/download-artifact@main
with: with:
name: 'Multi-Path-Artifact-${{ matrix.runs-on }}' name: 'Multi-Path-Artifact-${{ matrix.runs-on }}'
path: multi/artifact path: multi/artifact
@ -155,7 +159,7 @@ jobs:
shell: pwsh shell: pwsh
- name: 'Download symlinked artifact' - name: 'Download symlinked artifact'
uses: actions/download-artifact@v4 uses: actions/download-artifact@main
with: with:
name: 'Symlinked-Artifact-${{ matrix.runs-on }}' name: 'Symlinked-Artifact-${{ matrix.runs-on }}'
path: from/symlink path: from/symlink
@ -196,7 +200,7 @@ jobs:
# Download replaced Artifact #1 and verify the correctness of the content # Download replaced Artifact #1 and verify the correctness of the content
- name: 'Download artifact #1 again' - name: 'Download artifact #1 again'
uses: actions/download-artifact@v4 uses: actions/download-artifact@main
with: with:
name: 'Artifact-A-${{ matrix.runs-on }}' name: 'Artifact-A-${{ matrix.runs-on }}'
path: overwrite/some/new/path path: overwrite/some/new/path
@ -213,6 +217,101 @@ jobs:
Write-Error "File contents of downloaded artifact are incorrect" Write-Error "File contents of downloaded artifact are incorrect"
} }
shell: pwsh shell: pwsh
# Upload a single file without archiving (direct file upload)
- name: 'Create direct upload file'
run: echo -n 'direct file upload content' > direct-upload-${{ matrix.runs-on }}.txt
shell: bash
- name: 'Upload direct file artifact'
uses: ./
with:
name: 'Direct-File-${{ matrix.runs-on }}'
path: direct-upload-${{ matrix.runs-on }}.txt
archive: false
- name: 'Download direct file artifact'
uses: actions/download-artifact@main
with:
name: direct-upload-${{ matrix.runs-on }}.txt
path: direct-download
- name: 'Verify direct file artifact'
run: |
$file = "direct-download/direct-upload-${{ matrix.runs-on }}.txt"
if(!(Test-Path -path $file))
{
Write-Error "Expected file does not exist"
}
if(!((Get-Content $file -Raw).TrimEnd() -ceq "direct file upload content"))
{
Write-Error "File contents of downloaded artifact are incorrect"
}
shell: pwsh
upload-html-report:
name: Upload HTML Report
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node 24
uses: actions/setup-node@v4
with:
node-version: 24.x
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Compile
run: npm run build
- name: Create HTML report
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>archive: false</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 report (no archive)
uses: ./
with:
name: 'test-report'
path: report.html
archive: false
merge: merge:
name: Merge name: Merge
needs: build needs: build
@ -230,7 +329,7 @@ jobs:
# easier to identify each of the merged artifacts # easier to identify each of the merged artifacts
separate-directories: true separate-directories: true
- name: 'Download merged artifacts' - name: 'Download merged artifacts'
uses: actions/download-artifact@v4 uses: actions/download-artifact@main
with: with:
name: merged-artifacts name: merged-artifacts
path: all-merged-artifacts path: all-merged-artifacts
@ -266,7 +365,7 @@ jobs:
# Download merged artifacts and verify the correctness of the content # Download merged artifacts and verify the correctness of the content
- name: 'Download merged artifacts' - name: 'Download merged artifacts'
uses: actions/download-artifact@v4 uses: actions/download-artifact@main
with: with:
name: Merged-Artifact-As name: Merged-Artifact-As
path: merged-artifact-a path: merged-artifact-a
@ -290,3 +389,40 @@ jobs:
} }
shell: pwsh shell: pwsh
cleanup:
name: Cleanup Artifacts
needs: [build, merge]
runs-on: ubuntu-latest
steps:
- name: Delete test artifacts
uses: actions/github-script@v8
with:
script: |
const keep = ['report.html'];
const owner = context.repo.owner;
const repo = context.repo.repo;
const runId = context.runId;
const {data: {artifacts}} = await github.rest.actions.listWorkflowRunArtifacts({
owner,
repo,
run_id: runId
});
for (const a of artifacts) {
if (keep.includes(a.name)) {
console.log(`Keeping artifact '${a.name}'`);
continue;
}
try {
await github.rest.actions.deleteArtifact({
owner,
repo,
artifact_id: a.id
});
console.log(`Deleted artifact '${a.name}'`);
} catch (err) {
console.log(`Could not delete artifact '${a.name}': ${err.message}`);
}
}

View File

@ -72,6 +72,7 @@ const mockInputs = (
[Inputs.RetentionDays]: 0, [Inputs.RetentionDays]: 0,
[Inputs.CompressionLevel]: 6, [Inputs.CompressionLevel]: 6,
[Inputs.Overwrite]: false, [Inputs.Overwrite]: false,
[Inputs.Archive]: true,
...overrides ...overrides
} }
@ -273,4 +274,57 @@ describe('upload', () => {
`Skipping deletion of '${fixtures.artifactName}', it does not exist` `Skipping deletion of '${fixtures.artifactName}', it does not exist`
) )
}) })
test('passes skipArchive when archive is false', async () => {
mockInputs({
[Inputs.Archive]: false
})
mockFindFilesToUpload.mockResolvedValue({
filesToUpload: [fixtures.filesToUpload[0]],
rootDirectory: fixtures.rootDirectory
})
await run()
expect(artifact.default.uploadArtifact).toHaveBeenCalledWith(
fixtures.artifactName,
[fixtures.filesToUpload[0]],
fixtures.rootDirectory,
{compressionLevel: 6, skipArchive: true}
)
})
test('does not pass skipArchive when archive is true', async () => {
mockInputs({
[Inputs.Archive]: true
})
mockFindFilesToUpload.mockResolvedValue({
filesToUpload: [fixtures.filesToUpload[0]],
rootDirectory: fixtures.rootDirectory
})
await run()
expect(artifact.default.uploadArtifact).toHaveBeenCalledWith(
fixtures.artifactName,
[fixtures.filesToUpload[0]],
fixtures.rootDirectory,
{compressionLevel: 6}
)
})
test('fails when archive is false and multiple files are provided', async () => {
mockInputs({
[Inputs.Archive]: false
})
await run()
expect(core.setFailed).toHaveBeenCalledWith(
`When 'archive' is set to false, only a single file can be uploaded. Found ${fixtures.filesToUpload.length} files to upload.`
)
expect(artifact.default.uploadArtifact).not.toHaveBeenCalled()
})
}) })

View File

@ -3,10 +3,10 @@ description: 'Upload a build artifact that can be used by subsequent workflow st
author: 'GitHub' author: 'GitHub'
inputs: inputs:
name: name:
description: 'Artifact name' description: 'Artifact name. If the `archive` input is `false`, the name of the file uploaded will be the artifact name.'
default: 'artifact' default: 'artifact'
path: path:
description: 'A file, directory or wildcard pattern that describes what to upload' description: 'A file, directory or wildcard pattern that describes what to upload.'
required: true required: true
if-no-files-found: if-no-files-found:
description: > description: >
@ -45,6 +45,12 @@ inputs:
If true, hidden files will be included in the artifact. If true, hidden files will be included in the artifact.
If false, hidden files will be excluded from the artifact. If false, hidden files will be excluded from the artifact.
default: 'false' default: 'false'
archive:
description: >
If true, the artifact will be archived (zipped) before uploading.
If false, the artifact will be uploaded as-is without archiving.
When `archive` is `false`, only a single file can be uploaded. The name of the file will be used as the artifact name (ignoring the `name` parameter).
default: 'true'
outputs: outputs:
artifact-id: artifact-id:

13
dist/upload/index.js vendored
View File

@ -130457,6 +130457,7 @@ var Inputs;
Inputs["CompressionLevel"] = "compression-level"; Inputs["CompressionLevel"] = "compression-level";
Inputs["Overwrite"] = "overwrite"; Inputs["Overwrite"] = "overwrite";
Inputs["IncludeHiddenFiles"] = "include-hidden-files"; Inputs["IncludeHiddenFiles"] = "include-hidden-files";
Inputs["Archive"] = "archive";
})(Inputs || (Inputs = {})); })(Inputs || (Inputs = {}));
var NoFileOptions; var NoFileOptions;
(function (NoFileOptions) { (function (NoFileOptions) {
@ -130485,6 +130486,7 @@ function getInputs() {
const path = getInput(Inputs.Path, { required: true }); const path = getInput(Inputs.Path, { required: true });
const overwrite = getBooleanInput(Inputs.Overwrite); const overwrite = getBooleanInput(Inputs.Overwrite);
const includeHiddenFiles = getBooleanInput(Inputs.IncludeHiddenFiles); const includeHiddenFiles = getBooleanInput(Inputs.IncludeHiddenFiles);
const archive = getBooleanInput(Inputs.Archive);
const ifNoFilesFound = getInput(Inputs.IfNoFilesFound); const ifNoFilesFound = getInput(Inputs.IfNoFilesFound);
const noFileBehavior = NoFileOptions[ifNoFilesFound]; const noFileBehavior = NoFileOptions[ifNoFilesFound];
if (!noFileBehavior) { if (!noFileBehavior) {
@ -130495,7 +130497,8 @@ function getInputs() {
searchPath: path, searchPath: path,
ifNoFilesFound: noFileBehavior, ifNoFilesFound: noFileBehavior,
overwrite: overwrite, overwrite: overwrite,
includeHiddenFiles: includeHiddenFiles includeHiddenFiles: includeHiddenFiles,
archive: archive
}; };
const retentionDaysStr = getInput(Inputs.RetentionDays); const retentionDaysStr = getInput(Inputs.RetentionDays);
if (retentionDaysStr) { if (retentionDaysStr) {
@ -130576,6 +130579,11 @@ async function run() {
const s = searchResult.filesToUpload.length === 1 ? '' : 's'; const s = searchResult.filesToUpload.length === 1 ? '' : 's';
info(`With the provided path, there will be ${searchResult.filesToUpload.length} file${s} uploaded`); info(`With the provided path, there will be ${searchResult.filesToUpload.length} file${s} uploaded`);
core_debug(`Root artifact directory is ${searchResult.rootDirectory}`); core_debug(`Root artifact directory is ${searchResult.rootDirectory}`);
// Validate that only a single file is uploaded when archive is false
if (!inputs.archive && searchResult.filesToUpload.length > 1) {
setFailed(`When 'archive' is set to false, only a single file can be uploaded. Found ${searchResult.filesToUpload.length} files to upload.`);
return;
}
if (inputs.overwrite) { if (inputs.overwrite) {
await deleteArtifactIfExists(inputs.artifactName); await deleteArtifactIfExists(inputs.artifactName);
} }
@ -130586,6 +130594,9 @@ async function run() {
if (typeof inputs.compressionLevel !== 'undefined') { if (typeof inputs.compressionLevel !== 'undefined') {
options.compressionLevel = inputs.compressionLevel; options.compressionLevel = inputs.compressionLevel;
} }
if (!inputs.archive) {
options.skipArchive = true;
}
await upload_artifact_uploadArtifact(inputs.artifactName, searchResult.filesToUpload, searchResult.rootDirectory, options); await upload_artifact_uploadArtifact(inputs.artifactName, searchResult.filesToUpload, searchResult.rootDirectory, options);
} }
} }

2
package-lock.json generated
View File

@ -9,7 +9,7 @@
"version": "7.0.0", "version": "7.0.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@actions/artifact": "^6.1.0", "@actions/artifact": "^6.2.0",
"@actions/core": "^3.0.0", "@actions/core": "^3.0.0",
"@actions/github": "^9.0.0", "@actions/github": "^9.0.0",
"@actions/glob": "^0.6.1", "@actions/glob": "^0.6.1",

View File

@ -33,7 +33,7 @@
"node": ">=24" "node": ">=24"
}, },
"dependencies": { "dependencies": {
"@actions/artifact": "^6.1.0", "@actions/artifact": "^6.2.0",
"@actions/core": "^3.0.0", "@actions/core": "^3.0.0",
"@actions/github": "^9.0.0", "@actions/github": "^9.0.0",
"@actions/glob": "^0.6.1", "@actions/glob": "^0.6.1",

View File

@ -6,7 +6,8 @@ export enum Inputs {
RetentionDays = 'retention-days', RetentionDays = 'retention-days',
CompressionLevel = 'compression-level', CompressionLevel = 'compression-level',
Overwrite = 'overwrite', Overwrite = 'overwrite',
IncludeHiddenFiles = 'include-hidden-files' IncludeHiddenFiles = 'include-hidden-files',
Archive = 'archive'
} }
export enum NoFileOptions { export enum NoFileOptions {

View File

@ -10,6 +10,7 @@ export function getInputs(): UploadInputs {
const path = core.getInput(Inputs.Path, {required: true}) const path = core.getInput(Inputs.Path, {required: true})
const overwrite = core.getBooleanInput(Inputs.Overwrite) const overwrite = core.getBooleanInput(Inputs.Overwrite)
const includeHiddenFiles = core.getBooleanInput(Inputs.IncludeHiddenFiles) const includeHiddenFiles = core.getBooleanInput(Inputs.IncludeHiddenFiles)
const archive = core.getBooleanInput(Inputs.Archive)
const ifNoFilesFound = core.getInput(Inputs.IfNoFilesFound) const ifNoFilesFound = core.getInput(Inputs.IfNoFilesFound)
const noFileBehavior: NoFileOptions = NoFileOptions[ifNoFilesFound] const noFileBehavior: NoFileOptions = NoFileOptions[ifNoFilesFound]
@ -29,7 +30,8 @@ export function getInputs(): UploadInputs {
searchPath: path, searchPath: path,
ifNoFilesFound: noFileBehavior, ifNoFilesFound: noFileBehavior,
overwrite: overwrite, overwrite: overwrite,
includeHiddenFiles: includeHiddenFiles includeHiddenFiles: includeHiddenFiles,
archive: archive
} as UploadInputs } as UploadInputs
const retentionDaysStr = core.getInput(Inputs.RetentionDays) const retentionDaysStr = core.getInput(Inputs.RetentionDays)

View File

@ -57,6 +57,14 @@ export async function run(): Promise<void> {
) )
core.debug(`Root artifact directory is ${searchResult.rootDirectory}`) core.debug(`Root artifact directory is ${searchResult.rootDirectory}`)
// Validate that only a single file is uploaded when archive is false
if (!inputs.archive && searchResult.filesToUpload.length > 1) {
core.setFailed(
`When 'archive' is set to false, only a single file can be uploaded. Found ${searchResult.filesToUpload.length} files to upload.`
)
return
}
if (inputs.overwrite) { if (inputs.overwrite) {
await deleteArtifactIfExists(inputs.artifactName) await deleteArtifactIfExists(inputs.artifactName)
} }
@ -70,6 +78,10 @@ export async function run(): Promise<void> {
options.compressionLevel = inputs.compressionLevel options.compressionLevel = inputs.compressionLevel
} }
if (!inputs.archive) {
options.skipArchive = true
}
await uploadArtifact( await uploadArtifact(
inputs.artifactName, inputs.artifactName,
searchResult.filesToUpload, searchResult.filesToUpload,

View File

@ -35,4 +35,10 @@ export interface UploadInputs {
* Whether or not to include hidden files in the artifact * Whether or not to include hidden files in the artifact
*/ */
includeHiddenFiles: boolean includeHiddenFiles: boolean
/**
* Whether or not to archive (zip) the artifact before uploading.
* When false, only a single file can be uploaded.
*/
archive: boolean
} }