diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b8171bd..e0fa2ad 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -10,6 +10,10 @@ on: paths-ignore: - '**.md' +permissions: + contents: read + actions: write + jobs: build: name: Build @@ -94,7 +98,7 @@ jobs: # Download Artifact #1 and verify the correctness of the content - name: 'Download artifact #1' - uses: actions/download-artifact@v4 + uses: actions/download-artifact@main with: name: 'Artifact-A-${{ matrix.runs-on }}' path: some/new/path @@ -114,7 +118,7 @@ jobs: # Download Artifact #2 and verify the correctness of the content - name: 'Download artifact #2' - uses: actions/download-artifact@v4 + uses: actions/download-artifact@main with: name: 'Artifact-Wildcard-${{ matrix.runs-on }}' path: some/other/path @@ -135,7 +139,7 @@ jobs: # Download Artifact #4 and verify the correctness of the content - name: 'Download artifact #4' - uses: actions/download-artifact@v4 + uses: actions/download-artifact@main with: name: 'Multi-Path-Artifact-${{ matrix.runs-on }}' path: multi/artifact @@ -155,7 +159,7 @@ jobs: shell: pwsh - name: 'Download symlinked artifact' - uses: actions/download-artifact@v4 + uses: actions/download-artifact@main with: name: 'Symlinked-Artifact-${{ matrix.runs-on }}' path: from/symlink @@ -196,7 +200,7 @@ jobs: # Download replaced Artifact #1 and verify the correctness of the content - name: 'Download artifact #1 again' - uses: actions/download-artifact@v4 + uses: actions/download-artifact@main with: name: 'Artifact-A-${{ matrix.runs-on }}' path: overwrite/some/new/path @@ -213,6 +217,101 @@ jobs: Write-Error "File contents of downloaded artifact are incorrect" } 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' + + + + + + Artifact Upload Test Report + + + +

Artifact Upload Test Report

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

✔ Single file upload is working!

