Skip to content
Latchkey LogoLatchkey home

Making download-artifact from another workflow run actually work

A download-artifact from another workflow run is switched on by github-token, not by run-id. The run-id input already has a default, so setting it on its own changes nothing at all and the action keeps searching the run it is executing in.

Two lookup paths inside download-artifact and the one input that chooses between them
findBy is populated only when github-token is set. With no token the action uses the run-scoped Twirp client and run-id is never read.

What this error means

You added run-id to a download step, pointed it at a run you can see in the browser with the artifact plainly attached to it, and the step still says the artifact is not there. Or you added the token as well and now the step fails with a message about a resource not being accessible, which names no permission and no repository. Both of these look like the artifact is missing. Neither of them is about the artifact. The first is the action never having left your own run, and the second is it having left and been refused at the door.

Composite: the prefix is a literal in actions/download-artifact src/download-artifact.ts (v8.0.1); the rest is the REST API message, so the whole line is in no source file
Error: Unable to download artifact(s): Resource not accessible by integration

The branch that decides which lookup runs

This is the whole switch, quoted from the action. options.findBy is built inside an if (inputs.token), and everything cross-run hangs off findBy. Set run-id and repository without a token and they are parsed, validated, and then dropped on the floor.

That is easy to miss because run-id is not obviously optional. Its default in the action metadata is ${{ github.run_id }}, so it is always populated, and overriding a value that already had a value feels like it should do something.

actions/download-artifact, src/download-artifact.ts (v8.0.1)
const options: FindOptions = {}
if (inputs.token) {
  const [repositoryOwner, repositoryName] = inputs.repository.split('/')
  if (!repositoryOwner || !repositoryName) {
    throw new Error(
      `Invalid repository: '${inputs.repository}'. Must be in format owner/repo`
    )
  }

  options.findBy = {
    token: inputs.token,
    workflowRunId: inputs.runID,
    repositoryName,
    repositoryOwner
  }
}

Common causes

You set run-id and left the token out

This is the great majority. The workflow looks correct, the run id is correct, and the input is silently unused because findBy was never built. The step then searches the current run, finds nothing, and reports it in words that say nothing about which run it looked in.

The job does not grant actions: read

A workflow with an explicit permissions: block that omits actions, or a repository whose default workflow token permissions have been set to read contents only, leaves the token unable to list artifacts. The API answers 403 and the action reports a resource that is not accessible.

The source run is in another repository

The default job token cannot cross a repository boundary regardless of the permissions block, because its audience is the repository that issued it. You need a token that was issued with both repositories in scope, and the repository input set to the source.

The artifact is genuinely gone

Worth ruling out once the token is right. Artifacts expire, and a run old enough to be worth reaching across to is old enough to have lost them. The listing API will still show the entry with an expired flag on it, which is a quick way to separate this from a permission problem.

How to fix it

Add the token, then the permission, then the run id

  1. Set github-token on the step; nothing else about cross-run downloading takes effect until you do.
  2. Add actions: read to the job permissions: block, alongside whatever it already declares.
  3. Set run-id to the source run, usually ${{ github.event.workflow_run.id }}.
  4. Leave repository alone unless the source run is in a different repository.

Check what the token can see before blaming the artifact

One call settles whether this is a permission problem or a missing artifact. Run it with the same credential the workflow uses. If it lists the artifact, the workflow has a permissions problem; if it returns an empty list, you are looking at the wrong run.

Terminal
gh api "repos/OWNER/REPO/actions/runs/RUN_ID/artifacts?name=build-output" \
  --jq '.total_count, (.artifacts[] | {name, expired, expires_at})'

Use artifact-ids when the name is ambiguous

If the source run uploaded several artifacts whose names differ only by a matrix value, the name filter is a blunt instrument and the action returns the newest match. Listing the run and downloading by id is exact, and the ids are stable, which makes a failure easier to read later.

.github/workflows/deploy.yml (illustrative)
      - uses: actions/download-artifact@v8
        with:
          artifact-ids: ${{ needs.collect.outputs.artifact_id }}
          run-id: ${{ github.event.workflow_run.id }}
          github-token: ${{ secrets.GITHUB_TOKEN }}

Or avoid the cross-run download entirely

