Skip to content
Latchkey LogoLatchkey home

GitHub Actions artifact has expired, and the listing still shows it

A GitHub Actions artifact has expired message means the lookup succeeded and the download was refused. The artifact row is still there, with an expiry flag on it, and only the endpoint that serves the bytes answers 410, which is why this failure looks nothing like a missing artifact once you know where to look.

Two endpoints for one expired artifact, and the different answers each of them gives
The list endpoint returns the expired artifact with expired true and an expires_at date. The zip endpoint answers 410 with the message the action then prefixes.

What this error means

A job that downloads an artifact from an older run fails, and the message is short enough to be unhelpful. It is not the usual not-found text, which is several lines long and suggests checking the upload version. It is one clause, and it arrives after the step has already logged that it is preparing to download a named artifact with a size and a digest, which is the detail that gives the game away: the action knows the artifact exists, knows how large it was, and is being refused the contents.

Composite: the prefix is a literal in actions/download-artifact src/download-artifact.ts; the clause after it is the REST API 410 body, read directly on 2026-09-20. The whole line is in no source file
Error: Unable to download artifact(s): Artifact has expired

The two endpoints disagree, on purpose

An expired artifact is not deleted from the API. Listing a run still returns the row, with expired set to true and the date it lapsed. Asking for the zip is what fails, with 410 and a one-line body. Both of these were read directly from the REST API on 2026-09-20. The 410 body below is the whole of what came back; the listing beside it is cut to the three fields this page turns on, out of the twelve the endpoint returns for each artifact.

That split explains the shape of the failure inside the action. The metadata lookup finds the artifact, so the action logs its name, id, size and expected digest and moves on to the download, where the request is refused. The message you end up with is the API body with the action own prefix in front of it, which means the whole line exists as a literal in neither repository; searching for it and finding nothing is expected rather than suspicious.

REST API responses, read on 2026-09-20; the 410 body verbatim, the listing abridged to three fields
$ gh api "repos/OWNER/REPO/actions/runs/RUN_ID/artifacts?name=report.html"
{"artifacts":[{"expired":true,"expires_at":"2026-07-09T16:08:36Z","name":"report.html"}],"total_count":1}

$ gh api "repos/OWNER/REPO/actions/artifacts/ARTIFACT_ID/zip"
{"message":"Artifact has expired","documentation_url":"https://docs.github.com/rest/actions/artifacts#download-an-artifact","status":"410"}

Common causes

The run is older than the retention window

The ordinary case. A pipeline that reaches back to a previous release, a rollback script that pulls the last known good build, or a human re-running an old job all eventually cross the boundary. Nothing in the workflow changed; the calendar did.

The artifact is a Pages artifact, so it lasted a day

The Pages upload action sets retention to one day. A deploy re-run the next morning, or a manual re-run of just the deploy job from the runs list, finds nothing. This is the version of the failure that surprises people, because a day does not feel like an expiry.

The repository retention has been shortened

Retention can be lowered at the repository, organization or enterprise level, and a lowered setting applies to new uploads. A pipeline written when the default was ninety days keeps working for months and then starts failing on runs that are two weeks old, with no change in the workflow file.

Artifacts are being used as storage

A build output that something depends on weeks later is not an artifact, it is a release asset that happens to live in the wrong place. In our experience this is the one worth fixing rather than working around, because every other fix here just moves the expiry date.

How to fix it

Confirm the expiry before you change anything

  1. Take the run id from the URL of the run you are trying to download from.
  2. List that run artifacts and read the expired and expires_at fields.
  3. If expired is true, the artifact is unrecoverable and the fix is to regenerate it.
  4. If the row is absent, this is not an expiry and the not-found page applies instead.
Terminal
gh api "repos/OWNER/REPO/actions/runs/RUN_ID/artifacts" \
  --jq '.artifacts[] | {name, expired, expires_at}'

Set retention deliberately on the things you reuse

Raise retention-days on the artifacts a later run actually fetches, and leave everything else on the default so it ages out. Raising it globally in the repository settings costs storage on every artifact you produce, most of which nobody will ever open again.

.github/workflows/ci.yml (illustrative)
      - uses: actions/upload-artifact@v7
        with:
          name: release-build
          path: dist
          retention-days: 90

Move anything long-lived out of artifacts

If something has to survive a quarter, publish it where surviving is the point. A release asset, a package registry or an object store all keep it past any retention setting, and all of them give you a stable URL that does not depend on a run still existing. Artifacts are for handing work between jobs in the same pipeline.

