diff --git a/README.md b/README.md index 32b8d3e..0782521 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ Have a look under [Advanced Configuration](#advanced-configuration) for detailed # The version of uv to install (default: searches for version in config files, then latest) version: "" - # Path to a file containing the version of uv to install (default: searches uv.toml then pyproject.toml) + # Path to a file containing the version of uv to install, e.g., uv.toml, pyproject.toml, .tool-versions, requirements.txt or uv.lock (default: searches uv.toml then pyproject.toml) version-file: "" # Resolution strategy when resolving version ranges: 'highest' or 'lowest' diff --git a/__tests__/version/uv-lock-file.test.ts b/__tests__/version/uv-lock-file.test.ts new file mode 100644 index 0000000..98e48d4 --- /dev/null +++ b/__tests__/version/uv-lock-file.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "@jest/globals"; +import { getUvVersionFromUvLockContent } from "../../src/version/uv-lock-file"; + +const UV_LOCK = `version = 1 +requires-python = ">=3.12" + +[[package]] +name = "anyio" +version = "4.6.0" +source = { registry = "https://pypi.org/simple" } + +[[package]] +name = "uv" +version = "0.8.17" +source = { registry = "https://pypi.org/simple" } +`; + +describe("getUvVersionFromUvLockContent", () => { + it("returns the exact uv version locked in uv.lock", () => { + expect(getUvVersionFromUvLockContent(UV_LOCK)).toBe("0.8.17"); + }); + + it("returns undefined when uv is not a locked package", () => { + const content = `version = 1 + +[[package]] +name = "anyio" +version = "4.6.0" +`; + expect(getUvVersionFromUvLockContent(content)).toBeUndefined(); + }); + + it("returns undefined when there are no packages", () => { + expect(getUvVersionFromUvLockContent("version = 1\n")).toBeUndefined(); + }); +}); diff --git a/__tests__/version/version-request-resolver.test.ts b/__tests__/version/version-request-resolver.test.ts index e3478ff..754f29f 100644 --- a/__tests__/version/version-request-resolver.test.ts +++ b/__tests__/version/version-request-resolver.test.ts @@ -63,6 +63,24 @@ describe("resolveVersionRequest", () => { }); }); + it("uses the exact uv version locked in uv.lock when it is passed via version-file", () => { + const workingDirectory = createTempProject({ + "uv.lock": `version = 1\n\n[[package]]\nname = "uv"\nversion = "0.8.17"\nsource = { registry = "https://pypi.org/simple" }\n`, + }); + + const request = resolveVersionRequest({ + versionFile: path.join(workingDirectory, "uv.lock"), + workingDirectory, + }); + + expect(request).toEqual({ + format: "uv.lock", + source: "version-file", + sourcePath: path.join(workingDirectory, "uv.lock"), + specifier: "0.8.17", + }); + }); + it("uses requirements.txt when it is passed via version-file", () => { const workingDirectory = createTempProject({ "requirements.txt": "uv==0.6.17\nuvicorn==0.35.0\n", diff --git a/action.yml b/action.yml index 9a2d32a..4bc3eaf 100644 --- a/action.yml +++ b/action.yml @@ -7,7 +7,7 @@ inputs: description: "The version of uv to install e.g., `0.5.0` Defaults to the version in pyproject.toml or 'latest'." default: "" version-file: - description: "Path to a file containing the version of uv to install. Defaults to searching for uv.toml and if not found pyproject.toml." + description: "Path to a file containing the version of uv to install, e.g., uv.toml, pyproject.toml, .tool-versions, requirements.txt or uv.lock. Defaults to searching for uv.toml and if not found pyproject.toml." default: "" python-version: description: "The version of Python to set UV_PYTHON to" diff --git a/dist/setup/index.cjs b/dist/setup/index.cjs index e639f3d..21ce707 100644 --- a/dist/setup/index.cjs +++ b/dist/setup/index.cjs @@ -56204,7 +56204,7 @@ var require_semver5 = __commonJS({ }); // src/setup-uv.ts -var import_node_fs7 = __toESM(require("node:fs"), 1); +var import_node_fs8 = __toESM(require("node:fs"), 1); var path16 = __toESM(require("node:path"), 1); // node_modules/@actions/core/lib/command.js @@ -91289,7 +91289,7 @@ function handleMatchResult(matchedKey, primaryKey, stateKey, outputKey) { } // src/download/download-version.ts -var import_node_fs6 = require("node:fs"); +var import_node_fs7 = require("node:fs"); var path14 = __toESM(require("node:path"), 1); // node_modules/@actions/tool-cache/lib/tool-cache.js @@ -96830,7 +96830,7 @@ function parseVersionSpecifier(specifier) { var path13 = __toESM(require("node:path"), 1); // src/version/file-parser.ts -var import_node_fs5 = __toESM(require("node:fs"), 1); +var import_node_fs6 = __toESM(require("node:fs"), 1); // src/utils/config-file.ts var import_node_fs3 = __toESM(require("node:fs"), 1); @@ -97595,6 +97595,18 @@ function getUvVersionFromToolVersions(filePath) { return void 0; } +// src/version/uv-lock-file.ts +var import_node_fs5 = __toESM(require("node:fs"), 1); +function getUvVersionFromUvLock(filePath) { + const fileContent = import_node_fs5.default.readFileSync(filePath, "utf-8"); + return getUvVersionFromUvLockContent(fileContent); +} +function getUvVersionFromUvLockContent(fileContent) { + const parsed = parse2(fileContent); + const uvPackage = parsed.package?.find((pkg) => pkg.name === "uv"); + return uvPackage?.version; +} + // src/version/file-parser.ts var VERSION_FILE_PARSERS = [ { @@ -97605,7 +97617,7 @@ var VERSION_FILE_PARSERS = [ { format: "uv.toml", parse: (filePath) => { - const fileContent = import_node_fs5.default.readFileSync(filePath, "utf-8"); + const fileContent = import_node_fs6.default.readFileSync(filePath, "utf-8"); return getConfigValueFromTomlContent( filePath, fileContent, @@ -97617,7 +97629,7 @@ var VERSION_FILE_PARSERS = [ { format: "pyproject.toml", parse: (filePath) => { - const fileContent = import_node_fs5.default.readFileSync(filePath, "utf-8"); + const fileContent = import_node_fs6.default.readFileSync(filePath, "utf-8"); const pyproject = parsePyprojectContent(fileContent); const requiredVersion = pyproject.tool?.uv?.["required-version"]; if (requiredVersion !== void 0) { @@ -97627,10 +97639,15 @@ var VERSION_FILE_PARSERS = [ }, supports: (filePath) => filePath.endsWith("pyproject.toml") }, + { + format: "uv.lock", + parse: (filePath) => getUvVersionFromUvLock(filePath), + supports: (filePath) => filePath.endsWith("uv.lock") + }, { format: "requirements", parse: (filePath) => { - const fileContent = import_node_fs5.default.readFileSync(filePath, "utf-8"); + const fileContent = import_node_fs6.default.readFileSync(filePath, "utf-8"); return getUvVersionFromRequirementsText(fileContent); }, supports: (filePath) => filePath.endsWith(".txt") @@ -97638,7 +97655,7 @@ var VERSION_FILE_PARSERS = [ ]; function getParsedVersionFile(filePath) { info2(`Trying to find version for uv in: ${filePath}`); - if (!import_node_fs5.default.existsSync(filePath)) { + if (!import_node_fs6.default.existsSync(filePath)) { info2(`Could not find file: ${filePath}`); return void 0; } @@ -97965,7 +97982,7 @@ async function downloadArtifact(downloadUrl, artifactName, platform2, arch3, ver ); const extension = getExtension(platform2); const fullPathWithExtension = `${downloadPath}${extension}`; - await import_node_fs6.promises.copyFile(downloadPath, fullPathWithExtension); + await import_node_fs7.promises.copyFile(downloadPath, fullPathWithExtension); uvDir = await extractZip(fullPathWithExtension); } } else { @@ -98327,7 +98344,7 @@ async function run() { } } function detectEmptyWorkdir(inputs) { - if (import_node_fs7.default.readdirSync(inputs.workingDirectory).length === 0) { + if (import_node_fs8.default.readdirSync(inputs.workingDirectory).length === 0) { if (inputs.ignoreEmptyWorkdir) { info2( "Empty workdir detected. Ignoring because ignore-empty-workdir is enabled" diff --git a/docs/advanced-version-configuration.md b/docs/advanced-version-configuration.md index fc4b918..8f135fa 100644 --- a/docs/advanced-version-configuration.md +++ b/docs/advanced-version-configuration.md @@ -80,3 +80,14 @@ uv defined as a dependency in `pyproject.toml` or `requirements.txt`. with: version-file: "pyproject.toml" ``` + +If uv is locked as a dependency in your `uv.lock`, you can point `version-file` at the +lockfile to install the exact pinned version. This keeps CI runs deterministic and avoids +silently picking up a newer uv until the lockfile is updated. + +```yaml +- name: Install uv based on the version locked in uv.lock + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + version-file: "uv.lock" +``` diff --git a/src/version/file-parser.ts b/src/version/file-parser.ts index 25006a0..37e8c64 100644 --- a/src/version/file-parser.ts +++ b/src/version/file-parser.ts @@ -9,6 +9,7 @@ import { import { normalizeVersionSpecifier } from "./specifier"; import { getUvVersionFromToolVersions } from "./tool-versions-file"; import type { ParsedVersionFile, VersionFileFormat } from "./types"; +import { getUvVersionFromUvLock } from "./uv-lock-file"; interface VersionFileParser { format: VersionFileFormat; @@ -49,6 +50,11 @@ const VERSION_FILE_PARSERS: VersionFileParser[] = [ }, supports: (filePath) => filePath.endsWith("pyproject.toml"), }, + { + format: "uv.lock", + parse: (filePath) => getUvVersionFromUvLock(filePath), + supports: (filePath) => filePath.endsWith("uv.lock"), + }, { format: "requirements", parse: (filePath) => { diff --git a/src/version/types.ts b/src/version/types.ts index f7666fe..4ccefdd 100644 --- a/src/version/types.ts +++ b/src/version/types.ts @@ -11,6 +11,7 @@ export type VersionFileFormat = | ".tool-versions" | "pyproject.toml" | "requirements" + | "uv.lock" | "uv.toml"; export interface ParsedVersionFile { diff --git a/src/version/uv-lock-file.ts b/src/version/uv-lock-file.ts new file mode 100644 index 0000000..5faceba --- /dev/null +++ b/src/version/uv-lock-file.ts @@ -0,0 +1,24 @@ +import fs from "node:fs"; +import * as toml from "smol-toml"; + +interface UvLockPackage { + name?: string; + version?: string; +} + +interface UvLock { + package?: UvLockPackage[]; +} + +export function getUvVersionFromUvLock(filePath: string): string | undefined { + const fileContent = fs.readFileSync(filePath, "utf-8"); + return getUvVersionFromUvLockContent(fileContent); +} + +export function getUvVersionFromUvLockContent( + fileContent: string, +): string | undefined { + const parsed = toml.parse(fileContent) as UvLock; + const uvPackage = parsed.package?.find((pkg) => pkg.name === "uv"); + return uvPackage?.version; +}