Reaching into another run is the right answer for a workflow_run chain and the wrong one for most other cases. If the producing job and the consuming job can live in the same run, put them there with a needs: between them and the token question disappears along with the permission block.

Two lookups, one message

With no token the artifact client uses backend identifiers taken from the runtime token the runner already holds, and asks the artifact service for the artifacts of the current run. There is no parameter in that call for a different run, which is why run-id has nowhere to go.

With a token it calls the public REST endpoint instead, GET /repos/{owner}/{repo}/actions/runs/{run_id}/artifacts, with your name filter on the end. That call can name any run in any repository the token can read.

The catch is that both paths raise the identical error when they come back empty, down to the wording and the link at the end of it. So the not-found message cannot tell you which lookup ran. That is deliberate in the toolkit and it is the single reason this failure is hard: the log line you get for looking in the wrong place is the same line you get for looking in the right place and finding nothing.

Inputs you setWhich lookup runsWhat it can see
name onlyinternal, via the runtime tokenartifacts of the current run
name and run-idinternal, run-id unusedartifacts of the current run
name, run-id, github-tokenpublic REST, run-scopedthat run, if the token can read it
plus repositorypublic REST, other repositorythat run there, if the token reaches it

What the token has to be allowed to do

The default job token can read the Actions API of its own repository, but only if the job grants it. actions: read is the permission that covers listing and downloading artifacts, and a workflow with a restrictive permissions: block, or a repository whose default workflow permissions have been narrowed, will not have it.

The refusal that follows is a plain 403 from the REST API with a message that names nothing useful. The action wraps it in its own prefix and hands it to setFailed, which is why the quoted line at the top of this page reads as one sentence made of two halves. The action contributes Unable to download artifact(s): and the API contributes the rest, so grepping any source in either repository for the whole line finds nothing. This is worth stating plainly because an unfindable string looks exactly like an invented one.

For a different repository the default token is not enough at all, whatever permissions you give it, because its scope stops at the repository it was issued for. That needs a personal access token or a GitHub App installation that covers both repositories.

.github/workflows/deploy.yml (illustrative)
permissions:
  contents: read
  actions: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/download-artifact@v8
        with:
          name: build-output
          run-id: ${{ github.event.workflow_run.id }}
          github-token: ${{ secrets.GITHUB_TOKEN }}

Why there is no recorded run on this page

Recording this one honestly would mean publishing two workflow runs in two different workflows and a token with the scope that makes the interesting half work. The line that distinguishes the two code paths is the permission refusal, and a refusal is only meaningful next to the credential that was refused, which is the one thing we cannot show you. A redacted screenshot of a 403 proves nothing that the sentence above does not already say.

The mechanism is also entirely decided before any work happens. One if on a string input chooses the lookup, and the lookup either has a parameter for another run or does not. There is no timing in it, nothing transient, and nothing a runner could observe mid-job and put right.

How to prevent it

  • Treat github-token as the required input for any cross-run download, and run-id as the detail.
  • Declare actions: read explicitly in any job that downloads across runs.
  • Prefer one run with needs: over two runs and a token wherever the graph allows it.
  • Check expired in the artifact listing before you debug a credential.

Frequently asked questions

Why does run-id do nothing in actions/download-artifact?
Because the action only builds its cross-run lookup options inside a check on github-token. With no token it uses the run-scoped internal client, which has no parameter for a different run, so run-id is parsed and then unused. Add the token and the same run-id starts working.
What permission does download-artifact need for another run?
actions: read on the job, plus a token that covers the source repository. The default job token has the right audience for its own repository but only the permissions the workflow grants it, and a narrowed permissions: block is the usual reason a correct run-id still gets refused.
Can I download an artifact from a different repository?
Yes, with repository set to the source and a token issued for both. The default GITHUB_TOKEN cannot do it at any permission level, because its scope is the repository that issued it. Use a fine-grained personal access token or a GitHub App installed on both.
Why does the error not say which run it searched?
Because both lookups raise the same error text from the same place in the artifact toolkit. The internal and public code paths each throw an identical not-found message, so the wording carries no information about scope. That is why the diagnosis has to start from whether the token is set.

Related guides

References

A dependency tree fetched across runs wants a cache, not a token. Latchkey Fast Cache needs neither. Start free → 30-day trial · No credit card