Skip to content

Commit 997fffa

Browse files
committed
add merge artifact sub-action
1 parent 52899c8 commit 997fffa

11 files changed

+1100
-61
lines changed

.licenses/npm/minimatch.dep.yml

+26
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

__tests__/merge.test.ts

+175
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
import * as core from '@actions/core'
2+
import artifact from '@actions/artifact'
3+
import {run} from '../src/merge/merge-artifacts'
4+
import {Inputs} from '../src/merge/constants'
5+
import * as search from '../src/shared/search'
6+
7+
const fixtures = {
8+
artifactName: 'my-merged-artifact',
9+
tmpDirectory: '/tmp/merge-artifact',
10+
filesToUpload: [
11+
'/some/artifact/path/file-a.txt',
12+
'/some/artifact/path/file-b.txt',
13+
'/some/artifact/path/file-c.txt'
14+
],
15+
artifacts: [
16+
{
17+
name: 'my-artifact-a',
18+
id: 1,
19+
size: 100,
20+
createdAt: new Date('2024-01-01T00:00:00Z')
21+
},
22+
{
23+
name: 'my-artifact-b',
24+
id: 2,
25+
size: 100,
26+
createdAt: new Date('2024-01-01T00:00:00Z')
27+
},
28+
{
29+
name: 'my-artifact-c',
30+
id: 3,
31+
size: 100,
32+
createdAt: new Date('2024-01-01T00:00:00Z')
33+
}
34+
]
35+
}
36+
37+
jest.mock('@actions/github', () => ({
38+
context: {
39+
repo: {
40+
owner: 'actions',
41+
repo: 'toolkit'
42+
},
43+
runId: 123,
44+
serverUrl: 'https://github.com'
45+
}
46+
}))
47+
48+
jest.mock('@actions/core')
49+
50+
jest.mock('fs/promises', () => ({
51+
mkdtemp: jest.fn().mockResolvedValue('/tmp/merge-artifact'),
52+
rm: jest.fn().mockResolvedValue(undefined)
53+
}))
54+
55+
/* eslint-disable no-unused-vars */
56+
const mockInputs = (overrides?: Partial<{[K in Inputs]?: any}>) => {
57+
const inputs = {
58+
[Inputs.Name]: 'my-merged-artifact',
59+
[Inputs.Pattern]: '*',
60+
[Inputs.SeparateDirectories]: false,
61+
[Inputs.RetentionDays]: 0,
62+
[Inputs.CompressionLevel]: 6,
63+
[Inputs.DeleteMerged]: false,
64+
...overrides
65+
}
66+
67+
;(core.getInput as jest.Mock).mockImplementation((name: string) => {
68+
return inputs[name]
69+
})
70+
;(core.getBooleanInput as jest.Mock).mockImplementation((name: string) => {
71+
return inputs[name]
72+
})
73+
74+
return inputs
75+
}
76+
77+
describe('merge', () => {
78+
beforeEach(async () => {
79+
mockInputs()
80+
81+
jest
82+
.spyOn(artifact, 'listArtifacts')
83+
.mockResolvedValue({artifacts: fixtures.artifacts})
84+
85+
jest.spyOn(artifact, 'downloadArtifact').mockResolvedValue({
86+
downloadPath: fixtures.tmpDirectory
87+
})
88+
89+
jest.spyOn(search, 'findFilesToUpload').mockResolvedValue({
90+
filesToUpload: fixtures.filesToUpload,
91+
rootDirectory: fixtures.tmpDirectory
92+
})
93+
94+
jest.spyOn(artifact, 'uploadArtifact').mockResolvedValue({
95+
size: 123,
96+
id: 1337
97+
})
98+
99+
jest
100+
.spyOn(artifact, 'deleteArtifact')
101+
.mockImplementation(async artifactName => {
102+
const artifact = fixtures.artifacts.find(a => a.name === artifactName)
103+
if (!artifact) throw new Error(`Artifact ${artifactName} not found`)
104+
return {id: artifact.id}
105+
})
106+
})
107+
108+
it('merges artifacts', async () => {
109+
await run()
110+
111+
for (const a of fixtures.artifacts) {
112+
expect(artifact.downloadArtifact).toHaveBeenCalledWith(a.id, {
113+
path: fixtures.tmpDirectory
114+
})
115+
}
116+
117+
expect(artifact.uploadArtifact).toHaveBeenCalledWith(
118+
fixtures.artifactName,
119+
fixtures.filesToUpload,
120+
fixtures.tmpDirectory,
121+
{compressionLevel: 6}
122+
)
123+
})
124+
125+
it('fails if no artifacts found', async () => {
126+
mockInputs({[Inputs.Pattern]: 'this-does-not-match'})
127+
128+
expect(run()).rejects.toThrow()
129+
130+
expect(artifact.uploadArtifact).not.toBeCalled()
131+
expect(artifact.downloadArtifact).not.toBeCalled()
132+
})
133+
134+
it('supports custom compression level', async () => {
135+
mockInputs({
136+
[Inputs.CompressionLevel]: 2
137+
})
138+
139+
await run()
140+
141+
expect(artifact.uploadArtifact).toHaveBeenCalledWith(
142+
fixtures.artifactName,
143+
fixtures.filesToUpload,
144+
fixtures.tmpDirectory,
145+
{compressionLevel: 2}
146+
)
147+
})
148+
149+
it('supports custom retention days', async () => {
150+
mockInputs({
151+
[Inputs.RetentionDays]: 7
152+
})
153+
154+
await run()
155+
156+
expect(artifact.uploadArtifact).toHaveBeenCalledWith(
157+
fixtures.artifactName,
158+
fixtures.filesToUpload,
159+
fixtures.tmpDirectory,
160+
{retentionDays: 7, compressionLevel: 6}
161+
)
162+
})
163+
164+
it('supports deleting artifacts after merge', async () => {
165+
mockInputs({
166+
[Inputs.DeleteMerged]: true
167+
})
168+
169+
await run()
170+
171+
for (const a of fixtures.artifacts) {
172+
expect(artifact.deleteArtifact).toHaveBeenCalledWith(a.name)
173+
}
174+
})
175+
})

