diff --git a/SECURITY.md b/SECURITY.md index 77568b2..edf9984 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,3 +1,8 @@ +# Security policy + +Report suspected vulnerabilities according to [Astral's security policy](https://github.com/astral-sh/.github/blob/main/SECURITY.md). + +For this repository's security boundaries and reporting criteria, see the [setup-uv threat model](docs/threat-model.md). # Security Policy ## Reporting a Vulnerability diff --git a/__tests__/cache/restore-cache.test.ts b/__tests__/cache/restore-cache.test.ts index ec342d3..8c48045 100644 --- a/__tests__/cache/restore-cache.test.ts +++ b/__tests__/cache/restore-cache.test.ts @@ -72,3 +72,77 @@ describe("restoreCache", () => { ); }); }); +import { beforeEach, describe, expect, it, jest } from "@jest/globals"; +import { createSetupInputs } from "../helpers/setup-inputs"; + +const mockRestoreCache = jest.fn(); +const mockSaveState = jest.fn(); +const mockSetOutput = jest.fn(); + +jest.unstable_mockModule("@actions/cache", () => ({ + restoreCache: mockRestoreCache, +})); + +jest.unstable_mockModule("@actions/core", () => ({ + saveState: mockSaveState, + setOutput: mockSetOutput, +})); + +jest.unstable_mockModule("../../src/hash/hash-files", () => ({ + hashFiles: jest.fn(async () => "dependencyhash"), +})); + +jest.unstable_mockModule("../../src/utils/logging", () => ({ + info: jest.fn(), + warning: jest.fn(), +})); + +jest.unstable_mockModule("../../src/utils/platforms", () => ({ + getArch: jest.fn(() => "x86_64"), + getOSNameVersion: jest.fn(() => "ubuntu-24.04"), + getPlatform: jest.fn(async () => "unknown-linux-gnu"), +})); + +const { restoreCache } = await import("../../src/cache/restore-cache"); + +function cacheKeyOutput(): string { + const call = mockSetOutput.mock.calls.find(([name]) => name === "cache-key"); + expect(call).toBeDefined(); + return call?.[1] as string; +} + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe("restoreCache", () => { + it("encodes Python version ranges before adding them to the cache key", async () => { + await restoreCache(createSetupInputs(), ">3.10.11,<3.11"); + + const cacheKey = cacheKeyOutput(); + + expect(cacheKey).not.toContain(","); + expect(cacheKey).toContain("-%3E3.10.11%2C%3C3.11-"); + }); + + it("encodes cache suffixes before adding them to the cache key", async () => { + const inputs = createSetupInputs({ cacheSuffix: "tests-3.10,3.11" }); + + await restoreCache(inputs, "3.11"); + + const cacheKey = cacheKeyOutput(); + + expect(cacheKey).not.toContain(","); + expect(cacheKey).toContain("-tests-3.10%2C3.11"); + }); + + it("keeps cache keys unchanged for exact Python versions and simple suffixes", async () => { + const inputs = createSetupInputs({ cacheSuffix: "tests-3.11" }); + + await restoreCache(inputs, "3.11"); + + expect(cacheKeyOutput()).toBe( + "setup-uv-2-x86_64-unknown-linux-gnu-ubuntu-24.04-3.11-pruned-dependencyhash-tests-3.11", + ); + }); +}); diff --git a/__tests__/helpers/setup-inputs.ts b/__tests__/helpers/setup-inputs.ts index 1ca0d0d..52d238d 100644 --- a/__tests__/helpers/setup-inputs.ts +++ b/__tests__/helpers/setup-inputs.ts @@ -34,3 +34,39 @@ export function createSetupInputs( ...overrides, }; } +import { CacheLocalSource, type SetupInputs } from "../../src/utils/inputs"; + +export function createSetupInputs( + overrides: Partial = {}, +): SetupInputs { + return { + activateEnvironment: false, + addProblemMatchers: false, + cacheDependencyGlob: "uv.lock", + cacheLocalPath: { + path: "/tmp/setup-uv-cache", + source: CacheLocalSource.Input, + }, + cachePython: false, + cacheSuffix: "", + checksum: "", + downloadFromAstralMirror: false, + enableCache: true, + githubToken: "", + ignoreEmptyWorkdir: false, + ignoreNothingToCache: false, + noProject: false, + pruneCache: true, + pythonDir: "/tmp/uv-python-dir", + pythonVersion: "", + quiet: false, + resolutionStrategy: "highest", + restoreCache: false, + saveCache: true, + venvPath: "/workspace/.venv", + version: "", + versionFile: "", + workingDirectory: "/workspace", + ...overrides, + }; +} diff --git a/__tests__/version/uv-lock-file.test.ts b/__tests__/version/uv-lock-file.test.ts index 98e48d4..0261b7b 100644 --- a/__tests__/version/uv-lock-file.test.ts +++ b/__tests__/version/uv-lock-file.test.ts @@ -34,3 +34,39 @@ version = "4.6.0" expect(getUvVersionFromUvLockContent("version = 1\n")).toBeUndefined(); }); }); +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 754f29f..c7ecf84 100644 --- a/__tests__/version/version-request-resolver.test.ts +++ b/__tests__/version/version-request-resolver.test.ts @@ -81,6 +81,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/docs/advanced-version-configuration.md b/docs/advanced-version-configuration.md index 12e084c..df419e0 100644 --- a/docs/advanced-version-configuration.md +++ b/docs/advanced-version-configuration.md @@ -91,3 +91,14 @@ silently picking up a newer uv until the lockfile is updated. with: version-file: "uv.lock" ``` + +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/uv-lock-file.ts b/src/version/uv-lock-file.ts index 5faceba..cc0e438 100644 --- a/src/version/uv-lock-file.ts +++ b/src/version/uv-lock-file.ts @@ -22,3 +22,27 @@ export function getUvVersionFromUvLockContent( const uvPackage = parsed.package?.find((pkg) => pkg.name === "uv"); return uvPackage?.version; } +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; +}