Compare commits

..

No commits in common. "main" and "robherley/overwrite-artifact" have entirely different histories.

31 changed files with 15447 additions and 160211 deletions

View File

@ -34,7 +34,7 @@ jobs:
run: npm ci
- name: Rebuild the dist/ directory
run: npm run release
run: npm run build
- name: Compare the expected and actual dist/ directories
run: |

View File

@ -22,7 +22,7 @@ jobs:
steps:
- name: Update the ${{ env.TAG_NAME }} tag
id: update-major-tag
uses: actions/publish-action@v0.3.0
uses: actions/publish-action@v0.2.1
with:
source-tag: ${{ env.TAG_NAME }}
slack-webhook: ${{ secrets.SLACK_WEBHOOK }}

View File

@ -141,16 +141,12 @@ jobs:
}
shell: pwsh
- name: 'Alter file 1 content'
run: |
echo "This file has changed" > path/to/dir-1/file1.txt
# Replace the contents of Artifact #1
- name: 'Overwrite artifact #1'
- name: 'Overwrite artifact #1 again'
uses: ./
with:
name: 'Artifact-A-${{ matrix.runs-on }}'
path: path/to/dir-1/file1.txt
path: path/to/dir-2/file2.txt
overwrite: true
# Download replaced Artifact #1 and verify the correctness of the content
@ -162,90 +158,13 @@ jobs:
- name: 'Verify Artifact #1 again'
run: |
$file = "overwrite/some/new/path/file1.txt"
$file = "overwrite/some/new/path/file2.txt"
if(!(Test-Path -path $file))
{
Write-Error "Expected file does not exist"
}
if(!((Get-Content $file) -ceq "This file has changed"))
if(!((Get-Content $file) -ceq "Hello world from file #2"))
{
Write-Error "File contents of downloaded artifact are incorrect"
Write-Error "File contents of downloaded artifacts are incorrect"
}
shell: pwsh
merge:
name: Merge
needs: build
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
# Merge all artifacts from previous jobs
- name: Merge all artifacts in run
uses: ./merge/
with:
# our matrix produces artifacts with the same file, this prevents "stomping" on each other, also makes it
# easier to identify each of the merged artifacts
separate-directories: true
- name: 'Download merged artifacts'
uses: actions/download-artifact@v4
with:
name: merged-artifacts
path: all-merged-artifacts
- name: 'Check merged artifact has directories for each artifact'
run: |
$artifacts = @(
"Artifact-A-ubuntu-latest",
"Artifact-A-macos-latest",
"Artifact-A-windows-latest",
"Artifact-Wildcard-ubuntu-latest",
"Artifact-Wildcard-macos-latest",
"Artifact-Wildcard-windows-latest",
"Multi-Path-Artifact-ubuntu-latest",
"Multi-Path-Artifact-macos-latest",
"Multi-Path-Artifact-windows-latest"
)
foreach ($artifact in $artifacts) {
$path = "all-merged-artifacts/$artifact"
if (!(Test-Path $path)) {
Write-Error "$path does not exist."
}
}
shell: pwsh
# Merge Artifact-A-* from previous jobs
- name: Merge all Artifact-A
uses: ./merge/
with:
name: Merged-Artifact-As
pattern: 'Artifact-A-*'
separate-directories: true
# Download merged artifacts and verify the correctness of the content
- name: 'Download merged artifacts'
uses: actions/download-artifact@v4
with:
name: Merged-Artifact-As
path: merged-artifact-a
- name: 'Verify merged artifacts'
run: |
$files = @(
"merged-artifact-a/Artifact-A-ubuntu-latest/file1.txt",
"merged-artifact-a/Artifact-A-macos-latest/file1.txt",
"merged-artifact-a/Artifact-A-windows-latest/file1.txt"
)
foreach ($file in $files) {
if (!(Test-Path $file)) {
Write-Error "$file does not exist."
}
if (!((Get-Content $file) -ceq "This file has changed")) {
Write-Error "$file has incorrect content."
}
}
shell: pwsh

View File

@ -1,6 +1,6 @@
---
name: "@actions/artifact"
version: 2.1.8
version: 2.1.0
type: npm
summary:
homepage:

View File

@ -1,6 +1,6 @@
---
name: "@actions/core"
version: 1.10.1
version: 1.10.0
type: npm
summary:
homepage:

View File

@ -1,26 +0,0 @@
---
name: minimatch
version: 9.0.3
type: npm
summary:
homepage:
license: isc
licenses:
- sources: LICENSE
text: |
The ISC License
Copyright (c) 2011-2023 Isaac Z. Schlueter and Contributors
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR
IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
notices: []

View File

