From 3511ff7054b4bdbf897f4410d573261859a8eeb2 Mon Sep 17 00:00:00 2001 From: Kevin Stillhammer Date: Wed, 4 Feb 2026 08:40:32 +0100 Subject: [PATCH] feat: add venv-path input for activate-environment (#746) Allow customizing the venv location while preserving working-directory semantics via --directory. Supersedes: #736 --- .github/workflows/test.yml | 55 +++++++++++++++++++++++++ README.md | 5 ++- __tests__/utils/inputs.test.ts | 73 ++++++++++++++++++++++++++++++++++ action-types.yml | 2 + action.yml | 3 ++ dist/save-cache/index.js | 24 ++++++++++- dist/setup/index.js | 38 +++++++++++++----- docs/environment-and-tools.md | 11 +++++ src/setup-uv.ts | 7 ++-- src/utils/inputs.ts | 26 ++++++++++++ 10 files changed, 229 insertions(+), 15 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e2a8029..7d8cd59 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -386,6 +386,60 @@ jobs: env: UV_VENV: ${{ steps.setup-uv.outputs.venv }} + test-activate-environment-custom-path: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + steps: + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + with: + persist-credentials: false + - name: Install latest version + id: setup-uv + uses: ./ + with: + python-version: 3.13.1t + activate-environment: true + venv-path: ${{ runner.temp }}/custom-venv + - name: Verify VIRTUAL_ENV matches output + run: | + if [ "$VIRTUAL_ENV" != "$UV_VENV" ]; then + echo "VIRTUAL_ENV does not match venv output: $VIRTUAL_ENV vs $UV_VENV" + exit 1 + fi + shell: bash + env: + UV_VENV: ${{ steps.setup-uv.outputs.venv }} + - name: Verify venv location is runner.temp/custom-venv + run: | + python - <<'PY' + import os + from pathlib import Path + + venv = Path(os.environ["VIRTUAL_ENV"]).resolve() + temp = Path(os.environ["RUNNER_TEMP"]).resolve() + + if venv.name != "custom-venv": + raise SystemExit(f"Expected venv name 'custom-venv', got: {venv}") + if venv.parent != temp: + raise SystemExit(f"Expected venv under {temp}, got: {venv}") + if not venv.is_dir(): + raise SystemExit(f"Venv directory does not exist: {venv}") + PY + shell: bash + - name: Verify packages can be installed + run: uv pip install pip + shell: bash + - name: Verify python runs from custom venv + run: | + python - <<'PY' + import sys + if "custom-venv" not in sys.executable: + raise SystemExit(f"Python is not running from custom venv: {sys.executable}") + PY + shell: bash + test-musl: runs-on: ubuntu-latest container: alpine @@ -1069,6 +1123,7 @@ jobs: - test-tilde-expansion-tool-dirs - test-python-version - test-activate-environment + - test-activate-environment-custom-path - test-musl - test-cache-key-os-version - test-cache-local diff --git a/README.md b/README.md index 89d41f7..f3dea3e 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,9 @@ Have a look under [Advanced Configuration](#advanced-configuration) for detailed # Use uv venv to activate a venv ready to be used by later steps activate-environment: "false" + # Custom path for the virtual environment when using activate-environment (default: .venv in the working directory) + venv-path: "" + # The directory to execute all commands in and look for files such as pyproject.toml working-directory: "" @@ -167,7 +170,7 @@ You can set the working directory with the `working-directory` input. This controls where we look for `pyproject.toml`, `uv.toml` and `.python-version` files which are used to determine the version of uv and python to install. -It also controls where [the venv gets created](#activate-environment). +It also controls where [the venv gets created](#activate-environment), unless `venv-path` is set. ```yaml - name: Install uv based on the config files in the working-directory diff --git a/__tests__/utils/inputs.test.ts b/__tests__/utils/inputs.test.ts index d43aa8c..7d58c5c 100644 --- a/__tests__/utils/inputs.test.ts +++ b/__tests__/utils/inputs.test.ts @@ -5,6 +5,7 @@ jest.mock("@actions/core", () => { (name: string) => (mockInputs[name] ?? "") === "true", ), getInput: jest.fn((name: string) => mockInputs[name] ?? ""), + warning: jest.fn(), }; }); @@ -24,6 +25,7 @@ const ORIGINAL_HOME = process.env.HOME; describe("cacheDependencyGlob", () => { beforeEach(() => { jest.resetModules(); + jest.clearAllMocks(); mockInputs = {}; process.env.HOME = "/home/testuser"; }); @@ -84,3 +86,74 @@ describe("cacheDependencyGlob", () => { ); }); }); + +describe("venvPath", () => { + beforeEach(() => { + jest.resetModules(); + jest.clearAllMocks(); + mockInputs = {}; + process.env.HOME = "/home/testuser"; + }); + + afterEach(() => { + process.env.HOME = ORIGINAL_HOME; + }); + + it("defaults to .venv in the working directory", async () => { + mockInputs["working-directory"] = "/workspace"; + const { venvPath } = await import("../../src/utils/inputs"); + expect(venvPath).toBe("/workspace/.venv"); + }); + + it("resolves a relative venv-path", async () => { + mockInputs["working-directory"] = "/workspace"; + mockInputs["activate-environment"] = "true"; + mockInputs["venv-path"] = "custom-venv"; + const { venvPath } = await import("../../src/utils/inputs"); + expect(venvPath).toBe("/workspace/custom-venv"); + }); + + it("normalizes venv-path with trailing slash", async () => { + mockInputs["working-directory"] = "/workspace"; + mockInputs["activate-environment"] = "true"; + mockInputs["venv-path"] = "custom-venv/"; + const { venvPath } = await import("../../src/utils/inputs"); + expect(venvPath).toBe("/workspace/custom-venv"); + }); + + it("keeps an absolute venv-path unchanged", async () => { + mockInputs["working-directory"] = "/workspace"; + mockInputs["activate-environment"] = "true"; + mockInputs["venv-path"] = "/tmp/custom-venv"; + const { venvPath } = await import("../../src/utils/inputs"); + expect(venvPath).toBe("/tmp/custom-venv"); + }); + + it("expands tilde in venv-path", async () => { + mockInputs["working-directory"] = "/workspace"; + mockInputs["activate-environment"] = "true"; + mockInputs["venv-path"] = "~/.venv"; + const { venvPath } = await import("../../src/utils/inputs"); + expect(venvPath).toBe("/home/testuser/.venv"); + }); + + it("warns when venv-path is set but activate-environment is false", async () => { + mockInputs["working-directory"] = "/workspace"; + mockInputs["venv-path"] = "custom-venv"; + + const { activateEnvironment, venvPath } = await import( + "../../src/utils/inputs" + ); + + expect(activateEnvironment).toBe(false); + expect(venvPath).toBe("/workspace/custom-venv"); + + const mockedCore = jest.requireMock("@actions/core") as { + warning: jest.Mock; + }; + + expect(mockedCore.warning).toHaveBeenCalledWith( + "venv-path is only used when activate-environment is true", + ); + }); +}); diff --git a/action-types.yml b/action-types.yml index 4fc1d2d..61335a0 100644 --- a/action-types.yml +++ b/action-types.yml @@ -9,6 +9,8 @@ inputs: type: string activate-environment: type: boolean + venv-path: + type: string working-directory: type: string checksum: diff --git a/action.yml b/action.yml index ebeba73..f222155 100644 --- a/action.yml +++ b/action.yml @@ -15,6 +15,9 @@ inputs: activate-environment: description: "Use uv venv to activate a venv ready to be used by later steps. " default: "false" + venv-path: + description: "Custom path for the virtual environment when using activate-environment. Defaults to '.venv' in the working directory." + default: "" working-directory: description: "The directory to execute all commands in and look for files such as pyproject.toml" default: ${{ github.workspace }} diff --git a/dist/save-cache/index.js b/dist/save-cache/index.js index 7c28736..23586bd 100644 --- a/dist/save-cache/index.js +++ b/dist/save-cache/index.js @@ -91032,7 +91032,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.resolutionStrategy = exports.addProblemMatchers = exports.manifestFile = exports.githubToken = exports.pythonDir = 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 = exports.CacheLocalSource = void 0; +exports.resolutionStrategy = exports.addProblemMatchers = exports.manifestFile = exports.githubToken = exports.pythonDir = 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.venvPath = exports.activateEnvironment = exports.pythonVersion = exports.versionFile = exports.version = exports.workingDirectory = exports.CacheLocalSource = void 0; exports.getUvPythonDir = getUvPythonDir; const node_path_1 = __importDefault(__nccwpck_require__(6760)); const core = __importStar(__nccwpck_require__(7484)); @@ -91049,6 +91049,7 @@ exports.version = core.getInput("version"); exports.versionFile = getVersionFile(); exports.pythonVersion = core.getInput("python-version"); exports.activateEnvironment = core.getBooleanInput("activate-environment"); +exports.venvPath = getVenvPath(); exports.checkSum = core.getInput("checksum"); exports.enableCache = getEnableCache(); exports.restoreCache = core.getInput("restore-cache") === "true"; @@ -91075,6 +91076,17 @@ function getVersionFile() { } return versionFileInput; } +function getVenvPath() { + const venvPathInput = core.getInput("venv-path"); + if (venvPathInput !== "") { + if (!exports.activateEnvironment) { + core.warning("venv-path is only used when activate-environment is true"); + } + const tildeExpanded = expandTilde(venvPathInput); + return normalizePath(resolveRelativePath(tildeExpanded)); + } + return normalizePath(resolveRelativePath(".venv")); +} function getEnableCache() { const enableCacheInput = core.getInput("enable-cache"); if (enableCacheInput === "auto") { @@ -91203,6 +91215,16 @@ function expandTilde(input) { } return input; } +function normalizePath(inputPath) { + const normalized = node_path_1.default.normalize(inputPath); + const root = node_path_1.default.parse(normalized).root; + // Remove any trailing path separators, except when the whole path is the root. + let trimmed = normalized; + while (trimmed.length > root.length && trimmed.endsWith(node_path_1.default.sep)) { + trimmed = trimmed.slice(0, -1); + } + return trimmed; +} function resolveRelativePath(inputPath) { const hasNegation = inputPath.startsWith("!"); const pathWithoutNegation = hasNegation ? inputPath.substring(1) : inputPath; diff --git a/dist/setup/index.js b/dist/setup/index.js index b6dc60f..0f1c043 100644 --- a/dist/setup/index.js +++ b/dist/setup/index.js @@ -96550,17 +96550,15 @@ async function activateEnvironment() { if (process.env.UV_NO_MODIFY_PATH !== undefined) { throw new Error("UV_NO_MODIFY_PATH and activate-environment cannot be used together."); } - const execArgs = ["venv", ".venv", "--directory", inputs_1.workingDirectory]; - core.info("Activating python venv..."); - await exec.exec("uv", execArgs); - const venvPath = path.resolve(`${inputs_1.workingDirectory}${path.sep}.venv`); - let venvBinPath = `${venvPath}${path.sep}bin`; + core.info(`Creating and activating python venv at ${inputs_1.venvPath}...`); + await exec.exec("uv", ["venv", inputs_1.venvPath, "--directory", inputs_1.workingDirectory]); + let venvBinPath = `${inputs_1.venvPath}${path.sep}bin`; if (process.platform === "win32") { - venvBinPath = `${venvPath}${path.sep}Scripts`; + venvBinPath = `${inputs_1.venvPath}${path.sep}Scripts`; } core.addPath(path.resolve(venvBinPath)); - core.exportVariable("VIRTUAL_ENV", venvPath); - core.setOutput("venv", venvPath); + core.exportVariable("VIRTUAL_ENV", inputs_1.venvPath); + core.setOutput("venv", inputs_1.venvPath); } } function setCacheDir() { @@ -96732,7 +96730,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.resolutionStrategy = exports.addProblemMatchers = exports.manifestFile = exports.githubToken = exports.pythonDir = 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 = exports.CacheLocalSource = void 0; +exports.resolutionStrategy = exports.addProblemMatchers = exports.manifestFile = exports.githubToken = exports.pythonDir = 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.venvPath = exports.activateEnvironment = exports.pythonVersion = exports.versionFile = exports.version = exports.workingDirectory = exports.CacheLocalSource = void 0; exports.getUvPythonDir = getUvPythonDir; const node_path_1 = __importDefault(__nccwpck_require__(6760)); const core = __importStar(__nccwpck_require__(7484)); @@ -96749,6 +96747,7 @@ exports.version = core.getInput("version"); exports.versionFile = getVersionFile(); exports.pythonVersion = core.getInput("python-version"); exports.activateEnvironment = core.getBooleanInput("activate-environment"); +exports.venvPath = getVenvPath(); exports.checkSum = core.getInput("checksum"); exports.enableCache = getEnableCache(); exports.restoreCache = core.getInput("restore-cache") === "true"; @@ -96775,6 +96774,17 @@ function getVersionFile() { } return versionFileInput; } +function getVenvPath() { + const venvPathInput = core.getInput("venv-path"); + if (venvPathInput !== "") { + if (!exports.activateEnvironment) { + core.warning("venv-path is only used when activate-environment is true"); + } + const tildeExpanded = expandTilde(venvPathInput); + return normalizePath(resolveRelativePath(tildeExpanded)); + } + return normalizePath(resolveRelativePath(".venv")); +} function getEnableCache() { const enableCacheInput = core.getInput("enable-cache"); if (enableCacheInput === "auto") { @@ -96903,6 +96913,16 @@ function expandTilde(input) { } return input; } +function normalizePath(inputPath) { + const normalized = node_path_1.default.normalize(inputPath); + const root = node_path_1.default.parse(normalized).root; + // Remove any trailing path separators, except when the whole path is the root. + let trimmed = normalized; + while (trimmed.length > root.length && trimmed.endsWith(node_path_1.default.sep)) { + trimmed = trimmed.slice(0, -1); + } + return trimmed; +} function resolveRelativePath(inputPath) { const hasNegation = inputPath.startsWith("!"); const pathWithoutNegation = hasNegation ? inputPath.substring(1) : inputPath; diff --git a/docs/environment-and-tools.md b/docs/environment-and-tools.md index 00eee69..e3097eb 100644 --- a/docs/environment-and-tools.md +++ b/docs/environment-and-tools.md @@ -15,6 +15,17 @@ This allows directly using it in later steps: - run: uv pip install pip ``` +By default, the venv is created at `.venv` inside the `working-directory`. + +You can customize the venv location with `venv-path`, for example to place it in the runner temp directory: + +```yaml +- uses: astral-sh/setup-uv@v7 + with: + activate-environment: true + venv-path: ${{ runner.temp }}/custom-venv +``` + > [!WARNING] > > Activating the environment adds your dependencies to the `PATH`, which could break some workflows. diff --git a/src/setup-uv.ts b/src/setup-uv.ts index 22d8ca8..ce556af 100644 --- a/src/setup-uv.ts +++ b/src/setup-uv.ts @@ -24,6 +24,7 @@ import { resolutionStrategy, toolBinDir, toolDir, + venvPath, versionFile as versionFileInput, version as versionInput, workingDirectory, @@ -269,12 +270,10 @@ async function activateEnvironment(): Promise { "UV_NO_MODIFY_PATH and activate-environment cannot be used together.", ); } - const execArgs = ["venv", ".venv", "--directory", workingDirectory]; - core.info("Activating python venv..."); - await exec.exec("uv", execArgs); + core.info(`Creating and activating python venv at ${venvPath}...`); + await exec.exec("uv", ["venv", venvPath, "--directory", workingDirectory]); - const venvPath = path.resolve(`${workingDirectory}${path.sep}.venv`); let venvBinPath = `${venvPath}${path.sep}bin`; if (process.platform === "win32") { venvBinPath = `${venvPath}${path.sep}Scripts`; diff --git a/src/utils/inputs.ts b/src/utils/inputs.ts index 5490104..4c189d6 100644 --- a/src/utils/inputs.ts +++ b/src/utils/inputs.ts @@ -14,6 +14,7 @@ export const version = core.getInput("version"); export const versionFile = getVersionFile(); export const pythonVersion = core.getInput("python-version"); export const activateEnvironment = core.getBooleanInput("activate-environment"); +export const venvPath = getVenvPath(); export const checkSum = core.getInput("checksum"); export const enableCache = getEnableCache(); export const restoreCache = core.getInput("restore-cache") === "true"; @@ -45,6 +46,18 @@ function getVersionFile(): string { return versionFileInput; } +function getVenvPath(): string { + const venvPathInput = core.getInput("venv-path"); + if (venvPathInput !== "") { + if (!activateEnvironment) { + core.warning("venv-path is only used when activate-environment is true"); + } + const tildeExpanded = expandTilde(venvPathInput); + return normalizePath(resolveRelativePath(tildeExpanded)); + } + return normalizePath(resolveRelativePath(".venv")); +} + function getEnableCache(): boolean { const enableCacheInput = core.getInput("enable-cache"); if (enableCacheInput === "auto") { @@ -194,6 +207,19 @@ function expandTilde(input: string): string { return input; } +function normalizePath(inputPath: string): string { + const normalized = path.normalize(inputPath); + const root = path.parse(normalized).root; + + // Remove any trailing path separators, except when the whole path is the root. + let trimmed = normalized; + while (trimmed.length > root.length && trimmed.endsWith(path.sep)) { + trimmed = trimmed.slice(0, -1); + } + + return trimmed; +} + function resolveRelativePath(inputPath: string): string { const hasNegation = inputPath.startsWith("!"); const pathWithoutNegation = hasNegation ? inputPath.substring(1) : inputPath;