Repository: actions/download-artifact

Issue:
[feat req] download by artifact-id(s)
### What would you like to be added?

The ability to download an artifact by specifying it's `artifact-id` from actions/upload-artifact. The JavaScript library already [supports this](https://github.com/actions/download-artifact/blob/fa0a91b85d4f404e444e00e005971372dc801d16/src/download-artifact.ts#L115).

Since we often want to download multiple artifacts with one invocation of this Action, we could make the new input be `artifact-ids` (plural), instead of only `artifact-id` (singular). 

example:

```
- uses: actions/download-artifact@v4
  with:
    ...
    # IDs of the artifacts to download, comma-separated.
    # Either inputs `artifact-ids` or `name` can be used, but not both.
    # Optional.
    artifact-ids:
    ...
```

### Why is this needed?

`actions/upload-artifact@v4`  now uploads artifacts in an immutable manner: If an artifact were to be re-uploaded, or overwritten with the same name, then it gets a new `artifact-id`.
 - https://github.com/actions/upload-artifact?tab=readme-ov-file#improvements

To take advantage of the risks this new immutable `artifact-id` mitigates `actions/download-artifact@v4` can accept a new input to allow the user to specify know artifact ids, instead of the possibly overwritten artifact with the same `name`.

Discussion:
@ramonpetgrave64: While we wait for this to get picked up, I’ve been downloading the artifacts with `artifact-id` and `actions/github-script@v7` after `npm install @actions/artifact@2.1.9`. 

```
…

jobs:
  build-low-perms:
    outputs:
      build-artifact-id: ${{ steps.upload-artifact.outputs.artifact-id }}

…

      - name: upload-artifact
        id: upload-artifact
        uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a #v4.3.6
        with:
          name: ${{ inputs.build-artifact-name }}
          path: ${{ inputs.subject-path }}
          if-no-files-found: error

…

  sign:

…

      - run: npm install @actions/artifact@2.1.9
      - name: download-build-artifact
        uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
        env:
          ARTIFACT_ID: ${{ needs.build-low-perms.outputs.build-artifact-id }}
          ARTIFACTS_FOLDER: ./artifacts
        with:
          script: |
            const {default: artifactClient} = require('@actions/artifact')
            const { ARTIFACT_ID, ARTIFACTS_FOLDER } = process.env
            await artifactClient.downloadArtifact(ARTIFACT_ID, { path: ARTIFACTS_FOLDER })
```

