Skip to content

Commit 3511ff7

Browse files
authored
feat: add venv-path input for activate-environment (#746)
Allow customizing the venv location while preserving working-directory semantics via --directory. Supersedes: #736
1 parent 99b0f04 commit 3511ff7

10 files changed

Lines changed: 229 additions & 15 deletions

File tree

.github/workflows/test.yml

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -386,6 +386,60 @@ jobs:
386386
env:
387387
UV_VENV: ${{ steps.setup-uv.outputs.venv }}
388388

389+
test-activate-environment-custom-path:
390+
runs-on: ${{ matrix.os }}
391+
strategy:
392+
matrix:
393+
os: [ubuntu-latest, macos-latest, windows-latest]
394+
steps:
395+
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
396+
with:
397+
persist-credentials: false
398+
- name: Install latest version
399+
id: setup-uv
400+
uses: ./
401+
with:
402+
python-version: 3.13.1t
403+
activate-environment: true
404+
venv-path: ${{ runner.temp }}/custom-venv
405+
- name: Verify VIRTUAL_ENV matches output
406+
run: |
407+
if [ "$VIRTUAL_ENV" != "$UV_VENV" ]; then
408+
echo "VIRTUAL_ENV does not match venv output: $VIRTUAL_ENV vs $UV_VENV"
409+
exit 1
410+
fi
411+
shell: bash
412+
env:
413+
UV_VENV: ${{ steps.setup-uv.outputs.venv }}
414+
- name: Verify venv location is runner.temp/custom-venv
415+
run: |
416+
python - <<'PY'
417+
import os
418+
from pathlib import Path
419+
420+
venv = Path(os.environ["VIRTUAL_ENV"]).resolve()
421+
temp = Path(os.environ["RUNNER_TEMP"]).resolve()
422+
423+
if venv.name != "custom-venv":
424+
raise SystemExit(f"Expected venv name 'custom-venv', got: {venv}")
425+
if venv.parent != temp:
426+
raise SystemExit(f"Expected venv under {temp}, got: {venv}")
427+
if not venv.is_dir():
428+
raise SystemExit(f"Venv directory does not exist: {venv}")
429+
PY
430+
shell: bash
431+
- name: Verify packages can be installed
432+
run: uv pip install pip
433+
shell: bash
434+
- name: Verify python runs from custom venv
435+
run: |
436+
python - <<'PY'
437+
import sys
438+
if "custom-venv" not in sys.executable:
439+
raise SystemExit(f"Python is not running from custom venv: {sys.executable}")
440+
PY
441+
shell: bash
442+
389443
test-musl:
390444
runs-on: ubuntu-latest
391445
container: alpine
@@ -1069,6 +1123,7 @@ jobs:
10691123
- test-tilde-expansion-tool-dirs
10701124
- test-python-version
10711125
- test-activate-environment
1126+
- test-activate-environment-custom-path
10721127
- test-musl
10731128
- test-cache-key-os-version
10741129
- test-cache-local

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,9 @@ Have a look under [Advanced Configuration](#advanced-configuration) for detailed
5959
# Use uv venv to activate a venv ready to be used by later steps
6060
activate-environment: "false"
6161
62+
# Custom path for the virtual environment when using activate-environment (default: .venv in the working directory)
63+
venv-path: ""
64+
6265
# The directory to execute all commands in and look for files such as pyproject.toml
6366
working-directory: ""
6467
@@ -167,7 +170,7 @@ You can set the working directory with the `working-directory` input.
167170
This controls where we look for `pyproject.toml`, `uv.toml` and `.python-version` files
168171
which are used to determine the version of uv and python to install.
169172

170-
It also controls where [the venv gets created](#activate-environment).
173+
It also controls where [the venv gets created](#activate-environment), unless `venv-path` is set.
171174

172175
```yaml
173176
- name: Install uv based on the config files in the working-directory

__tests__/utils/inputs.test.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ jest.mock("@actions/core", () => {
55
(name: string) => (mockInputs[name] ?? "") === "true",
66
),
77
getInput: jest.fn((name: string) => mockInputs[name] ?? ""),
8+
warning: jest.fn(),
89
};
910
});
1011

@@ -24,6 +25,7 @@ const ORIGINAL_HOME = process.env.HOME;
2425
describe("cacheDependencyGlob", () => {
2526
beforeEach(() => {
2627
jest.resetModules();
28+
jest.clearAllMocks();
2729
mockInputs = {};
2830
process.env.HOME = "/home/testuser";
2931
});
@@ -84,3 +86,74 @@ describe("cacheDependencyGlob", () => {
8486
);
8587
});
8688
});
89+
90+
describe("venvPath", () => {
91+
beforeEach(() => {
92+
jest.resetModules();
93+
jest.clearAllMocks();
94+
mockInputs = {};
95+
process.env.HOME = "/home/testuser";
96+
});
97+
98+
afterEach(() => {
99+
process.env.HOME = ORIGINAL_HOME;
100+
});
101+
102+
it("defaults to .venv in the working directory", async () => {
103+
mockInputs["working-directory"] = "/workspace";
104+
const { venvPath } = await import("../../src/utils/inputs");
105+
expect(venvPath).toBe("/workspace/.venv");
106+
});
107+
108+
it("resolves a relative venv-path", async () => {
109+
mockInputs["working-directory"] = "/workspace";
110+
mockInputs["activate-environment"] = "true";
111+
mockInputs["venv-path"] = "custom-venv";
112+
const { venvPath } = await import("../../src/utils/inputs");
113+
expect(venvPath).toBe("/workspace/custom-venv");
114+
});
115+
116+
it("normalizes venv-path with trailing slash", async () => {
117+
mockInputs["working-directory"] = "/workspace";
118+
mockInputs["activate-environment"] = "true";
119+
mockInputs["venv-path"] = "custom-venv/";
120+
const { venvPath } = await import("../../src/utils/inputs");
121+
expect(venvPath).toBe("/workspace/custom-venv");
122+
});
123+
124+
it("keeps an absolute venv-path unchanged", async () => {
125+
mockInputs["working-directory"] = "/workspace";
126+
mockInputs["activate-environment"] = "true";
127+
mockInputs["venv-path"] = "/tmp/custom-venv";
128+
const { venvPath } = await import("../../src/utils/inputs");
129+
expect(venvPath).toBe("/tmp/custom-venv");
130+
});
131+
132+
it("expands tilde in venv-path", async () => {
133+
mockInputs["working-directory"] = "/workspace";
134+
mockInputs["activate-environment"] = "true";
135+
mockInputs["venv-path"] = "~/.venv";
136+
const { venvPath } = await import("../../src/utils/inputs");
137+
expect(venvPath).toBe("/home/testuser/.venv");
138+
});
139+
140+
it("warns when venv-path is set but activate-environment is false", async () => {
141+
mockInputs["working-directory"] = "/workspace";
142+
mockInputs["venv-path"] = "custom-venv";
143+
144+
const { activateEnvironment, venvPath } = await import(
145+
"../../src/utils/inputs"
146+
);
147+
148+
expect(activateEnvironment).toBe(false);
149+
expect(venvPath).toBe("/workspace/custom-venv");
150+
151+
const mockedCore = jest.requireMock("@actions/core") as {
152+
warning: jest.Mock;
153+
};
154+
155+
expect(mockedCore.warning).toHaveBeenCalledWith(
156+
"venv-path is only used when activate-environment is true",
157+
);
158+
});
159+
});

action-types.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ inputs:
99
type: string
1010
activate-environment:
1111
type: boolean
12+
venv-path:
13+
type: string
1214
working-directory:
1315
type: string
1416
checksum:

action.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ inputs:
1515
activate-environment:
1616
description: "Use uv venv to activate a venv ready to be used by later steps. "
1717
default: "false"
18+
venv-path:
19+
description: "Custom path for the virtual environment when using activate-environment. Defaults to '.venv' in the working directory."
20+
default: ""
1821
working-directory:
1922
description: "The directory to execute all commands in and look for files such as pyproject.toml"
2023
default: ${{ github.workspace }}

dist/save-cache/index.js

Lines changed: 23 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

dist/setup/index.js

Lines changed: 29 additions & 9 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/environment-and-tools.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,17 @@ This allows directly using it in later steps:
1515
- run: uv pip install pip
1616
```
1717
18+
By default, the venv is created at `.venv` inside the `working-directory`.
19+
20+
You can customize the venv location with `venv-path`, for example to place it in the runner temp directory:
21+
22+
```yaml
23+
- uses: astral-sh/setup-uv@v7
24+
with:
25+
activate-environment: true
26+
venv-path: ${{ runner.temp }}/custom-venv
27+
```
28+
1829
> [!WARNING]
1930
>
2031
> Activating the environment adds your dependencies to the `PATH`, which could break some workflows.

src/setup-uv.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
resolutionStrategy,
2525
toolBinDir,
2626
toolDir,
27+
venvPath,
2728
versionFile as versionFileInput,
2829
version as versionInput,
2930
workingDirectory,
@@ -269,12 +270,10 @@ async function activateEnvironment(): Promise<void> {
269270
"UV_NO_MODIFY_PATH and activate-environment cannot be used together.",
270271
);
271272
}
272-
const execArgs = ["venv", ".venv", "--directory", workingDirectory];
273273

274-
core.info("Activating python venv...");
275-
await exec.exec("uv", execArgs);
274+
core.info(`Creating and activating python venv at ${venvPath}...`);
275+
await exec.exec("uv", ["venv", venvPath, "--directory", workingDirectory]);
276276

277-
const venvPath = path.resolve(`${workingDirectory}${path.sep}.venv`);
278277
let venvBinPath = `${venvPath}${path.sep}bin`;
279278
if (process.platform === "win32") {
280279
venvBinPath = `${venvPath}${path.sep}Scripts`;

0 commit comments

Comments
 (0)