merge/README.md

+63
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
# `@actions/upload-artifact/merge`
2+
3+
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.
4+
5+
- [`@actions/upload-artifact/merge`](#actionsupload-artifactmerge)
6+
- [Usage](#usage)
7+
- [Inputs](#inputs)
8+
- [Outputs](#outputs)
9+
- [Examples](#examples)
10+
11+
## Usage
12+
13+
> [!IMPORTANT]
14+
> upload-artifact/merge@v4+ is not currently supported on GHES.
15+
16+
Note: this actions can only merge artifacts created with actions/upload-artifact@v4+
17+
18+
### Inputs
19+
20+
```yaml
21+
- uses: actions/upload-artifact/merge@v4
22+
with:
23+
# The name of the artifact that the artifacts will be merged into
24+
# Optional. Default is 'merged-artifacts'
25+
name:
26+
27+
# A glob pattern matching the artifacts that should be merged.
28+
# Optional. Default is '*'
29+
pattern:
30+
31+
# If true, the artifacts will be merged into separate directories.
32+
# If false, the artifacts will be merged into the root of the destination.
33+
# Optional. Default is 'false'
34+
separate-directories:
35+
36+
# If true, the artifacts that were merged will be deleted.
37+
# If false, the artifacts will still exist.
38+
# Optional. Default is 'false'
39+
delete-merged:
40+
41+
# Duration after which artifact will expire in days. 0 means using default retention.
42+
# Minimum 1 day.
43+
# Maximum 90 days unless changed from the repository settings page.
44+
# Optional. Defaults to repository settings.
45+
retention-days:
46+
47+
# The level of compression for Zlib to be applied to the artifact archive.
48+
# The value can range from 0 to 9.
49+
# For large files that are not easily compressed, a value of 0 is recommended for significantly faster uploads.
50+
# Optional. Default is '6'
51+
compression-level:
52+
```
53+
54+
### Outputs
55+
56+
| Name | Description | Example |
57+
| - | - | - |
58+
| `artifact-id` | GitHub ID of an Artifact, can be used by the REST API | `1234` |
59+
| `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` |
60+
61+
## Examples
62+
63+
TODO(robherley): add examples

merge/action.yml

+57
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
name: 'Merge Build Artifacts'
2+
description: 'Merge one or more build Artifacts'
3+
author: 'GitHub'
4+
inputs:
5+
name:
6+
description: 'The name of the artifact that the artifacts will be merged into.'
7+
required: true
8+
default: 'merged-artifacts'
9+
pattern:
10+
description: 'A glob pattern matching the artifact names that should be merged.'
11+
default: '*'
12+
separate-directories:
13+
description: 'When multiple artifacts are matched, this changes the behavior of how they are merged in the archive.
14+
If true, the matched artifacts will be extracted into individual named directories within the specified path.
15+
If false, the matched artifacts will combined in the same directory.'
16+
default: 'false'
17+
retention-days:
18+
description: >
19+
Duration after which artifact will expire in days. 0 means using default retention.
20+
21+
Minimum 1 day.
22+
Maximum 90 days unless changed from the repository settings page.
23+
compression-level:
24+
description: >
25+
The level of compression for Zlib to be applied to the artifact archive.
26+
The value can range from 0 to 9:
27+
- 0: No compression
28+
- 1: Best speed
29+
- 6: Default compression (same as GNU Gzip)
30+
- 9: Best compression
31+
Higher levels will result in better compression, but will take longer to complete.
32+
For large files that are not easily compressed, a value of 0 is recommended for significantly faster uploads.
33+
default: '6'
34+
delete-merged:
35+
description: >
36+
If true, the artifacts that were merged will be deleted.
37+
If false, the artifacts will still exist.
38+
default: 'false'
39+
40+
outputs:
41+
artifact-id:
42+
description: >
43+
A unique identifier for the artifact that was just uploaded. Empty if the artifact upload failed.
44+
45+
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
46+
artifact-url:
47+
description: >
48+
A download URL for the artifact that was just uploaded. Empty if the artifact upload failed.
49+
50+
This download URL only works for requests Authenticated with GitHub. Anonymous downloads will be prompted to first login.
51+
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
52+
53+
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.
54+
Common uses cases for such a download URL can be adding download links to artifacts in descriptions or comments on pull requests or issues.
55+
runs:
56+
using: 'node20'
57+
main: '../dist/merge/index.js'

0 commit comments

Comments
 (0)