From 6d2eb15b4979924f7be71aa06908c6211f80ac88 Mon Sep 17 00:00:00 2001 From: Merlin <158784988+merlinz01@users.noreply.github.com> Date: Thu, 9 Oct 2025 16:47:24 -0400 Subject: [PATCH] Cache python installs (#621) This pull request introduces support for caching Python installs in the GitHub Action, allowing users to cache not only dependencies but also the Python interpreter itself. This works by setting the `UV_PYTHON_INSTALL_DIR` to a subdirectory of the dependency cache path so that Python installs are directed there. Fixes #135 --------- Co-authored-by: Kevin Stillhammer --- .github/workflows/test.yml | 84 ++++++++++++++++++++++++++++++++------ README.md | 15 +++++++ action.yml | 3 ++ dist/save-cache/index.js | 40 ++++++++++++++++-- dist/setup/index.js | 28 +++++++++++-- src/cache/restore-cache.ts | 11 ++++- src/save-cache.ts | 18 +++++++- src/utils/inputs.ts | 21 ++++++++++ 8 files changed, 197 insertions(+), 23 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1767de9..3264ed7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -25,7 +25,7 @@ jobs: with: persist-credentials: false - name: Actionlint - uses: eifinger/actionlint-action@23c85443d840cd73bbecb9cddfc933cc21649a38 # v1.9.1 + uses: eifinger/actionlint-action@23c85443d840cd73bbecb9cddfc933cc21649a38 # v1.9.1 - name: Run zizmor uses: zizmorcore/zizmor-action@e673c3917a1aef3c65c972347ed84ccd013ecda4 # v0.2.0 - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 @@ -269,13 +269,7 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - os: - [ - ubuntu-latest, - macos-latest, - macos-14, - windows-latest, - ] + os: [ubuntu-latest, macos-latest, macos-14, windows-latest] steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: @@ -334,7 +328,7 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - os: [ ubuntu-latest, macos-latest, windows-latest ] + os: [ubuntu-latest, macos-latest, windows-latest] steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: @@ -371,8 +365,8 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - enable-cache: [ "true", "false", "auto" ] - os: [ "ubuntu-latest", "selfhosted-ubuntu-arm64", "windows-latest" ] + enable-cache: ["true", "false", "auto"] + os: ["ubuntu-latest", "selfhosted-ubuntu-arm64", "windows-latest"] steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: @@ -389,8 +383,8 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - enable-cache: [ "true", "false", "auto" ] - os: [ "ubuntu-latest", "selfhosted-ubuntu-arm64", "windows-latest" ] + enable-cache: ["true", "false", "auto"] + os: ["ubuntu-latest", "selfhosted-ubuntu-arm64", "windows-latest"] needs: test-setup-cache steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 @@ -836,6 +830,68 @@ jobs: exit 1 fi + test-cache-python-installs: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + persist-credentials: false + - name: Verify Python install dir is not populated + run: | + if [ -d ~/.local/share/uv/python ]; then + echo "Python install dir should not exist" + exit 1 + fi + - name: Setup uv with cache + uses: ./ + with: + enable-cache: true + cache-python: true + cache-suffix: ${{ github.run_id }}-${{ github.run_attempt }}-test-cache-python-installs + - run: uv sync --managed-python + working-directory: __tests__/fixtures/uv-project + - name: Verify Python install dir exists + run: | + if [ ! -d ~/.local/share/uv/python ]; then + echo "Python install dir should exist" + exit 1 + fi + test-restore-python-installs: + runs-on: ubuntu-latest + needs: test-cache-python-installs + steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + persist-credentials: false + - name: Verify Python install dir does not exist + run: | + if [ -d ~/.local/share/uv/python ]; then + echo "Python install dir should not exist" + exit 1 + fi + - name: Restore with cache + id: restore + uses: ./ + with: + enable-cache: true + cache-python: true + cache-suffix: ${{ github.run_id }}-${{ github.run_attempt }}-test-cache-python-installs + - name: Verify Python install dir exists + run: | + if [ ! -d ~/.local/share/uv/python ]; then + echo "Python install dir should exist" + exit 1 + fi + - name: Cache was hit + run: | + if [ "$CACHE_HIT" != "true" ]; then + exit 1 + fi + env: + CACHE_HIT: ${{ steps.restore.outputs.cache-hit }} + - run: uv sync --managed-python + working-directory: __tests__/fixtures/uv-project + all-tests-passed: runs-on: ubuntu-latest needs: @@ -878,6 +934,8 @@ jobs: - test-relative-path - test-cache-prune-force - test-cache-dir-from-file + - test-cache-python-installs + - test-restore-python-installs if: always() steps: - name: All tests passed diff --git a/README.md b/README.md index abd319e..b9f642a 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ Set up your GitHub Actions workflow with a specific version of [uv](https://docs - [Save cache](#save-cache) - [Local cache path](#local-cache-path) - [Disable cache pruning](#disable-cache-pruning) + - [Cache Python installs](#cache-python-installs) - [Ignore nothing to cache](#ignore-nothing-to-cache) - [GitHub authentication token](#github-authentication-token) - [UV_TOOL_DIR](#uv_tool_dir) @@ -355,6 +356,20 @@ input. prune-cache: false ``` +### Cache Python installs + +By default, the Python install dir (`uv python dir` / `UV_PYTHON_INSTALL_DIR`) is not cached, +for the same reason that the dependency cache is pruned. +If you want to cache Python installs along with your dependencies, set the `cache-python` input to `true`. + +```yaml +- name: Cache Python installs + uses: astral-sh/setup-uv@v6 + with: + enable-cache: true + cache-python: true +``` + ### Ignore nothing to cache By default, the action will fail if caching is enabled but there is nothing to upload (the uv cache directory does not exist). diff --git a/action.yml b/action.yml index ea7133b..997ecb5 100644 --- a/action.yml +++ b/action.yml @@ -56,6 +56,9 @@ inputs: prune-cache: description: "Prune cache before saving." default: "true" + cache-python: + description: "Upload managed Python installations to the Github Actions cache." + default: "false" ignore-nothing-to-cache: description: "Ignore when nothing is found to cache." default: "false" diff --git a/dist/save-cache/index.js b/dist/save-cache/index.js index 5fc3dd8..0100416 100644 --- a/dist/save-cache/index.js +++ b/dist/save-cache/index.js @@ -90593,8 +90593,12 @@ async function restoreCache() { } let matchedKey; core.info(`Trying to restore uv cache from GitHub Actions cache with key: ${cacheKey}`); + const cachePaths = [inputs_1.cacheLocalPath]; + if (inputs_1.cachePython) { + cachePaths.push(await (0, inputs_1.getUvPythonDir)()); + } try { - matchedKey = await cache.restoreCache([inputs_1.cacheLocalPath], cacheKey); + matchedKey = await cache.restoreCache(cachePaths, cacheKey); } catch (err) { const message = err.message; @@ -90620,7 +90624,8 @@ async function computeKeys() { const pythonVersion = await getPythonVersion(); const platform = await (0, platforms_1.getPlatform)(); const pruned = inputs_1.pruneCache ? "-pruned" : ""; - return `setup-uv-${CACHE_VERSION}-${(0, platforms_1.getArch)()}-${platform}-${pythonVersion}${pruned}${cacheDependencyPathHash}${suffix}`; + const python = inputs_1.cachePython ? "-py" : ""; + return `setup-uv-${CACHE_VERSION}-${(0, platforms_1.getArch)()}-${platform}-${pythonVersion}${pruned}${python}${cacheDependencyPathHash}${suffix}`; } async function getPythonVersion() { if (inputs_1.pythonVersion !== "") { @@ -90844,8 +90849,18 @@ async function saveCache() { if (!fs.existsSync(actualCachePath) && !inputs_1.ignoreNothingToCache) { throw new Error(`Cache path ${actualCachePath} does not exist on disk. This likely indicates that there are no dependencies to cache. Consider disabling the cache input if it is not needed.`); } + const cachePaths = [actualCachePath]; + if (inputs_1.cachePython) { + const pythonDir = await (0, inputs_1.getUvPythonDir)(); + core.info(`Including Python cache path: ${pythonDir}`); + if (!fs.existsSync(pythonDir) && !inputs_1.ignoreNothingToCache) { + throw new Error(`Python cache path ${pythonDir} does not exist on disk. This likely indicates that there are no dependencies to cache. Consider disabling the cache input if it is not needed.`); + } + cachePaths.push(pythonDir); + } + core.info(`Final cache paths: ${cachePaths.join(", ")}`); try { - await cache.saveCache([actualCachePath], cacheKey); + await cache.saveCache(cachePaths, cacheKey); core.info(`cache saved with the key: ${cacheKey}`); } catch (e) { @@ -90996,9 +91011,11 @@ var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.addProblemMatchers = exports.manifestFile = exports.githubToken = exports.toolDir = exports.toolBinDir = exports.ignoreEmptyWorkdir = exports.ignoreNothingToCache = exports.pruneCache = exports.cacheDependencyGlob = exports.cacheLocalPath = exports.cacheSuffix = exports.saveCache = exports.restoreCache = exports.enableCache = exports.checkSum = exports.activateEnvironment = exports.pythonVersion = exports.versionFile = exports.version = exports.workingDirectory = void 0; +exports.addProblemMatchers = exports.manifestFile = exports.githubToken = exports.toolDir = exports.toolBinDir = exports.ignoreEmptyWorkdir = exports.ignoreNothingToCache = exports.cachePython = exports.pruneCache = exports.cacheDependencyGlob = exports.cacheLocalPath = exports.cacheSuffix = exports.saveCache = exports.restoreCache = exports.enableCache = exports.checkSum = exports.activateEnvironment = exports.pythonVersion = exports.versionFile = exports.version = exports.workingDirectory = void 0; +exports.getUvPythonDir = getUvPythonDir; const node_path_1 = __importDefault(__nccwpck_require__(6760)); const core = __importStar(__nccwpck_require__(7484)); +const exec = __importStar(__nccwpck_require__(5236)); const config_file_1 = __nccwpck_require__(5465); exports.workingDirectory = core.getInput("working-directory"); exports.version = core.getInput("version"); @@ -91013,6 +91030,7 @@ exports.cacheSuffix = core.getInput("cache-suffix") || ""; exports.cacheLocalPath = getCacheLocalPath(); exports.cacheDependencyGlob = getCacheDependencyGlob(); exports.pruneCache = core.getInput("prune-cache") === "true"; +exports.cachePython = core.getInput("cache-python") === "true"; exports.ignoreNothingToCache = core.getInput("ignore-nothing-to-cache") === "true"; exports.ignoreEmptyWorkdir = core.getInput("ignore-empty-workdir") === "true"; exports.toolBinDir = getToolBinDir(); @@ -91106,6 +91124,20 @@ function getCacheDirFromConfig() { } return undefined; } +async function getUvPythonDir() { + if (process.env.UV_PYTHON_INSTALL_DIR !== undefined) { + core.info(`Using UV_PYTHON_INSTALL_DIR from environment: ${process.env.UV_PYTHON_INSTALL_DIR}`); + return process.env.UV_PYTHON_INSTALL_DIR; + } + core.info("Determining uv python dir using `uv python dir`..."); + const result = await exec.getExecOutput("uv", ["python", "dir"]); + if (result.exitCode !== 0) { + throw new Error(`Failed to get uv python dir: ${result.stderr || result.stdout}`); + } + const dir = result.stdout.trim(); + core.info(`Determined uv python dir: ${dir}`); + return dir; +} function getCacheDependencyGlob() { const cacheDependencyGlobInput = core.getInput("cache-dependency-glob"); if (cacheDependencyGlobInput !== "") { diff --git a/dist/setup/index.js b/dist/setup/index.js index f443a3e..299113a 100644 --- a/dist/setup/index.js +++ b/dist/setup/index.js @@ -125151,8 +125151,12 @@ async function restoreCache() { } let matchedKey; core.info(`Trying to restore uv cache from GitHub Actions cache with key: ${cacheKey}`); + const cachePaths = [inputs_1.cacheLocalPath]; + if (inputs_1.cachePython) { + cachePaths.push(await (0, inputs_1.getUvPythonDir)()); + } try { - matchedKey = await cache.restoreCache([inputs_1.cacheLocalPath], cacheKey); + matchedKey = await cache.restoreCache(cachePaths, cacheKey); } catch (err) { const message = err.message; @@ -125178,7 +125182,8 @@ async function computeKeys() { const pythonVersion = await getPythonVersion(); const platform = await (0, platforms_1.getPlatform)(); const pruned = inputs_1.pruneCache ? "-pruned" : ""; - return `setup-uv-${CACHE_VERSION}-${(0, platforms_1.getArch)()}-${platform}-${pythonVersion}${pruned}${cacheDependencyPathHash}${suffix}`; + const python = inputs_1.cachePython ? "-py" : ""; + return `setup-uv-${CACHE_VERSION}-${(0, platforms_1.getArch)()}-${platform}-${pythonVersion}${pruned}${python}${cacheDependencyPathHash}${suffix}`; } async function getPythonVersion() { if (inputs_1.pythonVersion !== "") { @@ -129708,9 +129713,11 @@ var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.addProblemMatchers = exports.manifestFile = exports.githubToken = exports.toolDir = exports.toolBinDir = exports.ignoreEmptyWorkdir = exports.ignoreNothingToCache = exports.pruneCache = exports.cacheDependencyGlob = exports.cacheLocalPath = exports.cacheSuffix = exports.saveCache = exports.restoreCache = exports.enableCache = exports.checkSum = exports.activateEnvironment = exports.pythonVersion = exports.versionFile = exports.version = exports.workingDirectory = void 0; +exports.addProblemMatchers = exports.manifestFile = exports.githubToken = exports.toolDir = exports.toolBinDir = exports.ignoreEmptyWorkdir = exports.ignoreNothingToCache = exports.cachePython = exports.pruneCache = exports.cacheDependencyGlob = exports.cacheLocalPath = exports.cacheSuffix = exports.saveCache = exports.restoreCache = exports.enableCache = exports.checkSum = exports.activateEnvironment = exports.pythonVersion = exports.versionFile = exports.version = exports.workingDirectory = void 0; +exports.getUvPythonDir = getUvPythonDir; const node_path_1 = __importDefault(__nccwpck_require__(76760)); const core = __importStar(__nccwpck_require__(37484)); +const exec = __importStar(__nccwpck_require__(95236)); const config_file_1 = __nccwpck_require__(27846); exports.workingDirectory = core.getInput("working-directory"); exports.version = core.getInput("version"); @@ -129725,6 +129732,7 @@ exports.cacheSuffix = core.getInput("cache-suffix") || ""; exports.cacheLocalPath = getCacheLocalPath(); exports.cacheDependencyGlob = getCacheDependencyGlob(); exports.pruneCache = core.getInput("prune-cache") === "true"; +exports.cachePython = core.getInput("cache-python") === "true"; exports.ignoreNothingToCache = core.getInput("ignore-nothing-to-cache") === "true"; exports.ignoreEmptyWorkdir = core.getInput("ignore-empty-workdir") === "true"; exports.toolBinDir = getToolBinDir(); @@ -129818,6 +129826,20 @@ function getCacheDirFromConfig() { } return undefined; } +async function getUvPythonDir() { + if (process.env.UV_PYTHON_INSTALL_DIR !== undefined) { + core.info(`Using UV_PYTHON_INSTALL_DIR from environment: ${process.env.UV_PYTHON_INSTALL_DIR}`); + return process.env.UV_PYTHON_INSTALL_DIR; + } + core.info("Determining uv python dir using `uv python dir`..."); + const result = await exec.getExecOutput("uv", ["python", "dir"]); + if (result.exitCode !== 0) { + throw new Error(`Failed to get uv python dir: ${result.stderr || result.stdout}`); + } + const dir = result.stdout.trim(); + core.info(`Determined uv python dir: ${dir}`); + return dir; +} function getCacheDependencyGlob() { const cacheDependencyGlobInput = core.getInput("cache-dependency-glob"); if (cacheDependencyGlobInput !== "") { diff --git a/src/cache/restore-cache.ts b/src/cache/restore-cache.ts index fac9f55..3c32c04 100644 --- a/src/cache/restore-cache.ts +++ b/src/cache/restore-cache.ts @@ -5,7 +5,9 @@ import { hashFiles } from "../hash/hash-files"; import { cacheDependencyGlob, cacheLocalPath, + cachePython, cacheSuffix, + getUvPythonDir, pruneCache, pythonVersion as pythonVersionInput, restoreCache as shouldRestoreCache, @@ -30,8 +32,12 @@ export async function restoreCache(): Promise { core.info( `Trying to restore uv cache from GitHub Actions cache with key: ${cacheKey}`, ); + const cachePaths = [cacheLocalPath]; + if (cachePython) { + cachePaths.push(await getUvPythonDir()); + } try { - matchedKey = await cache.restoreCache([cacheLocalPath], cacheKey); + matchedKey = await cache.restoreCache(cachePaths, cacheKey); } catch (err) { const message = (err as Error).message; core.warning(message); @@ -62,7 +68,8 @@ async function computeKeys(): Promise { const pythonVersion = await getPythonVersion(); const platform = await getPlatform(); const pruned = pruneCache ? "-pruned" : ""; - return `setup-uv-${CACHE_VERSION}-${getArch()}-${platform}-${pythonVersion}${pruned}${cacheDependencyPathHash}${suffix}`; + const python = cachePython ? "-py" : ""; + return `setup-uv-${CACHE_VERSION}-${getArch()}-${platform}-${pythonVersion}${pruned}${python}${cacheDependencyPathHash}${suffix}`; } async function getPythonVersion(): Promise { diff --git a/src/save-cache.ts b/src/save-cache.ts index 45cc62a..2e9f684 100644 --- a/src/save-cache.ts +++ b/src/save-cache.ts @@ -10,7 +10,9 @@ import { import { STATE_UV_PATH, STATE_UV_VERSION } from "./utils/constants"; import { cacheLocalPath, + cachePython, enableCache, + getUvPythonDir, ignoreNothingToCache, pruneCache as shouldPruneCache, saveCache as shouldSaveCache, @@ -68,8 +70,22 @@ async function saveCache(): Promise { `Cache path ${actualCachePath} does not exist on disk. This likely indicates that there are no dependencies to cache. Consider disabling the cache input if it is not needed.`, ); } + + const cachePaths = [actualCachePath]; + if (cachePython) { + const pythonDir = await getUvPythonDir(); + core.info(`Including Python cache path: ${pythonDir}`); + if (!fs.existsSync(pythonDir) && !ignoreNothingToCache) { + throw new Error( + `Python cache path ${pythonDir} does not exist on disk. This likely indicates that there are no dependencies to cache. Consider disabling the cache input if it is not needed.`, + ); + } + cachePaths.push(pythonDir); + } + + core.info(`Final cache paths: ${cachePaths.join(", ")}`); try { - await cache.saveCache([actualCachePath], cacheKey); + await cache.saveCache(cachePaths, cacheKey); core.info(`cache saved with the key: ${cacheKey}`); } catch (e) { if ( diff --git a/src/utils/inputs.ts b/src/utils/inputs.ts index faf2f59..b14a50c 100644 --- a/src/utils/inputs.ts +++ b/src/utils/inputs.ts @@ -1,5 +1,6 @@ import path from "node:path"; import * as core from "@actions/core"; +import * as exec from "@actions/exec"; import { getConfigValueFromTomlFile } from "./config-file"; export const workingDirectory = core.getInput("working-directory"); @@ -15,6 +16,7 @@ export const cacheSuffix = core.getInput("cache-suffix") || ""; export const cacheLocalPath = getCacheLocalPath(); export const cacheDependencyGlob = getCacheDependencyGlob(); export const pruneCache = core.getInput("prune-cache") === "true"; +export const cachePython = core.getInput("cache-python") === "true"; export const ignoreNothingToCache = core.getInput("ignore-nothing-to-cache") === "true"; export const ignoreEmptyWorkdir = @@ -123,6 +125,25 @@ function getCacheDirFromConfig(): string | undefined { return undefined; } +export async function getUvPythonDir(): Promise { + if (process.env.UV_PYTHON_INSTALL_DIR !== undefined) { + core.info( + `Using UV_PYTHON_INSTALL_DIR from environment: ${process.env.UV_PYTHON_INSTALL_DIR}`, + ); + return process.env.UV_PYTHON_INSTALL_DIR; + } + core.info("Determining uv python dir using `uv python dir`..."); + const result = await exec.getExecOutput("uv", ["python", "dir"]); + if (result.exitCode !== 0) { + throw new Error( + `Failed to get uv python dir: ${result.stderr || result.stdout}`, + ); + } + const dir = result.stdout.trim(); + core.info(`Determined uv python dir: ${dir}`); + return dir; +} + function getCacheDependencyGlob(): string { const cacheDependencyGlobInput = core.getInput("cache-dependency-glob"); if (cacheDependencyGlobInput !== "") {