Skip to content

Commit 8ea3c2c

Browse files
authored
Merge pull request #401 from actions/download-by-id
feat: implement new `artifact-ids` input
2 parents 95815c3 + d219c63 commit 8ea3c2c

7 files changed

Lines changed: 328 additions & 7 deletions

File tree

README.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ See also [upload-artifact](https://github.com/actions/upload-artifact).
1313
- [Outputs](#outputs)
1414
- [Examples](#examples)
1515
- [Download Single Artifact](#download-single-artifact)
16+
- [Download Artifacts by ID](#download-artifacts-by-id)
1617
- [Download All Artifacts](#download-all-artifacts)
1718
- [Download multiple (filtered) Artifacts to the same directory](#download-multiple-filtered-artifacts-to-the-same-directory)
1819
- [Download Artifacts from other Workflow Runs or Repositories](#download-artifacts-from-other-workflow-runs-or-repositories)
@@ -53,6 +54,11 @@ For assistance with breaking changes, see [MIGRATION.md](docs/MIGRATION.md).
5354
# Optional.
5455
name:
5556

57+
# IDs of the artifacts to download, comma-separated.
58+
# Either inputs `artifact-ids` or `name` can be used, but not both.
59+
# Optional.
60+
artifact-ids:
61+
5662
# Destination path. Supports basic tilde expansion.
5763
# Optional. Default is $GITHUB_WORKSPACE
5864
path:
@@ -117,6 +123,32 @@ steps:
117123
run: ls -R your/destination/dir
118124
```
119125

126+
### Download Artifacts by ID
127+
128+
The `artifact-ids` input allows downloading artifacts using their unique ID rather than name. This is particularly useful when working with immutable artifacts from `actions/upload-artifact@v4` which assigns a unique ID to each artifact.
129+
130+
```yaml
131+
steps:
132+
- uses: actions/download-artifact@v4
133+
with:
134+
artifact-ids: 12345
135+
- name: Display structure of downloaded files
136+
run: ls -R
137+
```
138+
139+
Multiple artifacts can be downloaded by providing a comma-separated list of IDs:
140+
141+
```yaml
142+
steps:
143+
- uses: actions/download-artifact@v4
144+
with:
145+
artifact-ids: 12345,67890
146+
path: path/to/artifacts
147+
- name: Display structure of downloaded files
148+
run: ls -R path/to/artifacts
149+
```
150+
151+
This will download multiple artifacts to separate directories (similar to downloading multiple artifacts by name).
120152

121153
### Download All Artifacts
122154

__tests__/download.test.ts

Lines changed: 151 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ describe('download', () => {
112112
await run()
113113

114114
expect(core.info).toHaveBeenCalledWith(
115-
'No input name or pattern filtered specified, downloading all artifacts'
115+
'No input name, artifact-ids or pattern filtered specified, downloading all artifacts'
116116
)
117117

118118
expect(core.info).toHaveBeenCalledWith('Total of 2 artifact(s) downloaded')
@@ -221,4 +221,154 @@ describe('download', () => {
221221
expect.stringContaining('digest validation failed')
222222
)
223223
})
224+
225+
test('downloads a single artifact by ID', async () => {
226+
const mockArtifact = {
227+
id: 456,
228+
name: 'artifact-by-id',
229+
size: 1024,
230+
digest: 'def456'
231+
}
232+
233+
mockInputs({
234+
[Inputs.Name]: '',
235+
[Inputs.Pattern]: '',
236+
[Inputs.ArtifactIds]: '456'
237+
})
238+
239+
jest.spyOn(artifact, 'listArtifacts').mockImplementation(() =>
240+
Promise.resolve({
241+
artifacts: [mockArtifact]
242+
})
243+
)
244+
245+
await run()
246+
247+
expect(core.info).toHaveBeenCalledWith('Downloading artifacts by ID')
248+
expect(core.debug).toHaveBeenCalledWith('Parsed artifact IDs: ["456"]')
249+
expect(artifact.downloadArtifact).toHaveBeenCalledTimes(1)
250+
expect(artifact.downloadArtifact).toHaveBeenCalledWith(
251+
456,
252+
expect.objectContaining({
253+
expectedHash: mockArtifact.digest
254+
})
255+
)
256+
expect(core.info).toHaveBeenCalledWith('Total of 1 artifact(s) downloaded')
257+
})
258+
259+
test('downloads multiple artifacts by ID', async () => {
260+
const mockArtifacts = [
261+
{id: 123, name: 'first-artifact', size: 1024, digest: 'abc123'},
262+
{id: 456, name: 'second-artifact', size: 2048, digest: 'def456'},
263+
{id: 789, name: 'third-artifact', size: 3072, digest: 'ghi789'}
264+
]
265+
266+
mockInputs({
267+
[Inputs.Name]: '',
268+
[Inputs.Pattern]: '',
269+
[Inputs.ArtifactIds]: '123, 456, 789'
270+
})
271+
272+
jest.spyOn(artifact, 'listArtifacts').mockImplementation(() =>
273+
Promise.resolve({
274+
artifacts: mockArtifacts
275+
})
276+
)
277+
278+
await run()
279+
280+
expect(core.info).toHaveBeenCalledWith('Downloading artifacts by ID')
281+
expect(core.debug).toHaveBeenCalledWith(
282+
'Parsed artifact IDs: ["123","456","789"]'
283+
)
284+
expect(artifact.downloadArtifact).toHaveBeenCalledTimes(3)
285+
mockArtifacts.forEach(mockArtifact => {
286+
expect(artifact.downloadArtifact).toHaveBeenCalledWith(
287+
mockArtifact.id,
288+
expect.objectContaining({
289+
expectedHash: mockArtifact.digest
290+
})
291+
)
292+
})
293+
expect(core.info).toHaveBeenCalledWith('Total of 3 artifact(s) downloaded')
294+
})
295+
296+
test('warns when some artifact IDs are not found', async () => {
297+
const mockArtifacts = [
298+
{id: 123, name: 'found-artifact', size: 1024, digest: 'abc123'}
299+
]
300+
301+
mockInputs({
302+
[Inputs.Name]: '',
303+
[Inputs.Pattern]: '',
304+
[Inputs.ArtifactIds]: '123, 456, 789'
305+
})
306+
307+
jest.spyOn(artifact, 'listArtifacts').mockImplementation(() =>
308+
Promise.resolve({
309+
artifacts: mockArtifacts
310+
})
311+
)
312+
313+
await run()
314+
315+
expect(core.warning).toHaveBeenCalledWith(
316+
'Could not find the following artifact IDs: 456, 789'
317+
)
318+
expect(core.debug).toHaveBeenCalledWith('Found 1 artifacts by ID')
319+
expect(artifact.downloadArtifact).toHaveBeenCalledTimes(1)
320+
})
321+
322+
test('throws error when no artifacts with requested IDs are found', async () => {
323+
mockInputs({
324+
[Inputs.Name]: '',
325+
[Inputs.Pattern]: '',
326+
[Inputs.ArtifactIds]: '123, 456'
327+
})
328+
329+
jest.spyOn(artifact, 'listArtifacts').mockImplementation(() =>
330+
Promise.resolve({
331+
artifacts: []
332+
})
333+
)
334+
335+
await expect(run()).rejects.toThrow(
336+
'None of the provided artifact IDs were found'
337+
)
338+
})
339+
340+
test('throws error when artifact-ids input is empty', async () => {
341+
mockInputs({
342+
[Inputs.Name]: '',
343+
[Inputs.Pattern]: '',
344+
[Inputs.ArtifactIds]: ' '
345+
})
346+
347+
await expect(run()).rejects.toThrow(
348+
"No valid artifact IDs provided in 'artifact-ids' input"
349+
)
350+
})
351+
352+
test('throws error when some artifact IDs are not valid numbers', async () => {
353+
mockInputs({
354+
[Inputs.Name]: '',
355+
[Inputs.Pattern]: '',
356+
[Inputs.ArtifactIds]: '123, abc, 456'
357+
})
358+
359+
await expect(run()).rejects.toThrow(
360+
"Invalid artifact ID: 'abc'. Must be a number."
361+
)
362+
})
363+
364+
test('throws error when both name and artifact-ids are provided', async () => {
365+
mockInputs({
366+
[Inputs.Name]: 'some-artifact',
367+
[Inputs.ArtifactIds]: '123'
368+
})
369+
370+
await expect(run()).rejects.toThrow(
371+
"Inputs 'name' and 'artifact-ids' cannot be used together. Please specify only one."
372+
)
373+
})
224374
})

action.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ inputs:
55
name:
66
description: 'Name of the artifact to download. If unspecified, all artifacts for the run are downloaded.'
77
required: false
8+
artifact-ids:
9+
description: 'IDs of the artifacts to download, comma-separated. Either inputs `artifact-ids` or `name` can be used, but not both.'
10+
required: false
811
path:
912
description: 'Destination path. Supports basic tilde expansion. Defaults to $GITHUB_WORKSPACE'
1013
required: false

dist/index.js

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -118710,6 +118710,7 @@ var Inputs;
118710118710
Inputs["RunID"] = "run-id";
118711118711
Inputs["Pattern"] = "pattern";
118712118712
Inputs["MergeMultiple"] = "merge-multiple";
118713+
Inputs["ArtifactIds"] = "artifact-ids";
118713118714
})(Inputs || (exports.Inputs = Inputs = {}));
118714118715
var Outputs;
118715118716
(function (Outputs) {
@@ -118783,15 +118784,23 @@ function run() {
118783118784
repository: core.getInput(constants_1.Inputs.Repository, { required: false }),
118784118785
runID: parseInt(core.getInput(constants_1.Inputs.RunID, { required: false })),
118785118786
pattern: core.getInput(constants_1.Inputs.Pattern, { required: false }),
118786-
mergeMultiple: core.getBooleanInput(constants_1.Inputs.MergeMultiple, { required: false })
118787+
mergeMultiple: core.getBooleanInput(constants_1.Inputs.MergeMultiple, {
118788+
required: false
118789+
}),
118790+
artifactIds: core.getInput(constants_1.Inputs.ArtifactIds, { required: false })
118787118791
};
118788118792
if (!inputs.path) {
118789118793
inputs.path = process.env['GITHUB_WORKSPACE'] || process.cwd();
118790118794
}
118791118795
if (inputs.path.startsWith(`~`)) {
118792118796
inputs.path = inputs.path.replace('~', os.homedir());
118793118797
}
118798+
// Check for mutually exclusive inputs
118799+
if (inputs.name && inputs.artifactIds) {
118800+
throw new Error(`Inputs 'name' and 'artifact-ids' cannot be used together. Please specify only one.`);
118801+
}
118794118802
const isSingleArtifactDownload = !!inputs.name;
118803+
const isDownloadByIds = !!inputs.artifactIds;
118795118804
const resolvedPath = path.resolve(inputs.path);
118796118805
core.debug(`Resolved path is ${resolvedPath}`);
118797118806
const options = {};
@@ -118808,6 +118817,7 @@ function run() {
118808118817
};
118809118818
}
118810118819
let artifacts = [];
118820+
let artifactIds = [];
118811118821
if (isSingleArtifactDownload) {
118812118822
core.info(`Downloading single artifact`);
118813118823
const { artifact: targetArtifact } = yield artifact_1.default.getArtifact(inputs.name, options);
@@ -118817,6 +118827,37 @@ function run() {
118817118827
core.debug(`Found named artifact '${inputs.name}' (ID: ${targetArtifact.id}, Size: ${targetArtifact.size})`);
118818118828
artifacts = [targetArtifact];
118819118829
}
118830+
else if (isDownloadByIds) {
118831+
core.info(`Downloading artifacts by ID`);
118832+
const artifactIdList = inputs.artifactIds
118833+
.split(',')
118834+
.map(id => id.trim())
118835+
.filter(id => id !== '');
118836+
if (artifactIdList.length === 0) {
118837+
throw new Error(`No valid artifact IDs provided in 'artifact-ids' input`);
118838+
}
118839+
core.debug(`Parsed artifact IDs: ${JSON.stringify(artifactIdList)}`);
118840+
// Parse the artifact IDs
118841+
artifactIds = artifactIdList.map(id => {
118842+
const numericId = parseInt(id, 10);
118843+
if (isNaN(numericId)) {
118844+
throw new Error(`Invalid artifact ID: '${id}'. Must be a number.`);
118845+
}
118846+
return numericId;
118847+
});
118848+
// We need to fetch all artifacts to get metadata for the specified IDs
118849+
const listArtifactResponse = yield artifact_1.default.listArtifacts(Object.assign({ latest: true }, options));
118850+
artifacts = listArtifactResponse.artifacts.filter(artifact => artifactIds.includes(artifact.id));
118851+
if (artifacts.length === 0) {
118852+
throw new Error(`None of the provided artifact IDs were found`);
118853+
}
118854+
if (artifacts.length < artifactIds.length) {
118855+
const foundIds = artifacts.map(a => a.id);
118856+
const missingIds = artifactIds.filter(id => !foundIds.includes(id));
118857+
core.warning(`Could not find the following artifact IDs: ${missingIds.join(', ')}`);
118858+
}
118859+
core.debug(`Found ${artifacts.length} artifacts by ID`);
118860+
}
118820118861
else {
118821118862
const listArtifactResponse = yield artifact_1.default.listArtifacts(Object.assign({ latest: true }, options));
118822118863
artifacts = listArtifactResponse.artifacts;
@@ -118828,7 +118869,7 @@ function run() {
118828118869
core.debug(`Filtered from ${listArtifactResponse.artifacts.length} to ${artifacts.length} artifacts`);
118829118870
}
118830118871
else {
118831-
core.info('No input name or pattern filtered specified, downloading all artifacts');
118872+
core.info('No input name, artifact-ids or pattern filtered specified, downloading all artifacts');
118832118873
if (!inputs.mergeMultiple) {
118833118874
core.info('An extra directory with the artifact name will be created for each download');
118834118875
}
@@ -128917,4 +128958,4 @@ module.exports = JSON.parse('[[[0,44],"disallowed_STD3_valid"],[[45,46],"valid"]
128917128958
/******/ module.exports = __webpack_exports__;
128918128959
/******/
128919128960
/******/ })()
128920-
;
128961+
;

docs/MIGRATION.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
- [Multiple uploads to the same named Artifact](#multiple-uploads-to-the-same-named-artifact)
55
- [Overwriting an Artifact](#overwriting-an-artifact)
66
- [Merging multiple artifacts](#merging-multiple-artifacts)
7+
- [Working with Immutable Artifacts](#working-with-immutable-artifacts)
78

89
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`.
910

@@ -207,3 +208,38 @@ jobs:
207208
```
208209

209210
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](https://github.com/actions/upload-artifact/blob/main/merge/README.md).
211+
212+
## Working with Immutable Artifacts
213+
214+
In `v4`, artifacts are immutable by default and each artifact gets a unique ID when uploaded. When an artifact with the same name is uploaded again (with or without `overwrite: true`), it gets a new artifact ID.
215+
216+
To take advantage of this immutability for security purposes (to avoid potential TOCTOU issues where an artifact might be replaced between upload and download), the new `artifact-ids` input allows you to download artifacts by their specific ID rather than by name:
217+
218+
```yaml
219+
jobs:
220+
upload:
221+
runs-on: ubuntu-latest
222+
steps:
223+
- name: Create a file
224+
run: echo "hello world" > my-file.txt
225+
- name: Upload Artifact
226+
id: upload
227+
uses: actions/upload-artifact@v4
228+
with:
229+
name: my-artifact
230+
path: my-file.txt
231+
# The upload step outputs the artifact ID
232+
- name: Print Artifact ID
233+
run: echo "Artifact ID is ${{ steps.upload.outputs.artifact-id }}"
234+
download:
235+
needs: upload
236+
runs-on: ubuntu-latest
237+
steps:
238+
- name: Download Artifact by ID
239+
uses: actions/download-artifact@v4
240+
with:
241+
# Use the artifact ID directly, not the name, to ensure you get exactly the artifact you expect
242+
artifact-ids: ${{ needs.upload.outputs.artifact-id }}
243+
```
244+
245+
This approach provides stronger guarantees about which artifact version you're downloading compared to using just the artifact name.

src/constants.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ export enum Inputs {
55
Repository = 'repository',
66
RunID = 'run-id',
77
Pattern = 'pattern',
8-
MergeMultiple = 'merge-multiple'
8+
MergeMultiple = 'merge-multiple',
9+
ArtifactIds = 'artifact-ids'
910
}
1011

1112
export enum Outputs {

0 commit comments

Comments
 (0)