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

File tree

7 files changed

+328
-7
lines changed

7 files changed

+328
-7
lines changed

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)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy