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.

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.
Error: Unable to download artifact(s): Artifact has expiredThe 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.
$ 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
- Take the run id from the URL of the run you are trying to download from.
- List that run artifacts and read the
expiredandexpires_atfields. - If
expiredis true, the artifact is unrecoverable and the fix is to regenerate it. - If the row is absent, this is not an expiry and the not-found page applies instead.
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.
- uses: actions/upload-artifact@v7
with:
name: release-build
path: dist
retention-days: 90Move 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.
- name: Attach the build to the release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh release upload "${{ github.ref_name }}" dist/app.tar.gz --clobberFail 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.
- 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 from | Retention | Set by |
|---|---|---|
actions/upload-artifact, no input | the repository default, 90 days as shipped | repository, organization or enterprise settings |
actions/upload-artifact, retention-days | 1 to 90, or higher if settings allow | the workflow, within the configured maximum |
actions/upload-pages-artifact | 1 day | the composite action own default |
| a run deleted by hand or by a cleanup job | gone immediately | whoever 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-daysexplicitly 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
expiredin the listing before debugging tokens or names.
Frequently asked questions
How long do GitHub Actions artifacts last?
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?
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?
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?
Related guides
References
- actions/download-artifact src/download-artifact.ts: the prefix added to every failure
- REST API: download an artifact, the endpoint that answers 410 once it has expired
- Configuring the retention period for artifacts and logs in a repository
- actions/upload-artifact action.yml: the retention-days bounds the action declares
- GitHub Actions documentation