mirror of
https://github.com/astral-sh/setup-uv.git
synced 2026-06-19 11:02:23 +00:00
feat: support uv.lock as a version-file source (#918)
Adds `uv.lock` as a supported `version-file` source. When `uv` is locked as a dependency in `uv.lock`, the action now installs the exact pinned version, closing the gap reported in #682. This is useful for deterministic CI: the same uv version is used until the lockfile is updated, which avoids "CI worked yesterday, fails today" drift and reduces supply-chain exposure from auto-installing the latest release. The implementation mirrors the existing `version-file` parsers — a new `uv.lock` entry in the parser registry reads the `[[package]]` whose `name = "uv"` and returns its locked `version`. Scoped to explicit `version-file: uv.lock`; workspace auto-detection is left as a possible follow-up to avoid precedence ambiguity with `uv.toml` / `pyproject.toml`. Validation (local, Node 23; dist build is esbuild-deterministic): - `npm run all` → build clean, biome clean, package clean, jest 77/77 - New tests: 3 unit (`uv-lock-file.test.ts`) + 1 integration — exact pin resolves through the full pipeline (`uv.lock` → `0.8.17`) - dist rebuilt + committed (single bundle, no spurious churn) related: #682
This commit is contained in:
@@ -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)
|
# The version of uv to install (default: searches for version in config files, then latest)
|
||||||
version: ""
|
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: ""
|
version-file: ""
|
||||||
|
|
||||||
# Resolution strategy when resolving version ranges: 'highest' or 'lowest'
|
# Resolution strategy when resolving version ranges: 'highest' or 'lowest'
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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", () => {
|
it("uses requirements.txt when it is passed via version-file", () => {
|
||||||
const workingDirectory = createTempProject({
|
const workingDirectory = createTempProject({
|
||||||
"requirements.txt": "uv==0.6.17\nuvicorn==0.35.0\n",
|
"requirements.txt": "uv==0.6.17\nuvicorn==0.35.0\n",
|
||||||
|
|||||||
+1
-1
@@ -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'."
|
description: "The version of uv to install e.g., `0.5.0` Defaults to the version in pyproject.toml or 'latest'."
|
||||||
default: ""
|
default: ""
|
||||||
version-file:
|
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: ""
|
default: ""
|
||||||
python-version:
|
python-version:
|
||||||
description: "The version of Python to set UV_PYTHON to"
|
description: "The version of Python to set UV_PYTHON to"
|
||||||
|
|||||||
+26
-9
@@ -56204,7 +56204,7 @@ var require_semver5 = __commonJS({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// src/setup-uv.ts
|
// 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);
|
var path16 = __toESM(require("node:path"), 1);
|
||||||
|
|
||||||
// node_modules/@actions/core/lib/command.js
|
// node_modules/@actions/core/lib/command.js
|
||||||
@@ -91289,7 +91289,7 @@ function handleMatchResult(matchedKey, primaryKey, stateKey, outputKey) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// src/download/download-version.ts
|
// 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);
|
var path14 = __toESM(require("node:path"), 1);
|
||||||
|
|
||||||
// node_modules/@actions/tool-cache/lib/tool-cache.js
|
// node_modules/@actions/tool-cache/lib/tool-cache.js
|
||||||
@@ -96830,7 +96830,7 @@ function parseVersionSpecifier(specifier) {
|
|||||||
var path13 = __toESM(require("node:path"), 1);
|
var path13 = __toESM(require("node:path"), 1);
|
||||||
|
|
||||||
// src/version/file-parser.ts
|
// 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
|
// src/utils/config-file.ts
|
||||||
var import_node_fs3 = __toESM(require("node:fs"), 1);
|
var import_node_fs3 = __toESM(require("node:fs"), 1);
|
||||||
@@ -97595,6 +97595,18 @@ function getUvVersionFromToolVersions(filePath) {
|
|||||||
return void 0;
|
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
|
// src/version/file-parser.ts
|
||||||
var VERSION_FILE_PARSERS = [
|
var VERSION_FILE_PARSERS = [
|
||||||
{
|
{
|
||||||
@@ -97605,7 +97617,7 @@ var VERSION_FILE_PARSERS = [
|
|||||||
{
|
{
|
||||||
format: "uv.toml",
|
format: "uv.toml",
|
||||||
parse: (filePath) => {
|
parse: (filePath) => {
|
||||||
const fileContent = import_node_fs5.default.readFileSync(filePath, "utf-8");
|
const fileContent = import_node_fs6.default.readFileSync(filePath, "utf-8");
|
||||||
return getConfigValueFromTomlContent(
|
return getConfigValueFromTomlContent(
|
||||||
filePath,
|
filePath,
|
||||||
fileContent,
|
fileContent,
|
||||||
@@ -97617,7 +97629,7 @@ var VERSION_FILE_PARSERS = [
|
|||||||
{
|
{
|
||||||
format: "pyproject.toml",
|
format: "pyproject.toml",
|
||||||
parse: (filePath) => {
|
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 pyproject = parsePyprojectContent(fileContent);
|
||||||
const requiredVersion = pyproject.tool?.uv?.["required-version"];
|
const requiredVersion = pyproject.tool?.uv?.["required-version"];
|
||||||
if (requiredVersion !== void 0) {
|
if (requiredVersion !== void 0) {
|
||||||
@@ -97627,10 +97639,15 @@ var VERSION_FILE_PARSERS = [
|
|||||||
},
|
},
|
||||||
supports: (filePath) => filePath.endsWith("pyproject.toml")
|
supports: (filePath) => filePath.endsWith("pyproject.toml")
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
format: "uv.lock",
|
||||||
|
parse: (filePath) => getUvVersionFromUvLock(filePath),
|
||||||
|
supports: (filePath) => filePath.endsWith("uv.lock")
|
||||||
|
},
|
||||||
{
|
{
|
||||||
format: "requirements",
|
format: "requirements",
|
||||||
parse: (filePath) => {
|
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);
|
return getUvVersionFromRequirementsText(fileContent);
|
||||||
},
|
},
|
||||||
supports: (filePath) => filePath.endsWith(".txt")
|
supports: (filePath) => filePath.endsWith(".txt")
|
||||||
@@ -97638,7 +97655,7 @@ var VERSION_FILE_PARSERS = [
|
|||||||
];
|
];
|
||||||
function getParsedVersionFile(filePath) {
|
function getParsedVersionFile(filePath) {
|
||||||
info2(`Trying to find version for uv in: ${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}`);
|
info2(`Could not find file: ${filePath}`);
|
||||||
return void 0;
|
return void 0;
|
||||||
}
|
}
|
||||||
@@ -97965,7 +97982,7 @@ async function downloadArtifact(downloadUrl, artifactName, platform2, arch3, ver
|
|||||||
);
|
);
|
||||||
const extension = getExtension(platform2);
|
const extension = getExtension(platform2);
|
||||||
const fullPathWithExtension = `${downloadPath}${extension}`;
|
const fullPathWithExtension = `${downloadPath}${extension}`;
|
||||||
await import_node_fs6.promises.copyFile(downloadPath, fullPathWithExtension);
|
await import_node_fs7.promises.copyFile(downloadPath, fullPathWithExtension);
|
||||||
uvDir = await extractZip(fullPathWithExtension);
|
uvDir = await extractZip(fullPathWithExtension);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -98327,7 +98344,7 @@ async function run() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
function detectEmptyWorkdir(inputs) {
|
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) {
|
if (inputs.ignoreEmptyWorkdir) {
|
||||||
info2(
|
info2(
|
||||||
"Empty workdir detected. Ignoring because ignore-empty-workdir is enabled"
|
"Empty workdir detected. Ignoring because ignore-empty-workdir is enabled"
|
||||||
|
|||||||
@@ -80,3 +80,14 @@ uv defined as a dependency in `pyproject.toml` or `requirements.txt`.
|
|||||||
with:
|
with:
|
||||||
version-file: "pyproject.toml"
|
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"
|
||||||
|
```
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
import { normalizeVersionSpecifier } from "./specifier";
|
import { normalizeVersionSpecifier } from "./specifier";
|
||||||
import { getUvVersionFromToolVersions } from "./tool-versions-file";
|
import { getUvVersionFromToolVersions } from "./tool-versions-file";
|
||||||
import type { ParsedVersionFile, VersionFileFormat } from "./types";
|
import type { ParsedVersionFile, VersionFileFormat } from "./types";
|
||||||
|
import { getUvVersionFromUvLock } from "./uv-lock-file";
|
||||||
|
|
||||||
interface VersionFileParser {
|
interface VersionFileParser {
|
||||||
format: VersionFileFormat;
|
format: VersionFileFormat;
|
||||||
@@ -49,6 +50,11 @@ const VERSION_FILE_PARSERS: VersionFileParser[] = [
|
|||||||
},
|
},
|
||||||
supports: (filePath) => filePath.endsWith("pyproject.toml"),
|
supports: (filePath) => filePath.endsWith("pyproject.toml"),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
format: "uv.lock",
|
||||||
|
parse: (filePath) => getUvVersionFromUvLock(filePath),
|
||||||
|
supports: (filePath) => filePath.endsWith("uv.lock"),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
format: "requirements",
|
format: "requirements",
|
||||||
parse: (filePath) => {
|
parse: (filePath) => {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export type VersionFileFormat =
|
|||||||
| ".tool-versions"
|
| ".tool-versions"
|
||||||
| "pyproject.toml"
|
| "pyproject.toml"
|
||||||
| "requirements"
|
| "requirements"
|
||||||
|
| "uv.lock"
|
||||||
| "uv.toml";
|
| "uv.toml";
|
||||||
|
|
||||||
export interface ParsedVersionFile {
|
export interface ParsedVersionFile {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user