Solution patch:
diff --git a/README.md b/README.md
index 6ed23d8..aa71e83 100644
--- a/README.md
+++ b/README.md
@@ -13,6 +13,7 @@ See also [upload-artifact](https://github.com/actions/upload-artifact).
     - [Outputs](#outputs)
   - [Examples](#examples)
     - [Download Single Artifact](#download-single-artifact)
+    - [Download Artifacts by ID](#download-artifacts-by-id)
     - [Download All Artifacts](#download-all-artifacts)
     - [Download multiple (filtered) Artifacts to the same directory](#download-multiple-filtered-artifacts-to-the-same-directory)
     - [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).
     # Optional.
     name:
 
+    # IDs of the artifacts to download, comma-separated.
+    # Either inputs `artifact-ids` or `name` can be used, but not both.
+    # Optional.
+    artifact-ids:
+
     # Destination path. Supports basic tilde expansion.
     # Optional. Default is $GITHUB_WORKSPACE
     path:
@@ -117,6 +123,32 @@ steps:
   run: ls -R your/destination/dir
 ```
 
+### Download Artifacts by ID
+
+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.
+
+```yaml
+steps:
+- uses: actions/download-artifact@v4
+  with:
+    artifact-ids: 12345
+- name: Display structure of downloaded files
+  run: ls -R
+```
+
+Multiple artifacts can be downloaded by providing a comma-separated list of IDs:
+
+```yaml
+steps:
+- uses: actions/download-artifact@v4
+  with:
+    artifact-ids: 12345,67890
+    path: path/to/artifacts
+- name: Display structure of downloaded files
+  run: ls -R path/to/artifacts
+```
+
+This will download multiple artifacts to separate directories (similar to downloading multiple artifacts by name).
 
 ### Download All Artifacts
 
diff --git a/action.yml b/action.yml
index 54a3eb6..7fc4fb5 100644
--- a/action.yml
+++ b/action.yml
@@ -5,6 +5,9 @@ inputs:
   name:
     description: 'Name of the artifact to download. If unspecified, all artifacts for the run are downloaded.'
     required: false
+  artifact-ids:
+    description: 'IDs of the artifacts to download, comma-separated. Either inputs `artifact-ids` or `name` can be used, but not both.'
+    required: false
   path:
     description: 'Destination path. Supports basic tilde expansion. Defaults to $GITHUB_WORKSPACE'
     required: false
diff --git a/dist/index.js b/dist/index.js
index d0a4ebc..374211f 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -118710,6 +118710,7 @@ var Inputs;
     Inputs["RunID"] = "run-id";
     Inputs["Pattern"] = "pattern";
     Inputs["MergeMultiple"] = "merge-multiple";
+    Inputs["ArtifactIds"] = "artifact-ids";
 })(Inputs || (exports.Inputs = Inputs = {}));
 var Outputs;
 (function (Outputs) {
@@ -118783,7 +118784,10 @@ function run() {
             repository: core.getInput(constants_1.Inputs.Repository, { required: false }),
             runID: parseInt(core.getInput(constants_1.Inputs.RunID, { required: false })),
             pattern: core.getInput(constants_1.Inputs.Pattern, { required: false }),
-            mergeMultiple: core.getBooleanInput(constants_1.Inputs.MergeMultiple, { required: false })
+            mergeMultiple: core.getBooleanInput(constants_1.Inputs.MergeMultiple, {
+                required: false
+            }),
+            artifactIds: core.getInput(constants_1.Inputs.ArtifactIds, { required: false })
         };
         if (!inputs.path) {
             inputs.path = process.env['GITHUB_WORKSPACE'] || process.cwd();
@@ -118791,7 +118795,12 @@ function run() {
         if (inputs.path.startsWith(`~`)) {
             inputs.path = inputs.path.replace('~', os.homedir());
         }
+        // Check for mutually exclusive inputs
+        if (inputs.name && inputs.artifactIds) {
+            throw new Error(`Inputs 'name' and 'artifact-ids' cannot be used together. Please specify only one.`);
+        }
         const isSingleArtifactDownload = !!inputs.name;
+        const isDownloadByIds = !!inputs.artifactIds;
         const resolvedPath = path.resolve(inputs.path);
         core.debug(`Resolved path is ${resolvedPath}`);
         const options = {};
@@ -118808,6 +118817,7 @@ function run() {
             };
         }
         let artifacts = [];
+        let artifactIds = [];
         if (isSingleArtifactDownload) {
             core.info(`Downloading single artifact`);
             const { artifact: targetArtifact } = yield artifact_1.default.getArtifact(inputs.name, options);
@@ -118817,6 +118827,37 @@ function run() {
             core.debug(`Found named artifact '${inputs.name}' (ID: ${targetArtifact.id}, Size: ${targetArtifact.size})`);
             artifacts = [targetArtifact];
         }
+        else if (isDownloadByIds) {
+            core.info(`Downloading artifacts by ID`);
+            const artifactIdList = inputs.artifactIds
+                .split(',')
+                .map(id => id.trim())
+                .filter(id => id !== '');
+            if (artifactIdList.length === 0) {
+                throw new Error(`No valid artifact IDs provided in 'artifact-ids' input`);
+            }
+            core.debug(`Parsed artifact IDs: ${JSON.stringify(artifactIdList)}`);
+            // Parse the artifact IDs
+            artifactIds = artifactIdList.map(id => {
+                const numericId = parseInt(id, 10);
+                if (isNaN(numericId)) {
+                    throw new Error(`Invalid artifact ID: '${id}'. Must be a number.`);
+                }
+                return numericId;
+            });
+            // We need to fetch all artifacts to get metadata for the specified IDs
+            const listArtifactResponse = yield artifact_1.default.listArtifacts(Object.assign({ latest: true }, options));
+            artifacts = listArtifactResponse.artifacts.filter(artifact => artifactIds.includes(artifact.id));
+            if (artifacts.length === 0) {
+                throw new Error(`None of the provided artifact IDs were found`);
+            }
+            if (artifacts.length < artifactIds.length) {
+                const foundIds = artifacts.map(a => a.id);
+                const missingIds = artifactIds.filter(id => !foundIds.includes(id));
+                core.warning(`Could not find the following artifact IDs: ${missingIds.join(', ')}`);
+            }
+            core.debug(`Found ${artifacts.length} artifacts by ID`);
+        }
         else {
             const listArtifactResponse = yield artifact_1.default.listArtifacts(Object.assign({ latest: true }, options));
             artifacts = listArtifactResponse.artifacts;
@@ -118828,7 +118869,7 @@ function run() {
                 core.debug(`Filtered from ${listArtifactResponse.artifacts.length} to ${artifacts.length} artifacts`);
             }
             else {
-                core.info('No input name or pattern filtered specified, downloading all artifacts');
+                core.info('No input name, artifact-ids or pattern filtered specified, downloading all artifacts');
                 if (!inputs.mergeMultiple) {
                     core.info('An extra directory with the artifact name will be created for each download');
                 }
@@ -128917,4 +128958,4 @@ module.exports = JSON.parse('[[[0,44],"disallowed_STD3_valid"],[[45,46],"valid"]
 /******/ 	module.exports = __webpack_exports__;
 /******/ 	
 /******/ })()
-;
\ No newline at end of file
+;
diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md
index d56279e..fd1781d 100644
--- a/docs/MIGRATION.md
+++ b/docs/MIGRATION.md
@@ -4,6 +4,7 @@
   - [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)
+  - [Working with Immutable Artifacts](#working-with-immutable-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`.
 
@@ -207,3 +208,38 @@ jobs:
 ```
 
 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).
+
+## Working with Immutable Artifacts
+
+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.
+
+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:
+
+```yaml
+jobs:
+  upload:
+    runs-on: ubuntu-latest
+    steps:
+      - name: Create a file
+        run: echo "hello world" > my-file.txt
+      - name: Upload Artifact
+        id: upload
+        uses: actions/upload-artifact@v4
+        with:
+          name: my-artifact
+          path: my-file.txt
+      # The upload step outputs the artifact ID
+      - name: Print Artifact ID
+        run: echo "Artifact ID is ${{ steps.upload.outputs.artifact-id }}"
+  download:
+    needs: upload
+    runs-on: ubuntu-latest
+    steps:
+      - name: Download Artifact by ID
+        uses: actions/download-artifact@v4
+        with:
+          # Use the artifact ID directly, not the name, to ensure you get exactly the artifact you expect
+          artifact-ids: ${{ needs.upload.outputs.artifact-id }}
+```
+
+This approach provides stronger guarantees about which artifact version you're downloading compared to using just the artifact name.
diff --git a/src/constants.ts b/src/constants.ts
index 17c7d34..b58f4e6 100644
--- a/src/constants.ts
+++ b/src/constants.ts
@@ -5,7 +5,8 @@ export enum Inputs {
   Repository = 'repository',
   RunID = 'run-id',
   Pattern = 'pattern',
-  MergeMultiple = 'merge-multiple'
+  MergeMultiple = 'merge-multiple',
+  ArtifactIds = 'artifact-ids'
 }
 
 export enum Outputs {
diff --git a/src/download-artifact.ts b/src/download-artifact.ts
index 1beef4d..5cc6b17 100644
--- a/src/download-artifact.ts
+++ b/src/download-artifact.ts
@@ -23,7 +23,10 @@ export async function run(): Promise<void> {
     repository: core.getInput(Inputs.Repository, {required: false}),
     runID: parseInt(core.getInput(Inputs.RunID, {required: false})),
     pattern: core.getInput(Inputs.Pattern, {required: false}),
-    mergeMultiple: core.getBooleanInput(Inputs.MergeMultiple, {required: false})
+    mergeMultiple: core.getBooleanInput(Inputs.MergeMultiple, {
+      required: false
+    }),
+    artifactIds: core.getInput(Inputs.ArtifactIds, {required: false})
   }
 
   if (!inputs.path) {
@@ -34,7 +37,15 @@ export async function run(): Promise<void> {
     inputs.path = inputs.path.replace('~', os.homedir())
   }
 
+  // Check for mutually exclusive inputs
+  if (inputs.name && inputs.artifactIds) {
+    throw new Error(
+      `Inputs 'name' and 'artifact-ids' cannot be used together. Please specify only one.`
+    )
+  }
+
   const isSingleArtifactDownload = !!inputs.name
+  const isDownloadByIds = !!inputs.artifactIds
   const resolvedPath = path.resolve(inputs.path)
   core.debug(`Resolved path is ${resolvedPath}`)
 
@@ -56,6 +67,7 @@ export async function run(): Promise<void> {
   }
 
   let artifacts: Artifact[] = []
+  let artifactIds: number[] = []
 
   if (isSingleArtifactDownload) {
     core.info(`Downloading single artifact`)
@@ -74,6 +86,52 @@ export async function run(): Promise<void> {
     )
 
     artifacts = [targetArtifact]
+  } else if (isDownloadByIds) {
+    core.info(`Downloading artifacts by ID`)
+
+    const artifactIdList = inputs.artifactIds
+      .split(',')
+      .map(id => id.trim())
+      .filter(id => id !== '')
+
+    if (artifactIdList.length === 0) {
+      throw new Error(`No valid artifact IDs provided in 'artifact-ids' input`)
+    }
+
+    core.debug(`Parsed artifact IDs: ${JSON.stringify(artifactIdList)}`)
+
+    // Parse the artifact IDs
+    artifactIds = artifactIdList.map(id => {
+      const numericId = parseInt(id, 10)
+      if (isNaN(numericId)) {
+        throw new Error(`Invalid artifact ID: '${id}'. Must be a number.`)
+      }
+      return numericId
+    })
+
+    // We need to fetch all artifacts to get metadata for the specified IDs
+    const listArtifactResponse = await artifactClient.listArtifacts({
+      latest: true,
+      ...options
+    })
+
+    artifacts = listArtifactResponse.artifacts.filter(artifact =>
+      artifactIds.includes(artifact.id)
+    )
+
+    if (artifacts.length === 0) {
+      throw new Error(`None of the provided artifact IDs were found`)
+    }
+
+    if (artifacts.length < artifactIds.length) {
+      const foundIds = artifacts.map(a => a.id)
+      const missingIds = artifactIds.filter(id => !foundIds.includes(id))
+      core.warning(
+        `Could not find the following artifact IDs: ${missingIds.join(', ')}`
+      )
+    }
+
+    core.debug(`Found ${artifacts.length} artifacts by ID`)
   } else {
     const listArtifactResponse = await artifactClient.listArtifacts({
       latest: true,
@@ -92,7 +150,7 @@ export async function run(): Promise<void> {
       )
     } else {
       core.info(
-        'No input name or pattern filtered specified, downloading all artifacts'
+        'No input name, artifact-ids or pattern filtered specified, downloading all artifacts'
       )
       if (!inputs.mergeMultiple) {
         core.info(



Test patch:
diff --git a/__tests__/download.test.ts b/__tests__/download.test.ts
index ac79f4e..032d857 100644
--- a/__tests__/download.test.ts
+++ b/__tests__/download.test.ts
@@ -112,7 +112,7 @@ describe('download', () => {
     await run()
 
     expect(core.info).toHaveBeenCalledWith(
-      'No input name or pattern filtered specified, downloading all artifacts'
+      'No input name, artifact-ids or pattern filtered specified, downloading all artifacts'
     )
 
     expect(core.info).toHaveBeenCalledWith('Total of 2 artifact(s) downloaded')
@@ -221,4 +221,154 @@ describe('download', () => {
       expect.stringContaining('digest validation failed')
     )
   })
+
+  test('downloads a single artifact by ID', async () => {
+    const mockArtifact = {
+      id: 456,
+      name: 'artifact-by-id',
+      size: 1024,
+      digest: 'def456'
+    }
+
+    mockInputs({
+      [Inputs.Name]: '',
+      [Inputs.Pattern]: '',
+      [Inputs.ArtifactIds]: '456'
+    })
+
+    jest.spyOn(artifact, 'listArtifacts').mockImplementation(() =>
+      Promise.resolve({
+        artifacts: [mockArtifact]
+      })
+    )
+
+    await run()
+
+    expect(core.info).toHaveBeenCalledWith('Downloading artifacts by ID')
+    expect(core.debug).toHaveBeenCalledWith('Parsed artifact IDs: ["456"]')
+    expect(artifact.downloadArtifact).toHaveBeenCalledTimes(1)
+    expect(artifact.downloadArtifact).toHaveBeenCalledWith(
+      456,
+      expect.objectContaining({
+        expectedHash: mockArtifact.digest
+      })
+    )
+    expect(core.info).toHaveBeenCalledWith('Total of 1 artifact(s) downloaded')
+  })
+
+  test('downloads multiple artifacts by ID', async () => {
+    const mockArtifacts = [
+      {id: 123, name: 'first-artifact', size: 1024, digest: 'abc123'},
+      {id: 456, name: 'second-artifact', size: 2048, digest: 'def456'},
+      {id: 789, name: 'third-artifact', size: 3072, digest: 'ghi789'}
+    ]
+
+    mockInputs({
+      [Inputs.Name]: '',
+      [Inputs.Pattern]: '',
+      [Inputs.ArtifactIds]: '123, 456, 789'
+    })
+
+    jest.spyOn(artifact, 'listArtifacts').mockImplementation(() =>
+      Promise.resolve({
+        artifacts: mockArtifacts
+      })
+    )
+
+    await run()
+
+    expect(core.info).toHaveBeenCalledWith('Downloading artifacts by ID')
+    expect(core.debug).toHaveBeenCalledWith(
+      'Parsed artifact IDs: ["123","456","789"]'
+    )
+    expect(artifact.downloadArtifact).toHaveBeenCalledTimes(3)
+    mockArtifacts.forEach(mockArtifact => {
+      expect(artifact.downloadArtifact).toHaveBeenCalledWith(
+        mockArtifact.id,
+        expect.objectContaining({
+          expectedHash: mockArtifact.digest
+        })
+      )
+    })
+    expect(core.info).toHaveBeenCalledWith('Total of 3 artifact(s) downloaded')
+  })
+
+  test('warns when some artifact IDs are not found', async () => {
+    const mockArtifacts = [
+      {id: 123, name: 'found-artifact', size: 1024, digest: 'abc123'}
+    ]
+
+    mockInputs({
+      [Inputs.Name]: '',
+      [Inputs.Pattern]: '',
+      [Inputs.ArtifactIds]: '123, 456, 789'
+    })
+
+    jest.spyOn(artifact, 'listArtifacts').mockImplementation(() =>
+      Promise.resolve({
+        artifacts: mockArtifacts
+      })
+    )
+
+    await run()
+
+    expect(core.warning).toHaveBeenCalledWith(
+      'Could not find the following artifact IDs: 456, 789'
+    )
+    expect(core.debug).toHaveBeenCalledWith('Found 1 artifacts by ID')
+    expect(artifact.downloadArtifact).toHaveBeenCalledTimes(1)
+  })
+
+  test('throws error when no artifacts with requested IDs are found', async () => {
+    mockInputs({
+      [Inputs.Name]: '',
+      [Inputs.Pattern]: '',
+      [Inputs.ArtifactIds]: '123, 456'
+    })
+
+    jest.spyOn(artifact, 'listArtifacts').mockImplementation(() =>
+      Promise.resolve({
+        artifacts: []
+      })
+    )
+
+    await expect(run()).rejects.toThrow(
+      'None of the provided artifact IDs were found'
+    )
+  })
+
+  test('throws error when artifact-ids input is empty', async () => {
+    mockInputs({
+      [Inputs.Name]: '',
+      [Inputs.Pattern]: '',
+      [Inputs.ArtifactIds]: '  '
+    })
+
+    await expect(run()).rejects.toThrow(
+      "No valid artifact IDs provided in 'artifact-ids' input"
+    )
+  })
+
+  test('throws error when some artifact IDs are not valid numbers', async () => {
+    mockInputs({
+      [Inputs.Name]: '',
+      [Inputs.Pattern]: '',
+      [Inputs.ArtifactIds]: '123, abc, 456'
+    })
+
+    await expect(run()).rejects.toThrow(
+      "Invalid artifact ID: 'abc'. Must be a number."
+    )
+  })
+
+  test('throws error when both name and artifact-ids are provided', async () => {
+    mockInputs({
+      [Inputs.Name]: 'some-artifact',
+      [Inputs.ArtifactIds]: '123'
+    })
+
+    await expect(run()).rejects.toThrow(
+      "Inputs 'name' and 'artifact-ids' cannot be used together. Please specify only one."
+    )
+  })
 })