diff --git a/README.md b/README.md index 7e9e522..8235c4b 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ Set up your GitHub Actions workflow with a specific version of [uv](https://docs - [Inputs](#inputs) - [Outputs](#outputs) - [Python version](#python-version) + - [Python architecture](#python-architecture) - [Working directory](#working-directory) - [Advanced Configuration](#advanced-configuration) - [How it works](#how-it-works) @@ -56,6 +57,9 @@ Have a look under [Advanced Configuration](#advanced-configuration) for detailed # The version of Python to set UV_PYTHON to (overrides the Python version from .tool-versions) python-version: "" + # The Python architecture to set UV_PYTHON_ARCH to, e.g., x86_64 or aarch64 + python-arch: "" + # Use uv venv to activate a venv ready to be used by later steps activate-environment: "false" @@ -193,6 +197,25 @@ jobs: run: uv run --frozen pytest ``` +### Python architecture + +Use `python-arch` to set `UV_PYTHON_ARCH` for the rest of the job. This selects the Python +architecture independently of the version, including versions requested by `.python-version`. +An explicit architecture or interpreter path in a Python request takes precedence. + +```yaml +- uses: astral-sh/setup-uv@main + with: + python-version: "3.14" + python-arch: x86_64 +- run: uv run --frozen pytest +``` + +The input overrides an existing `UV_PYTHON_ARCH` value. When it is omitted, the action respects +`UV_PYTHON_ARCH` from the environment. Both forms require a uv version that supports the variable; +the action reports an error if the installed version does not support it. The selected architecture +is included in the [cache key](docs/caching.md#cache-key). + ### Working directory You can set the working directory with the `working-directory` input. diff --git a/__tests__/cache/restore-cache.test.ts b/__tests__/cache/restore-cache.test.ts index bb97348..fdc80d5 100644 --- a/__tests__/cache/restore-cache.test.ts +++ b/__tests__/cache/restore-cache.test.ts @@ -4,6 +4,9 @@ import { createSetupInputs } from "../helpers/setup-inputs"; const mockRestoreCache = jest.fn(); const mockSaveState = jest.fn(); const mockSetOutput = jest.fn(); +const mockGetArch = jest.fn(() => "x86_64"); +const mockGetOSNameVersion = jest.fn(() => "ubuntu-24.04"); +const mockGetPlatform = jest.fn(async () => "unknown-linux-gnu"); jest.unstable_mockModule("@actions/cache", () => ({ restoreCache: mockRestoreCache, @@ -24,9 +27,9 @@ jest.unstable_mockModule("../../src/utils/logging", () => ({ })); jest.unstable_mockModule("../../src/utils/platforms", () => ({ - getArch: jest.fn(() => "x86_64"), - getOSNameVersion: jest.fn(() => "ubuntu-24.04"), - getPlatform: jest.fn(async () => "unknown-linux-gnu"), + getArch: mockGetArch, + getOSNameVersion: mockGetOSNameVersion, + getPlatform: mockGetPlatform, })); const { restoreCache } = await import("../../src/cache/restore-cache"); @@ -39,6 +42,9 @@ function cacheKeyOutput(): string { beforeEach(() => { jest.clearAllMocks(); + mockGetArch.mockReturnValue("x86_64"); + mockGetOSNameVersion.mockReturnValue("ubuntu-24.04"); + mockGetPlatform.mockResolvedValue("unknown-linux-gnu"); }); describe("restoreCache", () => { @@ -71,4 +77,39 @@ describe("restoreCache", () => { "setup-uv-2-x86_64-unknown-linux-gnu-ubuntu-24.04-3.11-dependencyhash-tests-3.11", ); }); + + it("includes the resolved Python architecture in the cache key", async () => { + const inputs = createSetupInputs({ pythonArch: "aarch64" }); + + await restoreCache(inputs, "3.14"); + + expect(cacheKeyOutput()).toContain("-3.14-python-aarch64-"); + }); + + it.each(["aarch64", "x86_64"])( + "separates %s Python caches on a Windows ARM64 runner", + async (pythonArch) => { + mockGetArch.mockReturnValue("aarch64"); + mockGetOSNameVersion.mockReturnValue("windows-11"); + mockGetPlatform.mockResolvedValue("pc-windows-msvc"); + const inputs = createSetupInputs({ + cachePython: true, + pythonArch, + restoreCache: true, + }); + + await restoreCache(inputs, "3.14"); + + const cacheKey = `setup-uv-2-aarch64-pc-windows-msvc-windows-11-3.14-python-${pythonArch}-py-dependencyhash`; + expect(cacheKeyOutput()).toBe(cacheKey); + expect(mockRestoreCache).toHaveBeenCalledWith( + [inputs.cacheLocalPath?.path], + cacheKey, + ); + expect(mockRestoreCache).toHaveBeenCalledWith( + [inputs.pythonDir], + `${cacheKey}-python`, + ); + }, + ); }); diff --git a/__tests__/helpers/setup-inputs.ts b/__tests__/helpers/setup-inputs.ts index 0a6a625..f20e57d 100644 --- a/__tests__/helpers/setup-inputs.ts +++ b/__tests__/helpers/setup-inputs.ts @@ -16,11 +16,13 @@ export function createSetupInputs( checksum: "", downloadFromAstralMirror: false, enableCache: true, + exportPythonArch: false, githubToken: "", ignoreEmptyWorkdir: false, ignoreNothingToCache: false, noProject: false, pruneCache: false, + pythonArch: "", pythonDir: "/tmp/uv-python-dir", pythonVersion: "", quiet: false, diff --git a/__tests__/utils/inputs.test.ts b/__tests__/utils/inputs.test.ts index f93a41b..126ca8f 100644 --- a/__tests__/utils/inputs.test.ts +++ b/__tests__/utils/inputs.test.ts @@ -19,6 +19,7 @@ const ORIGINAL_RUNNER_ENVIRONMENT = process.env.RUNNER_ENVIRONMENT; const ORIGINAL_RUNNER_TEMP = process.env.RUNNER_TEMP; const ORIGINAL_UV_CACHE_DIR = process.env.UV_CACHE_DIR; const ORIGINAL_UV_PYTHON = process.env.UV_PYTHON; +const ORIGINAL_UV_PYTHON_ARCH = process.env.UV_PYTHON_ARCH; const ORIGINAL_UV_PYTHON_INSTALL_DIR = process.env.UV_PYTHON_INSTALL_DIR; const mockDebug = jest.fn(); @@ -62,6 +63,7 @@ function resetEnvironment(): void { delete process.env.RUNNER_TEMP; delete process.env.UV_CACHE_DIR; delete process.env.UV_PYTHON; + delete process.env.UV_PYTHON_ARCH; delete process.env.UV_PYTHON_INSTALL_DIR; } @@ -77,6 +79,11 @@ function restoreEnvironment(): void { process.env.RUNNER_TEMP = ORIGINAL_RUNNER_TEMP; process.env.UV_CACHE_DIR = ORIGINAL_UV_CACHE_DIR; process.env.UV_PYTHON = ORIGINAL_UV_PYTHON; + if (ORIGINAL_UV_PYTHON_ARCH === undefined) { + delete process.env.UV_PYTHON_ARCH; + } else { + process.env.UV_PYTHON_ARCH = ORIGINAL_UV_PYTHON_ARCH; + } process.env.UV_PYTHON_INSTALL_DIR = ORIGINAL_UV_PYTHON_INSTALL_DIR; } @@ -98,6 +105,8 @@ describe("loadInputs", () => { source: CacheLocalSource.Default, }); expect(inputs.pythonDir).toBe("/runner-temp/uv-python-dir"); + expect(inputs.pythonArch).toBe(""); + expect(inputs.exportPythonArch).toBe(false); expect(inputs.venvPath).toBe("/workspace/.venv"); expect(inputs.manifestFile).toBeUndefined(); expect(inputs.resolutionStrategy).toBe("highest"); @@ -149,6 +158,28 @@ describe("loadInputs", () => { expect(inputs.pythonVersion).toBe(""); }); + it("prefers the python-arch input over UV_PYTHON_ARCH", () => { + mockInputs["working-directory"] = "/workspace"; + mockInputs["python-arch"] = "x86_64"; + process.env.UV_PYTHON_ARCH = "aarch64"; + + const inputs = loadInputs(); + expect(inputs.pythonArch).toBe("x86_64"); + expect(inputs.exportPythonArch).toBe(true); + }); + + it.each(["aarch64", "x86_64_v3", ""])( + "uses UV_PYTHON_ARCH when python-arch is omitted: %s", + (pythonArch) => { + mockInputs["working-directory"] = "/workspace"; + process.env.UV_PYTHON_ARCH = pythonArch; + + const inputs = loadInputs(); + expect(inputs.pythonArch).toBe(pythonArch); + expect(inputs.exportPythonArch).toBe(false); + }, + ); + it.each(["pull_request_target", "workflow_run", "release"])( "disables automatic caching for the %s event", (eventName) => { diff --git a/__tests__/utils/python-arch.test.ts b/__tests__/utils/python-arch.test.ts new file mode 100644 index 0000000..f846c7b --- /dev/null +++ b/__tests__/utils/python-arch.test.ts @@ -0,0 +1,152 @@ +import { promisify } from "node:util"; +import { + afterEach, + beforeEach, + describe, + expect, + it, + jest, +} from "@jest/globals"; + +const ORIGINAL_UV_PYTHON_ARCH = process.env.UV_PYTHON_ARCH; + +const mockExecFile = + jest.fn< + (...args: unknown[]) => Promise<{ + stdout: string; + stderr: string; + }> + >(); +const mockExportVariable = jest.fn(); + +jest.unstable_mockModule("node:child_process", () => ({ + execFile: Object.assign(mockExecFile, { [promisify.custom]: mockExecFile }), +})); +jest.unstable_mockModule("@actions/core", () => ({ + exportVariable: mockExportVariable, +})); +jest.unstable_mockModule("../../src/utils/logging", () => ({ + info: jest.fn(), +})); + +const { setupPythonArch } = await import("../../src/utils/python-arch"); + +const supportedProbe = Object.assign(new Error("uv exited with code 2"), { + code: 2, + stderr: + "error: Failed to parse environment variable `UV_PYTHON_ARCH` with invalid value `setup-uv-probe`: Unknown architecture: setup-uv-probe\n", +}); +const success = { stderr: "", stdout: "/cache\n" }; + +beforeEach(() => { + delete process.env.UV_PYTHON_ARCH; + jest.clearAllMocks(); + mockExecFile + .mockReset() + .mockRejectedValueOnce(supportedProbe) + .mockResolvedValueOnce(success); +}); + +afterEach(() => { + if (ORIGINAL_UV_PYTHON_ARCH === undefined) { + delete process.env.UV_PYTHON_ARCH; + } else { + process.env.UV_PYTHON_ARCH = ORIGINAL_UV_PYTHON_ARCH; + } +}); + +describe("setupPythonArch", () => { + it("leaves Python selection alone without an architecture", async () => { + await setupPythonArch("/tools/uv", "", false); + + expect(mockExecFile).not.toHaveBeenCalled(); + expect(mockExportVariable).not.toHaveBeenCalled(); + }); + + it("validates a resolved environment architecture without exporting it", async () => { + process.env.UV_PYTHON_ARCH = "aarch64"; + + await setupPythonArch("/tools/uv", "aarch64", false); + + expect(mockExecFile).toHaveBeenNthCalledWith( + 2, + "/tools/uv", + ["--no-config", "cache", "dir"], + expect.objectContaining({ + env: expect.objectContaining({ UV_PYTHON_ARCH: "aarch64" }), + }), + ); + expect(mockExportVariable).not.toHaveBeenCalled(); + expect(process.env.UV_PYTHON_ARCH).toBe("aarch64"); + }); + + it.each(["/runner temp/uv", "C:\\runner temp\\uv.exe"])( + "validates and exports the architecture using the installed uv: %s", + async (uvPath) => { + await setupPythonArch(uvPath, "x86_64", true); + + expect(mockExecFile).toHaveBeenNthCalledWith( + 1, + uvPath, + ["--no-config", "cache", "dir"], + expect.objectContaining({ + encoding: "utf8", + env: expect.objectContaining({ UV_PYTHON_ARCH: "setup-uv-probe" }), + }), + ); + expect(mockExecFile).toHaveBeenNthCalledWith( + 2, + uvPath, + ["--no-config", "cache", "dir"], + expect.objectContaining({ + env: expect.objectContaining({ UV_PYTHON_ARCH: "x86_64" }), + }), + ); + expect(mockExportVariable).toHaveBeenCalledWith( + "UV_PYTHON_ARCH", + "x86_64", + ); + }, + ); + + it("rejects uv versions that ignore UV_PYTHON_ARCH", async () => { + mockExecFile.mockReset().mockResolvedValue(success); + + await expect(setupPythonArch("/tools/uv", "x86_64", true)).rejects.toThrow( + "The installed version of uv does not support UV_PYTHON_ARCH", + ); + expect(mockExecFile).toHaveBeenCalledTimes(1); + expect(mockExportVariable).not.toHaveBeenCalled(); + }); + + it("reports unexpected probe failures", async () => { + mockExecFile.mockReset().mockRejectedValue( + Object.assign(new Error("uv exited with code 2"), { + code: 2, + stderr: "error: unrelated configuration error\n", + }), + ); + + await expect(setupPythonArch("/tools/uv", "x86_64", true)).rejects.toThrow( + "Failed to check uv's support for UV_PYTHON_ARCH: error: unrelated configuration error", + ); + expect(mockExportVariable).not.toHaveBeenCalled(); + }); + + it("reports invalid architectures before exporting them", async () => { + mockExecFile + .mockReset() + .mockRejectedValueOnce(supportedProbe) + .mockRejectedValueOnce( + Object.assign(new Error("uv exited with code 2"), { + code: 2, + stderr: "error: Unknown architecture: invalid\n", + }), + ); + + await expect(setupPythonArch("/tools/uv", "invalid", true)).rejects.toThrow( + "Failed to set Python architecture to invalid: error: Unknown architecture: invalid", + ); + expect(mockExportVariable).not.toHaveBeenCalled(); + }); +}); diff --git a/action-types.yml b/action-types.yml index a2b4ac3..3746c94 100644 --- a/action-types.yml +++ b/action-types.yml @@ -7,6 +7,8 @@ inputs: type: string python-version: type: string + python-arch: + type: string activate-environment: type: boolean venv-path: diff --git a/action.yml b/action.yml index 835f8bd..a52b558 100644 --- a/action.yml +++ b/action.yml @@ -12,6 +12,9 @@ inputs: python-version: description: "The version of Python to set UV_PYTHON to. Overrides the Python version from .tool-versions." required: false + python-arch: + description: "The Python architecture to set UV_PYTHON_ARCH to, e.g., x86_64 or aarch64. Overrides UV_PYTHON_ARCH." + required: false activate-environment: description: "Use uv venv to activate a venv ready to be used by later steps. " default: "false" diff --git a/dist/save-cache/index.cjs b/dist/save-cache/index.cjs index a9efef0..0c810b6 100644 --- a/dist/save-cache/index.cjs +++ b/dist/save-cache/index.cjs @@ -64447,6 +64447,9 @@ function loadInputs() { const version3 = getInput("version"); const versionFile = getVersionFile(workingDirectory); const pythonVersion = getPythonVersion(versionFile); + const pythonArchInput = getInput("python-arch"); + const pythonArch = pythonArchInput || process.env.UV_PYTHON_ARCH || ""; + const exportPythonArch = pythonArchInput !== ""; const activateEnvironment = getBooleanInput("activate-environment"); const noProject = getBooleanInput("no-project"); const venvPath = getVenvPath(workingDirectory, activateEnvironment); @@ -64484,12 +64487,14 @@ function loadInputs() { checksum, downloadFromAstralMirror, enableCache, + exportPythonArch, githubToken, ignoreEmptyWorkdir, ignoreNothingToCache, manifestFile, noProject, pruneCache: pruneCache2, + pythonArch, pythonDir, pythonVersion, quiet: quiet2, diff --git a/dist/setup/index.cjs b/dist/setup/index.cjs index a7cd34b..5baa82d 100644 --- a/dist/setup/index.cjs +++ b/dist/setup/index.cjs @@ -10833,7 +10833,7 @@ var require_mock_interceptor = __commonJS({ var require_mock_client = __commonJS({ "node_modules/@actions/http-client/node_modules/undici/lib/mock/mock-client.js"(exports2, module2) { "use strict"; - var { promisify: promisify6 } = require("node:util"); + var { promisify: promisify7 } = require("node:util"); var Client = require_client(); var { buildMockDispatch } = require_mock_utils(); var { @@ -10873,7 +10873,7 @@ var require_mock_client = __commonJS({ return new MockInterceptor(opts, this[kDispatches]); } async [kClose]() { - await promisify6(this[kOriginalClose])(); + await promisify7(this[kOriginalClose])(); this[kConnected] = 0; this[kMockAgent][Symbols.kClients].delete(this[kOrigin]); } @@ -10886,7 +10886,7 @@ var require_mock_client = __commonJS({ var require_mock_pool = __commonJS({ "node_modules/@actions/http-client/node_modules/undici/lib/mock/mock-pool.js"(exports2, module2) { "use strict"; - var { promisify: promisify6 } = require("node:util"); + var { promisify: promisify7 } = require("node:util"); var Pool = require_pool(); var { buildMockDispatch } = require_mock_utils(); var { @@ -10926,7 +10926,7 @@ var require_mock_pool = __commonJS({ return new MockInterceptor(opts, this[kDispatches]); } async [kClose]() { - await promisify6(this[kOriginalClose])(); + await promisify7(this[kOriginalClose])(); this[kConnected] = 0; this[kMockAgent][Symbols.kClients].delete(this[kOrigin]); } @@ -43099,7 +43099,7 @@ var require_mock_interceptor2 = __commonJS({ var require_mock_client2 = __commonJS({ "node_modules/undici/lib/mock/mock-client.js"(exports2, module2) { "use strict"; - var { promisify: promisify6 } = require("node:util"); + var { promisify: promisify7 } = require("node:util"); var Client = require_client2(); var { buildMockDispatch } = require_mock_utils2(); var { @@ -43147,7 +43147,7 @@ var require_mock_client2 = __commonJS({ this[kDispatches] = []; } async [kClose]() { - await promisify6(this[kOriginalClose])(); + await promisify7(this[kOriginalClose])(); this[kConnected] = 0; this[kMockAgent][Symbols.kClients].delete(this[kOrigin]); } @@ -43360,7 +43360,7 @@ var require_mock_call_history = __commonJS({ var require_mock_pool2 = __commonJS({ "node_modules/undici/lib/mock/mock-pool.js"(exports2, module2) { "use strict"; - var { promisify: promisify6 } = require("node:util"); + var { promisify: promisify7 } = require("node:util"); var Pool = require_pool2(); var { buildMockDispatch } = require_mock_utils2(); var { @@ -43408,7 +43408,7 @@ var require_mock_pool2 = __commonJS({ this[kDispatches] = []; } async [kClose]() { - await promisify6(this[kOriginalClose])(); + await promisify7(this[kOriginalClose])(); this[kConnected] = 0; this[kMockAgent][Symbols.kClients].delete(this[kOrigin]); } @@ -94038,11 +94038,12 @@ async function computeKeys(inputs, pythonVersion) { } const suffix = inputs.cacheSuffix ? `-${encodeURIComponent(inputs.cacheSuffix)}` : ""; const version3 = encodeURIComponent(pythonVersion ?? "unknown"); + const pythonArchKey = inputs.pythonArch ? `-python-${encodeURIComponent(inputs.pythonArch)}` : ""; const platform2 = await getPlatform(); const osNameVersion = getOSNameVersion(); const pruned = inputs.pruneCache ? "-pruned" : ""; const python = inputs.cachePython ? "-py" : ""; - return `setup-uv-${CACHE_VERSION}-${getArch()}-${platform2}-${osNameVersion}-${version3}${pruned}${python}${cacheDependencyPathHash}${suffix}`; + return `setup-uv-${CACHE_VERSION}-${getArch()}-${platform2}-${osNameVersion}-${version3}${pythonArchKey}${pruned}${python}${cacheDependencyPathHash}${suffix}`; } function handleMatchResult(matchedKey, primaryKey, stateKey, outputKey) { if (!matchedKey) { @@ -101990,6 +101991,9 @@ function loadInputs() { const version3 = getInput("version"); const versionFile = getVersionFile(workingDirectory); const pythonVersion = getPythonVersion(versionFile); + const pythonArchInput = getInput("python-arch"); + const pythonArch = pythonArchInput || process.env.UV_PYTHON_ARCH || ""; + const exportPythonArch = pythonArchInput !== ""; const activateEnvironment2 = getBooleanInput("activate-environment"); const noProject = getBooleanInput("no-project"); const venvPath = getVenvPath(workingDirectory, activateEnvironment2); @@ -102027,12 +102031,14 @@ function loadInputs() { checksum, downloadFromAstralMirror, enableCache, + exportPythonArch, githubToken, ignoreEmptyWorkdir, ignoreNothingToCache, manifestFile, noProject, pruneCache, + pythonArch, pythonDir, pythonVersion, quiet: quiet2, @@ -102281,16 +102287,68 @@ function getResolutionStrategy() { ); } -// src/utils/python-runtime.ts +// src/utils/python-arch.ts var import_node_child_process = require("node:child_process"); var import_node_util4 = require("node:util"); var execFileAsync = (0, import_node_util4.promisify)(import_node_child_process.execFile); +var PROBE_ARCH = "setup-uv-probe"; +async function setupPythonArch(uvPath, pythonArch, exportPythonArch) { + if (pythonArch === "") { + return; + } + const probe = await queryPythonArch(uvPath, PROBE_ARCH); + if (probe.exitCode === 0) { + throw new Error( + "The installed version of uv does not support UV_PYTHON_ARCH. Select a newer uv version or use an architecture-qualified python-version." + ); + } + if (!probe.stderr.includes( + `environment variable \`UV_PYTHON_ARCH\` with invalid value \`${PROBE_ARCH}\`` + )) { + throw new Error( + `Failed to check uv's support for UV_PYTHON_ARCH: ${probe.stderr.trim() || `uv exited with code ${probe.exitCode}`}` + ); + } + const selected = await queryPythonArch(uvPath, pythonArch); + if (selected.exitCode !== 0) { + throw new Error( + `Failed to set Python architecture to ${pythonArch}: ${selected.stderr.trim() || `uv exited with code ${selected.exitCode}`}` + ); + } + if (exportPythonArch) { + exportVariable("UV_PYTHON_ARCH", pythonArch); + info2(`Set UV_PYTHON_ARCH to ${pythonArch}`); + } +} +async function queryPythonArch(uvPath, pythonArch) { + try { + const { stderr } = await execFileAsync( + uvPath, + ["--no-config", "cache", "dir"], + { + encoding: "utf8", + env: { ...process.env, NO_COLOR: "1", UV_PYTHON_ARCH: pythonArch } + } + ); + return { exitCode: 0, stderr }; + } catch (error2) { + if (error2 instanceof Error && "code" in error2 && typeof error2.code === "number" && "stderr" in error2 && typeof error2.stderr === "string") { + return { exitCode: error2.code, stderr: error2.stderr }; + } + throw error2; + } +} + +// src/utils/python-runtime.ts +var import_node_child_process2 = require("node:child_process"); +var import_node_util5 = require("node:util"); +var execFileAsync2 = (0, import_node_util5.promisify)(import_node_child_process2.execFile); async function getPythonRuntimeId(inputs) { if (!inputs.activateEnvironment) { return ""; } try { - const { stdout } = await execFileAsync( + const { stdout } = await execFileAsync2( "uv", [ "python", @@ -102372,6 +102430,11 @@ async function run() { throw new Error(`Unsupported architecture: ${process.arch}`); } const setupResult = await setupUv(inputs, platform2, arch3); + const uvPath = path17.join( + setupResult.uvDir, + process.platform === "win32" ? "uv.exe" : "uv" + ); + await setupPythonArch(uvPath, inputs.pythonArch, inputs.exportPythonArch); addToolBinToPath(inputs); addUvToPathAndOutput(setupResult.uvDir); setToolDir(inputs); diff --git a/docs/caching.md b/docs/caching.md index c38d4b2..4d393e2 100644 --- a/docs/caching.md +++ b/docs/caching.md @@ -11,6 +11,7 @@ The cache key is automatically generated based on: `pc-windows-msvc`) - **OS version**: OS name and version (e.g., `ubuntu-22.04`, `macos-14`, `windows-2022`) - **Python version**: The Python version in use +- **Python architecture**: The architecture selected by `python-arch` or `UV_PYTHON_ARCH`, when set - **Cache options**: Whether pruning and Python caching are enabled - **Dependency hash**: Hash of files matching `cache-dependency-glob` - **Suffix**: Optional `cache-suffix` if provided diff --git a/docs/environment-and-tools.md b/docs/environment-and-tools.md index a6ccc01..58d5741 100644 --- a/docs/environment-and-tools.md +++ b/docs/environment-and-tools.md @@ -144,6 +144,7 @@ If you want to ignore this, set the `ignore-empty-workdir` input to `true`. This action sets several environment variables that influence uv's behavior and can be used by subsequent steps: - `UV_PYTHON`: Set when `python-version` is specified or the selected `.tool-versions` file contains a supported `python` entry. Controls which Python version uv uses. +- `UV_PYTHON_ARCH`: Set when `python-arch` is specified. Controls the architecture of Python requests that do not specify one. - `UV_CACHE_DIR`: Set when caching is enabled (unless already configured in uv config files). Controls where uv stores its cache. - `UV_TOOL_DIR`: Set when `tool-dir` input is specified. Controls where uv installs tool environments. - `UV_TOOL_BIN_DIR`: Set when `tool-bin-dir` input is specified. Controls where uv installs tool binaries. @@ -155,6 +156,7 @@ This action sets several environment variables that influence uv's behavior and - `UV_NO_MODIFY_PATH`: If set, prevents the action from modifying PATH. Cannot be used with `activate-environment`. - `UV_CACHE_DIR`: If already set, the action will respect it instead of setting its own cache directory. - `UV_PYTHON`: If already set and `python-version` is not specified, the action will respect it instead of using the `python` entry from `.tool-versions`. +- `UV_PYTHON_ARCH`: If already set and `python-arch` is not specified, the action will use it and include it in the cache key. Requires a uv version that supports `UV_PYTHON_ARCH`. ```yaml - name: Example using environment variables diff --git a/src/cache/restore-cache.ts b/src/cache/restore-cache.ts index b5fec8f..e803e10 100644 --- a/src/cache/restore-cache.ts +++ b/src/cache/restore-cache.ts @@ -98,11 +98,14 @@ async function computeKeys( ? `-${encodeURIComponent(inputs.cacheSuffix)}` : ""; const version = encodeURIComponent(pythonVersion ?? "unknown"); + const pythonArchKey = inputs.pythonArch + ? `-python-${encodeURIComponent(inputs.pythonArch)}` + : ""; const platform = await getPlatform(); const osNameVersion = getOSNameVersion(); const pruned = inputs.pruneCache ? "-pruned" : ""; const python = inputs.cachePython ? "-py" : ""; - return `setup-uv-${CACHE_VERSION}-${getArch()}-${platform}-${osNameVersion}-${version}${pruned}${python}${cacheDependencyPathHash}${suffix}`; + return `setup-uv-${CACHE_VERSION}-${getArch()}-${platform}-${osNameVersion}-${version}${pythonArchKey}${pruned}${python}${cacheDependencyPathHash}${suffix}`; } function handleMatchResult( diff --git a/src/setup-uv.ts b/src/setup-uv.ts index f83087f..08f0204 100644 --- a/src/setup-uv.ts +++ b/src/setup-uv.ts @@ -16,6 +16,7 @@ import { getPlatform, type Platform, } from "./utils/platforms"; +import { setupPythonArch } from "./utils/python-arch"; import { getPythonRuntimeId } from "./utils/python-runtime"; import { resolveUvVersion } from "./version/resolve"; @@ -86,6 +87,11 @@ async function run(): Promise { throw new Error(`Unsupported architecture: ${process.arch}`); } const setupResult = await setupUv(inputs, platform, arch); + const uvPath = path.join( + setupResult.uvDir, + process.platform === "win32" ? "uv.exe" : "uv", + ); + await setupPythonArch(uvPath, inputs.pythonArch, inputs.exportPythonArch); addToolBinToPath(inputs); addUvToPathAndOutput(setupResult.uvDir); diff --git a/src/utils/inputs.ts b/src/utils/inputs.ts index 62ddfc2..c342a2b 100644 --- a/src/utils/inputs.ts +++ b/src/utils/inputs.ts @@ -24,6 +24,8 @@ export interface SetupInputs { version: string; versionFile: string; pythonVersion: string; + pythonArch: string; + exportPythonArch: boolean; activateEnvironment: boolean; noProject: boolean; venvPath: string; @@ -54,6 +56,9 @@ export function loadInputs(): SetupInputs { const version = core.getInput("version"); const versionFile = getVersionFile(workingDirectory); const pythonVersion = getPythonVersion(versionFile); + const pythonArchInput = core.getInput("python-arch"); + const pythonArch = pythonArchInput || process.env.UV_PYTHON_ARCH || ""; + const exportPythonArch = pythonArchInput !== ""; const activateEnvironment = core.getBooleanInput("activate-environment"); const noProject = core.getBooleanInput("no-project"); const venvPath = getVenvPath(workingDirectory, activateEnvironment); @@ -94,12 +99,14 @@ export function loadInputs(): SetupInputs { checksum, downloadFromAstralMirror, enableCache, + exportPythonArch, githubToken, ignoreEmptyWorkdir, ignoreNothingToCache, manifestFile, noProject, pruneCache, + pythonArch, pythonDir, pythonVersion, quiet, diff --git a/src/utils/python-arch.ts b/src/utils/python-arch.ts new file mode 100644 index 0000000..da8dd85 --- /dev/null +++ b/src/utils/python-arch.ts @@ -0,0 +1,75 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import * as core from "@actions/core"; +import * as log from "./logging"; + +const execFileAsync = promisify(execFile); +const PROBE_ARCH = "setup-uv-probe"; + +export async function setupPythonArch( + uvPath: string, + pythonArch: string, + exportPythonArch: boolean, +): Promise { + if (pythonArch === "") { + return; + } + + // Older uv releases ignore unknown environment variables. An invalid architecture + // confirms that uv recognizes UV_PYTHON_ARCH without finding or downloading Python. + const probe = await queryPythonArch(uvPath, PROBE_ARCH); + if (probe.exitCode === 0) { + throw new Error( + "The installed version of uv does not support UV_PYTHON_ARCH. Select a newer uv version or use an architecture-qualified python-version.", + ); + } + if ( + !probe.stderr.includes( + `environment variable \`UV_PYTHON_ARCH\` with invalid value \`${PROBE_ARCH}\``, + ) + ) { + throw new Error( + `Failed to check uv's support for UV_PYTHON_ARCH: ${probe.stderr.trim() || `uv exited with code ${probe.exitCode}`}`, + ); + } + + const selected = await queryPythonArch(uvPath, pythonArch); + if (selected.exitCode !== 0) { + throw new Error( + `Failed to set Python architecture to ${pythonArch}: ${selected.stderr.trim() || `uv exited with code ${selected.exitCode}`}`, + ); + } + + if (exportPythonArch) { + core.exportVariable("UV_PYTHON_ARCH", pythonArch); + log.info(`Set UV_PYTHON_ARCH to ${pythonArch}`); + } +} + +async function queryPythonArch( + uvPath: string, + pythonArch: string, +): Promise<{ exitCode: number; stderr: string }> { + try { + const { stderr } = await execFileAsync( + uvPath, + ["--no-config", "cache", "dir"], + { + encoding: "utf8", + env: { ...process.env, NO_COLOR: "1", UV_PYTHON_ARCH: pythonArch }, + }, + ); + return { exitCode: 0, stderr }; + } catch (error) { + if ( + error instanceof Error && + "code" in error && + typeof error.code === "number" && + "stderr" in error && + typeof error.stderr === "string" + ) { + return { exitCode: error.code, stderr: error.stderr }; + } + throw error; + } +}