A GitHub Actions artifact has expired failure, Unable to download artifact(s): Artifact has expired, means actions/download-artifact found the artifact but the download endpoint answered 410 because its retention period had passed. The contents cannot be recovered: re-run the job that produced it, set retention-days on anything a later run needs (1 to 90 days, or up to 400 on a private repository whose settings allow it), and publish long-lived output as a release asset instead.

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
## 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 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.
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"}
## How long an artifact actually lasts The published default is ninety days for artifacts and logs, and it is customizable at the repository, organization and enterprise level: between 1 and 90 days on a public repository and between 1 and 400 days on a private one. Since 1 October 2026 the same period also applies to workflow runs, checks and commit statuses, not only to artifacts and logs. 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, with one exception: because the retention period now covers workflow runs as well, a run older than the repository period can be gone along with its artifacts, and the listing then fails for the run itself rather than returning an expired row. 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. > For the long not-found message, which is a different failure at a different point in the step, see [GitHub Actions artifact not found for name](https://latchkey.dev/learn/github-actions/github-actions-failed-to-download-artifact-not-found). ## 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. ## FAQ ### 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, which a private repository can set as high as 400 days. ### 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. ## References - [actions/download-artifact src/download-artifact.ts: the prefix added to every failure](https://github.com/actions/download-artifact/blob/main/src/download-artifact.ts) - [REST API: download an artifact, the endpoint that answers 410 once it has expired](https://docs.github.com/en/rest/actions/artifacts#download-an-artifact) - [Configuring the retention period for artifacts and logs in a repository](https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/enabling-features-for-your-repository/managing-github-actions-settings-for-a-repository) - [actions/upload-artifact action.yml: the retention-days bounds the action declares](https://github.com/actions/upload-artifact/blob/main/action.yml) - [GitHub changelog: Actions retention will cover checks, workflow runs and statuses](https://github.blog/changelog/2026-07-17-actions-retention-will-cover-checks-workflow-runs-and-statuses/) 