@ -1,11 +1,5 @@
# `@actions/upload-artifact`
> [!WARNING]
> actions/upload-artifact@v3 is scheduled for deprecation on **November 30, 2024**. [Learn more.](https://github.blog/changelog/2024-04-16-deprecation-notice-v3-of-the-artifact-actions/)
> Similarly, v1/v2 are scheduled for deprecation on **June 30, 2024**.
> Please update your workflow to use v4 of the artifact actions.
> This deprecation will not impact any existing versions of GitHub Enterprise Server being used by customers.
Upload [Actions Artifacts](https://docs.github.com/en/actions/using-workflows/storing-workflow-data-as-artifacts) from your Workflow Runs. Internally powered by [@actions/artifact](https://github.com/actions/toolkit/tree/main/packages/artifact) package.
See also [download-artifact](https://github.com/actions/download-artifact).
@ -47,8 +41,6 @@ The release of upload-artifact@v4 and download-artifact@v4 are major changes to
For more information, see the [`@actions/artifact`](https://github.com/actions/toolkit/tree/main/packages/artifact) documentation.
There is also a new sub-action, `actions/upload-artifact/merge`. For more info, check out that action's [README](./merge/README.md).
### Improvements
1. Uploads are significantly faster, upwards of 90% improvement in worst case scenarios.
@ -414,7 +406,7 @@ jobs:
### Number of Artifacts
Within an individual job, there is a limit of 500 artifacts that can be created for that job.
Within an individual job, there is a limit of 10 artifacts that can be created for that job.
You may also be limited by Artifacts if you have exceeded your shared storage quota. Storage is calculated every 6-12 hours. See [the documentation](https://docs.github.com/en/billing/managing-billing-for-github-actions/about-billing-for-github-actions#calculating-minute-and-storage-spending) for more info.

View File

@ -1,175 +0,0 @@
import * as core from '@actions/core'
import artifact from '@actions/artifact'
import {run} from '../src/merge/merge-artifacts'
import {Inputs} from '../src/merge/constants'
import * as search from '../src/shared/search'
const fixtures = {
artifactName: 'my-merged-artifact',
tmpDirectory: '/tmp/merge-artifact',
filesToUpload: [
'/some/artifact/path/file-a.txt',
'/some/artifact/path/file-b.txt',
'/some/artifact/path/file-c.txt'
],
artifacts: [
{
name: 'my-artifact-a',
id: 1,
size: 100,
createdAt: new Date('2024-01-01T00:00:00Z')
},
{
name: 'my-artifact-b',
id: 2,
size: 100,
createdAt: new Date('2024-01-01T00:00:00Z')
},
{
name: 'my-artifact-c',
id: 3,
size: 100,
createdAt: new Date('2024-01-01T00:00:00Z')
}
]
}
jest.mock('@actions/github', () => ({
context: {
repo: {
owner: 'actions',
repo: 'toolkit'
},
runId: 123,
serverUrl: 'https://github.com'
}
}))
jest.mock('@actions/core')
jest.mock('fs/promises', () => ({
mkdtemp: jest.fn().mockResolvedValue('/tmp/merge-artifact'),
rm: jest.fn().mockResolvedValue(undefined)
}))
/* eslint-disable no-unused-vars */
const mockInputs = (overrides?: Partial<{[K in Inputs]?: any}>) => {
const inputs = {
[Inputs.Name]: 'my-merged-artifact',
[Inputs.Pattern]: '*',
[Inputs.SeparateDirectories]: false,
[Inputs.RetentionDays]: 0,
[Inputs.CompressionLevel]: 6,
[Inputs.DeleteMerged]: false,
...overrides
}
;(core.getInput as jest.Mock).mockImplementation((name: string) => {
return inputs[name]
})
;(core.getBooleanInput as jest.Mock).mockImplementation((name: string) => {
return inputs[name]
})
return inputs
}
describe('merge', () => {
beforeEach(async () => {
mockInputs()
jest
.spyOn(artifact, 'listArtifacts')
.mockResolvedValue({artifacts: fixtures.artifacts})
jest.spyOn(artifact, 'downloadArtifact').mockResolvedValue({
downloadPath: fixtures.tmpDirectory
})
jest.spyOn(search, 'findFilesToUpload').mockResolvedValue({
filesToUpload: fixtures.filesToUpload,
rootDirectory: fixtures.tmpDirectory
})
jest.spyOn(artifact, 'uploadArtifact').mockResolvedValue({
size: 123,
id: 1337
})
jest
.spyOn(artifact, 'deleteArtifact')
.mockImplementation(async artifactName => {
const artifact = fixtures.artifacts.find(a => a.name === artifactName)
if (!artifact) throw new Error(`Artifact ${artifactName} not found`)
return {id: artifact.id}
})
})
it('merges artifacts', async () => {
await run()
for (const a of fixtures.artifacts) {
expect(artifact.downloadArtifact).toHaveBeenCalledWith(a.id, {
path: fixtures.tmpDirectory
})
}
expect(artifact.uploadArtifact).toHaveBeenCalledWith(
fixtures.artifactName,
fixtures.filesToUpload,
fixtures.tmpDirectory,
{compressionLevel: 6}
)
})
it('fails if no artifacts found', async () => {
mockInputs({[Inputs.Pattern]: 'this-does-not-match'})
expect(run()).rejects.toThrow()
expect(artifact.uploadArtifact).not.toBeCalled()
expect(artifact.downloadArtifact).not.toBeCalled()
})
it('supports custom compression level', async () => {
mockInputs({
[Inputs.CompressionLevel]: 2
})
await run()
expect(artifact.uploadArtifact).toHaveBeenCalledWith(
fixtures.artifactName,
fixtures.filesToUpload,
fixtures.tmpDirectory,
{compressionLevel: 2}
)
})
it('supports custom retention days', async () => {
mockInputs({
[Inputs.RetentionDays]: 7
})
await run()
expect(artifact.uploadArtifact).toHaveBeenCalledWith(
fixtures.artifactName,
fixtures.filesToUpload,
fixtures.tmpDirectory,
{retentionDays: 7, compressionLevel: 6}
)
})
it('supports deleting artifacts after merge', async () => {
mockInputs({
[Inputs.DeleteMerged]: true
})
await run()
for (const a of fixtures.artifacts) {
expect(artifact.deleteArtifact).toHaveBeenCalledWith(a.name)
}
})
})

View File

@ -2,7 +2,7 @@ import * as core from '@actions/core'
import * as path from 'path'
import * as io from '@actions/io'
import {promises as fs} from 'fs'
import {findFilesToUpload} from '../src/shared/search'
import {findFilesToUpload} from '../src/search'
const root = path.join(__dirname, '_temp', 'search')
const searchItem1Path = path.join(

View File

@ -1,231 +0,0 @@
import * as core from '@actions/core'
import * as github from '@actions/github'
import artifact, {ArtifactNotFoundError} from '@actions/artifact'
import {run} from '../src/upload/upload-artifact'
import {Inputs} from '../src/upload/constants'
import * as search from '../src/shared/search'
const fixtures = {
artifactName: 'artifact-name',
rootDirectory: '/some/artifact/path',
filesToUpload: [
'/some/artifact/path/file1.txt',
'/some/artifact/path/file2.txt'
]
}
jest.mock('@actions/github', () => ({
context: {
repo: {
owner: 'actions',
repo: 'toolkit'
},
runId: 123,
serverUrl: 'https://github.com'
}
}))
jest.mock('@actions/core')
/* eslint-disable no-unused-vars */
const mockInputs = (overrides?: Partial<{[K in Inputs]?: any}>) => {
const inputs = {
[Inputs.Name]: 'artifact-name',
[Inputs.Path]: '/some/artifact/path',
[Inputs.IfNoFilesFound]: 'warn',
[Inputs.RetentionDays]: 0,
[Inputs.CompressionLevel]: 6,
[Inputs.Overwrite]: false,
...overrides
}
;(core.getInput as jest.Mock).mockImplementation((name: string) => {
return inputs[name]
})
;(core.getBooleanInput as jest.Mock).mockImplementation((name: string) => {
return inputs[name]
})
return inputs
}
describe('upload', () => {
beforeEach(async () => {
mockInputs()
jest.spyOn(search, 'findFilesToUpload').mockResolvedValue({
filesToUpload: fixtures.filesToUpload,
rootDirectory: fixtures.rootDirectory
})
jest.spyOn(artifact, 'uploadArtifact').mockResolvedValue({
size: 123,
id: 1337
})
})
it('uploads a single file', async () => {
jest.spyOn(search, 'findFilesToUpload').mockResolvedValue({
filesToUpload: [fixtures.filesToUpload[0]],
rootDirectory: fixtures.rootDirectory
})
await run()
expect(artifact.uploadArtifact).toHaveBeenCalledWith(
fixtures.artifactName,
[fixtures.filesToUpload[0]],
fixtures.rootDirectory,
{compressionLevel: 6}
)
})
it('uploads multiple files', async () => {
await run()
expect(artifact.uploadArtifact).toHaveBeenCalledWith(
fixtures.artifactName,
fixtures.filesToUpload,
fixtures.rootDirectory,
{compressionLevel: 6}
)
})
it('sets outputs', async () => {
await run()
expect(core.setOutput).toHaveBeenCalledWith('artifact-id', 1337)
expect(core.setOutput).toHaveBeenCalledWith(
'artifact-url',
`${github.context.serverUrl}/${github.context.repo.owner}/${
github.context.repo.repo
}/actions/runs/${github.context.runId}/artifacts/${1337}`
)
})
it('supports custom compression level', async () => {
mockInputs({
[Inputs.CompressionLevel]: 2
})
await run()
expect(artifact.uploadArtifact).toHaveBeenCalledWith(
fixtures.artifactName,
fixtures.filesToUpload,
fixtures.rootDirectory,
{compressionLevel: 2}
)
})
it('supports custom retention days', async () => {
mockInputs({
[Inputs.RetentionDays]: 7
})
await run()
expect(artifact.uploadArtifact).toHaveBeenCalledWith(
fixtures.artifactName,
fixtures.filesToUpload,
fixtures.rootDirectory,
{retentionDays: 7, compressionLevel: 6}
)
})
it('supports warn if-no-files-found', async () => {
mockInputs({
[Inputs.IfNoFilesFound]: 'warn'
})
jest.spyOn(search, 'findFilesToUpload').mockResolvedValue({
filesToUpload: [],
rootDirectory: fixtures.rootDirectory
})
await run()
expect(core.warning).toHaveBeenCalledWith(
`No files were found with the provided path: ${fixtures.rootDirectory}. No artifacts will be uploaded.`
)
})
it('supports error if-no-files-found', async () => {
mockInputs({
[Inputs.IfNoFilesFound]: 'error'
})
jest.spyOn(search, 'findFilesToUpload').mockResolvedValue({
filesToUpload: [],
rootDirectory: fixtures.rootDirectory
})
await run()
expect(core.setFailed).toHaveBeenCalledWith(
`No files were found with the provided path: ${fixtures.rootDirectory}. No artifacts will be uploaded.`
)
})
it('supports ignore if-no-files-found', async () => {
mockInputs({
[Inputs.IfNoFilesFound]: 'ignore'
})
jest.spyOn(search, 'findFilesToUpload').mockResolvedValue({
filesToUpload: [],
rootDirectory: fixtures.rootDirectory
})
await run()
expect(core.info).toHaveBeenCalledWith(
`No files were found with the provided path: ${fixtures.rootDirectory}. No artifacts will be uploaded.`
)
})
it('supports overwrite', async () => {
mockInputs({
[Inputs.Overwrite]: true
})
jest.spyOn(artifact, 'deleteArtifact').mockResolvedValue({
id: 1337
})
await run()
expect(artifact.uploadArtifact).toHaveBeenCalledWith(
fixtures.artifactName,
fixtures.filesToUpload,
fixtures.rootDirectory,
{compressionLevel: 6}
)
expect(artifact.deleteArtifact).toHaveBeenCalledWith(fixtures.artifactName)
})
it('supports overwrite and continues if not found', async () => {
mockInputs({
[Inputs.Overwrite]: true
})
jest
.spyOn(artifact, 'deleteArtifact')
.mockRejectedValue(new ArtifactNotFoundError('not found'))
await run()
expect(artifact.uploadArtifact).toHaveBeenCalledWith(
fixtures.artifactName,
fixtures.filesToUpload,
fixtures.rootDirectory,
{compressionLevel: 6}
)
expect(artifact.deleteArtifact).toHaveBeenCalledWith(fixtures.artifactName)
expect(core.debug).toHaveBeenCalledWith(
`Skipping deletion of '${fixtures.artifactName}', it does not exist`
)
})
})

View File

@ -58,4 +58,4 @@ outputs:
Common uses cases for such a download URL can be adding download links to artifacts in descriptions or comments on pull requests or issues.
runs:
using: 'node20'
main: 'dist/upload/index.js'
main: 'dist/index.js'

File diff suppressed because it is too large Load Diff

136233
dist/merge/index.js vendored

File diff suppressed because one or more lines are too long

View File

@ -3,7 +3,6 @@
- [Migration](#migration)
- [Multiple uploads to the same named Artifact](#multiple-uploads-to-the-same-named-artifact)
- [Overwriting an Artifact](#overwriting-an-artifact)
- [Merging multiple artifacts](#merging-multiple-artifacts)
Several behavioral differences exist between Artifact actions `v3` and below vs `v4`. This document outlines common scenarios in `v3`, and how they would be handled in `v4`.
@ -33,7 +32,6 @@ jobs:
- name: Download All Artifacts
uses: actions/download-artifact@v3
with:
name: my-artifact
path: my-artifact
- run: ls -R my-artifact
```
@ -74,7 +72,6 @@ jobs:
- uses: actions/download-artifact@v3
+ uses: actions/download-artifact@v4
with:
- name: my-artifact
path: my-artifact
+ pattern: my-artifact-*
+ merge-multiple: true
@ -145,65 +142,3 @@ jobs:
```
Note that this will create an _entirely_ new Artifact, with a different ID from the previous.
## Merging multiple artifacts
In `v3`, multiple uploads from multiple jobs could be done to the same Artifact. This would result in a single archive, which could be useful for sending to upstream systems outside of Actions via API or UI downloads.
```yaml
jobs:
upload:
strategy:
matrix:
runs-on: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.runs-on }}
steps:
- name: Create a File
run: echo "hello from ${{ matrix.runs-on }}" > file-${{ matrix.runs-on }}.txt
- name: Upload Artifact
uses: actions/upload-artifact@v3
with:
name: all-my-files # NOTE: same artifact name
path: file-${{ matrix.runs-on }}.txt
```
The single `all-my-files` artifact would contain the following:
```
.
∟ file-ubuntu-latest.txt
∟ file-macos-latest.txt
∟ file-windows-latest.txt
```
To achieve the same in `v4` you can change it like so:
```diff
jobs:
upload:
strategy:
matrix:
runs-on: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.runs-on }}
steps:
- name: Create a File
run: echo "hello from ${{ matrix.runs-on }}" > file-${{ matrix.runs-on }}.txt
- name: Upload Artifact
- uses: actions/upload-artifact@v3
+ uses: actions/upload-artifact@v4
with:
- name: all-my-files
+ name: my-artifact-${{ matrix.runs-on }}
path: file-${{ matrix.runs-on }}.txt
+ merge:
+ runs-on: ubuntu-latest
+ needs: upload
+ steps:
+ - name: Merge Artifacts
+ uses: actions/upload-artifact/merge@v4
+ with:
+ name: all-my-files
+ pattern: my-artifact-*
```
Note that this will download all artifacts to a temporary directory and reupload them as a single artifact. For more information on inputs and other use cases for `actions/upload-artifact/merge@v4`, see [the action documentation](../merge/README.md).

View File

@ -1,200 +0,0 @@
# `@actions/upload-artifact/merge`
Merge multiple [Actions Artifacts](https://docs.github.com/en/actions/using-workflows/storing-workflow-data-as-artifacts) in Workflow Runs. Internally powered by [@actions/artifact](https://github.com/actions/toolkit/tree/main/packages/artifact) package.
- [`@actions/upload-artifact/merge`](#actionsupload-artifactmerge)
- [Usage](#usage)
- [Inputs](#inputs)
- [Outputs](#outputs)
- [Examples](#examples)
- [Combining all artifacts in a workflow run](#combining-all-artifacts-in-a-workflow-run)
- [Prefix directories in merged artifact](#prefix-directories-in-merged-artifact)
- [Deleting artifacts after merge](#deleting-artifacts-after-merge)
- [Retention and Compression Level](#retention-and-compression-level)
## Usage
> [!IMPORTANT]
> upload-artifact/merge@v4+ is not currently supported on GHES.
Note: this actions can only merge artifacts created with actions/upload-artifact@v4+
This sub-action is a helper to merge multiple artifacts after they are created. To do so, it will download multiple artifacts to a temporary directory and reupload them as a single artifact.
For most cases, this may not be the most efficient solution. See [the migration docs](../docs/MIGRATION.md#multiple-uploads-to-the-same-named-artifact) on how to download multiple artifacts to the same directory on a runner. This action should only be necessary for cases where multiple artifacts will need to be downloaded outside the runner environment, like downloads via the UI or REST API.
### Inputs
```yaml
- uses: actions/upload-artifact/merge@v4
with:
# The name of the artifact that the artifacts will be merged into
# Optional. Default is 'merged-artifacts'
name:
# A glob pattern matching the artifacts that should be merged.
# Optional. Default is '*'
pattern:
# If true, the artifacts will be merged into separate directories.
# If false, the artifacts will be merged into the root of the destination.
# Optional. Default is 'false'
separate-directories:
# If true, the artifacts that were merged will be deleted.
# If false, the artifacts will still exist.
# Optional. Default is 'false'
delete-merged:
# Duration after which artifact will expire in days. 0 means using default retention.
# Minimum 1 day.
# Maximum 90 days unless changed from the repository settings page.
# Optional. Defaults to repository settings.
retention-days:
# The level of compression for Zlib to be applied to the artifact archive.
# The value can range from 0 to 9.
# For large files that are not easily compressed, a value of 0 is recommended for significantly faster uploads.
# Optional. Default is '6'
compression-level:
```
### Outputs
| Name | Description | Example |
| - | - | - |
| `artifact-id` | GitHub ID of an Artifact, can be used by the REST API | `1234` |
| `artifact-url` | URL to download an Artifact. Can be used in many scenarios such as linking to artifacts in issues or pull requests. Users must be logged-in in order for this URL to work. This URL is valid as long as the artifact has not expired or the artifact, run or repository have not been deleted | `https://github.com/example-org/example-repo/actions/runs/1/artifacts/1234` |
## Examples
For each of these examples, assume we have a prior job matrix that generates three artifacts: `my-artifact-a`, `my-artifact-b` and `my-artifact-c`.
e.g.
```yaml
jobs:
upload:
runs-on: ubuntu-latest
strategy:
matrix:
foo: [a, b, c]
steps:
- name: Run a one-line script
run: echo "hello from job ${{ matrix.foo }}" > file-${{ matrix.foo }}.txt
- name: Upload
uses: actions/upload-artifact@v4
with:
name: my-artifact-${{ matrix.foo }}
path: file-${{ matrix.foo }}.txt
```
Each of the following examples will use the `needs: upload` as a prerequesite before any merging operations.
### Combining all artifacts in a workflow run
By default (with no inputs), calling this action will take all the artifacts in the workflow run and combined them into a single artifact called `merged-artifacts`:
```yaml
jobs:
# ... <upload job> ...
merge:
runs-on: ubuntu-latest
needs: upload
steps:
- name: Merge Artifacts
uses: actions/upload-artifact/merge@v4
```
This will result in an artifact called `merged-artifacts` with the following content:
```
.
∟ file-a.txt
∟ file-b.txt
∟ file-c.txt
```
To change the name of the artifact and filter on what artifacts are added, you can use the `name` and `pattern` inputs:
```yaml
jobs:
# ... <upload job> ...
merge:
runs-on: ubuntu-latest
needs: upload
steps:
- name: Merge Artifacts
uses: actions/upload-artifact/merge@v4
with:
name: my-amazing-merged-artifact
pattern: my-artifact-*
```
### Prefix directories in merged artifact
To prevent overwriting files in artifacts that may have the same name, you can use the `separate-directories` to prefix the extracted files with directories (named after the original artifact):
```yaml
jobs:
# ... <upload job> ...
merge:
runs-on: ubuntu-latest
needs: upload
steps:
- name: Merge Artifacts
uses: actions/upload-artifact/merge@v4
with:
separate-directories: true
```
This will result in the following artifact structure:
```
.
∟ my-artifact-a
∟ file-a.txt
∟ my-artifact-b
∟ file-b.txt
∟ my-artifact-c
∟ file-c.txt
```
### Deleting artifacts after merge
After merge, the old artifacts may no longer be required. To automatically delete them after they are merged into a new artifact, you can use `delete-merged` like so:
```yaml
jobs:
# ... <upload job> ...
merge:
runs-on: ubuntu-latest
needs: upload
steps:
- name: Merge Artifacts
uses: actions/upload-artifact/merge@v4
with:
delete-merged: true
```
After this runs, the matching artifact (`my-artifact-a`, `my-artifact-b` and `my-artifact-c`) will be merged.
### Retention and Compression Level
Similar to actions/upload-artifact, both [`retention-days`](../README.md#retention-period) and [`compression-level`](../README.md#altering-compressions-level-speed-v-size) are supported:
```yaml
jobs:
# ... <upload job> ...
merge:
runs-on: ubuntu-latest
needs: upload
steps:
- name: Merge Artifacts
uses: actions/upload-artifact/merge@v4
with:
retention-days: 1
compression-level: 9
```

View File

@ -1,57 +0,0 @@
name: 'Merge Build Artifacts'
description: 'Merge one or more build Artifacts'
author: 'GitHub'
inputs:
name:
description: 'The name of the artifact that the artifacts will be merged into.'
required: true
default: 'merged-artifacts'
pattern:
description: 'A glob pattern matching the artifact names that should be merged.'
default: '*'
separate-directories:
description: 'When multiple artifacts are matched, this changes the behavior of how they are merged in the archive.
If true, the matched artifacts will be extracted into individual named directories within the specified path.
If false, the matched artifacts will combined in the same directory.'
default: 'false'
retention-days:
description: >
Duration after which artifact will expire in days. 0 means using default retention.
Minimum 1 day.
Maximum 90 days unless changed from the repository settings page.
compression-level:
description: >
The level of compression for Zlib to be applied to the artifact archive.
The value can range from 0 to 9:
- 0: No compression
- 1: Best speed
- 6: Default compression (same as GNU Gzip)
- 9: Best compression
Higher levels will result in better compression, but will take longer to complete.
For large files that are not easily compressed, a value of 0 is recommended for significantly faster uploads.
default: '6'
delete-merged:
description: >
If true, the artifacts that were merged will be deleted.
If false, the artifacts will still exist.
default: 'false'
outputs:
artifact-id:
description: >
A unique identifier for the artifact that was just uploaded. Empty if the artifact upload failed.
This ID can be used as input to other APIs to download, delete or get more information about an artifact: https://docs.github.com/en/rest/actions/artifacts
artifact-url:
description: >
A download URL for the artifact that was just uploaded. Empty if the artifact upload failed.
This download URL only works for requests Authenticated with GitHub. Anonymous downloads will be prompted to first login.
If an anonymous download URL is needed than a short time restricted URL can be generated using the download artifact API: https://docs.github.com/en/rest/actions/artifacts#download-an-artifact
This URL will be valid for as long as the artifact exists and the workflow run and repository exists. Once an artifact has expired this URL will no longer work.
Common uses cases for such a download URL can be adding download links to artifacts in descriptions or comments on pull requests or issues.
runs:
using: 'node20'
main: '../dist/merge/index.js'

1668
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,11 +1,11 @@
{
"name": "upload-artifact",
"version": "4.3.6",
"version": "4.2.0",
"description": "Upload an Actions Artifact in a workflow run",
"main": "dist/upload/index.js",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"release": "ncc build src/upload/index.ts -o dist/upload && ncc build src/merge/index.ts -o dist/merge",
"release": "ncc build src/upload-artifact.ts && git add -f dist/index.js",
"check-all": "concurrently \"npm:format-check\" \"npm:lint\" \"npm:test\" \"npm:build\"",
"format": "prettier --write **/*.ts",
"format-check": "prettier --check **/*.ts",
@ -29,12 +29,11 @@
},
"homepage": "https://github.com/actions/upload-artifact#readme",
"dependencies": {
"@actions/artifact": "2.1.8",
"@actions/core": "^1.10.1",
"@actions/artifact": "^2.1.0",
"@actions/core": "^1.10.0",
"@actions/github": "^6.0.0",
"@actions/glob": "^0.3.0",
"@actions/io": "^1.1.2",
"minimatch": "^9.0.3"
"@actions/io": "^1.1.2"
},
"devDependencies": {
"@types/jest": "^29.2.5",

View File

@ -1,9 +0,0 @@
/* eslint-disable no-unused-vars */
export enum Inputs {
Name = 'name',
Pattern = 'pattern',
SeparateDirectories = 'separate-directories',
RetentionDays = 'retention-days',
CompressionLevel = 'compression-level',
DeleteMerged = 'delete-merged'
}

View File

@ -1,6 +0,0 @@
import * as core from '@actions/core'
import {run} from './merge-artifacts'
run().catch(error => {
core.setFailed((error as Error).message)
})

View File

@ -1,44 +0,0 @@
import * as core from '@actions/core'
import {Inputs} from './constants'
import {MergeInputs} from './merge-inputs'
/**
* Helper to get all the inputs for the action
*/
export function getInputs(): MergeInputs {
const name = core.getInput(Inputs.Name, {required: true})
const pattern = core.getInput(Inputs.Pattern, {required: true})
const separateDirectories = core.getBooleanInput(Inputs.SeparateDirectories)
const deleteMerged = core.getBooleanInput(Inputs.DeleteMerged)
const inputs = {
name,
pattern,
separateDirectories,
deleteMerged,
retentionDays: 0,
compressionLevel: 6
} as MergeInputs
const retentionDaysStr = core.getInput(Inputs.RetentionDays)
if (retentionDaysStr) {
inputs.retentionDays = parseInt(retentionDaysStr)
if (isNaN(inputs.retentionDays)) {
core.setFailed('Invalid retention-days')
}
}
const compressionLevelStr = core.getInput(Inputs.CompressionLevel)
if (compressionLevelStr) {
inputs.compressionLevel = parseInt(compressionLevelStr)
if (isNaN(inputs.compressionLevel)) {
core.setFailed('Invalid compression-level')
}
if (inputs.compressionLevel < 0 || inputs.compressionLevel > 9) {
core.setFailed('Invalid compression-level. Valid values are 0-9')
}
}
return inputs
}

View File

@ -1,93 +0,0 @@
import * as path from 'path'
import {mkdtemp, rm} from 'fs/promises'
import * as core from '@actions/core'
import {Minimatch} from 'minimatch'
import artifactClient, {UploadArtifactOptions} from '@actions/artifact'
import {getInputs} from './input-helper'
import {uploadArtifact} from '../shared/upload-artifact'
import {findFilesToUpload} from '../shared/search'
const PARALLEL_DOWNLOADS = 5
export const chunk = <T>(arr: T[], n: number): T[][] =>
arr.reduce((acc, cur, i) => {
const index = Math.floor(i / n)
acc[index] = [...(acc[index] || []), cur]
return acc
}, [] as T[][])
export async function run(): Promise<void> {
const inputs = getInputs()
const tmpDir = await mkdtemp('merge-artifact')
const listArtifactResponse = await artifactClient.listArtifacts({
latest: true
})
const matcher = new Minimatch(inputs.pattern)
const artifacts = listArtifactResponse.artifacts.filter(artifact =>
matcher.match(artifact.name)
)
core.debug(
`Filtered from ${listArtifactResponse.artifacts.length} to ${artifacts.length} artifacts`
)
if (artifacts.length === 0) {
throw new Error(`No artifacts found matching pattern '${inputs.pattern}'`)
}
core.info(`Preparing to download the following artifacts:`)
artifacts.forEach(artifact => {
core.info(`- ${artifact.name} (ID: ${artifact.id}, Size: ${artifact.size})`)
})
const downloadPromises = artifacts.map(artifact =>
artifactClient.downloadArtifact(artifact.id, {
path: inputs.separateDirectories
? path.join(tmpDir, artifact.name)
: tmpDir
})
)
const chunkedPromises = chunk(downloadPromises, PARALLEL_DOWNLOADS)
for (const chunk of chunkedPromises) {
await Promise.all(chunk)
}
const options: UploadArtifactOptions = {}
if (inputs.retentionDays) {
options.retentionDays = inputs.retentionDays
}
if (typeof inputs.compressionLevel !== 'undefined') {
options.compressionLevel = inputs.compressionLevel
}
const searchResult = await findFilesToUpload(tmpDir)
await uploadArtifact(
inputs.name,
searchResult.filesToUpload,
searchResult.rootDirectory,
options
)
core.info(
`The ${artifacts.length} artifact(s) have been successfully merged!`
)
if (inputs.deleteMerged) {
const deletePromises = artifacts.map(artifact =>
artifactClient.deleteArtifact(artifact.name)
)
await Promise.all(deletePromises)
core.info(`The ${artifacts.length} artifact(s) have been deleted`)
}
try {
await rm(tmpDir, {recursive: true})
} catch (error) {
core.warning(
`Unable to remove temporary directory: ${(error as Error).message}`
)
}
}

View File

@ -1,33 +0,0 @@
export interface MergeInputs {
/**
* The name of the artifact that the artifacts will be merged into
*/
name: string
/**
* A glob pattern matching the artifacts that should be merged.
*/
pattern: string
/**
* Duration after which artifact will expire in days
*/
retentionDays: number
/**
* The level of compression for Zlib to be applied to the artifact archive.
*/
compressionLevel?: number
/**
* If true, the artifacts that were merged will be deleted.
* If false, the artifacts will still exist.
*/
deleteMerged: boolean
/**
* If true, the artifacts will be merged into separate directories.
* If false, the artifacts will be merged into the root of the destination.
*/
separateDirectories: boolean
}

View File

@ -1,28 +0,0 @@
import * as core from '@actions/core'
import * as github from '@actions/github'
import artifact, {UploadArtifactOptions} from '@actions/artifact'
export async function uploadArtifact(
artifactName: string,
filesToUpload: string[],
rootDirectory: string,
options: UploadArtifactOptions
) {
const uploadResponse = await artifact.uploadArtifact(
artifactName,
filesToUpload,
rootDirectory,
options
)
core.info(
`Artifact ${artifactName} has been successfully uploaded! Final size is ${uploadResponse.size} bytes. Artifact ID is ${uploadResponse.id}`
)
core.setOutput('artifact-id', uploadResponse.id)
const repository = github.context.repo
const artifactURL = `${github.context.serverUrl}/${repository.owner}/${repository.repo}/actions/runs/${github.context.runId}/artifacts/${uploadResponse.id}`
core.info(`Artifact download URL: ${artifactURL}`)
core.setOutput('artifact-url', artifactURL)
}

94
src/upload-artifact.ts Normal file
View File

@ -0,0 +1,94 @@
import * as core from '@actions/core'
import * as github from '@actions/github'
import artifact, {
UploadArtifactOptions,
ArtifactNotFoundError
} from '@actions/artifact'
import {findFilesToUpload} from './search'
import {getInputs} from './input-helper'
import {NoFileOptions} from './constants'
async function deleteArtifactIfExists(artifactName: string): Promise<void> {
try {
await artifact.deleteArtifact(artifactName)
} catch (error) {
if (error instanceof ArtifactNotFoundError) {
core.debug(`Skipping deletion of '${artifactName}', it does not exist`)
return
}
// Best effort, we don't want to fail the action if this fails
core.debug(`Unable to delete artifact: ${(error as Error).message}`)
}
}
async function run(): Promise<void> {
try {
const inputs = getInputs()
const searchResult = await findFilesToUpload(inputs.searchPath)
if (searchResult.filesToUpload.length === 0) {
// No files were found, different use cases warrant different types of behavior if nothing is found
switch (inputs.ifNoFilesFound) {
case NoFileOptions.warn: {
core.warning(
`No files were found with the provided path: ${inputs.searchPath}. No artifacts will be uploaded.`
)
break
}
case NoFileOptions.error: {
core.setFailed(
`No files were found with the provided path: ${inputs.searchPath}. No artifacts will be uploaded.`
)
break
}
case NoFileOptions.ignore: {
core.info(
`No files were found with the provided path: ${inputs.searchPath}. No artifacts will be uploaded.`
)
break
}
}
} else {
const s = searchResult.filesToUpload.length === 1 ? '' : 's'
core.info(
`With the provided path, there will be ${searchResult.filesToUpload.length} file${s} uploaded`
)
core.debug(`Root artifact directory is ${searchResult.rootDirectory}`)
if (inputs.overwrite) {
await deleteArtifactIfExists(inputs.artifactName)
}
const options: UploadArtifactOptions = {}
if (inputs.retentionDays) {
options.retentionDays = inputs.retentionDays
}
if (typeof inputs.compressionLevel !== 'undefined') {
options.compressionLevel = inputs.compressionLevel
}
const uploadResponse = await artifact.uploadArtifact(
inputs.artifactName,
searchResult.filesToUpload,
searchResult.rootDirectory,
options
)
core.info(
`Artifact ${inputs.artifactName} has been successfully uploaded! Final size is ${uploadResponse.size} bytes. Artifact ID is ${uploadResponse.id}`
)
core.setOutput('artifact-id', uploadResponse.id)
const repository = github.context.repo
const artifactURL = `${github.context.serverUrl}/${repository.owner}/${repository.repo}/actions/runs/${github.context.runId}/artifacts/${uploadResponse.id}`
core.info(`Artifact download URL: ${artifactURL}`)
core.setOutput('artifact-url', artifactURL)
}
} catch (error) {
core.setFailed((error as Error).message)
}
}
run()

View File

@ -1,6 +0,0 @@
import * as core from '@actions/core'
import {run} from './upload-artifact'
run().catch(error => {
core.setFailed((error as Error).message)
})

View File

@ -1,77 +0,0 @@
import * as core from '@actions/core'
import artifact, {
UploadArtifactOptions,
ArtifactNotFoundError
} from '@actions/artifact'
import {findFilesToUpload} from '../shared/search'
import {getInputs} from './input-helper'
import {NoFileOptions} from './constants'
import {uploadArtifact} from '../shared/upload-artifact'
async function deleteArtifactIfExists(artifactName: string): Promise<void> {
try {
await artifact.deleteArtifact(artifactName)
} catch (error) {
if (error instanceof ArtifactNotFoundError) {
core.debug(`Skipping deletion of '${artifactName}', it does not exist`)
return
}
// Best effort, we don't want to fail the action if this fails
core.debug(`Unable to delete artifact: ${(error as Error).message}`)
}
}
export async function run(): Promise<void> {
const inputs = getInputs()
const searchResult = await findFilesToUpload(inputs.searchPath)
if (searchResult.filesToUpload.length === 0) {
// No files were found, different use cases warrant different types of behavior if nothing is found
switch (inputs.ifNoFilesFound) {
case NoFileOptions.warn: {
core.warning(
`No files were found with the provided path: ${inputs.searchPath}. No artifacts will be uploaded.`
)
break
}
case NoFileOptions.error: {
core.setFailed(
`No files were found with the provided path: ${inputs.searchPath}. No artifacts will be uploaded.`
)
break
}
case NoFileOptions.ignore: {
core.info(
`No files were found with the provided path: ${inputs.searchPath}. No artifacts will be uploaded.`
)
break
}
}
} else {
const s = searchResult.filesToUpload.length === 1 ? '' : 's'
core.info(
`With the provided path, there will be ${searchResult.filesToUpload.length} file${s} uploaded`
)
core.debug(`Root artifact directory is ${searchResult.rootDirectory}`)
if (inputs.overwrite) {
await deleteArtifactIfExists(inputs.artifactName)
}
const options: UploadArtifactOptions = {}
if (inputs.retentionDays) {
options.retentionDays = inputs.retentionDays
}
if (typeof inputs.compressionLevel !== 'undefined') {
options.compressionLevel = inputs.compressionLevel
}
await uploadArtifact(
inputs.artifactName,
searchResult.filesToUpload,
searchResult.rootDirectory,
options
)
}
}