Refactor inputs (#823)

Don't load at import time and make it easier to test
This commit is contained in:
Kevin Stillhammer 2026-03-28 16:23:26 +01:00 committed by GitHub
parent 868d1f74d9
commit f82eb19c06
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 2363 additions and 2112 deletions

View file

@ -17,7 +17,6 @@ const {
fetchManifest,
getAllVersions,
getArtifact,
getLatestVersion,
parseManifest,
} = await import("../../src/download/manifest");

View file

@ -1,3 +1,6 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import {
afterEach,
beforeEach,
@ -7,9 +10,13 @@ import {
jest,
} from "@jest/globals";
// Will be mutated per test before (re-)importing the module under test
let mockInputs: Record<string, string> = {};
const tempDirs: string[] = [];
const ORIGINAL_HOME = process.env.HOME;
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_INSTALL_DIR = process.env.UV_PYTHON_INSTALL_DIR;
const mockDebug = jest.fn();
const mockGetBooleanInput = jest.fn(
@ -27,118 +34,228 @@ jest.unstable_mockModule("@actions/core", () => ({
warning: mockWarning,
}));
async function importInputsModule() {
return await import("../../src/utils/inputs");
const { CacheLocalSource, loadInputs } = await import("../../src/utils/inputs");
function createTempProject(files: Record<string, string>): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "setup-uv-inputs-test-"));
tempDirs.push(dir);
for (const [relativePath, content] of Object.entries(files)) {
const filePath = path.join(dir, relativePath);
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, content);
}
return dir;
}
function resetEnvironment(): void {
jest.clearAllMocks();
mockInputs = {};
process.env.HOME = "/home/testuser";
delete process.env.RUNNER_ENVIRONMENT;
delete process.env.RUNNER_TEMP;
delete process.env.UV_CACHE_DIR;
delete process.env.UV_PYTHON_INSTALL_DIR;
}
function restoreEnvironment(): void {
while (tempDirs.length > 0) {
const dir = tempDirs.pop();
if (dir !== undefined) {
fs.rmSync(dir, { force: true, recursive: true });
}
}
process.env.HOME = ORIGINAL_HOME;
process.env.RUNNER_ENVIRONMENT = ORIGINAL_RUNNER_ENVIRONMENT;
process.env.RUNNER_TEMP = ORIGINAL_RUNNER_TEMP;
process.env.UV_CACHE_DIR = ORIGINAL_UV_CACHE_DIR;
process.env.UV_PYTHON_INSTALL_DIR = ORIGINAL_UV_PYTHON_INSTALL_DIR;
}
beforeEach(() => {
resetEnvironment();
});
afterEach(() => {
restoreEnvironment();
});
describe("loadInputs", () => {
it("loads defaults for a github-hosted runner", () => {
mockInputs["working-directory"] = "/workspace";
mockInputs["enable-cache"] = "auto";
process.env.RUNNER_ENVIRONMENT = "github-hosted";
process.env.RUNNER_TEMP = "/runner-temp";
const inputs = loadInputs();
expect(inputs.enableCache).toBe(true);
expect(inputs.cacheLocalPath).toEqual({
path: "/runner-temp/setup-uv-cache",
source: CacheLocalSource.Default,
});
expect(inputs.pythonDir).toBe("/runner-temp/uv-python-dir");
expect(inputs.venvPath).toBe("/workspace/.venv");
expect(inputs.manifestFile).toBeUndefined();
expect(inputs.resolutionStrategy).toBe("highest");
});
it("uses cache-dir from pyproject.toml when present", () => {
mockInputs["working-directory"] = createTempProject({
"pyproject.toml": `[project]
name = "uv-project"
version = "0.1.0"
[tool.uv]
cache-dir = "/tmp/pyproject-toml-defined-cache-path"
`,
});
const inputs = loadInputs();
expect(inputs.cacheLocalPath).toEqual({
path: "/tmp/pyproject-toml-defined-cache-path",
source: CacheLocalSource.Config,
});
expect(mockInfo).toHaveBeenCalledWith(
expect.stringContaining("Found cache-dir in"),
);
});
it("uses UV_CACHE_DIR from the environment", () => {
mockInputs["working-directory"] = createTempProject({});
process.env.UV_CACHE_DIR = "/env/cache-dir";
const inputs = loadInputs();
expect(inputs.cacheLocalPath).toEqual({
path: "/env/cache-dir",
source: CacheLocalSource.Env,
});
expect(mockInfo).toHaveBeenCalledWith(
"UV_CACHE_DIR is already set to /env/cache-dir",
);
});
it("uses UV_PYTHON_INSTALL_DIR from the environment", () => {
mockInputs["working-directory"] = "/workspace";
process.env.UV_PYTHON_INSTALL_DIR = "/env/python-dir";
const inputs = loadInputs();
expect(inputs.pythonDir).toBe("/env/python-dir");
expect(mockInfo).toHaveBeenCalledWith(
"UV_PYTHON_INSTALL_DIR is already set to /env/python-dir",
);
});
it("warns when parsing a malformed pyproject.toml for cache-dir", () => {
mockInputs["working-directory"] = createTempProject({
"pyproject.toml": `[project]
name = "malformed-pyproject-toml-project"
version = "0.1.0"
[malformed-toml
`,
});
const inputs = loadInputs();
expect(inputs.cacheLocalPath).toBeUndefined();
expect(mockWarning).toHaveBeenCalledWith(
expect.stringContaining("Error while parsing pyproject.toml:"),
);
});
it("throws for an invalid resolution strategy", () => {
mockInputs["working-directory"] = "/workspace";
mockInputs["resolution-strategy"] = "middle";
expect(() => loadInputs()).toThrow(
"Invalid resolution-strategy: middle. Must be 'highest' or 'lowest'.",
);
});
});
describe("cacheDependencyGlob", () => {
beforeEach(() => {
jest.resetModules();
jest.clearAllMocks();
mockInputs = {};
process.env.HOME = "/home/testuser";
});
afterEach(() => {
process.env.HOME = ORIGINAL_HOME;
});
it("returns empty string when input not provided", async () => {
it("returns empty string when input not provided", () => {
mockInputs["working-directory"] = "/workspace";
const { cacheDependencyGlob } = await importInputsModule();
expect(cacheDependencyGlob).toBe("");
const inputs = loadInputs();
expect(inputs.cacheDependencyGlob).toBe("");
});
it("resolves a single relative path", async () => {
it.each([
["requirements.txt", "/workspace/requirements.txt"],
["./uv.lock", "/workspace/uv.lock"],
])("resolves %s to %s", (globInput, expected) => {
mockInputs["working-directory"] = "/workspace";
mockInputs["cache-dependency-glob"] = "requirements.txt";
const { cacheDependencyGlob } = await importInputsModule();
expect(cacheDependencyGlob).toBe("/workspace/requirements.txt");
mockInputs["cache-dependency-glob"] = globInput;
const inputs = loadInputs();
expect(inputs.cacheDependencyGlob).toBe(expected);
});
it("strips leading ./ from relative path", async () => {
mockInputs["working-directory"] = "/workspace";
mockInputs["cache-dependency-glob"] = "./uv.lock";
const { cacheDependencyGlob } = await importInputsModule();
expect(cacheDependencyGlob).toBe("/workspace/uv.lock");
});
it("handles multiple lines, trimming whitespace, tilde expansion and absolute paths", async () => {
it("handles multiple lines, trimming whitespace, tilde expansion and absolute paths", () => {
mockInputs["working-directory"] = "/workspace";
mockInputs["cache-dependency-glob"] =
" ~/.cache/file1\n ./rel/file2 \nfile3.txt";
const { cacheDependencyGlob } = await importInputsModule();
expect(cacheDependencyGlob).toBe(
const inputs = loadInputs();
expect(inputs.cacheDependencyGlob).toBe(
[
"/home/testuser/.cache/file1", // expanded tilde, absolute path unchanged
"/workspace/rel/file2", // ./ stripped and resolved
"/workspace/file3.txt", // relative path resolved
"/home/testuser/.cache/file1",
"/workspace/rel/file2",
"/workspace/file3.txt",
].join("\n"),
);
});
it("keeps absolute path unchanged in multiline input", async () => {
mockInputs["working-directory"] = "/workspace";
mockInputs["cache-dependency-glob"] = "/abs/path.lock\nrelative.lock";
const { cacheDependencyGlob } = await importInputsModule();
expect(cacheDependencyGlob).toBe(
it.each([
[
"/abs/path.lock\nrelative.lock",
["/abs/path.lock", "/workspace/relative.lock"].join("\n"),
);
});
it("handles exclusions in relative paths correct", async () => {
mockInputs["working-directory"] = "/workspace";
mockInputs["cache-dependency-glob"] = "!/abs/path.lock\n!relative.lock";
const { cacheDependencyGlob } = await importInputsModule();
expect(cacheDependencyGlob).toBe(
],
[
"!/abs/path.lock\n!relative.lock",
["!/abs/path.lock", "!/workspace/relative.lock"].join("\n"),
);
],
])("normalizes multiline glob %s", (globInput, expected) => {
mockInputs["working-directory"] = "/workspace";
mockInputs["cache-dependency-glob"] = globInput;
const inputs = loadInputs();
expect(inputs.cacheDependencyGlob).toBe(expected);
});
});
describe("tool directories", () => {
beforeEach(() => {
jest.resetModules();
jest.clearAllMocks();
mockInputs = {};
process.env.HOME = "/home/testuser";
});
afterEach(() => {
process.env.HOME = ORIGINAL_HOME;
});
it("expands tilde for tool-bin-dir and tool-dir", async () => {
it("expands tilde for tool-bin-dir and tool-dir", () => {
mockInputs["working-directory"] = "/workspace";
mockInputs["tool-bin-dir"] = "~/tool-bin-dir";
mockInputs["tool-dir"] = "~/tool-dir";
const { toolBinDir, toolDir } = await importInputsModule();
const inputs = loadInputs();
expect(toolBinDir).toBe("/home/testuser/tool-bin-dir");
expect(toolDir).toBe("/home/testuser/tool-dir");
expect(inputs.toolBinDir).toBe("/home/testuser/tool-bin-dir");
expect(inputs.toolDir).toBe("/home/testuser/tool-dir");
});
});
describe("cacheLocalPath", () => {
beforeEach(() => {
jest.resetModules();
jest.clearAllMocks();
mockInputs = {};
process.env.HOME = "/home/testuser";
});
afterEach(() => {
process.env.HOME = ORIGINAL_HOME;
});
it("expands tilde in cache-local-path", async () => {
it("expands tilde in cache-local-path", () => {
mockInputs["working-directory"] = "/workspace";
mockInputs["cache-local-path"] = "~/uv-cache/cache-local-path";
const { CacheLocalSource, cacheLocalPath } = await importInputsModule();
const inputs = loadInputs();
expect(cacheLocalPath).toEqual({
expect(inputs.cacheLocalPath).toEqual({
path: "/home/testuser/uv-cache/cache-local-path",
source: CacheLocalSource.Input,
});
@ -146,63 +263,37 @@ describe("cacheLocalPath", () => {
});
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 () => {
it("defaults to .venv in the working directory", () => {
mockInputs["working-directory"] = "/workspace";
const { venvPath } = await importInputsModule();
expect(venvPath).toBe("/workspace/.venv");
const inputs = loadInputs();
expect(inputs.venvPath).toBe("/workspace/.venv");
});
it("resolves a relative venv-path", async () => {
it.each([
["custom-venv", "/workspace/custom-venv"],
["custom-venv/", "/workspace/custom-venv"],
["/tmp/custom-venv", "/tmp/custom-venv"],
["~/.venv", "/home/testuser/.venv"],
])("resolves venv-path %s to %s", (venvPathInput, expected) => {
mockInputs["working-directory"] = "/workspace";
mockInputs["activate-environment"] = "true";
mockInputs["venv-path"] = "custom-venv";
const { venvPath } = await importInputsModule();
expect(venvPath).toBe("/workspace/custom-venv");
mockInputs["venv-path"] = venvPathInput;
const inputs = loadInputs();
expect(inputs.venvPath).toBe(expected);
});
it("normalizes venv-path with trailing slash", async () => {
mockInputs["working-directory"] = "/workspace";
mockInputs["activate-environment"] = "true";
mockInputs["venv-path"] = "custom-venv/";
const { venvPath } = await importInputsModule();
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 importInputsModule();
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 importInputsModule();
expect(venvPath).toBe("/home/testuser/.venv");
});
it("warns when venv-path is set but activate-environment is false", async () => {
it("warns when venv-path is set but activate-environment is false", () => {
mockInputs["working-directory"] = "/workspace";
mockInputs["venv-path"] = "custom-venv";
const { activateEnvironment, venvPath } = await importInputsModule();
const inputs = loadInputs();
expect(activateEnvironment).toBe(false);
expect(venvPath).toBe("/workspace/custom-venv");
expect(inputs.activateEnvironment).toBe(false);
expect(inputs.venvPath).toBe("/workspace/custom-venv");
expect(mockWarning).toHaveBeenCalledWith(
"venv-path is only used when activate-environment is true",
);

656
dist/save-cache/index.cjs generated vendored

File diff suppressed because it is too large Load diff

2973
dist/setup/index.cjs generated vendored

File diff suppressed because it is too large Load diff

View file

@ -1,15 +1,7 @@
import * as cache from "@actions/cache";
import * as core from "@actions/core";
import { hashFiles } from "../hash/hash-files";
import {
cacheDependencyGlob,
cacheLocalPath,
cachePython,
cacheSuffix,
pruneCache,
pythonDir,
restoreCache as shouldRestoreCache,
} from "../utils/inputs";
import type { SetupInputs } from "../utils/inputs";
import { getArch, getOSNameVersion, getPlatform } from "../utils/platforms";
export const STATE_CACHE_KEY = "cache-key";
@ -18,18 +10,21 @@ export const STATE_PYTHON_CACHE_MATCHED_KEY = "python-cache-matched-key";
const CACHE_VERSION = "2";
export async function restoreCache(pythonVersion?: string): Promise<void> {
const cacheKey = await computeKeys(pythonVersion);
export async function restoreCache(
inputs: SetupInputs,
pythonVersion?: string,
): Promise<void> {
const cacheKey = await computeKeys(inputs, pythonVersion);
core.saveState(STATE_CACHE_KEY, cacheKey);
core.setOutput("cache-key", cacheKey);
if (!shouldRestoreCache) {
if (!inputs.restoreCache) {
core.info("restore-cache is false. Skipping restore cache step.");
core.setOutput("python-cache-hit", false);
return;
}
if (cacheLocalPath === undefined) {
if (inputs.cacheLocalPath === undefined) {
throw new Error(
"cache-local-path is not set. Cannot restore cache without a valid cache path.",
);
@ -37,15 +32,15 @@ export async function restoreCache(pythonVersion?: string): Promise<void> {
await restoreCacheFromKey(
cacheKey,
cacheLocalPath.path,
inputs.cacheLocalPath.path,
STATE_CACHE_MATCHED_KEY,
"cache-hit",
);
if (cachePython) {
if (inputs.cachePython) {
await restoreCacheFromKey(
`${cacheKey}-python`,
pythonDir,
inputs.pythonDir,
STATE_PYTHON_CACHE_MATCHED_KEY,
"python-cache-hit",
);
@ -76,28 +71,34 @@ async function restoreCacheFromKey(
handleMatchResult(matchedKey, cacheKey, stateKey, outputKey);
}
async function computeKeys(pythonVersion?: string): Promise<string> {
async function computeKeys(
inputs: SetupInputs,
pythonVersion?: string,
): Promise<string> {
let cacheDependencyPathHash = "-";
if (cacheDependencyGlob !== "") {
if (inputs.cacheDependencyGlob !== "") {
core.info(
`Searching files using cache dependency glob: ${cacheDependencyGlob.split("\n").join(",")}`,
`Searching files using cache dependency glob: ${inputs.cacheDependencyGlob.split("\n").join(",")}`,
);
cacheDependencyPathHash += await hashFiles(
inputs.cacheDependencyGlob,
true,
);
cacheDependencyPathHash += await hashFiles(cacheDependencyGlob, true);
if (cacheDependencyPathHash === "-") {
core.warning(
`No file matched to [${cacheDependencyGlob.split("\n").join(",")}]. The cache will never get invalidated. Make sure you have checked out the target repository and configured the cache-dependency-glob input correctly.`,
`No file matched to [${inputs.cacheDependencyGlob.split("\n").join(",")}]. The cache will never get invalidated. Make sure you have checked out the target repository and configured the cache-dependency-glob input correctly.`,
);
}
}
if (cacheDependencyPathHash === "-") {
cacheDependencyPathHash = "-no-dependency-glob";
}
const suffix = cacheSuffix ? `-${cacheSuffix}` : "";
const suffix = inputs.cacheSuffix ? `-${inputs.cacheSuffix}` : "";
const version = pythonVersion ?? "unknown";
const platform = await getPlatform();
const osNameVersion = getOSNameVersion();
const pruned = pruneCache ? "-pruned" : "";
const python = cachePython ? "-py" : "";
const pruned = inputs.pruneCache ? "-pruned" : "";
const python = inputs.cachePython ? "-py" : "";
return `setup-uv-${CACHE_VERSION}-${getArch()}-${platform}-${osNameVersion}-${version}${pruned}${python}${cacheDependencyPathHash}${suffix}`;
}

View file

@ -33,7 +33,7 @@ export async function downloadVersion(
platform: Platform,
arch: Architecture,
version: string,
checkSum: string | undefined,
checksum: string | undefined,
githubToken: string,
manifestUrl?: string,
): Promise<{ version: string; cachedToolDir: string }> {
@ -47,10 +47,10 @@ export async function downloadVersion(
// For the default astral-sh/versions source, checksum validation relies on
// user input or the built-in KNOWN_CHECKSUMS table, not manifest sha256 values.
const checksum =
const resolvedChecksum =
manifestUrl === undefined
? checkSum
: resolveChecksum(checkSum, artifact.checksum);
? checksum
: resolveChecksum(checksum, artifact.checksum);
const mirrorUrl = rewriteToMirror(artifact.downloadUrl);
const downloadUrl = mirrorUrl ?? artifact.downloadUrl;
@ -64,7 +64,7 @@ export async function downloadVersion(
platform,
arch,
version,
checksum,
resolvedChecksum,
downloadToken,
);
} catch (err) {
@ -82,7 +82,7 @@ export async function downloadVersion(
platform,
arch,
version,
checksum,
resolvedChecksum,
githubToken,
);
}
@ -161,11 +161,11 @@ function getMissingArtifactMessage(
}
function resolveChecksum(
checkSum: string | undefined,
checksum: string | undefined,
manifestChecksum: string,
): string {
return checkSum !== undefined && checkSum !== ""
? checkSum
return checksum !== undefined && checksum !== ""
? checksum
: manifestChecksum;
}

View file

@ -9,21 +9,14 @@ import {
STATE_PYTHON_CACHE_MATCHED_KEY,
} from "./cache/restore-cache";
import { STATE_UV_PATH, STATE_UV_VERSION } from "./utils/constants";
import {
cacheLocalPath,
cachePython,
enableCache,
ignoreNothingToCache,
pythonDir,
pruneCache as shouldPruneCache,
saveCache as shouldSaveCache,
} from "./utils/inputs";
import { loadInputs, type SetupInputs } from "./utils/inputs";
export async function run(): Promise<void> {
try {
if (enableCache) {
if (shouldSaveCache) {
await saveCache();
const inputs = loadInputs();
if (inputs.enableCache) {
if (inputs.saveCache) {
await saveCache(inputs);
} else {
core.info("save-cache is false. Skipping save cache step.");
}
@ -43,7 +36,7 @@ export async function run(): Promise<void> {
}
}
async function saveCache(): Promise<void> {
async function saveCache(inputs: SetupInputs): Promise<void> {
const cacheKey = core.getState(STATE_CACHE_KEY);
const matchedKey = core.getState(STATE_CACHE_MATCHED_KEY);
@ -54,13 +47,13 @@ async function saveCache(): Promise<void> {
if (matchedKey === cacheKey) {
core.info(`Cache hit occurred on key ${cacheKey}, not saving cache.`);
} else {
if (shouldPruneCache) {
if (inputs.pruneCache) {
await pruneCache();
}
const actualCachePath = getUvCachePath();
const actualCachePath = getUvCachePath(inputs);
if (!fs.existsSync(actualCachePath)) {
if (ignoreNothingToCache) {
if (inputs.ignoreNothingToCache) {
core.info(
"No cacheable uv cache paths were found. Ignoring because ignore-nothing-to-cache is enabled.",
);
@ -79,10 +72,10 @@ async function saveCache(): Promise<void> {
}
}
if (cachePython) {
if (!fs.existsSync(pythonDir)) {
if (inputs.cachePython) {
if (!fs.existsSync(inputs.pythonDir)) {
core.warning(
`Python cache path ${pythonDir} does not exist on disk. Skipping Python cache save because no managed Python installation was found. If you want uv to install managed Python instead of using a system interpreter, set UV_PYTHON_PREFERENCE=only-managed.`,
`Python cache path ${inputs.pythonDir} does not exist on disk. Skipping Python cache save because no managed Python installation was found. If you want uv to install managed Python instead of using a system interpreter, set UV_PYTHON_PREFERENCE=only-managed.`,
);
return;
}
@ -90,7 +83,7 @@ async function saveCache(): Promise<void> {
const pythonCacheKey = `${cacheKey}-python`;
await saveCacheToKey(
pythonCacheKey,
pythonDir,
inputs.pythonDir,
STATE_PYTHON_CACHE_MATCHED_KEY,
"Python cache",
);
@ -113,22 +106,22 @@ async function pruneCache(): Promise<void> {
await exec.exec(uvPath, execArgs, options);
}
function getUvCachePath(): string {
if (cacheLocalPath === undefined) {
function getUvCachePath(inputs: SetupInputs): string {
if (inputs.cacheLocalPath === undefined) {
throw new Error(
"cache-local-path is not set. Cannot save cache without a valid cache path.",
);
}
if (
process.env.UV_CACHE_DIR &&
process.env.UV_CACHE_DIR !== cacheLocalPath.path
process.env.UV_CACHE_DIR !== inputs.cacheLocalPath.path
) {
core.warning(
`The environment variable UV_CACHE_DIR has been changed to "${process.env.UV_CACHE_DIR}", by an action or step running after astral-sh/setup-uv. This can lead to unexpected behavior. If you expected this to happen set the cache-local-path input to "${process.env.UV_CACHE_DIR}" instead of "${cacheLocalPath.path}".`,
`The environment variable UV_CACHE_DIR has been changed to "${process.env.UV_CACHE_DIR}", by an action or step running after astral-sh/setup-uv. This can lead to unexpected behavior. If you expected this to happen set the cache-local-path input to "${process.env.UV_CACHE_DIR}" instead of "${inputs.cacheLocalPath.path}".`,
);
return process.env.UV_CACHE_DIR;
}
return cacheLocalPath.path;
return inputs.cacheLocalPath.path;
}
async function saveCacheToKey(

View file

@ -9,26 +9,7 @@ import {
tryGetFromToolCache,
} from "./download/download-version";
import { STATE_UV_PATH, STATE_UV_VERSION } from "./utils/constants";
import {
activateEnvironment as activateEnvironmentInput,
addProblemMatchers,
CacheLocalSource,
cacheLocalPath,
checkSum,
enableCache,
githubToken,
ignoreEmptyWorkdir,
manifestFile,
pythonDir,
pythonVersion,
resolutionStrategy,
toolBinDir,
toolDir,
venvPath,
versionFile as versionFileInput,
version as versionInput,
workingDirectory,
} from "./utils/inputs";
import { CacheLocalSource, loadInputs, type SetupInputs } from "./utils/inputs";
import {
type Architecture,
getArch,
@ -39,9 +20,9 @@ import { getUvVersionFromFile } from "./version/resolve";
const sourceDir = __dirname;
async function getPythonVersion(): Promise<string> {
if (pythonVersion !== "") {
return pythonVersion;
async function getPythonVersion(inputs: SetupInputs): Promise<string> {
if (inputs.pythonVersion !== "") {
return inputs.pythonVersion;
}
let output = "";
@ -55,7 +36,7 @@ async function getPythonVersion(): Promise<string> {
};
try {
const execArgs = ["python", "find", "--directory", workingDirectory];
const execArgs = ["python", "find", "--directory", inputs.workingDirectory];
await exec.exec("uv", execArgs, options);
const pythonPath = output.trim();
@ -71,37 +52,38 @@ async function getPythonVersion(): Promise<string> {
}
async function run(): Promise<void> {
detectEmptyWorkdir();
const platform = await getPlatform();
const arch = getArch();
try {
const inputs = loadInputs();
detectEmptyWorkdir(inputs);
const platform = await getPlatform();
const arch = getArch();
if (platform === undefined) {
throw new Error(`Unsupported platform: ${process.platform}`);
}
if (arch === undefined) {
throw new Error(`Unsupported architecture: ${process.arch}`);
}
const setupResult = await setupUv(platform, arch, checkSum, githubToken);
const setupResult = await setupUv(inputs, platform, arch);
addToolBinToPath();
addToolBinToPath(inputs);
addUvToPathAndOutput(setupResult.uvDir);
setToolDir();
addPythonDirToPath();
setupPython();
await activateEnvironment();
addMatchers();
setCacheDir();
setToolDir(inputs);
addPythonDirToPath(inputs);
setupPython(inputs);
await activateEnvironment(inputs);
addMatchers(inputs);
setCacheDir(inputs);
core.setOutput("uv-version", setupResult.version);
core.saveState(STATE_UV_VERSION, setupResult.version);
core.info(`Successfully installed uv version ${setupResult.version}`);
const pythonVersion = await getPythonVersion();
core.setOutput("python-version", pythonVersion);
const detectedPythonVersion = await getPythonVersion(inputs);
core.setOutput("python-version", detectedPythonVersion);
if (enableCache) {
await restoreCache(pythonVersion);
if (inputs.enableCache) {
await restoreCache(inputs, detectedPythonVersion);
}
// https://github.com/nodejs/node/issues/56645#issuecomment-3077594952
await new Promise((resolve) => setTimeout(resolve, 50));
@ -111,9 +93,9 @@ async function run(): Promise<void> {
}
}
function detectEmptyWorkdir(): void {
if (fs.readdirSync(workingDirectory).length === 0) {
if (ignoreEmptyWorkdir) {
function detectEmptyWorkdir(inputs: SetupInputs): void {
if (fs.readdirSync(inputs.workingDirectory).length === 0) {
if (inputs.ignoreEmptyWorkdir) {
core.info(
"Empty workdir detected. Ignoring because ignore-empty-workdir is enabled",
);
@ -126,12 +108,11 @@ function detectEmptyWorkdir(): void {
}
async function setupUv(
inputs: SetupInputs,
platform: Platform,
arch: Architecture,
checkSum: string | undefined,
githubToken: string,
): Promise<{ uvDir: string; version: string }> {
const resolvedVersion = await determineVersion();
const resolvedVersion = await determineVersion(inputs);
const toolCacheResult = tryGetFromToolCache(arch, resolvedVersion);
if (toolCacheResult.installedPath) {
core.info(`Found uv in tool-cache for ${toolCacheResult.version}`);
@ -145,9 +126,9 @@ async function setupUv(
platform,
arch,
resolvedVersion,
checkSum,
githubToken,
manifestFile,
inputs.checksum,
inputs.githubToken,
inputs.manifestFile,
);
return {
@ -156,34 +137,34 @@ async function setupUv(
};
}
async function determineVersion(): Promise<string> {
async function determineVersion(inputs: SetupInputs): Promise<string> {
return await resolveVersion(
getRequestedVersion(),
manifestFile,
resolutionStrategy,
getRequestedVersion(inputs),
inputs.manifestFile,
inputs.resolutionStrategy,
);
}
function getRequestedVersion(): string {
if (versionInput !== "") {
return versionInput;
function getRequestedVersion(inputs: SetupInputs): string {
if (inputs.version !== "") {
return inputs.version;
}
if (versionFileInput !== "") {
const versionFromFile = getUvVersionFromFile(versionFileInput);
if (inputs.versionFile !== "") {
const versionFromFile = getUvVersionFromFile(inputs.versionFile);
if (versionFromFile === undefined) {
throw new Error(
`Could not determine uv version from file: ${versionFileInput}`,
`Could not determine uv version from file: ${inputs.versionFile}`,
);
}
return versionFromFile;
}
const versionFromUvToml = getUvVersionFromFile(
`${workingDirectory}${path.sep}uv.toml`,
`${inputs.workingDirectory}${path.sep}uv.toml`,
);
const versionFromPyproject = getUvVersionFromFile(
`${workingDirectory}${path.sep}pyproject.toml`,
`${inputs.workingDirectory}${path.sep}pyproject.toml`,
);
if (versionFromUvToml === undefined && versionFromPyproject === undefined) {
@ -207,15 +188,17 @@ function addUvToPathAndOutput(cachedPath: string): void {
}
}
function addToolBinToPath(): void {
if (toolBinDir !== undefined) {
core.exportVariable("UV_TOOL_BIN_DIR", toolBinDir);
core.info(`Set UV_TOOL_BIN_DIR to ${toolBinDir}`);
function addToolBinToPath(inputs: SetupInputs): void {
if (inputs.toolBinDir !== undefined) {
core.exportVariable("UV_TOOL_BIN_DIR", inputs.toolBinDir);
core.info(`Set UV_TOOL_BIN_DIR to ${inputs.toolBinDir}`);
if (process.env.UV_NO_MODIFY_PATH !== undefined) {
core.info(`UV_NO_MODIFY_PATH is set, not adding ${toolBinDir} to path`);
core.info(
`UV_NO_MODIFY_PATH is set, not adding ${inputs.toolBinDir} to path`,
);
} else {
core.addPath(toolBinDir);
core.info(`Added ${toolBinDir} to the path`);
core.addPath(inputs.toolBinDir);
core.info(`Added ${inputs.toolBinDir} to the path`);
}
} else {
if (process.env.UV_NO_MODIFY_PATH !== undefined) {
@ -235,73 +218,73 @@ function addToolBinToPath(): void {
}
}
function setToolDir(): void {
if (toolDir !== undefined) {
core.exportVariable("UV_TOOL_DIR", toolDir);
core.info(`Set UV_TOOL_DIR to ${toolDir}`);
function setToolDir(inputs: SetupInputs): void {
if (inputs.toolDir !== undefined) {
core.exportVariable("UV_TOOL_DIR", inputs.toolDir);
core.info(`Set UV_TOOL_DIR to ${inputs.toolDir}`);
}
}
function addPythonDirToPath(): void {
core.exportVariable("UV_PYTHON_INSTALL_DIR", pythonDir);
core.info(`Set UV_PYTHON_INSTALL_DIR to ${pythonDir}`);
function addPythonDirToPath(inputs: SetupInputs): void {
core.exportVariable("UV_PYTHON_INSTALL_DIR", inputs.pythonDir);
core.info(`Set UV_PYTHON_INSTALL_DIR to ${inputs.pythonDir}`);
if (process.env.UV_NO_MODIFY_PATH !== undefined) {
core.info("UV_NO_MODIFY_PATH is set, not adding python dir to path");
} else {
core.addPath(pythonDir);
core.info(`Added ${pythonDir} to the path`);
core.addPath(inputs.pythonDir);
core.info(`Added ${inputs.pythonDir} to the path`);
}
}
function setupPython(): void {
if (pythonVersion !== "") {
core.exportVariable("UV_PYTHON", pythonVersion);
core.info(`Set UV_PYTHON to ${pythonVersion}`);
function setupPython(inputs: SetupInputs): void {
if (inputs.pythonVersion !== "") {
core.exportVariable("UV_PYTHON", inputs.pythonVersion);
core.info(`Set UV_PYTHON to ${inputs.pythonVersion}`);
}
}
async function activateEnvironment(): Promise<void> {
if (activateEnvironmentInput) {
async function activateEnvironment(inputs: SetupInputs): Promise<void> {
if (inputs.activateEnvironment) {
if (process.env.UV_NO_MODIFY_PATH !== undefined) {
throw new Error(
"UV_NO_MODIFY_PATH and activate-environment cannot be used together.",
);
}
core.info(`Creating and activating python venv at ${venvPath}...`);
core.info(`Creating and activating python venv at ${inputs.venvPath}...`);
await exec.exec("uv", [
"venv",
venvPath,
inputs.venvPath,
"--directory",
workingDirectory,
inputs.workingDirectory,
"--clear",
]);
let venvBinPath = `${venvPath}${path.sep}bin`;
let venvBinPath = `${inputs.venvPath}${path.sep}bin`;
if (process.platform === "win32") {
venvBinPath = `${venvPath}${path.sep}Scripts`;
venvBinPath = `${inputs.venvPath}${path.sep}Scripts`;
}
core.addPath(path.resolve(venvBinPath));
core.exportVariable("VIRTUAL_ENV", venvPath);
core.setOutput("venv", venvPath);
core.exportVariable("VIRTUAL_ENV", inputs.venvPath);
core.setOutput("venv", inputs.venvPath);
}
}
function setCacheDir(): void {
if (cacheLocalPath !== undefined) {
if (cacheLocalPath.source === CacheLocalSource.Config) {
function setCacheDir(inputs: SetupInputs): void {
if (inputs.cacheLocalPath !== undefined) {
if (inputs.cacheLocalPath.source === CacheLocalSource.Config) {
core.info(
"Using cache-dir from uv config file, not modifying UV_CACHE_DIR",
);
return;
}
core.exportVariable("UV_CACHE_DIR", cacheLocalPath.path);
core.info(`Set UV_CACHE_DIR to ${cacheLocalPath.path}`);
core.exportVariable("UV_CACHE_DIR", inputs.cacheLocalPath.path);
core.info(`Set UV_CACHE_DIR to ${inputs.cacheLocalPath.path}`);
}
}
function addMatchers(): void {
if (addProblemMatchers) {
function addMatchers(inputs: SetupInputs): void {
if (inputs.addProblemMatchers) {
const matchersPath = path.join(sourceDir, "..", "..", ".github");
core.info(`##[add-matcher]${path.join(matchersPath, "python.json")}`);
}

View file

@ -9,68 +9,152 @@ export enum CacheLocalSource {
Default,
}
export const workingDirectory = core.getInput("working-directory");
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";
export const saveCache = core.getInput("save-cache") === "true";
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 =
core.getInput("ignore-empty-workdir") === "true";
export const toolBinDir = getToolBinDir();
export const toolDir = getToolDir();
export const pythonDir = getUvPythonDir();
export const githubToken = core.getInput("github-token");
export const manifestFile = getManifestFile();
export const addProblemMatchers =
core.getInput("add-problem-matchers") === "true";
export const resolutionStrategy = getResolutionStrategy();
export interface CacheLocalPath {
path: string;
source: CacheLocalSource;
}
function getVersionFile(): string {
const versionFileInput = core.getInput("version-file");
export interface SetupInputs {
workingDirectory: string;
version: string;
versionFile: string;
pythonVersion: string;
activateEnvironment: boolean;
venvPath: string;
checksum: string;
enableCache: boolean;
restoreCache: boolean;
saveCache: boolean;
cacheSuffix: string;
cacheLocalPath?: CacheLocalPath;
cacheDependencyGlob: string;
pruneCache: boolean;
cachePython: boolean;
ignoreNothingToCache: boolean;
ignoreEmptyWorkdir: boolean;
toolBinDir?: string;
toolDir?: string;
pythonDir: string;
githubToken: string;
manifestFile?: string;
addProblemMatchers: boolean;
resolutionStrategy: "highest" | "lowest";
}
export function loadInputs(): SetupInputs {
const workingDirectory = core.getInput("working-directory");
const version = core.getInput("version");
const versionFile = getVersionFile(
workingDirectory,
core.getInput("version-file"),
);
const pythonVersion = core.getInput("python-version");
const activateEnvironment = core.getBooleanInput("activate-environment");
const venvPath = getVenvPath(
workingDirectory,
core.getInput("venv-path"),
activateEnvironment,
);
const checksum = core.getInput("checksum");
const enableCache = getEnableCache(core.getInput("enable-cache"));
const restoreCache = core.getInput("restore-cache") === "true";
const saveCache = core.getInput("save-cache") === "true";
const cacheSuffix = core.getInput("cache-suffix") || "";
const cacheLocalPath = getCacheLocalPath(
workingDirectory,
versionFile,
enableCache,
);
const cacheDependencyGlob = getCacheDependencyGlob(
workingDirectory,
core.getInput("cache-dependency-glob"),
);
const pruneCache = core.getInput("prune-cache") === "true";
const cachePython = core.getInput("cache-python") === "true";
const ignoreNothingToCache =
core.getInput("ignore-nothing-to-cache") === "true";
const ignoreEmptyWorkdir = core.getInput("ignore-empty-workdir") === "true";
const toolBinDir = getToolBinDir(
workingDirectory,
core.getInput("tool-bin-dir"),
);
const toolDir = getToolDir(workingDirectory, core.getInput("tool-dir"));
const pythonDir = getUvPythonDir();
const githubToken = core.getInput("github-token");
const manifestFile = getManifestFile(core.getInput("manifest-file"));
const addProblemMatchers = core.getInput("add-problem-matchers") === "true";
const resolutionStrategy = getResolutionStrategy(
core.getInput("resolution-strategy"),
);
return {
activateEnvironment,
addProblemMatchers,
cacheDependencyGlob,
cacheLocalPath,
cachePython,
cacheSuffix,
checksum,
enableCache,
githubToken,
ignoreEmptyWorkdir,
ignoreNothingToCache,
manifestFile,
pruneCache,
pythonDir,
pythonVersion,
resolutionStrategy,
restoreCache,
saveCache,
toolBinDir,
toolDir,
venvPath,
version,
versionFile,
workingDirectory,
};
}
function getVersionFile(
workingDirectory: string,
versionFileInput: string,
): string {
if (versionFileInput !== "") {
const tildeExpanded = expandTilde(versionFileInput);
return resolveRelativePath(tildeExpanded);
return resolveRelativePath(workingDirectory, tildeExpanded);
}
return versionFileInput;
}
function getVenvPath(): string {
const venvPathInput = core.getInput("venv-path");
function getVenvPath(
workingDirectory: string,
venvPathInput: string,
activateEnvironment: boolean,
): string {
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(workingDirectory, tildeExpanded));
}
return normalizePath(resolveRelativePath(".venv"));
return normalizePath(resolveRelativePath(workingDirectory, ".venv"));
}
function getEnableCache(): boolean {
const enableCacheInput = core.getInput("enable-cache");
function getEnableCache(enableCacheInput: string): boolean {
if (enableCacheInput === "auto") {
return process.env.RUNNER_ENVIRONMENT === "github-hosted";
}
return enableCacheInput === "true";
}
function getToolBinDir(): string | undefined {
const toolBinDirInput = core.getInput("tool-bin-dir");
function getToolBinDir(
workingDirectory: string,
toolBinDirInput: string,
): string | undefined {
if (toolBinDirInput !== "") {
const tildeExpanded = expandTilde(toolBinDirInput);
return resolveRelativePath(tildeExpanded);
return resolveRelativePath(workingDirectory, tildeExpanded);
}
if (process.platform === "win32") {
if (process.env.RUNNER_TEMP !== undefined) {
@ -83,11 +167,13 @@ function getToolBinDir(): string | undefined {
return undefined;
}
function getToolDir(): string | undefined {
const toolDirInput = core.getInput("tool-dir");
function getToolDir(
workingDirectory: string,
toolDirInput: string,
): string | undefined {
if (toolDirInput !== "") {
const tildeExpanded = expandTilde(toolDirInput);
return resolveRelativePath(tildeExpanded);
return resolveRelativePath(workingDirectory, tildeExpanded);
}
if (process.platform === "win32") {
if (process.env.RUNNER_TEMP !== undefined) {
@ -100,21 +186,23 @@ function getToolDir(): string | undefined {
return undefined;
}
function getCacheLocalPath():
| {
path: string;
source: CacheLocalSource;
}
| undefined {
function getCacheLocalPath(
workingDirectory: string,
versionFile: string,
enableCache: boolean,
): CacheLocalPath | undefined {
const cacheLocalPathInput = core.getInput("cache-local-path");
if (cacheLocalPathInput !== "") {
const tildeExpanded = expandTilde(cacheLocalPathInput);
return {
path: resolveRelativePath(tildeExpanded),
path: resolveRelativePath(workingDirectory, tildeExpanded),
source: CacheLocalSource.Input,
};
}
const cacheDirFromConfig = getCacheDirFromConfig();
const cacheDirFromConfig = getCacheDirFromConfig(
workingDirectory,
versionFile,
);
if (cacheDirFromConfig !== undefined) {
return { path: cacheDirFromConfig, source: CacheLocalSource.Config };
}
@ -122,7 +210,7 @@ function getCacheLocalPath():
core.info(`UV_CACHE_DIR is already set to ${process.env.UV_CACHE_DIR}`);
return { path: process.env.UV_CACHE_DIR, source: CacheLocalSource.Env };
}
if (getEnableCache()) {
if (enableCache) {
if (process.env.RUNNER_ENVIRONMENT === "github-hosted") {
if (process.env.RUNNER_TEMP !== undefined) {
return {
@ -147,9 +235,12 @@ function getCacheLocalPath():
}
}
function getCacheDirFromConfig(): string | undefined {
function getCacheDirFromConfig(
workingDirectory: string,
versionFile: string,
): string | undefined {
for (const filePath of [versionFile, "uv.toml", "pyproject.toml"]) {
const resolvedPath = resolveRelativePath(filePath);
const resolvedPath = resolveRelativePath(workingDirectory, filePath);
try {
const cacheDir = getConfigValueFromTomlFile(resolvedPath, "cache-dir");
if (cacheDir !== undefined) {
@ -175,9 +266,8 @@ export function getUvPythonDir(): string {
if (process.env.RUNNER_ENVIRONMENT !== "github-hosted") {
if (process.platform === "win32") {
return `${process.env.APPDATA}${path.sep}uv${path.sep}python`;
} else {
return `${process.env.HOME}${path.sep}.local${path.sep}share${path.sep}uv${path.sep}python`;
}
return `${process.env.HOME}${path.sep}.local${path.sep}share${path.sep}uv${path.sep}python`;
}
if (process.env.RUNNER_TEMP !== undefined) {
return `${process.env.RUNNER_TEMP}${path.sep}uv-python-dir`;
@ -187,14 +277,16 @@ export function getUvPythonDir(): string {
);
}
function getCacheDependencyGlob(): string {
const cacheDependencyGlobInput = core.getInput("cache-dependency-glob");
function getCacheDependencyGlob(
workingDirectory: string,
cacheDependencyGlobInput: string,
): string {
if (cacheDependencyGlobInput !== "") {
return cacheDependencyGlobInput
.split("\n")
.map((part) => part.trim())
.map((part) => expandTilde(part))
.map((part) => resolveRelativePath(part))
.map((part) => resolveRelativePath(workingDirectory, part))
.join("\n");
}
return cacheDependencyGlobInput;
@ -220,7 +312,10 @@ function normalizePath(inputPath: string): string {
return trimmed;
}
function resolveRelativePath(inputPath: string): string {
function resolveRelativePath(
workingDirectory: string,
inputPath: string,
): string {
const hasNegation = inputPath.startsWith("!");
const pathWithoutNegation = hasNegation ? inputPath.substring(1) : inputPath;
@ -232,16 +327,16 @@ function resolveRelativePath(inputPath: string): string {
return hasNegation ? `!${resolvedPath}` : resolvedPath;
}
function getManifestFile(): string | undefined {
const manifestFileInput = core.getInput("manifest-file");
function getManifestFile(manifestFileInput: string): string | undefined {
if (manifestFileInput !== "") {
return manifestFileInput;
}
return undefined;
}
function getResolutionStrategy(): "highest" | "lowest" {
const resolutionStrategyInput = core.getInput("resolution-strategy");
function getResolutionStrategy(
resolutionStrategyInput: string,
): "highest" | "lowest" {
if (resolutionStrategyInput === "lowest") {
return "lowest";
}