mirror of
https://github.com/astral-sh/ruff-action.git
synced 2026-08-08 18:27:03 +00:00
Adds `uv.lock` as a supported `version-file` format, alongside the existing `pyproject.toml` and `requirements.txt`. A `uv.lock` pins the exact resolved ruff version, so reading it gives a reproducible install that matches the project's lockfile — the single source of truth — instead of the lower-bound / range typically declared in `pyproject.toml`. The parser reads the `[[package]]` entry whose `name` is `ruff` and returns its `version`. If ruff is absent or the file cannot be parsed, the action warns and falls back to `latest` (same behaviour as the other formats). Validation: - `npm run all` — tsc typecheck clean, biome clean, dist rebuilt, jest 65/65 pass - Added unit tests (`__tests__/version/file-parser.test.ts`) + a `uv.lock` fixture - Added an integration job (`test-default-version-from-uv-lock`) that runs the action against the fixture and asserts ruff 0.9.5 is installed closes #375
210 lines
5.9 KiB
TypeScript
210 lines
5.9 KiB
TypeScript
import fs from "node:fs";
|
|
import * as core from "@actions/core";
|
|
import * as toml from "smol-toml";
|
|
import { normalizeVersionSpecifier } from "./specifier";
|
|
import type { ParsedVersionFile, VersionFileFormat } from "./types";
|
|
|
|
interface VersionFileParser {
|
|
format: VersionFileFormat;
|
|
parse(filePath: string): string | undefined;
|
|
supports(filePath: string): boolean;
|
|
}
|
|
|
|
interface Pyproject {
|
|
project?: {
|
|
dependencies?: string[];
|
|
"optional-dependencies"?: Record<string, string[]>;
|
|
};
|
|
"dependency-groups"?: Record<string, Array<string | object>>;
|
|
tool?: {
|
|
poetry?: {
|
|
dependencies?: Record<string, string | object>;
|
|
group?: Record<string, { dependencies: Record<string, string | object> }>;
|
|
};
|
|
};
|
|
}
|
|
|
|
interface UvLock {
|
|
package?: Array<{ name?: string; version?: string }>;
|
|
}
|
|
|
|
const VERSION_FILE_PARSERS: VersionFileParser[] = [
|
|
{
|
|
format: "pyproject.toml",
|
|
parse: (filePath) => {
|
|
const fileContent = fs.readFileSync(filePath, "utf-8");
|
|
return getRuffVersionFromPyprojectContent(fileContent);
|
|
},
|
|
supports: (filePath) => filePath.endsWith("pyproject.toml"),
|
|
},
|
|
{
|
|
format: "uv.lock",
|
|
parse: (filePath) => {
|
|
const fileContent = fs.readFileSync(filePath, "utf-8");
|
|
return getRuffVersionFromUvLockContent(fileContent);
|
|
},
|
|
supports: (filePath) => filePath.endsWith("uv.lock"),
|
|
},
|
|
{
|
|
format: "requirements",
|
|
parse: (filePath) => {
|
|
const fileContent = fs.readFileSync(filePath, "utf-8");
|
|
return getRuffVersionFromRequirementsText(fileContent);
|
|
},
|
|
supports: (filePath) => filePath.endsWith(".txt"),
|
|
},
|
|
];
|
|
|
|
export function getParsedVersionFile(
|
|
filePath: string,
|
|
): ParsedVersionFile | undefined {
|
|
core.info(`Trying to find version for ruff in: ${filePath}`);
|
|
|
|
if (!fs.existsSync(filePath)) {
|
|
core.warning(`Could not find file: ${filePath}`);
|
|
return undefined;
|
|
}
|
|
|
|
const parser = getVersionFileParser(filePath);
|
|
if (parser === undefined) {
|
|
return undefined;
|
|
}
|
|
|
|
try {
|
|
const specifier = parser.parse(filePath);
|
|
if (specifier === undefined) {
|
|
return undefined;
|
|
}
|
|
|
|
const normalizedSpecifier = normalizeVersionSpecifier(specifier);
|
|
core.info(`Found version for ruff in ${filePath}: ${normalizedSpecifier}`);
|
|
return {
|
|
format: parser.format,
|
|
specifier: normalizedSpecifier,
|
|
};
|
|
} catch (error) {
|
|
core.warning(
|
|
`Error while parsing ${filePath}: ${(error as Error).message}`,
|
|
);
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
export function getRuffVersionFromFile(filePath: string): string | undefined {
|
|
return getParsedVersionFile(filePath)?.specifier;
|
|
}
|
|
|
|
export function findRuffVersionInSpec(spec: string): string | undefined {
|
|
const trimmedSpec = spec.trim();
|
|
|
|
if (!trimmedSpec.startsWith("ruff")) {
|
|
return undefined;
|
|
}
|
|
|
|
let versionSpec = trimmedSpec.slice("ruff".length);
|
|
if (!versionSpec.match(/^(?:\s+|[=<>~!])/)) {
|
|
return undefined;
|
|
}
|
|
|
|
versionSpec = versionSpec.replace(/\\$/, "").trim();
|
|
|
|
const match = versionSpec.match(/^([^;]+)(?:;.*)?$/);
|
|
|
|
if (match) {
|
|
let version = match[1].trim();
|
|
if (version) {
|
|
version = normalizeVersionSpecifier(version);
|
|
if (trimmedSpec.includes(";")) {
|
|
core.warning(
|
|
"Environment markers are ignored. ruff is a standalone tool that works independently of Python version.",
|
|
);
|
|
}
|
|
core.info(`Found ruff version in requirements file: ${version}`);
|
|
return version;
|
|
}
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
export function getRuffVersionFromRequirementsText(
|
|
fileContent: string,
|
|
): string | undefined {
|
|
return getRuffVersionFromAllDependencies(fileContent.split("\n"));
|
|
}
|
|
|
|
export function getRuffVersionFromPyprojectContent(
|
|
pyprojectContent: string,
|
|
): string | undefined {
|
|
const pyproject = parsePyprojectContent(pyprojectContent);
|
|
return getRuffVersionFromParsedPyproject(pyproject);
|
|
}
|
|
|
|
export function parsePyprojectContent(pyprojectContent: string): Pyproject {
|
|
return toml.parse(pyprojectContent) as Pyproject;
|
|
}
|
|
|
|
export function getRuffVersionFromUvLockContent(
|
|
uvLockContent: string,
|
|
): string | undefined {
|
|
const uvLock = toml.parse(uvLockContent) as UvLock;
|
|
const ruffPackage = (uvLock.package || []).find((pkg) => pkg.name === "ruff");
|
|
if (ruffPackage?.version === undefined) {
|
|
return undefined;
|
|
}
|
|
core.info(`Found ruff version in uv.lock: ${ruffPackage.version}`);
|
|
return ruffPackage.version;
|
|
}
|
|
|
|
function getVersionFileParser(filePath: string): VersionFileParser | undefined {
|
|
return VERSION_FILE_PARSERS.find((parser) => parser.supports(filePath));
|
|
}
|
|
|
|
function getRuffVersionFromParsedPyproject(
|
|
pyproject: Pyproject,
|
|
): string | undefined {
|
|
const dependencies: string[] = pyproject.project?.dependencies || [];
|
|
const optionalDependencies: string[] = Object.values(
|
|
pyproject.project?.["optional-dependencies"] || {},
|
|
).flat();
|
|
const devDependencies: string[] = Object.values(
|
|
pyproject["dependency-groups"] || {},
|
|
)
|
|
.flat()
|
|
.filter((item: string | object) => typeof item === "string");
|
|
|
|
return (
|
|
getRuffVersionFromAllDependencies(
|
|
dependencies.concat(optionalDependencies, devDependencies),
|
|
) || getRuffVersionFromPoetryGroups(pyproject)
|
|
);
|
|
}
|
|
|
|
function getRuffVersionFromPoetryGroups(
|
|
pyproject: Pyproject,
|
|
): string | undefined {
|
|
const poetry = pyproject.tool?.poetry || {};
|
|
const poetryGroups = Object.values(poetry.group || {});
|
|
if (poetry.dependencies) {
|
|
poetryGroups.unshift({ dependencies: poetry.dependencies });
|
|
}
|
|
|
|
return poetryGroups
|
|
.flatMap((group) => Object.entries(group.dependencies))
|
|
.map(([name, spec]) => {
|
|
if (typeof spec === "string") {
|
|
return findRuffVersionInSpec(`${name} ${spec}`);
|
|
}
|
|
return undefined;
|
|
})
|
|
.find((version) => version !== undefined);
|
|
}
|
|
|
|
function getRuffVersionFromAllDependencies(
|
|
allDependencies: string[],
|
|
): string | undefined {
|
|
return allDependencies
|
|
.map((dependency) => findRuffVersionInSpec(dependency))
|
|
.find((version) => version !== undefined);
|
|
}
|