.github/workflows/release.yml (illustrative)
      - name: Attach the build to the release
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: gh release upload "${{ github.ref_name }}" dist/app.tar.gz --clobber

Fail early rather than at the download

When a workflow reaches back to a named run, check the artifact before you depend on it. One API call in an early step turns an expiry into a clear failure at the top of the job, with the date in the message, instead of a one-clause error three steps in.

.github/workflows/deploy.yml (illustrative)
      - name: Check the source artifact is still live
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          expired=$(gh api "repos/${{ github.repository }}/actions/runs/${RUN_ID}/artifacts?name=release-build" \
            --jq '.artifacts[0].expired')
          [ "$expired" = "false" ] || { echo "artifact expired or missing" >&2; exit 1; }

How long an artifact actually lasts

The published default is ninety days for build logs and artifacts, and it is customizable at the repository, organization and enterprise level. The retention-days input on the upload can shorten it per artifact, and the action documents its own bounds: a minimum of one day and a maximum of ninety unless the repository settings have been raised.

The number that catches people is not the default. actions/upload-pages-artifact sets retention-days to 1, because a Pages artifact is consumed by the deploy job minutes later. Re-run a Pages workflow the following morning and the artifact is gone, which produces this failure on a run that is a day old rather than a quarter old.

Where the artifact came fromRetentionSet by
actions/upload-artifact, no inputthe repository default, 90 days as shippedrepository, organization or enterprise settings
actions/upload-artifact, retention-days1 to 90, or higher if settings allowthe workflow, within the configured maximum
actions/upload-pages-artifact1 daythe composite action own default
a run deleted by hand or by a cleanup jobgone immediatelywhoever ran the deletion

Telling this apart from a genuinely missing artifact

The two failures read differently and it is worth learning the difference, because the fixes have nothing in common. A missing artifact produces a long message naming the artifact and suggesting the upload version; it comes from the metadata lookup, before any download is attempted. An expired artifact produces the short clause, after the action has already printed the artifact size and digest.

One command settles it in either direction. List the run and read the expired field. If it is true, the artifact was real and is now unavailable, and no change to tokens, names or permissions will bring it back. If the row is absent entirely, you are looking at a different problem and a different page.

It is also worth knowing which side of the action you are on. The expiry refusal only appears on the public REST path, which is the one used when github-token is set, typically for a cross-run download. A download inside the run that produced the artifact cannot hit this, because the artifact cannot expire within the lifetime of its own run.

Why there is no recorded run on this page

Filming this would mean waiting out a retention window. Ninety days on a default artifact, or a day on a Pages one, and in either case the recording would be a job log containing one clause. Rather than wait, the two halves were obtained directly: the REST responses quoted above are verbatim from calls made on 2026-09-20 against a public repository whose artifacts had lapsed, and the prefix around them is quoted from the line in the action source that adds it.

There is also nothing here that a runner could repair, and that is not a limitation so much as the point of the page. The bytes are gone from storage. Retrying reaches the same 410, a larger machine reaches it faster, and the only recovery is to produce the artifact again by re-running the job that made it.

How to prevent it

  • Set retention-days explicitly on any artifact another run will fetch.
  • Publish anything that has to outlive a pipeline to a release or an object store.
  • Remember the Pages artifact expires in a day, not ninety.
  • Check expired in the listing before debugging tokens or names.

Frequently asked questions

How long do GitHub Actions artifacts last?
Ninety days by default for artifacts and logs, and the period is configurable at the repository, organization and enterprise level. The retention-days input can shorten it per upload, with a minimum of one day and a maximum of ninety unless the repository settings raise that ceiling.
Can an expired GitHub Actions artifact be recovered?
No. The listing still shows the row with expired set to true, but the download endpoint answers 410 and the stored bytes are gone. The only recovery is to re-run the job that produced it, which is also why long-lived outputs belong somewhere other than artifacts.
Why does the artifact still appear in the API after it expired?
Because the metadata and the contents are separate. The list endpoint keeps returning the artifact with expired true and an expires_at timestamp, which is what lets you tell an expiry from a typo. Only the zip endpoint refuses, and it refuses with 410 rather than 404.
Why is this error shorter than the usual artifact not found one?
Because it comes from a different point in the step. The long message is raised by the metadata lookup when the search returns nothing. This one is raised by the download after the lookup succeeded, so the text is the API refusal with the action prefix in front of it and nothing else.

Related guides

References

An artifact kept so the next run need not rebuild is a cache. Latchkey Fast Cache is one line. Start free → 30-day trial · No credit card