# How to Cache uvx Tools in GitHub Actions


A workflow that runs `uvx some-tool` fetches that tool from PyPI on every run. [setup-uv](https://pydevtools.com/handbook/how-to/how-to-upgrade-setup-uv-from-v7-to-v8.md)'s cache is built for projects with a lockfile, so it does not help a job that only runs standalone tools. This guide keys the cache to a pinned date instead, so [uvx](https://pydevtools.com/handbook/reference/uvx.md) tools restore from cache with no `pyproject.toml` or `uv.lock` in the repository.

If your job installs a project (`uv sync` against a committed lockfile), you want the lockfile-keyed cache in [Setting up GitHub Actions with uv](https://pydevtools.com/handbook/tutorial/setting-up-github-actions-with-uv.md) instead. This guide covers the other case: running tools like `ruff` or `sqlite-utils` with no project to key on.

## Why the default cache misses uvx tools

`enable-cache: true` invalidates the cache when a dependency file changes. The files it watches are fixed:

```
**/*requirements*.txt
**/*requirements*.in
**/*constraints*.txt
**/*constraints*.in
**/pyproject.toml
**/uv.lock
**/*.py.lock
```

A tools-only job has none of them, so the key never changes and the restored cache is empty. setup-uv compounds the problem by pruning pre-built wheels after each run, keeping only source-built wheels. Since `uvx` tools arrive almost entirely as pre-built wheels, the default prune discards exactly what you wanted to keep.

## Add the caching workflow

This workflow caches every `uvx` tool it runs, keyed to a single date. Copy it into `.github/workflows/tools.yml`:

```yaml {filename=".github/workflows/tools.yml"}
name: Run tools

on:
  workflow_dispatch:

env:
  # Pins every uvx tool to versions published on or before this date,
  # and drives the cache key below. Bump it to refresh both.
  UV_EXCLUDE_NEWER: "2026-07-12"

jobs:
  tools:
    runs-on: ubuntu-latest
    steps:
      - name: Install uv and restore the tool cache
        id: setup-uv
        uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990  # v8.3.2
        with:
          enable-cache: true
          cache-dependency-glob: ""                              # ignore lockfiles; there are none
          cache-suffix: "tools-${{ env.UV_EXCLUDE_NEWER }}"      # key the cache on the pin date
          prune-cache: false                                     # keep pre-built wheels

      - name: Run cached-only on a cache hit
        if: steps.setup-uv.outputs.cache-hit == 'true'
        run: echo "UV_OFFLINE=1" >> "$GITHUB_ENV"

      - name: Run the tools
        run: |
          uvx sqlite-utils --version
          uvx ruff --version
```

The action is pinned to a commit SHA with the version in a trailing comment, the pattern from [how to pin GitHub Actions by SHA for Python projects](https://pydevtools.com/handbook/how-to/how-to-pin-github-actions-by-sha-for-python-projects.md). Check the [setup-uv releases page](https://github.com/astral-sh/setup-uv/releases) for the current SHA before committing.

## How the date pins tools and keys the cache

`UV_EXCLUDE_NEWER` is the environment-variable form of uv's `--exclude-newer` flag, covered in [how to use `--exclude-newer` for reproducible Python environments](https://pydevtools.com/handbook/how-to/how-to-use-exclude-newer-for-reproducible-python-environments.md). It tells [uv](https://pydevtools.com/handbook/reference/uv.md) to ignore any package version published after `2026-07-12`, so each `uvx` call resolves to the same versions on every run.

`cache-dependency-glob: ""` switches off the default lockfile keying so the cache no longer looks for files that do not exist. `cache-suffix: "tools-${{ env.UV_EXCLUDE_NEWER }}"` puts the date into the cache key directly, so the key changes only when the date does. `prune-cache: false` keeps the downloaded wheels in the cache instead of stripping them after the run.

## Fail the build when a tool is not cached

The second step exports `UV_OFFLINE=1` whenever the cache was restored (`steps.setup-uv.outputs.cache-hit == 'true'`). On a cache hit, uv runs offline: a tool that was already cached runs from disk, and a tool that was not cached fails at once instead of quietly downloading from PyPI.

That failure is the signal you want. When you add a new `uvx` line, the first run misses the cache, downloads the tool, and stores it. If a later run still cannot find it offline, the cache key and the pinned tools have drifted apart, and the build tells you so rather than hiding a slow, uncached install.

## Refresh the tools

Change one line to pick up newer tool versions:

```yaml
env:
  UV_EXCLUDE_NEWER: "2026-08-15"
```

The new date re-pins every tool to versions published on or before it and changes `cache-suffix`, which invalidates the old cache. The next run downloads the updated tools once and caches them under the new key.

## Learn More

- [setup-uv caching documentation](https://github.com/astral-sh/setup-uv/blob/main/docs/caching.md)
- [uv caching concepts](https://docs.astral.sh/uv/concepts/cache/)
- [Using uv in GitHub Actions](https://docs.astral.sh/uv/guides/integration/github/) (official guide)
- [Caching uvx tools in GitHub Actions](https://til.simonwillison.net/github-actions/uvx-github-actions-cache), the origin of this pattern
