gh-cache-download: download GitHub Actions caches

A gh extension and a composite action that download a GitHub Actions cache entry by key, so its contents can be inspected locally.

The problem

GitHub stores cache entries for actions/cache, setup-node, setup-python and similar actions, but there is no button, API call or gh command that returns the archive. The cache service hands out download URLs only to workflow runs. Debugging a cache that is restored but not used, or a cache that is unexpectedly large, needs the contents.

The demand is documented:

  • cli/cli#9125, "Add a way to download github actions caches", has 113 reactions and has been open since May 2024. A gh maintainer forwarded it to the Actions team in July 2025. The workarounds in the thread are workflow snippets that restore the cache with actions/cache and upload it as an artifact. They need the original path list, because it determines the cache version.
  • actions/cache#105, "Ability to download cache and inspect contents", has 34 reactions. It was closed by the stale bot in 2023 after a maintainer said downloading was not planned.
  • gh cache list, gh cache delete and actions/gh-actions-cache list and delete entries but cannot download them.

How it works

gh-cache-download has two parts. The first is a gh extension, a single Python file without dependencies. gh cache-download get KEY looks the key up with GET /repos/{owner}/{repo}/actions/caches (exact match), dispatches a small helper workflow on the branch that owns the cache, waits for the run and downloads the result. gh cache-download init writes that helper workflow, which has to be committed and pushed. It only runs when dispatched and has read permissions only.

The second part is a composite action that the helper workflow uses. Inside the run it calls the cache service endpoint GetCacheEntryDownloadURL with the key and version, downloads the archive and uploads it as an artifact. Because it fetches by key and version, it does not need to know which paths were cached.

actions/cache stores paths relative to the workspace, so anything cached outside it, such as ~/.npm, appears as ../../../.npm/... in the archive. gh cache-download extract (or get -x) never writes outside the target directory: each .. becomes a __parent__ directory, absolute paths lose their leading /, and members that would escape, such as symlinks to absolute paths, are skipped.

This is a real run against a cache saved by the repository's own CI from e2e-data in the workspace and ~/.e2e-home outside it:

$ gh cache list -R Jwrede/gh-cache-download
9507487879  e2e-40288214454-1  459 B  2026-10-26T07:50:08Z  2026-10-26T07:50:10Z

$ gh cache-download get e2e-40288214454-1 -R Jwrede/gh-cache-download -x
Cache 9507487879: refs/heads/main, 459 bytes, version b107c4986568
Run 40288300859: https://github.com/Jwrede/gh-cache-download/actions/runs/40288300859
Archive: cache-9507487879/cache.tzst (459 bytes)
Extracted 6 entries to cache-9507487879/files

$ find cache-9507487879/files -type f -o -type l
cache-9507487879/files/e2e-data/link
cache-9507487879/files/e2e-data/sub/a.txt
cache-9507487879/files/__parent__/__parent__/__parent__/.e2e-home/b.txt

The whole get took 14 seconds.

Testing

The Python unit tests cover the key lookup (exact match only, prefix matches listed when a key is missing, --ref required when a key exists on several branches) and the refusal of pull request refs. Extraction is tested on plain tar, gzip and zstd archives that contain a symlink, a hard link, a path outside the workspace and a symlink to /etc/passwd, which must be skipped. The get command runs against a fake gh that records the calls, including a failed dispatch and a failed run. Node tests cover the action's version selection and archive type detection.

CI runs the unit tests on Python 3.10 and 3.14. An end-to-end job creates files inside and outside the workspace, saves them with actions/cache/save, downloads the entry with the action, extracts it with the CLI and compares the contents and the archive size.

Limitations

  • A run can only read caches of its own ref and of the default branch, so the helper workflow must exist on the branch that owns the cache.
  • Caches created by pull request runs (refs/pull/N/merge) cannot be fetched with a manual dispatch. For those, the action has to be added to the pull request's workflow.
  • GetCacheEntryDownloadURL is the internal API that actions/cache v4+ uses. GitHub does not document it as a public API, so it may change. The tool is tested on github.com only, with archives from Linux runners.

The code and installation instructions are at github.com/Jwrede/gh-cache-download.