diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a22926f..c56bd26 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -985,14 +985,18 @@ jobs: run: rm -rf "$UV_PYTHON_INSTALL_DIR" test-cache-python-installs: - runs-on: ubuntu-latest + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, windows-2022] steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Verify Python install dir is not populated + shell: bash run: | - if [ -d /home/runner/work/_temp/uv-python-dir ]; then + if [ -e "$RUNNER_TEMP/uv-python-dir" ]; then echo "Python install dir should not exist" exit 1 fi @@ -1005,21 +1009,26 @@ jobs: - run: uv sync --managed-python working-directory: __tests__/fixtures/uv-project - name: Verify Python install dir exists + shell: bash run: | - if [ ! -d /home/runner/work/_temp/uv-python-dir ]; then + if [ ! -d "$UV_PYTHON_INSTALL_DIR" ]; then echo "Python install dir should exist" exit 1 fi test-restore-python-installs: - runs-on: ubuntu-latest + runs-on: ${{ matrix.os }} needs: test-cache-python-installs + strategy: + matrix: + os: [ubuntu-latest, windows-2022] steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Verify Python install dir does not exist + shell: bash run: | - if [ -d /home/runner/work/_temp/uv-python-dir ]; then + if [ -e "$RUNNER_TEMP/uv-python-dir" ]; then echo "Python install dir should not exist" exit 1 fi @@ -1030,19 +1039,21 @@ jobs: 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 /home/runner/work/_temp/uv-python-dir ]; then - echo "Python install dir should exist" - exit 1 - fi - - name: Cache was hit - run: | - if [ "$CACHE_HIT" != "true" ]; then - exit 1 - fi + - name: Verify the restored Python minor-version link resolves to the patch directory + shell: python env: CACHE_HIT: ${{ steps.restore.outputs.python-cache-hit }} + run: | + import os + from pathlib import Path + + assert os.environ["CACHE_HIT"] == "true", "expected a Python cache hit" + installs = Path(os.environ["UV_PYTHON_INSTALL_DIR"]).resolve() + assert installs.is_dir(), "Python install dir should exist" + patch_dir, = installs.glob("cpython-*.*.*-*") + minor_prefix = patch_dir.name.rsplit(".", 1)[0] + alias, = installs.glob(f"{minor_prefix}-*") + assert alias.resolve(strict=True) == patch_dir, alias - run: uv sync --managed-python working-directory: __tests__/fixtures/uv-project diff --git a/README.md b/README.md index 7938a1d..ca9a252 100644 --- a/README.md +++ b/README.md @@ -143,7 +143,7 @@ Have a look under [Advanced Configuration](#advanced-configuration) for detailed - `venv`: Path to the activated venv if activate-environment is true. - `python-version`: The Python version that was set. - `python-runtime-id`: An opaque identifier reported by uv for the activated venv's Python runtime. Empty when `activate-environment` is false. -- `python-cache-hit`: A boolean value to indicate the Python cache entry was found. +- `python-cache-hit`: A boolean indicating whether the Python cache was restored successfully. ### Python version diff --git a/__tests__/cache/restore-cache.test.ts b/__tests__/cache/restore-cache.test.ts index fdc80d5..ab05810 100644 --- a/__tests__/cache/restore-cache.test.ts +++ b/__tests__/cache/restore-cache.test.ts @@ -1,3 +1,4 @@ +import { fileURLToPath } from "node:url"; import { beforeEach, describe, expect, it, jest } from "@jest/globals"; import { createSetupInputs } from "../helpers/setup-inputs"; @@ -48,6 +49,42 @@ beforeEach(() => { }); describe("restoreCache", () => { + it.each([ + ["win32", false], + ["linux", true], + ] as const)( + "keeps the matched Python cache key on %s when the install directory cannot be read", + async (platform, pythonCacheHit) => { + const inputs = createSetupInputs({ + cachePython: true, + // A file at the install directory path makes readdir fail. + pythonDir: fileURLToPath(import.meta.url), + restoreCache: true, + }); + mockRestoreCache + .mockReturnValueOnce("uv-key") + .mockReturnValueOnce("python-key"); + + const originalPlatform = process.platform; + Object.defineProperty(process, "platform", { value: platform }); + try { + await restoreCache(inputs); + } finally { + Object.defineProperty(process, "platform", { value: originalPlatform }); + } + + expect(mockSetOutput).toHaveBeenCalledWith("cache-hit", true); + expect(mockSetOutput).toHaveBeenCalledWith( + "python-cache-hit", + pythonCacheHit, + ); + expect(mockSaveState).toHaveBeenCalledWith( + "python-cache-matched-key", + "python-key", + ); + }, + ); + it("encodes Python version ranges before adding them to the cache key", async () => { await restoreCache(createSetupInputs(), ">3.10.11,<3.11"); diff --git a/action.yml b/action.yml index a52b558..16220a0 100644 --- a/action.yml +++ b/action.yml @@ -113,7 +113,7 @@ outputs: python-runtime-id: description: "An opaque identifier reported by uv for the activated venv's Python runtime. Empty when activate-environment is false." python-cache-hit: - description: "A boolean value to indicate the Python cache entry was found" + description: "A boolean indicating whether the Python cache was restored successfully." runs: using: "node24" main: "dist/setup/index.cjs" diff --git a/dist/setup/index.cjs b/dist/setup/index.cjs index 92c0500..9ac2d96 100644 --- a/dist/setup/index.cjs +++ b/dist/setup/index.cjs @@ -93966,6 +93966,53 @@ function getWindowsNameVersion() { return `windows-${match4[1]}`; } +// src/cache/repair-python-junctions.ts +var import_promises = require("node:fs/promises"); +var import_node_path = __toESM(require("node:path"), 1); +async function repairPythonJunctions(pythonDir) { + const entries = await (0, import_promises.readdir)(pythonDir, { withFileTypes: true }); + const directories = new Set( + entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name.toLowerCase()) + ); + for (const entry of entries) { + const name = entry.name.toLowerCase(); + if (!entry.isSymbolicLink() || !/^cpython-\d+\.\d+[+-].+$/.test(name)) { + continue; + } + const link = import_node_path.default.join(pythonDir, entry.name); + let target; + try { + target = import_node_path.default.resolve(pythonDir, await (0, import_promises.readlink)(link)); + } catch (err) { + if (await resolvesWithAccessDenied(link)) { + throw err; + } + continue; + } + const sibling = import_node_path.default.relative(pythonDir, target).toLowerCase(); + const version3 = /^(cpython-\d+\.\d+)\.\d+(?:(?:a|b|rc)\d+)?([+-].+)$/.exec( + sibling + ); + if (!version3 || version3[1] + version3[2] !== name || !directories.has(sibling)) { + continue; + } + if (!await resolvesWithAccessDenied(link)) { + continue; + } + await (0, import_promises.realpath)(target); + await (0, import_promises.unlink)(link); + await (0, import_promises.symlink)(target, link, "junction"); + } +} +async function resolvesWithAccessDenied(link) { + try { + await (0, import_promises.realpath)(link); + return false; + } catch (err) { + return err.code === "EPERM"; + } +} + // src/cache/restore-cache.ts var STATE_CACHE_KEY = "cache-key"; var STATE_CACHE_MATCHED_KEY = "cache-matched-key"; @@ -93996,26 +94043,37 @@ async function restoreCache2(inputs, pythonVersion) { `${cacheKey}-python`, inputs.pythonDir, STATE_PYTHON_CACHE_MATCHED_KEY, - "python-cache-hit" + "python-cache-hit", + // TODO: Remove this repair once @actions/cache preserves Windows junctions. + // https://github.com/actions/toolkit/issues/2562 + process.platform === "win32" ? repairPythonJunctions : void 0 ); } else { setOutput("python-cache-hit", false); } } -async function restoreCacheFromKey(cacheKey, cachePath, stateKey, outputKey) { +async function restoreCacheFromKey(cacheKey, cachePath, stateKey, outputKey, afterRestore) { info2( `Trying to restore cache from GitHub Actions cache with key: ${cacheKey}` ); - let matchedKey; try { - matchedKey = await restoreCache([cachePath], cacheKey); + const matchedKey = await restoreCache([cachePath], cacheKey); + if (!matchedKey) { + info2(`No GitHub Actions cache found for key: ${cacheKey}`); + setOutput(outputKey, false); + return; + } + saveState(stateKey, matchedKey); + await afterRestore?.(cachePath); + info2( + `cache restored from GitHub Actions cache with key: ${matchedKey}` + ); + setOutput(outputKey, true); } catch (err) { const message = err.message; warning2(message); setOutput(outputKey, false); - return; } - handleMatchResult(matchedKey, cacheKey, stateKey, outputKey); } async function computeKeys(inputs, pythonVersion) { let cacheDependencyPathHash = "-"; @@ -94045,16 +94103,6 @@ async function computeKeys(inputs, pythonVersion) { const python = inputs.cachePython ? "-py" : ""; return `setup-uv-${CACHE_VERSION}-${getArch()}-${platform2}-${osNameVersion}-${version3}${pythonArchKey}${pruned}${python}${cacheDependencyPathHash}${suffix}`; } -function handleMatchResult(matchedKey, primaryKey, stateKey, outputKey) { - if (!matchedKey) { - info2(`No GitHub Actions cache found for key: ${primaryKey}`); - setOutput(outputKey, false); - return; - } - saveState(stateKey, matchedKey); - info2(`cache restored from GitHub Actions cache with key: ${matchedKey}`); - setOutput(outputKey, true); -} // src/download/download-version.ts var import_node_fs7 = require("node:fs"); @@ -102164,7 +102212,7 @@ function getExtension(platform2) { // src/utils/inputs.ts var import_node_fs8 = __toESM(require("node:fs"), 1); -var import_node_path = __toESM(require("node:path"), 1); +var import_node_path2 = __toESM(require("node:path"), 1); function loadInputs() { const workingDirectory = getInput("working-directory"); const version3 = getInput("version"); @@ -102310,7 +102358,7 @@ function getToolBinDir(workingDirectory) { } if (process.platform === "win32") { if (process.env.RUNNER_TEMP !== void 0) { - return `${process.env.RUNNER_TEMP}${import_node_path.default.sep}uv-tool-bin-dir`; + return `${process.env.RUNNER_TEMP}${import_node_path2.default.sep}uv-tool-bin-dir`; } throw Error( "Could not determine UV_TOOL_BIN_DIR. Please make sure RUNNER_TEMP is set or provide the tool-bin-dir input" @@ -102326,7 +102374,7 @@ function getToolDir(workingDirectory) { } if (process.platform === "win32") { if (process.env.RUNNER_TEMP !== void 0) { - return `${process.env.RUNNER_TEMP}${import_node_path.default.sep}uv-tool-dir`; + return `${process.env.RUNNER_TEMP}${import_node_path2.default.sep}uv-tool-dir`; } throw Error( "Could not determine UV_TOOL_DIR. Please make sure RUNNER_TEMP is set or provide the tool-dir input" @@ -102358,7 +102406,7 @@ function getCacheLocalPath(workingDirectory, versionFile, enableCache) { if (process.env.RUNNER_ENVIRONMENT === "github-hosted") { if (process.env.RUNNER_TEMP !== void 0) { return { - path: `${process.env.RUNNER_TEMP}${import_node_path.default.sep}setup-uv-cache`, + path: `${process.env.RUNNER_TEMP}${import_node_path2.default.sep}setup-uv-cache`, source: 3 /* Default */ }; } @@ -102368,12 +102416,12 @@ function getCacheLocalPath(workingDirectory, versionFile, enableCache) { } if (process.platform === "win32") { return { - path: `${process.env.APPDATA}${import_node_path.default.sep}uv${import_node_path.default.sep}cache`, + path: `${process.env.APPDATA}${import_node_path2.default.sep}uv${import_node_path2.default.sep}cache`, source: 3 /* Default */ }; } return { - path: `${process.env.HOME}${import_node_path.default.sep}.cache${import_node_path.default.sep}uv`, + path: `${process.env.HOME}${import_node_path2.default.sep}.cache${import_node_path2.default.sep}uv`, source: 3 /* Default */ }; } @@ -102404,12 +102452,12 @@ function getUvPythonDir() { } if (process.env.RUNNER_ENVIRONMENT !== "github-hosted") { if (process.platform === "win32") { - return `${process.env.APPDATA}${import_node_path.default.sep}uv${import_node_path.default.sep}python`; + return `${process.env.APPDATA}${import_node_path2.default.sep}uv${import_node_path2.default.sep}python`; } - return `${process.env.HOME}${import_node_path.default.sep}.local${import_node_path.default.sep}share${import_node_path.default.sep}uv${import_node_path.default.sep}python`; + return `${process.env.HOME}${import_node_path2.default.sep}.local${import_node_path2.default.sep}share${import_node_path2.default.sep}uv${import_node_path2.default.sep}python`; } if (process.env.RUNNER_TEMP !== void 0) { - return `${process.env.RUNNER_TEMP}${import_node_path.default.sep}uv-python-dir`; + return `${process.env.RUNNER_TEMP}${import_node_path2.default.sep}uv-python-dir`; } throw Error( "Could not determine UV_PYTHON_INSTALL_DIR. Please make sure RUNNER_TEMP is set or provide the UV_PYTHON_INSTALL_DIR environment variable" @@ -102429,10 +102477,10 @@ function expandTilde(input) { return input; } function normalizePath(inputPath) { - const normalized = import_node_path.default.normalize(inputPath); - const root = import_node_path.default.parse(normalized).root; + const normalized = import_node_path2.default.normalize(inputPath); + const root = import_node_path2.default.parse(normalized).root; let trimmed = normalized; - while (trimmed.length > root.length && trimmed.endsWith(import_node_path.default.sep)) { + while (trimmed.length > root.length && trimmed.endsWith(import_node_path2.default.sep)) { trimmed = trimmed.slice(0, -1); } return trimmed; @@ -102440,7 +102488,7 @@ function normalizePath(inputPath) { function resolveRelativePath(workingDirectory, inputPath) { const hasNegation = inputPath.startsWith("!"); const pathWithoutNegation = hasNegation ? inputPath.substring(1) : inputPath; - const resolvedPath = import_node_path.default.resolve(workingDirectory, pathWithoutNegation); + const resolvedPath = import_node_path2.default.resolve(workingDirectory, pathWithoutNegation); debug( `Resolving relative path ${inputPath} to ${hasNegation ? "!" : ""}${resolvedPath}` ); diff --git a/src/cache/repair-python-junctions.ts b/src/cache/repair-python-junctions.ts new file mode 100644 index 0000000..c2aae19 --- /dev/null +++ b/src/cache/repair-python-junctions.ts @@ -0,0 +1,60 @@ +import { readdir, readlink, realpath, symlink, unlink } from "node:fs/promises"; +import nodePath from "node:path"; + +export async function repairPythonJunctions(pythonDir: string): Promise { + const entries = await readdir(pythonDir, { withFileTypes: true }); + const directories = new Set( + entries + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name.toLowerCase()), + ); + + for (const entry of entries) { + const name = entry.name.toLowerCase(); + if (!entry.isSymbolicLink() || !/^cpython-\d+\.\d+[+-].+$/.test(name)) { + continue; + } + const link = nodePath.join(pythonDir, entry.name); + let target: string; + try { + target = nodePath.resolve(pythonDir, await readlink(link)); + } catch (err) { + // Fail only if the link still has the access error that requires repair. + if (await resolvesWithAccessDenied(link)) { + throw err; + } + continue; + } + + // uv omits only the patch and prerelease from its minor-version links. + const sibling = nodePath.relative(pythonDir, target).toLowerCase(); + const version = /^(cpython-\d+\.\d+)\.\d+(?:(?:a|b|rc)\d+)?([+-].+)$/.exec( + sibling, + ); + if ( + !version || + version[1] + version[2] !== name || + !directories.has(sibling) + ) { + continue; + } + // Git for Windows tar can restore a junction as a file symlink when it + // extracts the link before its target. Opening it as a directory fails. + if (!(await resolvesWithAccessDenied(link))) { + continue; + } + // An inaccessible installation can also cause EPERM through a valid link. + await realpath(target); + await unlink(link); + await symlink(target, link, "junction"); + } +} + +async function resolvesWithAccessDenied(link: string): Promise { + try { + await realpath(link); + return false; + } catch (err) { + return (err as NodeJS.ErrnoException).code === "EPERM"; + } +} diff --git a/src/cache/restore-cache.ts b/src/cache/restore-cache.ts index e803e10..9778a5e 100644 --- a/src/cache/restore-cache.ts +++ b/src/cache/restore-cache.ts @@ -4,6 +4,7 @@ import { hashFiles } from "../hash/hash-files"; import type { SetupInputs } from "../utils/inputs"; import * as log from "../utils/logging"; import { getArch, getOSNameVersion, getPlatform } from "../utils/platforms"; +import { repairPythonJunctions } from "./repair-python-junctions"; export const STATE_CACHE_KEY = "cache-key"; export const STATE_CACHE_MATCHED_KEY = "cache-matched-key"; @@ -44,6 +45,9 @@ export async function restoreCache( inputs.pythonDir, STATE_PYTHON_CACHE_MATCHED_KEY, "python-cache-hit", + // TODO: Remove this repair once @actions/cache preserves Windows junctions. + // https://github.com/actions/toolkit/issues/2562 + process.platform === "win32" ? repairPythonJunctions : undefined, ); } else { core.setOutput("python-cache-hit", false); @@ -55,21 +59,33 @@ async function restoreCacheFromKey( cachePath: string, stateKey: string, outputKey: string, + afterRestore?: (cachePath: string) => Promise, ): Promise { log.info( `Trying to restore cache from GitHub Actions cache with key: ${cacheKey}`, ); - let matchedKey: string | undefined; try { - matchedKey = await cache.restoreCache([cachePath], cacheKey); + const matchedKey = await cache.restoreCache([cachePath], cacheKey); + if (!matchedKey) { + log.info(`No GitHub Actions cache found for key: ${cacheKey}`); + core.setOutput(outputKey, false); + return; + } + + // Record the match before the post-restore hook so the post step can skip + // archiving the directory again even if the hook fails. A PR that restored + // from its base branch could otherwise save the broken copy in its own cache. + core.saveState(stateKey, matchedKey); + await afterRestore?.(cachePath); + log.info( + `cache restored from GitHub Actions cache with key: ${matchedKey}`, + ); + core.setOutput(outputKey, true); } catch (err) { const message = (err as Error).message; log.warning(message); core.setOutput(outputKey, false); - return; } - - handleMatchResult(matchedKey, cacheKey, stateKey, outputKey); } async function computeKeys( @@ -107,20 +123,3 @@ async function computeKeys( const python = inputs.cachePython ? "-py" : ""; return `setup-uv-${CACHE_VERSION}-${getArch()}-${platform}-${osNameVersion}-${version}${pythonArchKey}${pruned}${python}${cacheDependencyPathHash}${suffix}`; } - -function handleMatchResult( - matchedKey: string | undefined, - primaryKey: string, - stateKey: string, - outputKey: string, -): void { - if (!matchedKey) { - log.info(`No GitHub Actions cache found for key: ${primaryKey}`); - core.setOutput(outputKey, false); - return; - } - - core.saveState(stateKey, matchedKey); - log.info(`cache restored from GitHub Actions cache with key: ${matchedKey}`); - core.setOutput(outputKey, true); -}