+ + + EOF + + - name: Upload HTML report (no archive) + uses: ./ + with: + name: 'test-report' + path: report.html + archive: false + merge: name: Merge needs: build @@ -230,7 +329,7 @@ jobs: # easier to identify each of the merged artifacts separate-directories: true - name: 'Download merged artifacts' - uses: actions/download-artifact@v4 + uses: actions/download-artifact@main with: name: merged-artifacts path: all-merged-artifacts @@ -266,7 +365,7 @@ jobs: # Download merged artifacts and verify the correctness of the content - name: 'Download merged artifacts' - uses: actions/download-artifact@v4 + uses: actions/download-artifact@main with: name: Merged-Artifact-As path: merged-artifact-a @@ -290,3 +389,40 @@ jobs: } 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}`); + } + } diff --git a/__tests__/upload.test.ts b/__tests__/upload.test.ts index 6e0c26a..de81a1c 100644 --- a/__tests__/upload.test.ts +++ b/__tests__/upload.test.ts @@ -72,6 +72,7 @@ const mockInputs = ( [Inputs.RetentionDays]: 0, [Inputs.CompressionLevel]: 6, [Inputs.Overwrite]: false, + [Inputs.Archive]: true, ...overrides } @@ -273,4 +274,57 @@ describe('upload', () => { `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() + }) }) diff --git a/action.yml b/action.yml index 28f04cc..7cb4d1e 100644 --- a/action.yml +++ b/action.yml @@ -3,10 +3,10 @@ description: 'Upload a build artifact that can be used by subsequent workflow st author: 'GitHub' inputs: 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' 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 if-no-files-found: description: > @@ -45,6 +45,12 @@ inputs: If true, hidden files will be included in the artifact. If false, hidden files will be excluded from the artifact. 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: artifact-id: diff --git a/dist/upload/index.js b/dist/upload/index.js index f08f27c..a4ca651 100644 --- a/dist/upload/index.js +++ b/dist/upload/index.js @@ -130457,6 +130457,7 @@ var Inputs; Inputs["CompressionLevel"] = "compression-level"; Inputs["Overwrite"] = "overwrite"; Inputs["IncludeHiddenFiles"] = "include-hidden-files"; + Inputs["Archive"] = "archive"; })(Inputs || (Inputs = {})); var NoFileOptions; (function (NoFileOptions) { @@ -130485,6 +130486,7 @@ function getInputs() { const path = getInput(Inputs.Path, { required: true }); const overwrite = getBooleanInput(Inputs.Overwrite); const includeHiddenFiles = getBooleanInput(Inputs.IncludeHiddenFiles); + const archive = getBooleanInput(Inputs.Archive); const ifNoFilesFound = getInput(Inputs.IfNoFilesFound); const noFileBehavior = NoFileOptions[ifNoFilesFound]; if (!noFileBehavior) { @@ -130495,7 +130497,8 @@ function getInputs() { searchPath: path, ifNoFilesFound: noFileBehavior, overwrite: overwrite, - includeHiddenFiles: includeHiddenFiles + includeHiddenFiles: includeHiddenFiles, + archive: archive }; const retentionDaysStr = getInput(Inputs.RetentionDays); if (retentionDaysStr) { @@ -130576,6 +130579,11 @@ async function run() { const s = searchResult.filesToUpload.length === 1 ? '' : 's'; info(`With the provided path, there will be ${searchResult.filesToUpload.length} file${s} uploaded`); 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) { await deleteArtifactIfExists(inputs.artifactName); } @@ -130586,6 +130594,9 @@ async function run() { if (typeof inputs.compressionLevel !== 'undefined') { options.compressionLevel = inputs.compressionLevel; } + if (!inputs.archive) { + options.skipArchive = true; + } await upload_artifact_uploadArtifact(inputs.artifactName, searchResult.filesToUpload, searchResult.rootDirectory, options); } } diff --git a/package-lock.json b/package-lock.json index a0c89f3..5739b1d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "7.0.0", "license": "MIT", "dependencies": { - "@actions/artifact": "^6.1.0", + "@actions/artifact": "^6.2.0", "@actions/core": "^3.0.0", "@actions/github": "^9.0.0", "@actions/glob": "^0.6.1", diff --git a/package.json b/package.json index c668d43..b71533f 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "node": ">=24" }, "dependencies": { - "@actions/artifact": "^6.1.0", + "@actions/artifact": "^6.2.0", "@actions/core": "^3.0.0", "@actions/github": "^9.0.0", "@actions/glob": "^0.6.1", diff --git a/src/upload/constants.ts b/src/upload/constants.ts index 8407bc1..61ec717 100644 --- a/src/upload/constants.ts +++ b/src/upload/constants.ts @@ -6,7 +6,8 @@ export enum Inputs { RetentionDays = 'retention-days', CompressionLevel = 'compression-level', Overwrite = 'overwrite', - IncludeHiddenFiles = 'include-hidden-files' + IncludeHiddenFiles = 'include-hidden-files', + Archive = 'archive' } export enum NoFileOptions { diff --git a/src/upload/input-helper.ts b/src/upload/input-helper.ts index 527ce6c..29605c6 100644 --- a/src/upload/input-helper.ts +++ b/src/upload/input-helper.ts @@ -10,6 +10,7 @@ export function getInputs(): UploadInputs { const path = core.getInput(Inputs.Path, {required: true}) const overwrite = core.getBooleanInput(Inputs.Overwrite) const includeHiddenFiles = core.getBooleanInput(Inputs.IncludeHiddenFiles) + const archive = core.getBooleanInput(Inputs.Archive) const ifNoFilesFound = core.getInput(Inputs.IfNoFilesFound) const noFileBehavior: NoFileOptions = NoFileOptions[ifNoFilesFound] @@ -29,7 +30,8 @@ export function getInputs(): UploadInputs { searchPath: path, ifNoFilesFound: noFileBehavior, overwrite: overwrite, - includeHiddenFiles: includeHiddenFiles + includeHiddenFiles: includeHiddenFiles, + archive: archive } as UploadInputs const retentionDaysStr = core.getInput(Inputs.RetentionDays) diff --git a/src/upload/upload-artifact.ts b/src/upload/upload-artifact.ts index 1cffa3d..432ec11 100644 --- a/src/upload/upload-artifact.ts +++ b/src/upload/upload-artifact.ts @@ -57,6 +57,14 @@ export async function run(): Promise { ) 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) { await deleteArtifactIfExists(inputs.artifactName) } @@ -70,6 +78,10 @@ export async function run(): Promise { options.compressionLevel = inputs.compressionLevel } + if (!inputs.archive) { + options.skipArchive = true + } + await uploadArtifact( inputs.artifactName, searchResult.filesToUpload, diff --git a/src/upload/upload-inputs.ts b/src/upload/upload-inputs.ts index b3fa72e..61f221d 100644 --- a/src/upload/upload-inputs.ts +++ b/src/upload/upload-inputs.ts @@ -35,4 +35,10 @@ export interface UploadInputs { * Whether or not to include hidden files in the artifact */ includeHiddenFiles: boolean + + /** + * Whether or not to archive (zip) the artifact before uploading. + * When false, only a single file can be uploaded. + */ + archive: boolean }