Compare commits

..

1 Commits

Author SHA1 Message Date
Kevin Stillhammer
01149c4575 Speed up version client by partial response reads 2026-03-14 18:00:39 +01:00
35 changed files with 5196 additions and 3792 deletions

View File

@@ -1,9 +0,0 @@
{
"compilerOptions": {
"module": "nodenext",
"moduleResolution": "nodenext",
"target": "es2022",
"types": ["node"]
},
"include": ["check-all-tests-passed-needs.ts"]
}

View File

@@ -19,6 +19,6 @@ jobs:
pull-requests: read
steps:
- name: 🚀 Run Release Drafter
uses: release-drafter/release-drafter@139054aeaa9adc52ab36ddf67437541f039b88e2 # v7.1.1
uses: release-drafter/release-drafter@6db134d15f3909ccc9eefd369f02bd1e9cffdf97 # v6.2.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -1,58 +0,0 @@
name: Release
on:
workflow_dispatch:
inputs:
version:
description: "Release version (e.g., 8.1.0)"
required: true
type: string
permissions: {}
jobs:
release:
name: Release
runs-on: ubuntu-latest
environment: release
permissions:
contents: write
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Validate version
env:
VERSION: ${{ inputs.version }}
run: |
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$ ]]; then
echo "::error::Version must match MAJOR.MINOR.PATCH (e.g., 8.1.0)"
exit 1
fi
- name: Publish release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: v${{ inputs.version }}
run: |
RELEASE_JSON=$(gh release view "$TAG" --json isDraft,targetCommitish 2>&1) || {
echo "::error::No release found for $TAG"
exit 1
}
IS_DRAFT=$(echo "$RELEASE_JSON" | jq -r '.isDraft')
TARGET=$(echo "$RELEASE_JSON" | jq -r '.targetCommitish')
if [[ "$IS_DRAFT" != "true" ]]; then
echo "::error::Release $TAG already exists and is not a draft"
exit 1
fi
if [[ "$TARGET" != "$GITHUB_SHA" ]]; then
echo "::error::Draft release target ($TARGET) does not match current commit ($GITHUB_SHA)"
exit 1
fi
echo "Publishing draft release $TAG"
gh release edit "$TAG" --draft=false

View File

@@ -27,7 +27,7 @@ jobs:
- name: Actionlint
uses: eifinger/actionlint-action@7802e0cc3ab3f81cbffb36fb0bf1a3621d994b89 # v1.10.1
- name: Run zizmor
uses: zizmorcore/zizmor-action@71321a20a9ded102f6e9ce5718a2fcec2c4f70d8 # v0.5.2
uses: zizmorcore/zizmor-action@0dce2577a4760a2749d8cfb7a84b7d5585ebcb7d # v0.5.0
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version-file: .nvmrc
@@ -38,7 +38,7 @@ jobs:
npm run all
- name: Check all jobs are in all-tests-passed.needs
run: |
tsc -p tsconfig.json
tsc --module nodenext --moduleResolution nodenext --target es2022 check-all-tests-passed-needs.ts
node check-all-tests-passed-needs.js
working-directory: .github/scripts
- name: Make sure no changes from linters are detected
@@ -164,22 +164,10 @@ jobs:
- name: Latest version gets installed
run: |
LATEST_VERSION=$(gh api -H "Accept: application/vnd.github+json" -H "X-GitHub-Api-Version: 2022-11-28" /repos/astral-sh/uv/releases/latest | jq -r '.tag_name')
UV_VERSION_OUTPUT=$(uv --version)
if [[ ! "$UV_VERSION_OUTPUT" =~ ^uv[[:space:]]+([^[:space:]]+) ]]; then
echo "Could not parse uv version from: $UV_VERSION_OUTPUT"
exit 1
fi
UV_VERSION="${BASH_REMATCH[1]}"
echo "Latest version is $LATEST_VERSION"
echo "uv --version output is $UV_VERSION_OUTPUT"
echo "Parsed uv version is $UV_VERSION"
if [ "$UV_VERSION" != "$LATEST_VERSION" ]; then
echo "Wrong uv version: $UV_VERSION_OUTPUT"
exit 1
if [ "$(uv --version)" != "uv $LATEST_VERSION" ]; then
echo "Wrong uv version: $(uv --version)"
exit 1
fi
env:
GH_TOKEN: ${{ github.token }}
@@ -808,12 +796,12 @@ jobs:
- name: Install from custom manifest file
uses: ./
with:
manifest-file: "https://raw.githubusercontent.com/astral-sh/setup-uv/${{ github.ref }}/__tests__/download/custom-manifest.ndjson"
manifest-file: "https://raw.githubusercontent.com/astral-sh/setup-uv/${{ github.ref }}/__tests__/download/custom-manifest.json"
- run: uv sync
working-directory: __tests__/fixtures/uv-project
- name: Correct version gets installed
run: |
if [ "$(uv --version)" != "uv 0.9.26" ]; then
if [ "$(uv --version)" != "uv 0.7.12-alpha.1" ]; then
echo "Wrong uv version: $(uv --version)"
exit 1
fi

View File

@@ -0,0 +1,51 @@
---
name: Update Major Minor Tags
on:
push:
branches-ignore:
- "**"
tags:
- "v*.*.*"
permissions: {}
jobs:
update_major_minor_tags:
name: Make sure major and minor tags are up to date on a patch release
runs-on: ubuntu-24.04-arm
permissions:
contents: write
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: true # needed for git push below
- name: Update Major Minor Tags
run: |
set -x
cd "${GITHUB_WORKSPACE}" || exit
# Set up variables.
TAG="${GITHUB_REF#refs/tags/}" # v1.2.3
MINOR="${TAG%.*}" # v1.2
MAJOR="${MINOR%.*}" # v1
if [ "${GITHUB_REF}" = "${TAG}" ]; then
echo "This workflow is not triggered by tag push: GITHUB_REF=${GITHUB_REF}"
exit 1
fi
MESSAGE="Release ${TAG}"
# Set up Git.
git config user.name "${GITHUB_ACTOR}"
git config user.email "${GITHUB_ACTOR}@users.noreply.github.com"
# Update MAJOR/MINOR tag
git tag -fa "${MAJOR}" -m "${MESSAGE}"
git tag -fa "${MINOR}" -m "${MESSAGE}"
# Push
git push --force origin "${MINOR}"
git push --force origin "${MAJOR}"

View File

@@ -26,7 +26,7 @@ Set up your GitHub Actions workflow with a specific version of [uv](https://docs
```yaml
- name: Install the latest version of uv
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
uses: astral-sh/setup-uv@v7
```
If you do not specify a version, this action will look for a [required-version](https://docs.astral.sh/uv/reference/settings/#required-version)
@@ -42,7 +42,7 @@ Have a look under [Advanced Configuration](#advanced-configuration) for detailed
```yaml
- name: Install uv with all available options
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
uses: astral-sh/setup-uv@v7
with:
# The version of uv to install (default: searches for version in config files, then latest)
version: ""
@@ -114,7 +114,7 @@ Have a look under [Advanced Configuration](#advanced-configuration) for detailed
# Custom path to set UV_TOOL_BIN_DIR to
tool-bin-dir: ""
# URL to a custom manifest file in the astral-sh/versions format
# URL to a custom manifest file (NDJSON preferred, legacy JSON array is deprecated)
manifest-file: ""
# Add problem matchers
@@ -139,7 +139,7 @@ This will override any python version specifications in `pyproject.toml` and `.p
```yaml
- name: Install the latest version of uv and set the python version to 3.13t
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
uses: astral-sh/setup-uv@v7
with:
python-version: 3.13t
- run: uv pip install --python=3.13t pip
@@ -157,7 +157,7 @@ jobs:
steps:
- uses: actions/checkout@v5
- name: Install the latest version of uv and set the python version
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
uses: astral-sh/setup-uv@v7
with:
python-version: ${{ matrix.python-version }}
- name: Test with python ${{ matrix.python-version }}
@@ -174,7 +174,7 @@ It also controls where [the venv gets created](#activate-environment), unless `v
```yaml
- name: Install uv based on the config files in the working-directory
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
uses: astral-sh/setup-uv@v7
with:
working-directory: my/subproject/dir
```
@@ -190,8 +190,8 @@ For more advanced configuration options, see our detailed documentation:
## How it works
By default, this action resolves uv versions from the
[`astral-sh/versions`](https://github.com/astral-sh/versions) manifest and downloads uv from the
By default, this action resolves uv versions from
[`astral-sh/versions`](https://github.com/astral-sh/versions) (NDJSON) and downloads uv from the
official [GitHub Releases](https://github.com/astral-sh/uv).
It then uses the [GitHub Actions Toolkit](https://github.com/actions/toolkit) to cache uv as a
@@ -216,7 +216,7 @@ For example:
- name: Checkout the repository
uses: actions/checkout@main
- name: Install the latest version of uv
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
- name: Test
@@ -228,7 +228,7 @@ To install a specific version of Python, use
```yaml
- name: Install the latest version of uv
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
- name: Install Python 3.12
@@ -247,7 +247,7 @@ output:
uses: actions/checkout@main
- name: Install the default version of uv
id: setup-uv
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
uses: astral-sh/setup-uv@v7
- name: Print the installed version
run: echo "Installed uv version is ${{ steps.setup-uv.outputs.uv-version }}"
```

View File

@@ -0,0 +1,9 @@
[
{
"arch": "x86_64",
"artifactName": "uv-x86_64-unknown-linux-gnu.tar.gz",
"downloadUrl": "https://release.pyx.dev/0.7.12-alpha.1/uv-x86_64-unknown-linux-gnu.tar.gz",
"platform": "unknown-linux-gnu",
"version": "0.7.12-alpha.1"
}
]

View File

@@ -1 +0,0 @@
{"version":"0.9.26","artifacts":[{"platform":"x86_64-unknown-linux-gnu","variant":"default","url":"https://github.com/astral-sh/uv/releases/download/0.9.26/uv-x86_64-unknown-linux-gnu.tar.gz","archive_format":"tar.gz","sha256":"30ccbf0a66dc8727a02b0e245c583ee970bdafecf3a443c1686e1b30ec4939e8"}]}

View File

@@ -32,16 +32,32 @@ jest.unstable_mockModule("@actions/tool-cache", () => ({
}));
// biome-ignore lint/suspicious/noExplicitAny: Mock requires flexible typing in tests.
const mockGetLatestVersion = jest.fn<any>();
const mockGetLatestVersionFromNdjson = jest.fn<any>();
// biome-ignore lint/suspicious/noExplicitAny: Mock requires flexible typing in tests.
const mockGetAllVersions = jest.fn<any>();
const mockGetAllVersionsFromNdjson = jest.fn<any>();
// biome-ignore lint/suspicious/noExplicitAny: Mock requires flexible typing in tests.
const mockGetArtifact = jest.fn<any>();
const mockGetArtifactFromNdjson = jest.fn<any>();
// biome-ignore lint/suspicious/noExplicitAny: Mock requires flexible typing in tests.
const mockGetHighestSatisfyingVersionFromNdjson = jest.fn<any>();
jest.unstable_mockModule("../../src/download/manifest", () => ({
getAllVersions: mockGetAllVersions,
getArtifact: mockGetArtifact,
getLatestVersion: mockGetLatestVersion,
jest.unstable_mockModule("../../src/download/versions-client", () => ({
getAllVersions: mockGetAllVersionsFromNdjson,
getArtifact: mockGetArtifactFromNdjson,
getHighestSatisfyingVersion: mockGetHighestSatisfyingVersionFromNdjson,
getLatestVersion: mockGetLatestVersionFromNdjson,
}));
// biome-ignore lint/suspicious/noExplicitAny: Mock requires flexible typing in tests.
const mockGetAllManifestVersions = jest.fn<any>();
// biome-ignore lint/suspicious/noExplicitAny: Mock requires flexible typing in tests.
const mockGetLatestVersionInManifest = jest.fn<any>();
// biome-ignore lint/suspicious/noExplicitAny: Mock requires flexible typing in tests.
const mockGetManifestArtifact = jest.fn<any>();
jest.unstable_mockModule("../../src/download/version-manifest", () => ({
getAllVersions: mockGetAllManifestVersions,
getLatestKnownVersion: mockGetLatestVersionInManifest,
getManifestArtifact: mockGetManifestArtifact,
}));
// biome-ignore lint/suspicious/noExplicitAny: Mock requires flexible typing in tests.
@@ -51,9 +67,11 @@ jest.unstable_mockModule("../../src/download/checksum/checksum", () => ({
validateChecksum: mockValidateChecksum,
}));
const { downloadVersion, resolveVersion, rewriteToMirror } = await import(
"../../src/download/download-version"
);
const {
downloadVersionFromManifest,
downloadVersionFromNdjson,
resolveVersion,
} = await import("../../src/download/download-version");
describe("download-version", () => {
beforeEach(() => {
@@ -63,9 +81,13 @@ describe("download-version", () => {
mockExtractTar.mockReset();
mockExtractZip.mockReset();
mockCacheDir.mockReset();
mockGetLatestVersion.mockReset();
mockGetAllVersions.mockReset();
mockGetArtifact.mockReset();
mockGetLatestVersionFromNdjson.mockReset();
mockGetAllVersionsFromNdjson.mockReset();
mockGetArtifactFromNdjson.mockReset();
mockGetHighestSatisfyingVersionFromNdjson.mockReset();
mockGetAllManifestVersions.mockReset();
mockGetLatestVersionInManifest.mockReset();
mockGetManifestArtifact.mockReset();
mockValidateChecksum.mockReset();
mockDownloadTool.mockResolvedValue("/tmp/downloaded");
@@ -75,28 +97,49 @@ describe("download-version", () => {
});
describe("resolveVersion", () => {
it("uses the default manifest to resolve latest", async () => {
mockGetLatestVersion.mockResolvedValue("0.9.26");
it("uses astral-sh/versions to resolve latest", async () => {
mockGetLatestVersionFromNdjson.mockResolvedValue("0.9.26");
const version = await resolveVersion("latest", undefined);
expect(version).toBe("0.9.26");
expect(mockGetLatestVersion).toHaveBeenCalledTimes(1);
expect(mockGetLatestVersion).toHaveBeenCalledWith(undefined);
expect(mockGetLatestVersionFromNdjson).toHaveBeenCalledTimes(1);
});
it("uses the default manifest to resolve available versions", async () => {
mockGetAllVersions.mockResolvedValue(["0.9.26", "0.9.25"]);
it("streams astral-sh/versions to resolve the highest matching version", async () => {
mockGetHighestSatisfyingVersionFromNdjson.mockResolvedValue("0.9.26");
const version = await resolveVersion("^0.9.0", undefined);
expect(version).toBe("0.9.26");
expect(mockGetAllVersions).toHaveBeenCalledTimes(1);
expect(mockGetAllVersions).toHaveBeenCalledWith(undefined);
expect(mockGetHighestSatisfyingVersionFromNdjson).toHaveBeenCalledWith(
"^0.9.0",
);
expect(mockGetAllVersionsFromNdjson).not.toHaveBeenCalled();
});
it("still loads all versions when resolving the lowest matching version", async () => {
mockGetAllVersionsFromNdjson.mockResolvedValue(["0.9.26", "0.9.25"]);
const version = await resolveVersion("^0.9.0", undefined, "lowest");
expect(version).toBe("0.9.25");
expect(mockGetAllVersionsFromNdjson).toHaveBeenCalledTimes(1);
expect(mockGetHighestSatisfyingVersionFromNdjson).not.toHaveBeenCalled();
});
it("does not fall back when astral-sh/versions fails", async () => {
mockGetLatestVersionFromNdjson.mockRejectedValue(
new Error("NDJSON unavailable"),
);
await expect(resolveVersion("latest", undefined)).rejects.toThrow(
"NDJSON unavailable",
);
});
it("uses manifest-file when provided", async () => {
mockGetAllVersions.mockResolvedValue(["0.9.26", "0.9.25"]);
mockGetAllManifestVersions.mockResolvedValue(["0.9.26", "0.9.25"]);
const version = await resolveVersion(
"^0.9.0",
@@ -104,35 +147,37 @@ describe("download-version", () => {
);
expect(version).toBe("0.9.26");
expect(mockGetAllVersions).toHaveBeenCalledWith(
expect(mockGetAllManifestVersions).toHaveBeenCalledWith(
"https://example.com/custom.ndjson",
);
});
});
describe("downloadVersion", () => {
it("fails when manifest lookup fails", async () => {
mockGetArtifact.mockRejectedValue(new Error("manifest unavailable"));
describe("downloadVersionFromNdjson", () => {
it("fails when NDJSON metadata lookup fails", async () => {
mockGetArtifactFromNdjson.mockRejectedValue(
new Error("NDJSON unavailable"),
);
await expect(
downloadVersion(
downloadVersionFromNdjson(
"unknown-linux-gnu",
"x86_64",
"0.9.26",
undefined,
"token",
),
).rejects.toThrow("manifest unavailable");
).rejects.toThrow("NDJSON unavailable");
expect(mockDownloadTool).not.toHaveBeenCalled();
expect(mockValidateChecksum).not.toHaveBeenCalled();
});
it("fails when no matching artifact exists in the default manifest", async () => {
mockGetArtifact.mockResolvedValue(undefined);
it("fails when no matching artifact exists in NDJSON metadata", async () => {
mockGetArtifactFromNdjson.mockResolvedValue(undefined);
await expect(
downloadVersion(
downloadVersionFromNdjson(
"unknown-linux-gnu",
"x86_64",
"0.9.26",
@@ -147,14 +192,14 @@ describe("download-version", () => {
expect(mockValidateChecksum).not.toHaveBeenCalled();
});
it("uses built-in checksums for default manifest downloads", async () => {
mockGetArtifact.mockResolvedValue({
it("uses built-in checksums for default NDJSON downloads", async () => {
mockGetArtifactFromNdjson.mockResolvedValue({
archiveFormat: "tar.gz",
checksum: "manifest-checksum-that-should-be-ignored",
downloadUrl: "https://example.com/uv.tar.gz",
sha256: "ndjson-checksum-that-should-be-ignored",
url: "https://example.com/uv.tar.gz",
});
await downloadVersion(
await downloadVersionFromNdjson(
"unknown-linux-gnu",
"x86_64",
"0.9.26",
@@ -170,126 +215,23 @@ describe("download-version", () => {
"0.9.26",
);
});
});
it("rewrites GitHub Releases URLs to the Astral mirror", async () => {
mockGetArtifact.mockResolvedValue({
archiveFormat: "tar.gz",
checksum: "abc123",
downloadUrl:
"https://github.com/astral-sh/uv/releases/download/0.9.26/uv-x86_64-unknown-linux-gnu.tar.gz",
});
await downloadVersion(
"unknown-linux-gnu",
"x86_64",
"0.9.26",
undefined,
"token",
);
expect(mockDownloadTool).toHaveBeenCalledWith(
"https://releases.astral.sh/github/uv/releases/download/0.9.26/uv-x86_64-unknown-linux-gnu.tar.gz",
undefined,
undefined,
);
});
it("does not rewrite non-GitHub URLs", async () => {
mockGetArtifact.mockResolvedValue({
archiveFormat: "tar.gz",
checksum: "abc123",
downloadUrl: "https://example.com/uv.tar.gz",
});
await downloadVersion(
"unknown-linux-gnu",
"x86_64",
"0.9.26",
undefined,
"token",
);
expect(mockDownloadTool).toHaveBeenCalledWith(
"https://example.com/uv.tar.gz",
undefined,
"token",
);
});
it("falls back to GitHub Releases when the mirror fails", async () => {
mockGetArtifact.mockResolvedValue({
archiveFormat: "tar.gz",
checksum: "abc123",
downloadUrl:
"https://github.com/astral-sh/uv/releases/download/0.9.26/uv-x86_64-unknown-linux-gnu.tar.gz",
});
mockDownloadTool
.mockRejectedValueOnce(new Error("mirror unavailable"))
.mockResolvedValueOnce("/tmp/downloaded");
await downloadVersion(
"unknown-linux-gnu",
"x86_64",
"0.9.26",
undefined,
"token",
);
expect(mockDownloadTool).toHaveBeenCalledTimes(2);
expect(mockDownloadTool).toHaveBeenNthCalledWith(
1,
"https://releases.astral.sh/github/uv/releases/download/0.9.26/uv-x86_64-unknown-linux-gnu.tar.gz",
undefined,
undefined,
);
expect(mockDownloadTool).toHaveBeenNthCalledWith(
2,
"https://github.com/astral-sh/uv/releases/download/0.9.26/uv-x86_64-unknown-linux-gnu.tar.gz",
undefined,
"token",
);
expect(mockWarning).toHaveBeenCalledWith(
"Failed to download from mirror, falling back to GitHub Releases: mirror unavailable",
);
});
it("does not fall back for non-GitHub URLs", async () => {
mockGetArtifact.mockResolvedValue({
archiveFormat: "tar.gz",
checksum: "abc123",
downloadUrl: "https://example.com/uv.tar.gz",
});
mockDownloadTool.mockRejectedValue(new Error("download failed"));
await expect(
downloadVersion(
"unknown-linux-gnu",
"x86_64",
"0.9.26",
undefined,
"token",
),
).rejects.toThrow("download failed");
expect(mockDownloadTool).toHaveBeenCalledTimes(1);
});
describe("downloadVersionFromManifest", () => {
it("uses manifest-file checksum metadata when checksum input is unset", async () => {
mockGetArtifact.mockResolvedValue({
mockGetManifestArtifact.mockResolvedValue({
archiveFormat: "tar.gz",
checksum: "manifest-checksum",
downloadUrl: "https://example.com/custom-uv.tar.gz",
});
await downloadVersion(
await downloadVersionFromManifest(
"https://example.com/custom.ndjson",
"unknown-linux-gnu",
"x86_64",
"0.9.26",
"",
"token",
"https://example.com/custom.ndjson",
);
expect(mockValidateChecksum).toHaveBeenCalledWith(
@@ -302,19 +244,19 @@ describe("download-version", () => {
});
it("prefers checksum input over manifest-file checksum metadata", async () => {
mockGetArtifact.mockResolvedValue({
mockGetManifestArtifact.mockResolvedValue({
archiveFormat: "tar.gz",
checksum: "manifest-checksum",
downloadUrl: "https://example.com/custom-uv.tar.gz",
});
await downloadVersion(
await downloadVersionFromManifest(
"https://example.com/custom.ndjson",
"unknown-linux-gnu",
"x86_64",
"0.9.26",
"user-checksum",
"token",
"https://example.com/custom.ndjson",
);
expect(mockValidateChecksum).toHaveBeenCalledWith(
@@ -326,28 +268,4 @@ describe("download-version", () => {
);
});
});
describe("rewriteToMirror", () => {
it("rewrites a GitHub Releases URL to the Astral mirror", () => {
expect(
rewriteToMirror(
"https://github.com/astral-sh/uv/releases/download/0.9.26/uv-x86_64-unknown-linux-gnu.tar.gz",
),
).toBe(
"https://releases.astral.sh/github/uv/releases/download/0.9.26/uv-x86_64-unknown-linux-gnu.tar.gz",
);
});
it("returns undefined for non-GitHub URLs", () => {
expect(rewriteToMirror("https://example.com/uv.tar.gz")).toBeUndefined();
});
it("returns undefined for a different GitHub repo", () => {
expect(
rewriteToMirror(
"https://github.com/other/repo/releases/download/v1.0/file.tar.gz",
),
).toBeUndefined();
});
});
});

View File

@@ -1,180 +0,0 @@
import { beforeEach, describe, expect, it, jest } from "@jest/globals";
// biome-ignore lint/suspicious/noExplicitAny: Mock requires flexible typing in tests.
const mockFetch = jest.fn<any>();
jest.unstable_mockModule("@actions/core", () => ({
debug: jest.fn(),
info: jest.fn(),
}));
jest.unstable_mockModule("../../src/utils/fetch", () => ({
fetch: mockFetch,
}));
const {
clearManifestCache,
fetchManifest,
getAllVersions,
getArtifact,
getLatestVersion,
parseManifest,
} = await import("../../src/download/manifest");
const sampleManifestResponse = `{"version":"0.9.26","artifacts":[{"platform":"aarch64-apple-darwin","variant":"default","url":"https://github.com/astral-sh/uv/releases/download/0.9.26/uv-aarch64-apple-darwin.tar.gz","archive_format":"tar.gz","sha256":"fcf0a9ea6599c6ae28a4c854ac6da76f2c889354d7c36ce136ef071f7ab9721f"},{"platform":"x86_64-pc-windows-msvc","variant":"default","url":"https://github.com/astral-sh/uv/releases/download/0.9.26/uv-x86_64-pc-windows-msvc.zip","archive_format":"zip","sha256":"eb02fd95d8e0eed462b4a67ecdd320d865b38c560bffcda9a0b87ec944bdf036"}]}
{"version":"0.9.25","artifacts":[{"platform":"aarch64-apple-darwin","variant":"default","url":"https://github.com/astral-sh/uv/releases/download/0.9.25/uv-aarch64-apple-darwin.tar.gz","archive_format":"tar.gz","sha256":"606b3c6949d971709f2526fa0d9f0fd23ccf60e09f117999b406b424af18a6a6"}]}`;
const multiVariantManifestResponse = `{"version":"0.9.26","artifacts":[{"platform":"aarch64-apple-darwin","variant":"python-managed","url":"https://github.com/astral-sh/uv/releases/download/0.9.26/uv-aarch64-apple-darwin-managed.tar.gz","archive_format":"tar.gz","sha256":"managed-checksum"},{"platform":"aarch64-apple-darwin","variant":"default","url":"https://github.com/astral-sh/uv/releases/download/0.9.26/uv-aarch64-apple-darwin.zip","archive_format":"zip","sha256":"default-checksum"}]}`;
function createMockResponse(
ok: boolean,
status: number,
statusText: string,
data: string,
) {
return {
ok,
status,
statusText,
text: async () => data,
};
}
describe("manifest", () => {
beforeEach(() => {
clearManifestCache();
mockFetch.mockReset();
});
describe("fetchManifest", () => {
it("fetches and parses manifest data", async () => {
mockFetch.mockResolvedValue(
createMockResponse(true, 200, "OK", sampleManifestResponse),
);
const versions = await fetchManifest();
expect(versions).toHaveLength(2);
expect(versions[0]?.version).toBe("0.9.26");
expect(versions[1]?.version).toBe("0.9.25");
});
it("throws on a failed fetch", async () => {
mockFetch.mockResolvedValue(
createMockResponse(false, 500, "Internal Server Error", ""),
);
await expect(fetchManifest()).rejects.toThrow(
"Failed to fetch manifest data: 500 Internal Server Error",
);
});
it("caches results per URL", async () => {
mockFetch.mockResolvedValue(
createMockResponse(true, 200, "OK", sampleManifestResponse),
);
await fetchManifest("https://example.com/custom.ndjson");
await fetchManifest("https://example.com/custom.ndjson");
expect(mockFetch).toHaveBeenCalledTimes(1);
});
});
describe("getAllVersions", () => {
it("returns all version strings", async () => {
mockFetch.mockResolvedValue(
createMockResponse(true, 200, "OK", sampleManifestResponse),
);
const versions = await getAllVersions(
"https://example.com/custom.ndjson",
);
expect(versions).toEqual(["0.9.26", "0.9.25"]);
});
});
describe("getLatestVersion", () => {
it("returns the first version string", async () => {
mockFetch.mockResolvedValue(
createMockResponse(true, 200, "OK", sampleManifestResponse),
);
await expect(
getLatestVersion("https://example.com/custom.ndjson"),
).resolves.toBe("0.9.26");
});
});
describe("getArtifact", () => {
beforeEach(() => {
mockFetch.mockResolvedValue(
createMockResponse(true, 200, "OK", sampleManifestResponse),
);
});
it("finds an artifact by version and platform", async () => {
const artifact = await getArtifact("0.9.26", "aarch64", "apple-darwin");
expect(artifact).toEqual({
archiveFormat: "tar.gz",
checksum:
"fcf0a9ea6599c6ae28a4c854ac6da76f2c889354d7c36ce136ef071f7ab9721f",
downloadUrl:
"https://github.com/astral-sh/uv/releases/download/0.9.26/uv-aarch64-apple-darwin.tar.gz",
});
});
it("finds a windows artifact", async () => {
const artifact = await getArtifact("0.9.26", "x86_64", "pc-windows-msvc");
expect(artifact).toEqual({
archiveFormat: "zip",
checksum:
"eb02fd95d8e0eed462b4a67ecdd320d865b38c560bffcda9a0b87ec944bdf036",
downloadUrl:
"https://github.com/astral-sh/uv/releases/download/0.9.26/uv-x86_64-pc-windows-msvc.zip",
});
});
it("prefers the default variant when multiple artifacts share a platform", async () => {
mockFetch.mockResolvedValue(
createMockResponse(true, 200, "OK", multiVariantManifestResponse),
);
const artifact = await getArtifact("0.9.26", "aarch64", "apple-darwin");
expect(artifact).toEqual({
archiveFormat: "zip",
checksum: "default-checksum",
downloadUrl:
"https://github.com/astral-sh/uv/releases/download/0.9.26/uv-aarch64-apple-darwin.zip",
});
});
it("returns undefined for an unknown version", async () => {
const artifact = await getArtifact("0.0.1", "aarch64", "apple-darwin");
expect(artifact).toBeUndefined();
});
it("returns undefined for an unknown platform", async () => {
const artifact = await getArtifact(
"0.9.26",
"aarch64",
"unknown-linux-musl",
);
expect(artifact).toBeUndefined();
});
});
describe("parseManifest", () => {
it("throws for malformed manifest data", () => {
expect(() => parseManifest('{"version":"0.1.0"', "test-source")).toThrow(
"Failed to parse manifest data from test-source",
);
});
});
});

View File

@@ -0,0 +1,136 @@
import { beforeEach, describe, expect, it, jest } from "@jest/globals";
const mockWarning = jest.fn();
jest.unstable_mockModule("@actions/core", () => ({
debug: jest.fn(),
info: jest.fn(),
warning: mockWarning,
}));
// biome-ignore lint/suspicious/noExplicitAny: Mock requires flexible typing in tests.
const mockFetch = jest.fn<any>();
jest.unstable_mockModule("../../src/utils/fetch", () => ({
fetch: mockFetch,
}));
const {
clearManifestCache,
getAllVersions,
getLatestKnownVersion,
getManifestArtifact,
} = await import("../../src/download/version-manifest");
const legacyManifestResponse = JSON.stringify([
{
arch: "x86_64",
artifactName: "uv-x86_64-unknown-linux-gnu.tar.gz",
downloadUrl:
"https://example.com/releases/download/0.7.12-alpha.1/uv-x86_64-unknown-linux-gnu.tar.gz",
platform: "unknown-linux-gnu",
version: "0.7.12-alpha.1",
},
{
arch: "x86_64",
artifactName: "uv-x86_64-unknown-linux-gnu.tar.gz",
downloadUrl:
"https://example.com/releases/download/0.7.13/uv-x86_64-unknown-linux-gnu.tar.gz",
platform: "unknown-linux-gnu",
version: "0.7.13",
},
]);
const ndjsonManifestResponse = `{"version":"0.10.0","artifacts":[{"platform":"x86_64-unknown-linux-gnu","variant":"default","url":"https://example.com/releases/download/0.10.0/uv-x86_64-unknown-linux-gnu.tar.gz","archive_format":"tar.gz","sha256":"checksum-100"}]}
{"version":"0.9.30","artifacts":[{"platform":"x86_64-unknown-linux-gnu","variant":"default","url":"https://example.com/releases/download/0.9.30/uv-x86_64-unknown-linux-gnu.tar.gz","archive_format":"tar.gz","sha256":"checksum-0930"}]}`;
const multiVariantManifestResponse = `{"version":"0.10.0","artifacts":[{"platform":"x86_64-unknown-linux-gnu","variant":"managed-python","url":"https://example.com/releases/download/0.10.0/uv-x86_64-unknown-linux-gnu-managed-python.tar.gz","archive_format":"tar.gz","sha256":"checksum-managed"},{"platform":"x86_64-unknown-linux-gnu","variant":"default","url":"https://example.com/releases/download/0.10.0/uv-x86_64-unknown-linux-gnu-default.zip","archive_format":"zip","sha256":"checksum-default"}]}`;
function createMockResponse(
ok: boolean,
status: number,
statusText: string,
data: string,
) {
return {
ok,
status,
statusText,
text: async () => data,
};
}
describe("version-manifest", () => {
beforeEach(() => {
clearManifestCache();
mockFetch.mockReset();
mockWarning.mockReset();
});
it("supports the legacy JSON manifest format", async () => {
mockFetch.mockResolvedValue(
createMockResponse(true, 200, "OK", legacyManifestResponse),
);
const latest = await getLatestKnownVersion(
"https://example.com/legacy.json",
);
const artifact = await getManifestArtifact(
"https://example.com/legacy.json",
"0.7.13",
"x86_64",
"unknown-linux-gnu",
);
expect(latest).toBe("0.7.13");
expect(artifact).toEqual({
archiveFormat: undefined,
checksum: undefined,
downloadUrl:
"https://example.com/releases/download/0.7.13/uv-x86_64-unknown-linux-gnu.tar.gz",
});
expect(mockWarning).toHaveBeenCalledTimes(1);
});
it("supports NDJSON manifests", async () => {
mockFetch.mockResolvedValue(
createMockResponse(true, 200, "OK", ndjsonManifestResponse),
);
const versions = await getAllVersions("https://example.com/custom.ndjson");
const artifact = await getManifestArtifact(
"https://example.com/custom.ndjson",
"0.10.0",
"x86_64",
"unknown-linux-gnu",
);
expect(versions).toEqual(["0.10.0", "0.9.30"]);
expect(artifact).toEqual({
archiveFormat: "tar.gz",
checksum: "checksum-100",
downloadUrl:
"https://example.com/releases/download/0.10.0/uv-x86_64-unknown-linux-gnu.tar.gz",
});
expect(mockWarning).not.toHaveBeenCalled();
});
it("prefers the default variant when a manifest contains multiple variants", async () => {
mockFetch.mockResolvedValue(
createMockResponse(true, 200, "OK", multiVariantManifestResponse),
);
const artifact = await getManifestArtifact(
"https://example.com/multi-variant.ndjson",
"0.10.0",
"x86_64",
"unknown-linux-gnu",
);
expect(artifact).toEqual({
archiveFormat: "zip",
checksum: "checksum-default",
downloadUrl:
"https://example.com/releases/download/0.10.0/uv-x86_64-unknown-linux-gnu-default.zip",
});
});
});

View File

@@ -0,0 +1,241 @@
import { beforeEach, describe, expect, it, jest } from "@jest/globals";
// biome-ignore lint/suspicious/noExplicitAny: Mock requires flexible typing in tests.
const mockFetch = jest.fn<any>();
jest.unstable_mockModule("../../src/utils/fetch", () => ({
fetch: mockFetch,
}));
const {
clearCache,
fetchVersionData,
getAllVersions,
getArtifact,
getHighestSatisfyingVersion,
getLatestVersion,
parseVersionData,
} = await import("../../src/download/versions-client");
const sampleNdjsonResponse = `{"version":"0.9.26","artifacts":[{"platform":"aarch64-apple-darwin","variant":"default","url":"https://github.com/astral-sh/uv/releases/download/0.9.26/uv-aarch64-apple-darwin.tar.gz","archive_format":"tar.gz","sha256":"fcf0a9ea6599c6ae28a4c854ac6da76f2c889354d7c36ce136ef071f7ab9721f"},{"platform":"x86_64-pc-windows-msvc","variant":"default","url":"https://github.com/astral-sh/uv/releases/download/0.9.26/uv-x86_64-pc-windows-msvc.zip","archive_format":"zip","sha256":"eb02fd95d8e0eed462b4a67ecdd320d865b38c560bffcda9a0b87ec944bdf036"}]}
{"version":"0.9.25","artifacts":[{"platform":"aarch64-apple-darwin","variant":"default","url":"https://github.com/astral-sh/uv/releases/download/0.9.25/uv-aarch64-apple-darwin.tar.gz","archive_format":"tar.gz","sha256":"606b3c6949d971709f2526fa0d9f0fd23ccf60e09f117999b406b424af18a6a6"}]}`;
const multiVariantNdjsonResponse = `{"version":"0.9.26","artifacts":[{"platform":"aarch64-apple-darwin","variant":"python-managed","url":"https://github.com/astral-sh/uv/releases/download/0.9.26/uv-aarch64-apple-darwin-managed.tar.gz","archive_format":"tar.gz","sha256":"managed-checksum"},{"platform":"aarch64-apple-darwin","variant":"default","url":"https://github.com/astral-sh/uv/releases/download/0.9.26/uv-aarch64-apple-darwin.zip","archive_format":"zip","sha256":"default-checksum"}]}`;
function createMockStream(chunks: string[]): ReadableStream<Uint8Array> {
const encoder = new TextEncoder();
return new ReadableStream<Uint8Array>({
start(controller) {
for (const chunk of chunks) {
controller.enqueue(encoder.encode(chunk));
}
controller.close();
},
});
}
function createMockResponse(
ok: boolean,
status: number,
statusText: string,
data: string,
chunks: string[] = [data],
) {
return {
body: createMockStream(chunks),
ok,
status,
statusText,
text: async () => data,
};
}
describe("versions-client", () => {
beforeEach(() => {
clearCache();
mockFetch.mockReset();
});
describe("fetchVersionData", () => {
it("should fetch and parse NDJSON data", async () => {
mockFetch.mockResolvedValue(
createMockResponse(true, 200, "OK", sampleNdjsonResponse),
);
const versions = await fetchVersionData();
expect(versions).toHaveLength(2);
expect(versions[0].version).toBe("0.9.26");
expect(versions[1].version).toBe("0.9.25");
});
it("should throw error on failed fetch", async () => {
mockFetch.mockResolvedValue(
createMockResponse(false, 500, "Internal Server Error", ""),
);
await expect(fetchVersionData()).rejects.toThrow(
"Failed to fetch version data: 500 Internal Server Error",
);
});
it("should cache results", async () => {
mockFetch.mockResolvedValue(
createMockResponse(true, 200, "OK", sampleNdjsonResponse),
);
await fetchVersionData();
await fetchVersionData();
expect(mockFetch).toHaveBeenCalledTimes(1);
});
});
describe("getLatestVersion", () => {
it("should return the first version (newest)", async () => {
mockFetch.mockResolvedValue(
createMockResponse(true, 200, "OK", sampleNdjsonResponse),
);
const latest = await getLatestVersion();
expect(latest).toBe("0.9.26");
});
it("should stop after the first record when resolving latest", async () => {
mockFetch.mockResolvedValue(
createMockResponse(
true,
200,
"OK",
`${sampleNdjsonResponse}\n{"version":`,
[`${sampleNdjsonResponse.split("\n")[0]}\n`, '{"version":'],
),
);
const latest = await getLatestVersion();
expect(latest).toBe("0.9.26");
});
});
describe("getAllVersions", () => {
it("should return all version strings", async () => {
mockFetch.mockResolvedValue(
createMockResponse(true, 200, "OK", sampleNdjsonResponse),
);
const versions = await getAllVersions();
expect(versions).toEqual(["0.9.26", "0.9.25"]);
});
});
describe("getHighestSatisfyingVersion", () => {
it("should return the first matching version from the stream", async () => {
mockFetch.mockResolvedValue(
createMockResponse(
true,
200,
"OK",
`${sampleNdjsonResponse}\n{"version":`,
[`${sampleNdjsonResponse.split("\n")[0]}\n`, '{"version":'],
),
);
const version = await getHighestSatisfyingVersion("^0.9.0");
expect(version).toBe("0.9.26");
});
});
describe("getArtifact", () => {
beforeEach(() => {
mockFetch.mockResolvedValue(
createMockResponse(true, 200, "OK", sampleNdjsonResponse),
);
});
it("should find artifact by version and platform", async () => {
const artifact = await getArtifact("0.9.26", "aarch64", "apple-darwin");
expect(artifact).toEqual({
archiveFormat: "tar.gz",
sha256:
"fcf0a9ea6599c6ae28a4c854ac6da76f2c889354d7c36ce136ef071f7ab9721f",
url: "https://github.com/astral-sh/uv/releases/download/0.9.26/uv-aarch64-apple-darwin.tar.gz",
});
});
it("should stop once the requested version is found", async () => {
mockFetch.mockResolvedValue(
createMockResponse(
true,
200,
"OK",
`${sampleNdjsonResponse}\n{"version":`,
[`${sampleNdjsonResponse.split("\n")[0]}\n`, '{"version":'],
),
);
const artifact = await getArtifact("0.9.26", "aarch64", "apple-darwin");
expect(artifact).toEqual({
archiveFormat: "tar.gz",
sha256:
"fcf0a9ea6599c6ae28a4c854ac6da76f2c889354d7c36ce136ef071f7ab9721f",
url: "https://github.com/astral-sh/uv/releases/download/0.9.26/uv-aarch64-apple-darwin.tar.gz",
});
});
it("should find windows artifact", async () => {
const artifact = await getArtifact("0.9.26", "x86_64", "pc-windows-msvc");
expect(artifact).toEqual({
archiveFormat: "zip",
sha256:
"eb02fd95d8e0eed462b4a67ecdd320d865b38c560bffcda9a0b87ec944bdf036",
url: "https://github.com/astral-sh/uv/releases/download/0.9.26/uv-x86_64-pc-windows-msvc.zip",
});
});
it("should prefer the default variant when multiple artifacts share a platform", async () => {
mockFetch.mockResolvedValue(
createMockResponse(true, 200, "OK", multiVariantNdjsonResponse),
);
const artifact = await getArtifact("0.9.26", "aarch64", "apple-darwin");
expect(artifact).toEqual({
archiveFormat: "zip",
sha256: "default-checksum",
url: "https://github.com/astral-sh/uv/releases/download/0.9.26/uv-aarch64-apple-darwin.zip",
});
});
it("should return undefined for unknown version", async () => {
const artifact = await getArtifact("0.0.1", "aarch64", "apple-darwin");
expect(artifact).toBeUndefined();
});
it("should return undefined for unknown platform", async () => {
const artifact = await getArtifact(
"0.9.26",
"aarch64",
"unknown-linux-musl",
);
expect(artifact).toBeUndefined();
});
});
describe("parseVersionData", () => {
it("should throw for malformed NDJSON", () => {
expect(() =>
parseVersionData('{"version":"0.1.0"', "test-source"),
).toThrow("Failed to parse version data from test-source");
});
});
});

View File

@@ -1,6 +1,3 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import {
afterEach,
beforeEach,
@@ -10,13 +7,9 @@ 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(
@@ -34,220 +27,118 @@ jest.unstable_mockModule("@actions/core", () => ({
warning: mockWarning,
}));
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;
async function importInputsModule() {
return await import("../../src/utils/inputs");
}
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 {
for (const dir of tempDirs.splice(0)) {
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", () => {
it("returns empty string when input not provided", () => {
mockInputs["working-directory"] = "/workspace";
const inputs = loadInputs();
expect(inputs.cacheDependencyGlob).toBe("");
beforeEach(() => {
jest.resetModules();
jest.clearAllMocks();
mockInputs = {};
process.env.HOME = "/home/testuser";
});
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"] = globInput;
const inputs = loadInputs();
expect(inputs.cacheDependencyGlob).toBe(expected);
afterEach(() => {
process.env.HOME = ORIGINAL_HOME;
});
it("handles multiple lines, trimming whitespace, tilde expansion and absolute paths", () => {
it("returns empty string when input not provided", async () => {
mockInputs["working-directory"] = "/workspace";
const { cacheDependencyGlob } = await importInputsModule();
expect(cacheDependencyGlob).toBe("");
});
it("resolves a single relative path", async () => {
mockInputs["working-directory"] = "/workspace";
mockInputs["cache-dependency-glob"] = "requirements.txt";
const { cacheDependencyGlob } = await importInputsModule();
expect(cacheDependencyGlob).toBe("/workspace/requirements.txt");
});
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 () => {
mockInputs["working-directory"] = "/workspace";
mockInputs["cache-dependency-glob"] =
" ~/.cache/file1\n ./rel/file2 \nfile3.txt";
const inputs = loadInputs();
expect(inputs.cacheDependencyGlob).toBe(
const { cacheDependencyGlob } = await importInputsModule();
expect(cacheDependencyGlob).toBe(
[
"/home/testuser/.cache/file1",
"/workspace/rel/file2",
"/workspace/file3.txt",
"/home/testuser/.cache/file1", // expanded tilde, absolute path unchanged
"/workspace/rel/file2", // ./ stripped and resolved
"/workspace/file3.txt", // relative path resolved
].join("\n"),
);
});
it.each([
[
"/abs/path.lock\nrelative.lock",
["/abs/path.lock", "/workspace/relative.lock"].join("\n"),
],
[
"!/abs/path.lock\n!relative.lock",
["!/abs/path.lock", "!/workspace/relative.lock"].join("\n"),
],
])("normalizes multiline glob %s", (globInput, expected) => {
it("keeps absolute path unchanged in multiline input", async () => {
mockInputs["working-directory"] = "/workspace";
mockInputs["cache-dependency-glob"] = globInput;
mockInputs["cache-dependency-glob"] = "/abs/path.lock\nrelative.lock";
const { cacheDependencyGlob } = await importInputsModule();
expect(cacheDependencyGlob).toBe(
["/abs/path.lock", "/workspace/relative.lock"].join("\n"),
);
});
const inputs = loadInputs();
expect(inputs.cacheDependencyGlob).toBe(expected);
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", "!/workspace/relative.lock"].join("\n"),
);
});
});
describe("tool directories", () => {
it("expands tilde for tool-bin-dir and tool-dir", () => {
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 () => {
mockInputs["working-directory"] = "/workspace";
mockInputs["tool-bin-dir"] = "~/tool-bin-dir";
mockInputs["tool-dir"] = "~/tool-dir";
const inputs = loadInputs();
const { toolBinDir, toolDir } = await importInputsModule();
expect(inputs.toolBinDir).toBe("/home/testuser/tool-bin-dir");
expect(inputs.toolDir).toBe("/home/testuser/tool-dir");
expect(toolBinDir).toBe("/home/testuser/tool-bin-dir");
expect(toolDir).toBe("/home/testuser/tool-dir");
});
});
describe("cacheLocalPath", () => {
it("expands tilde in cache-local-path", () => {
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 () => {
mockInputs["working-directory"] = "/workspace";
mockInputs["cache-local-path"] = "~/uv-cache/cache-local-path";
const inputs = loadInputs();
const { CacheLocalSource, cacheLocalPath } = await importInputsModule();
expect(inputs.cacheLocalPath).toEqual({
expect(cacheLocalPath).toEqual({
path: "/home/testuser/uv-cache/cache-local-path",
source: CacheLocalSource.Input,
});
@@ -255,37 +146,63 @@ describe("cacheLocalPath", () => {
});
describe("venvPath", () => {
it("defaults to .venv in the working directory", () => {
mockInputs["working-directory"] = "/workspace";
const inputs = loadInputs();
expect(inputs.venvPath).toBe("/workspace/.venv");
beforeEach(() => {
jest.resetModules();
jest.clearAllMocks();
mockInputs = {};
process.env.HOME = "/home/testuser";
});
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) => {
afterEach(() => {
process.env.HOME = ORIGINAL_HOME;
});
it("defaults to .venv in the working directory", async () => {
mockInputs["working-directory"] = "/workspace";
const { venvPath } = await importInputsModule();
expect(venvPath).toBe("/workspace/.venv");
});
it("resolves a relative venv-path", async () => {
mockInputs["working-directory"] = "/workspace";
mockInputs["activate-environment"] = "true";
mockInputs["venv-path"] = venvPathInput;
const inputs = loadInputs();
expect(inputs.venvPath).toBe(expected);
mockInputs["venv-path"] = "custom-venv";
const { venvPath } = await importInputsModule();
expect(venvPath).toBe("/workspace/custom-venv");
});
it("warns when venv-path is set but activate-environment is false", () => {
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 () => {
mockInputs["working-directory"] = "/workspace";
mockInputs["venv-path"] = "custom-venv";
const inputs = loadInputs();
const { activateEnvironment, venvPath } = await importInputsModule();
expect(inputs.activateEnvironment).toBe(false);
expect(inputs.venvPath).toBe("/workspace/custom-venv");
expect(activateEnvironment).toBe(false);
expect(venvPath).toBe("/workspace/custom-venv");
expect(mockWarning).toHaveBeenCalledWith(
"venv-path is only used when activate-environment is true",
);

View File

@@ -75,7 +75,7 @@ inputs:
description: "Custom path to set UV_TOOL_BIN_DIR to."
required: false
manifest-file:
description: "URL to a custom manifest file in the astral-sh/versions format."
description: "URL to a custom manifest file. Supports the astral-sh/versions NDJSON format and the legacy JSON array format (deprecated)."
required: false
add-problem-matchers:
description: "Add problem matchers."

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

File diff suppressed because it is too large Load Diff

3738
dist/setup/index.cjs generated vendored

File diff suppressed because it is too large Load Diff

976
dist/update-known-checksums/index.cjs generated vendored

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,7 @@ This document covers advanced options for configuring which version of uv to ins
```yaml
- name: Install the latest version of uv
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
uses: astral-sh/setup-uv@v7
with:
version: "latest"
```
@@ -15,7 +15,7 @@ This document covers advanced options for configuring which version of uv to ins
```yaml
- name: Install a specific version of uv
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
uses: astral-sh/setup-uv@v7
with:
version: "0.4.4"
```
@@ -28,21 +28,21 @@ to install the latest version that satisfies the range.
```yaml
- name: Install a semver range of uv
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
uses: astral-sh/setup-uv@v7
with:
version: ">=0.4.0"
```
```yaml
- name: Pinning a minor version of uv
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
uses: astral-sh/setup-uv@v7
with:
version: "0.4.x"
```
```yaml
- name: Install a pep440-specifier-satisfying version of uv
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
uses: astral-sh/setup-uv@v7
with:
version: ">=0.4.25,<0.5"
```
@@ -54,7 +54,7 @@ You can change this behavior using the `resolution-strategy` input:
```yaml
- name: Install the lowest compatible version of uv
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
uses: astral-sh/setup-uv@v7
with:
version: ">=0.4.0"
resolution-strategy: "lowest"
@@ -76,7 +76,7 @@ uv defined as a dependency in `pyproject.toml` or `requirements.txt`.
```yaml
- name: Install uv based on the version defined in pyproject.toml
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
uses: astral-sh/setup-uv@v7
with:
version-file: "pyproject.toml"
```

View File

@@ -23,7 +23,7 @@ The computed cache key is available as the `cache-key` output:
```yaml
- name: Setup uv
id: setup-uv
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
- name: Print cache key
@@ -50,7 +50,7 @@ You can optionally define a custom cache key suffix.
```yaml
- name: Enable caching and define a custom cache key suffix
id: setup-uv
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
cache-suffix: "optional-suffix"
@@ -89,7 +89,7 @@ changes. If you use relative paths, they are relative to the working directory.
```yaml
- name: Define a cache dependency glob
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
cache-dependency-glob: "**/pyproject.toml"
@@ -97,7 +97,7 @@ changes. If you use relative paths, they are relative to the working directory.
```yaml
- name: Define a list of cache dependency globs
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
cache-dependency-glob: |
@@ -107,7 +107,7 @@ changes. If you use relative paths, they are relative to the working directory.
```yaml
- name: Define an absolute cache dependency glob
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
cache-dependency-glob: "/tmp/my-folder/requirements*.txt"
@@ -115,7 +115,7 @@ changes. If you use relative paths, they are relative to the working directory.
```yaml
- name: Never invalidate the cache
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
cache-dependency-glob: ""
@@ -128,7 +128,7 @@ By default, the cache will be restored.
```yaml
- name: Don't restore an existing cache
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
restore-cache: false
@@ -142,7 +142,7 @@ By default, the cache will be saved.
```yaml
- name: Don't save the cache after the run
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
save-cache: false
@@ -168,7 +168,7 @@ It defaults to `setup-uv-cache` in the `TMP` dir, `D:\a\_temp\setup-uv-cache` on
```yaml
- name: Define a custom uv cache path
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
uses: astral-sh/setup-uv@v7
with:
cache-local-path: "/path/to/cache"
```
@@ -187,7 +187,7 @@ input.
```yaml
- name: Don't prune the cache before saving it
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
prune-cache: false
@@ -205,7 +205,7 @@ To force managed Python installs, set `UV_PYTHON_PREFERENCE=only-managed`.
```yaml
- name: Cache Python installs
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
cache-python: true
@@ -213,17 +213,12 @@ To force managed Python installs, set `UV_PYTHON_PREFERENCE=only-managed`.
## Ignore nothing to cache
By default, the action will fail if caching is enabled but there is nothing to upload (the uv cache directory does not exist) with an error like
```console
Error: Cache path /home/runner/.cache/uv does not exist on disk. This likely indicates that there are no dependencies to cache. Consider disabling the cache input if it is not needed.
```
By default, the action will fail if caching is enabled but there is nothing to upload (the uv cache directory does not exist).
If you want to ignore this, set the `ignore-nothing-to-cache` input to `true`.
```yaml
- name: Ignore nothing to cache
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
ignore-nothing-to-cache: true

View File

@@ -10,7 +10,7 @@ are automatically verified by this action. The sha256 hashes can be found on the
```yaml
- name: Install a specific version and validate the checksum
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
uses: astral-sh/setup-uv@v7
with:
version: "0.3.1"
checksum: "e11b01402ab645392c7ad6044db63d37e4fd1e745e015306993b07695ea5f9f8"
@@ -19,14 +19,14 @@ are automatically verified by this action. The sha256 hashes can be found on the
## Manifest file
By default, setup-uv reads version metadata from
[`astral-sh/versions`](https://github.com/astral-sh/versions).
[`astral-sh/versions`](https://github.com/astral-sh/versions) (NDJSON format).
The `manifest-file` input lets you override that source with your own URL, for example to test
custom uv builds or alternate download locations.
### Format
The manifest file must use the same format as `astral-sh/versions`: one JSON object per line, where each object represents a version and its artifacts. The versions must be sorted in descending order. For example:
The manifest file must be in NDJSON format, where each line is a JSON object representing a version and its artifacts. For example:
```json
{"version":"0.10.7","artifacts":[{"platform":"x86_64-unknown-linux-gnu","variant":"default","url":"https://example.com/uv-x86_64-unknown-linux-gnu.tar.gz","archive_format":"tar.gz","sha256":"..."}]}
@@ -37,9 +37,26 @@ setup-uv currently only supports `default` as the `variant`.
The `archive_format` field is currently ignored.
### Legacy format: JSON array (deprecated)
The previous JSON array format is still supported for compatibility, but deprecated and will be
removed in a future major release.
```json
[
{
"version": "0.7.13",
"artifactName": "uv-aarch64-apple-darwin.tar.gz",
"arch": "aarch64",
"platform": "apple-darwin",
"downloadUrl": "https://github.com/astral-sh/uv/releases/download/0.7.13/uv-aarch64-apple-darwin.tar.gz"
}
]
```
```yaml
- name: Use a custom manifest file
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
uses: astral-sh/setup-uv@v7
with:
manifest-file: "https://example.com/my-custom-manifest.ndjson"
```
@@ -58,7 +75,7 @@ You can disable this by setting the `add-problem-matchers` input to `false`.
```yaml
- name: Install the latest version of uv without problem matchers
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
uses: astral-sh/setup-uv@v7
with:
add-problem-matchers: false
```

View File

@@ -9,7 +9,7 @@ This allows directly using it in later steps:
```yaml
- name: Install the latest version of uv and activate the environment
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
uses: astral-sh/setup-uv@v7
with:
activate-environment: true
- run: uv pip install pip
@@ -20,7 +20,7 @@ By default, the venv is created at `.venv` inside the `working-directory`.
You can customize the venv location with `venv-path`, for example to place it in the runner temp directory:
```yaml
- uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
- uses: astral-sh/setup-uv@v7
with:
activate-environment: true
venv-path: ${{ runner.temp }}/custom-venv
@@ -51,7 +51,7 @@ are not sufficient, you can provide a custom GitHub token with the necessary per
```yaml
- name: Install the latest version of uv with a custom GitHub token
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
uses: astral-sh/setup-uv@v7
with:
github-token: ${{ secrets.CUSTOM_GITHUB_TOKEN }}
```
@@ -69,7 +69,7 @@ input:
```yaml
- name: Install the latest version of uv with a custom tool dir
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
uses: astral-sh/setup-uv@v7
with:
tool-dir: "/path/to/tool/dir"
```
@@ -88,7 +88,7 @@ If you want to change this behaviour (especially on self-hosted runners) you can
```yaml
- name: Install the latest version of uv with a custom tool bin dir
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
uses: astral-sh/setup-uv@v7
with:
tool-bin-dir: "/path/to/tool-bin/dir"
```
@@ -105,7 +105,7 @@ This action supports expanding the `~` character to the user's home directory fo
```yaml
- name: Expand the tilde character
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
uses: astral-sh/setup-uv@v7
with:
cache-local-path: "~/path/to/cache"
tool-dir: "~/path/to/tool/dir"
@@ -122,7 +122,7 @@ If you want to ignore this, set the `ignore-empty-workdir` input to `true`.
```yaml
- name: Ignore empty workdir
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
uses: astral-sh/setup-uv@v7
with:
ignore-empty-workdir: true
```
@@ -145,7 +145,7 @@ This action sets several environment variables that influence uv's behavior and
```yaml
- name: Example using environment variables
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
uses: astral-sh/setup-uv@v7
with:
python-version: "3.12"
tool-dir: "/custom/tool/dir"

View File

@@ -9,6 +9,7 @@
"build": "tsc --noEmit",
"check": "biome check --write",
"package": "node scripts/build-dist.mjs",
"bench:versions": "node scripts/bench-versions-client.mjs",
"test:unit": "node --experimental-vm-modules ./node_modules/jest/bin/jest.js",
"test": "npm run build && npm run test:unit",
"act": "act pull_request -W .github/workflows/test.yml --container-architecture linux/amd64 -s GITHUB_TOKEN=\"$(gh auth token)\"",

View File

@@ -0,0 +1,483 @@
import { performance } from "node:perf_hooks";
import * as pep440 from "@renovatebot/pep440";
import * as semver from "semver";
import { ProxyAgent, fetch as undiciFetch } from "undici";
const DEFAULT_URL =
"https://raw.githubusercontent.com/astral-sh/versions/main/v1/uv.ndjson";
const DEFAULT_ITERATIONS = 100;
const DEFAULT_ARCH = "aarch64";
const DEFAULT_PLATFORM = "apple-darwin";
function getProxyAgent() {
const httpProxy = process.env.HTTP_PROXY || process.env.http_proxy;
if (httpProxy) {
return new ProxyAgent(httpProxy);
}
const httpsProxy = process.env.HTTPS_PROXY || process.env.https_proxy;
if (httpsProxy) {
return new ProxyAgent(httpsProxy);
}
return undefined;
}
async function fetch(url) {
return await undiciFetch(url, {
dispatcher: getProxyAgent(),
});
}
function parseArgs(argv) {
const options = {
arch: DEFAULT_ARCH,
iterations: DEFAULT_ITERATIONS,
platform: DEFAULT_PLATFORM,
url: DEFAULT_URL,
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
const next = argv[index + 1];
if (arg === "--iterations" && next !== undefined) {
options.iterations = Number.parseInt(next, 10);
index += 1;
continue;
}
if (arg === "--url" && next !== undefined) {
options.url = next;
index += 1;
continue;
}
if (arg === "--arch" && next !== undefined) {
options.arch = next;
index += 1;
continue;
}
if (arg === "--platform" && next !== undefined) {
options.platform = next;
index += 1;
}
}
if (!Number.isInteger(options.iterations) || options.iterations <= 0) {
throw new Error("--iterations must be a positive integer");
}
return options;
}
function parseVersionLine(line, sourceDescription, lineNumber) {
let parsed;
try {
parsed = JSON.parse(line);
} catch (error) {
throw new Error(
`Failed to parse version data from ${sourceDescription} at line ${lineNumber}: ${error.message}`,
);
}
if (
typeof parsed !== "object" ||
parsed === null ||
typeof parsed.version !== "string" ||
!Array.isArray(parsed.artifacts)
) {
throw new Error(
`Invalid NDJSON record in ${sourceDescription} at line ${lineNumber}.`,
);
}
return parsed;
}
function parseVersionData(data, sourceDescription) {
const versions = [];
for (const [index, line] of data.split("\n").entries()) {
const trimmed = line.trim();
if (trimmed === "") {
continue;
}
versions.push(parseVersionLine(trimmed, sourceDescription, index + 1));
}
if (versions.length === 0) {
throw new Error(`No version data found in ${sourceDescription}.`);
}
return versions;
}
async function readEntireResponse(response) {
if (response.body === null) {
const text = await response.text();
return {
bytesRead: Buffer.byteLength(text, "utf8"),
text,
};
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
const chunks = [];
let bytesRead = 0;
while (true) {
const { done, value } = await reader.read();
if (done) {
chunks.push(decoder.decode());
break;
}
bytesRead += value.byteLength;
chunks.push(decoder.decode(value, { stream: true }));
}
return {
bytesRead,
text: chunks.join(""),
};
}
async function fetchAllVersions(url) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(
`Failed to fetch version data: ${response.status} ${response.statusText}`,
);
}
const { bytesRead, text } = await readEntireResponse(response);
return {
bytesRead,
versions: parseVersionData(text, url),
};
}
async function streamUntil(url, predicate) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(
`Failed to fetch version data: ${response.status} ${response.statusText}`,
);
}
if (response.body === null) {
const { bytesRead, versions } = await fetchAllVersions(url);
return {
bytesRead,
matchedVersion: versions.find(predicate),
};
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let bytesRead = 0;
let buffer = "";
let lineNumber = 0;
while (true) {
const { done, value } = await reader.read();
if (done) {
buffer += decoder.decode();
break;
}
bytesRead += value.byteLength;
buffer += decoder.decode(value, { stream: true });
let newlineIndex = buffer.indexOf("\n");
while (newlineIndex !== -1) {
const line = buffer.slice(0, newlineIndex);
buffer = buffer.slice(newlineIndex + 1);
const trimmed = line.trim();
if (trimmed !== "") {
lineNumber += 1;
const versionData = parseVersionLine(trimmed, url, lineNumber);
if (predicate(versionData)) {
await reader.cancel();
return { bytesRead, matchedVersion: versionData };
}
}
newlineIndex = buffer.indexOf("\n");
}
}
if (buffer.trim() !== "") {
lineNumber += 1;
const versionData = parseVersionLine(buffer.trim(), url, lineNumber);
if (predicate(versionData)) {
return { bytesRead, matchedVersion: versionData };
}
}
return { bytesRead, matchedVersion: undefined };
}
function versionSatisfies(version, versionSpecifier) {
return (
semver.satisfies(version, versionSpecifier) ||
pep440.satisfies(version, versionSpecifier)
);
}
function maxSatisfying(versions, versionSpecifier) {
const semverMatch = semver.maxSatisfying(versions, versionSpecifier);
if (semverMatch !== null) {
return semverMatch;
}
return pep440.maxSatisfying(versions, versionSpecifier) ?? undefined;
}
function selectArtifact(artifacts) {
if (artifacts.length === 1) {
return artifacts[0];
}
const defaultVariant = artifacts.find(
(candidate) => candidate.variant === "default",
);
if (defaultVariant !== undefined) {
return defaultVariant;
}
return artifacts[0];
}
async function benchmarkCase(name, expected, implementations, iterations) {
const results = {
name,
new: [],
old: [],
};
for (let iteration = 0; iteration < iterations; iteration += 1) {
const order = iteration % 2 === 0 ? ["old", "new"] : ["new", "old"];
for (const label of order) {
const implementation = implementations[label];
const startedAt = performance.now();
const outcome = await implementation.run();
const durationMs = performance.now() - startedAt;
if (outcome.value !== expected) {
throw new Error(
`${name} ${label} produced ${JSON.stringify(outcome.value)}; expected ${JSON.stringify(expected)}`,
);
}
results[label].push({
bytesRead: outcome.bytesRead,
durationMs,
});
}
}
return results;
}
function summarize(samples) {
const durations = samples
.map((sample) => sample.durationMs)
.sort((left, right) => left - right);
const bytes = samples
.map((sample) => sample.bytesRead)
.sort((left, right) => left - right);
const sum = (values) => values.reduce((total, value) => total + value, 0);
const percentile = (values, ratio) => {
const index = Math.min(
values.length - 1,
Math.max(0, Math.ceil(values.length * ratio) - 1),
);
return values[index];
};
return {
avgBytes: sum(bytes) / bytes.length,
avgMs: sum(durations) / durations.length,
maxMs: durations[durations.length - 1],
medianMs: percentile(durations, 0.5),
minMs: durations[0],
p95Ms: percentile(durations, 0.95),
};
}
function formatNumber(value, digits = 2) {
return value.toFixed(digits);
}
function formatSummary(name, oldSummary, newSummary) {
const speedup = oldSummary.avgMs / newSummary.avgMs;
const timeReduction =
((oldSummary.avgMs - newSummary.avgMs) / oldSummary.avgMs) * 100;
const byteReduction =
((oldSummary.avgBytes - newSummary.avgBytes) / oldSummary.avgBytes) * 100;
return [
`Scenario: ${name}`,
` old avg: ${formatNumber(oldSummary.avgMs)} ms | median: ${formatNumber(oldSummary.medianMs)} ms | p95: ${formatNumber(oldSummary.p95Ms)} ms | avg bytes: ${Math.round(oldSummary.avgBytes)}`,
` new avg: ${formatNumber(newSummary.avgMs)} ms | median: ${formatNumber(newSummary.medianMs)} ms | p95: ${formatNumber(newSummary.p95Ms)} ms | avg bytes: ${Math.round(newSummary.avgBytes)}`,
` delta: ${formatNumber(timeReduction)}% faster | ${formatNumber(speedup)}x speedup | ${formatNumber(byteReduction)}% fewer bytes read`,
].join("\n");
}
async function main() {
const options = parseArgs(process.argv.slice(2));
console.log(`Preparing benchmark data from ${options.url}`);
const baseline = await fetchAllVersions(options.url);
const latestVersion = baseline.versions[0]?.version;
if (!latestVersion) {
throw new Error("No versions found in NDJSON data");
}
const latestArtifact = selectArtifact(
baseline.versions[0].artifacts.filter(
(candidate) =>
candidate.platform === `${options.arch}-${options.platform}`,
),
);
if (!latestArtifact) {
throw new Error(
`No artifact found for ${options.arch}-${options.platform} in ${latestVersion}`,
);
}
const rangeSpecifier = `^${latestVersion.split(".")[0]}.${latestVersion.split(".")[1]}.0`;
console.log(
`Running ${options.iterations} iterations per scenario against ${options.url}`,
);
console.log(`Latest version: ${latestVersion}`);
console.log(`Range benchmark: ${rangeSpecifier}`);
console.log(`Artifact benchmark: ${options.arch}-${options.platform}`);
console.log("");
const scenarios = [
await benchmarkCase(
"latest version",
latestVersion,
{
new: {
run: async () => {
const { bytesRead, matchedVersion } = await streamUntil(
options.url,
() => true,
);
return {
bytesRead,
value: matchedVersion?.version,
};
},
},
old: {
run: async () => {
const { bytesRead, versions } = await fetchAllVersions(options.url);
return {
bytesRead,
value: versions[0]?.version,
};
},
},
},
options.iterations,
),
await benchmarkCase(
"highest satisfying range",
latestVersion,
{
new: {
run: async () => {
const { bytesRead, matchedVersion } = await streamUntil(
options.url,
(candidate) =>
versionSatisfies(candidate.version, rangeSpecifier),
);
return {
bytesRead,
value: matchedVersion?.version,
};
},
},
old: {
run: async () => {
const { bytesRead, versions } = await fetchAllVersions(options.url);
return {
bytesRead,
value: maxSatisfying(
versions.map((versionData) => versionData.version),
rangeSpecifier,
),
};
},
},
},
options.iterations,
),
await benchmarkCase(
"exact version artifact",
latestArtifact.url,
{
new: {
run: async () => {
const { bytesRead, matchedVersion } = await streamUntil(
options.url,
(candidate) => candidate.version === latestVersion,
);
const artifact = matchedVersion
? selectArtifact(
matchedVersion.artifacts.filter(
(candidate) =>
candidate.platform ===
`${options.arch}-${options.platform}`,
),
)
: undefined;
return {
bytesRead,
value: artifact?.url,
};
},
},
old: {
run: async () => {
const { bytesRead, versions } = await fetchAllVersions(options.url);
const versionData = versions.find(
(candidate) => candidate.version === latestVersion,
);
const artifact = selectArtifact(
versionData.artifacts.filter(
(candidate) =>
candidate.platform === `${options.arch}-${options.platform}`,
),
);
return {
bytesRead,
value: artifact?.url,
};
},
},
},
options.iterations,
),
];
for (const scenario of scenarios) {
const oldSummary = summarize(scenario.old);
const newSummary = summarize(scenario.new);
console.log(formatSummary(scenario.name, oldSummary, newSummary));
console.log("");
}
}
await main();

View File

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

View File

@@ -1,215 +1,5 @@
// AUTOGENERATED_DO_NOT_EDIT
export const KNOWN_CHECKSUMS: { [key: string]: string } = {
"aarch64-apple-darwin-0.11.3":
"2bc3d0c7bf2bd08325b1e170abac6f7e5b3346e1d4eab3370d17cefec934996f",
"aarch64-pc-windows-msvc-0.11.3":
"e99c56f9ab5e1e1ddcaea3e2389990c94baf38e0d7cb2148de08baf2d3261d49",
"aarch64-unknown-linux-gnu-0.11.3":
"711382e3158433f06b11d99afb440f4416359fc3c84558886d8ed8826a921bff",
"aarch64-unknown-linux-musl-0.11.3":
"8ecec82cb9a744d5fabff6d16d7777218a7730f699d2aa0d2f751c17858e2efa",
"arm-unknown-linux-musleabihf-0.11.3":
"3d021046a94ad11f12b9d83f36442a1a28e92e7149c3f79ba2951c96653dafac",
"armv7-unknown-linux-gnueabihf-0.11.3":
"13c9a0f5f624275ccd36db2896607f4fee3585f420734b16f6c66d70e32aa458",
"armv7-unknown-linux-musleabihf-0.11.3":
"260a88e2f00daab0363a745fde036a7881002d7a81094388f31925acb284110b",
"i686-pc-windows-msvc-0.11.3":
"036fa39fa5ea3cb86c127324924b913b5858e8d91c4cb413edacfc3123001696",
"i686-unknown-linux-gnu-0.11.3":
"b9410c8dae2fa0d4939af5b0ee7272d5591bd55890e8274dcf7f1aea84bfe043",
"i686-unknown-linux-musl-0.11.3":
"afe533fd409105e753d844490c65a4375e75bfb3812e49122684f996bed9e90a",
"powerpc64le-unknown-linux-gnu-0.11.3":
"5cdcadf4d50a5354312bc8ef37c2a6cfab4e2f13ccdf8380d3012b927b4ded95",
"riscv64gc-unknown-linux-gnu-0.11.3":
"8271e07ed9695870f4b0ae5ec722e3ae08fff280068f08bc6a8ca76c67d7fefa",
"riscv64gc-unknown-linux-musl-0.11.3":
"b750fc8393ced9939448849b05e94de6bf1e998bb7030c4ebe744b47b372bce9",
"s390x-unknown-linux-gnu-0.11.3":
"6dc4f555a5f6515f7fddb281422d2a8a3943853dae5de837bbb5d996d7576c71",
"x86_64-apple-darwin-0.11.3":
"b0e05e0b43a000fdc2132ee3f3400ba5dee427bc2337d3ec4eb8cf4f3d5722af",
"x86_64-pc-windows-msvc-0.11.3":
"ae681c0aaec7cc96af184648cb88d73f8393ed60fa5880abdd6bdb910f9b227c",
"x86_64-unknown-linux-gnu-0.11.3":
"c0f3236f146e55472663cfbcc9be3042a9f1092275bbe3fe2a56a6cbfd3da5ce",
"x86_64-unknown-linux-musl-0.11.3":
"8b40cf16b849634b81a530a3d0a0bcae5f24996ef9ae782976fd69b6266d3b8e",
"aarch64-apple-darwin-0.11.2":
"4beaa9550f93ef7f0fc02f7c28c9c48cd61fe30db00f5ac8947e0a425c3fb282",
"aarch64-pc-windows-msvc-0.11.2":
"ffdded8338205f53727b51d404563a5ac8eaa9aea53279a7b7c42177e11d478c",
"aarch64-unknown-linux-gnu-0.11.2":
"04792cac761c4a6ba78267f36f2af541b7f92196d42ac55d21d3ff6b0f5ab6a5",
"aarch64-unknown-linux-musl-0.11.2":
"275d91dd1f1955136591e7ec5e1fa21e84d0d37ead7da7c35c3683df748d9855",
"arm-unknown-linux-musleabihf-0.11.2":
"ce572dac1a8f9a92960f89e99351352fae068d34b24bed86fb88e75fd5dd67d9",
"armv7-unknown-linux-gnueabihf-0.11.2":
"3e90d7de9e3a4e2d8d1bd9ce164362fce22248474986e712039479fb6fd73136",
"armv7-unknown-linux-musleabihf-0.11.2":
"5222cdd7c7dd3263f8c243831606a9f01a1a07a40ffc3c26c03afb34491075c2",
"i686-pc-windows-msvc-0.11.2":
"506f8274b253b2386881a121f3b7d915b637019bda15876bbd1357235305cf12",
"i686-unknown-linux-gnu-0.11.2":
"c7ec378bab887443a70786382e58d76489da14a7e33b155915d648cca4bdb46c",
"i686-unknown-linux-musl-0.11.2":
"ade8714be45457899568c5b03ef885a0cc94476c07a0bdbe34531ba84231bab2",
"powerpc64le-unknown-linux-gnu-0.11.2":
"3f3a50e99364efc8ff7add10e79757a2b8458700a38180ec5f313524481b9fbc",
"riscv64gc-unknown-linux-gnu-0.11.2":
"e56a93f0ff21d6908461a6ecbf465beae19ae22719f900284abb7680bd07ec41",
"riscv64gc-unknown-linux-musl-0.11.2":
"4f263571bb457a16a31cb38fba4fcc9cf1059d1d32c5b2e54c43175fcd59205d",
"s390x-unknown-linux-gnu-0.11.2":
"42ebe40775f2a77a514fa47399fde86473bf35bd33b6896c6410a0309fc4d205",
"x86_64-apple-darwin-0.11.2":
"a9c3653245031304c50dd60ac0301bf6c112e12c38c32302a71d4fa6a63ba2cb",
"x86_64-pc-windows-msvc-0.11.2":
"171b7ccda1bbd562da6babeffcf533a1c6cc7862cf998da826e1db534fc43e48",
"x86_64-unknown-linux-gnu-0.11.2":
"7ac2ca0449c8d68dae9b99e635cd3bc9b22a4cb1de64b7c43716398447d42981",
"x86_64-unknown-linux-musl-0.11.2":
"4700d9fc75734247587deb3e25dd2c6c24f4ac69e8fe91d6acad4a6013115c06",
"aarch64-apple-darwin-0.11.1":
"f7815f739ed5d0e4202e6292acedb8659b9ae7de663d07188d8c6cbd7f96303f",
"aarch64-pc-windows-msvc-0.11.1":
"b789db0c1504dd3b02c090bd5783487497cc46cc2eb71754874cdd1ef59eb52a",
"aarch64-unknown-linux-gnu-0.11.1":
"1340e62da1ee3c1109764340e1247e8a1a232c30dde4a0f0548976dcaa90f06d",
"aarch64-unknown-linux-musl-0.11.1":
"bd04ffce77ee8d77f39823c13606183581847c2f5dcd704f2ea0f15e376b1a27",
"arm-unknown-linux-musleabihf-0.11.1":
"625c0e756e2374fce864ceaa6beedd5821e276e2b6307f2b719f2d62b449b89c",
"armv7-unknown-linux-gnueabihf-0.11.1":
"baf8daaab20b0502d1853dbfd916afb0762c024ae7f0df1c2deb2a1a1c1c3467",
"armv7-unknown-linux-musleabihf-0.11.1":
"684c25b74e83bcb1b177152379cfe2c974ba731aa5af278e1d161e41709f8bcf",
"i686-pc-windows-msvc-0.11.1":
"3c07858a08c54e4e5753239354c7b07ae69071b2b6f5aa2cc970e612adcb4740",
"i686-unknown-linux-gnu-0.11.1":
"6e83167c05708570563b10b6cc7e8c289daef5f51fde0b152e41af2a7ef70813",
"i686-unknown-linux-musl-0.11.1":
"b0d5152635c257fec76f95cb9268112b47ff70bd33a23866295a4f2ed9f46b7f",
"powerpc64le-unknown-linux-gnu-0.11.1":
"e42d2abfac46f57564789e2bfa6dbea4ae3135892e36ae066ba0ae77b69bb676",
"riscv64gc-unknown-linux-gnu-0.11.1":
"5e2c757b35dab015ad37f74ee3e060208390b5f4defb6684876f1be0664f3f6e",
"riscv64gc-unknown-linux-musl-0.11.1":
"6f590a824aed363cbec4079f7ddab87b5685119e0f5f0e71cd114c7b7c326199",
"s390x-unknown-linux-gnu-0.11.1":
"4208173c74e29572b799178709b5ed5828b24888659f944a4b47c0aaf78b42d2",
"x86_64-apple-darwin-0.11.1":
"2103670e8e949605e51926c7b953923ff6f6befbfb55aee928f5e760c9c910f8",
"x86_64-pc-windows-msvc-0.11.1":
"6659250cebbd3bb6ee48bcb21a3f0c6656450d63fb97f0f069bcb532bdb688ed",
"x86_64-unknown-linux-gnu-0.11.1":
"7c0c8069053e6e99e5911ff32b916be571f3419cd8e11bd28fb7da2c7dcaa553",
"x86_64-unknown-linux-musl-0.11.1":
"4e949471a95b37088a1ff1a585f69abed4d3cd3f921f50709a46b6ba62986d38",
"aarch64-apple-darwin-0.11.0":
"0c0f32c6a3473c5928aff96c3233715edfc79290e892f255cac93710cde7b91a",
"aarch64-pc-windows-msvc-0.11.0":
"95419e04a3ef5f13fb2a06bd6d787ba80a9d8981d6f097780e5a979817a2879d",
"aarch64-unknown-linux-gnu-0.11.0":
"8e179ca110343a17f801444ff9ef117dba56ef5fc9f6a4c9bb77b318ddba5f24",
"aarch64-unknown-linux-musl-0.11.0":
"658be4b8ec905635f1295468d4d5120d9e1ab1722eec9a104473ce993590babe",
"arm-unknown-linux-musleabihf-0.11.0":
"bfdcbd5fa41c8a9877a72c2b55a95da2bc79933885ef56c699b65bb2ed9cea91",
"armv7-unknown-linux-gnueabihf-0.11.0":
"0cad4e1b6769e48aa1e80cf639ddcc7c1bfe9ed017e95868fed185a8d818c949",
"armv7-unknown-linux-musleabihf-0.11.0":
"2aa9da83c6c0cf8a06bc9df14d51056284fa067ef5390b4db79998ff12f3bee7",
"i686-pc-windows-msvc-0.11.0":
"3b09d70e686087e096dbd8a2af21b922a2cac7d613dc053c3281c3ddbb961961",
"i686-unknown-linux-gnu-0.11.0":
"59928a0267501c20d9f9942f5f1d81a991ec55e29a19e002ae3d5c178c674c89",
"i686-unknown-linux-musl-0.11.0":
"1f438d6f6f851f0dabad3307ce7fd46541ecc5c42ebb664f382eb6c9a424a67d",
"powerpc64le-unknown-linux-gnu-0.11.0":
"29f17fb43595492b1a36cda57df7adad74183132df32799d32897268ff4e26dd",
"riscv64gc-unknown-linux-gnu-0.11.0":
"84ef37dda1003c5b65fa6c8f84242d35a7fcc84cc5ea9490d702edc36cad1f67",
"s390x-unknown-linux-gnu-0.11.0":
"b25be62f3b642348a2fece5c658624586661b8d1103891ab6903768b0529edc4",
"x86_64-apple-darwin-0.11.0":
"31aaec764166af8885cf99321fd6ed24fef80225a6f26ed1ae8ce04111688a7e",
"x86_64-pc-windows-msvc-0.11.0":
"e21d00b172df83531564a95e75a2bdc0c59b471dbb3515f0c1b4d6ef657dc451",
"x86_64-unknown-linux-gnu-0.11.0":
"cc0fbb42b3642125f600a55b0b095bea65cddaadb94c6ea2b6ba5d79c5825089",
"x86_64-unknown-linux-musl-0.11.0":
"bf6b0757c73d1726faa2a819b155d4d864919a95766720215d78fdcd09d42d26",
"aarch64-apple-darwin-0.10.12":
"ae738b5661a900579ec621d3918c0ef17bdec0da2a8a6d8b161137cd15f25414",
"aarch64-pc-windows-msvc-0.10.12":
"e79881e2c4f98a0f3a37b8770bf224e8fee70f6dcf8fc17055d8291bb1b0b867",
"aarch64-unknown-linux-gnu-0.10.12":
"0ed7d20f49f6b9b60d45fdfcac28f3ac01a671a6ef08672401ed2833423fea2a",
"aarch64-unknown-linux-musl-0.10.12":
"55bd1c1c10ec8b95a8c184f5e18b566703c6ab105f0fc118aaa4d748aabf28e4",
"arm-unknown-linux-musleabihf-0.10.12":
"9714e5059b05110a1c7ddbc18c971c13e0260e10551b7b77d82cbf907a4ebd9b",
"armv7-unknown-linux-gnueabihf-0.10.12":
"eaa02f36d5112029601b18ac3d1a0c03a83bb20cb4154c2f5345f777fa6c4101",
"armv7-unknown-linux-musleabihf-0.10.12":
"bd735652298c6e62cdd2ac939babe176a3356613e6803baa33d0bc10e8d9e4ed",
"i686-pc-windows-msvc-0.10.12":
"2312e75b9c77befdc1bff30da18f16df03083452852952553bee91da362c1a1d",
"i686-unknown-linux-gnu-0.10.12":
"8501844b34e3a28cfbba5a4b857eebd696d952e0bb4160357451ad80f3f49db8",
"i686-unknown-linux-musl-0.10.12":
"56cad78abcf5b710d2f7b9f774fcfd6bbed340d2aa9d9fc9e3b515542ec5e953",
"powerpc64le-unknown-linux-gnu-0.10.12":
"3c8017d9112221c83f43e8a15a58099663c0b2bdeabc8b43bb800413dfa21218",
"riscv64gc-unknown-linux-gnu-0.10.12":
"b1ca482b6b5dd7bf6ab733a3695cb0ab5b8e992ca96527efae93aa78fcc52a9b",
"s390x-unknown-linux-gnu-0.10.12":
"e1a0345eefe6fd3300948cd6f18aab092f9b88a243782113e645ce96530a6693",
"x86_64-apple-darwin-0.10.12":
"17443e293f2ae407bb2d8d34b875ebfe0ae01cf1296de5647e69e7b2e2b428f0",
"x86_64-pc-windows-msvc-0.10.12":
"4c1d55501869b3330d4aabf45ad6024ce2367e0f3af83344395702d272c22e88",
"x86_64-unknown-linux-gnu-0.10.12":
"ec72570c9d1f33021aa80b176d7baba390de2cfeb1abcbefca346d563bf17484",
"x86_64-unknown-linux-musl-0.10.12":
"adccf40b5d1939a5e0093081ec2307ea24235adf7c2d96b122c561fa37711c46",
"aarch64-apple-darwin-0.10.11":
"437a7d498dd6564d5bf986074249ba1fc600e73da55ae04d7bd4c24d5f149b95",
"aarch64-pc-windows-msvc-0.10.11":
"6a3eec4105c775dd87c11ef8ec41564648273751ff807c8955c24ddbcc636d03",
"aarch64-unknown-linux-gnu-0.10.11":
"23003df007937dd607409c8ddf010baa82bad2673e60e254632ca5b04edcce13",
"aarch64-unknown-linux-musl-0.10.11":
"5d80a7f6343d2676dfde1e5126582070a2bbc62df6f60d5527a169be3788532a",
"arm-unknown-linux-musleabihf-0.10.11":
"d3c248497c450d22a39c1d43a4a358c0c852e6056f5f49be96495eea41afb96c",
"armv7-unknown-linux-gnueabihf-0.10.11":
"7895a6470dfba051af4e74253599482fc0b37141b5d229956b383365e1a22902",
"armv7-unknown-linux-musleabihf-0.10.11":
"d2880c08acfdaef0985488972c8b14969f7139c27545046e2f6202f0e0f4d9d8",
"i686-pc-windows-msvc-0.10.11":
"c17f3dc3b2c47490057f17a1f0c37270f11a7b7cedf9bf2c0f841ce02bc7001b",
"i686-unknown-linux-gnu-0.10.11":
"1ab69ff7dd104a902731758ee05b782dfd9bdb263384e61650de638f33f586df",
"i686-unknown-linux-musl-0.10.11":
"cffb80d303fc1655e259d0b769c489f452e97425a6b6d3393d766413783a1d8c",
"powerpc64le-unknown-linux-gnu-0.10.11":
"ddc6a20670e60219e947b1b04813be80d7e9f4c4a0234231c8ed9298eec04aa6",
"riscv64gc-unknown-linux-gnu-0.10.11":
"c0719473cf5f8b475e917b8dfef6ae5d876b86a00a82ef91e47a02f561399f4f",
"s390x-unknown-linux-gnu-0.10.11":
"305ee734c585918515a22fe43b7cf253c38d468771373a0c02364d67498e07b2",
"x86_64-apple-darwin-0.10.11":
"ff90020b554cf02ef8008535c9aab6ef27bb7be6b075359300dec79c361df897",
"x86_64-pc-windows-msvc-0.10.11":
"9ee74df98582f37fdd6069e1caac80d2616f9a489f5dbb2b1c152f30be69c58e",
"x86_64-unknown-linux-gnu-0.10.11":
"5a360b0de092ddf4131f5313d0411b48c4e95e8107e40c3f8f2e9fcb636b3583",
"x86_64-unknown-linux-musl-0.10.11":
"d78246139dc6cf3ed6d03c84da762686bced7ad1de67977ee372a45b95a1f6d0",
"aarch64-apple-darwin-0.10.10":
"8a09f0ef51ee7f7170731b4cb8bde5bf9ba6da5304f49a7df6cdab42a1f37b5d",
"aarch64-pc-windows-msvc-0.10.10":

View File

@@ -4,15 +4,20 @@ import * as core from "@actions/core";
import * as tc from "@actions/tool-cache";
import * as pep440 from "@renovatebot/pep440";
import * as semver from "semver";
import {
ASTRAL_MIRROR_PREFIX,
GITHUB_RELEASES_PREFIX,
TOOL_CACHE_NAME,
VERSIONS_MANIFEST_URL,
} from "../utils/constants";
import { TOOL_CACHE_NAME, VERSIONS_NDJSON_URL } from "../utils/constants";
import type { Architecture, Platform } from "../utils/platforms";
import { validateChecksum } from "./checksum/checksum";
import { getAllVersions, getArtifact, getLatestVersion } from "./manifest";
import {
getAllVersions as getAllManifestVersions,
getLatestKnownVersion as getLatestVersionInManifest,
getManifestArtifact,
} from "./version-manifest";
import {
getAllVersions as getAllVersionsFromNdjson,
getArtifact as getArtifactFromNdjson,
getHighestSatisfyingVersion as getHighestSatisfyingVersionFromNdjson,
getLatestVersion as getLatestVersionFromNdjson,
} from "./versions-client";
export function tryGetFromToolCache(
arch: Architecture,
@@ -29,85 +34,73 @@ export function tryGetFromToolCache(
return { installedPath, version: resolvedVersion };
}
export async function downloadVersion(
export async function downloadVersionFromNdjson(
platform: Platform,
arch: Architecture,
version: string,
checksum: string | undefined,
checkSum: string | undefined,
githubToken: string,
manifestUrl?: string,
): Promise<{ version: string; cachedToolDir: string }> {
const artifact = await getArtifact(version, arch, platform, manifestUrl);
const artifact = await getArtifactFromNdjson(version, arch, platform);
if (!artifact) {
throw new Error(
getMissingArtifactMessage(version, arch, platform, manifestUrl),
`Could not find artifact for version ${version}, arch ${arch}, platform ${platform} in ${VERSIONS_NDJSON_URL} .`,
);
}
// 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 resolvedChecksum =
manifestUrl === undefined
? checksum
: resolveChecksum(checksum, artifact.checksum);
const mirrorUrl = rewriteToMirror(artifact.downloadUrl);
const downloadUrl = mirrorUrl ?? artifact.downloadUrl;
// Don't send the GitHub token to the Astral mirror.
const downloadToken = mirrorUrl !== undefined ? undefined : githubToken;
try {
return await downloadArtifact(
downloadUrl,
`uv-${arch}-${platform}`,
platform,
arch,
version,
resolvedChecksum,
downloadToken,
);
} catch (err) {
if (mirrorUrl === undefined) {
throw err;
}
core.warning(
`Failed to download from mirror, falling back to GitHub Releases: ${(err as Error).message}`,
);
return await downloadArtifact(
artifact.downloadUrl,
`uv-${arch}-${platform}`,
platform,
arch,
version,
resolvedChecksum,
githubToken,
);
}
// user input or the built-in KNOWN_CHECKSUMS table, not NDJSON sha256 values.
return await downloadVersion(
artifact.url,
`uv-${arch}-${platform}`,
platform,
arch,
version,
checkSum,
githubToken,
);
}
/**
* Rewrite a GitHub Releases URL to the Astral mirror.
* Returns `undefined` if the URL does not match the expected GitHub prefix.
*/
export function rewriteToMirror(url: string): string | undefined {
if (!url.startsWith(GITHUB_RELEASES_PREFIX)) {
return undefined;
export async function downloadVersionFromManifest(
manifestUrl: string,
platform: Platform,
arch: Architecture,
version: string,
checkSum: string | undefined,
githubToken: string,
): Promise<{ version: string; cachedToolDir: string }> {
const artifact = await getManifestArtifact(
manifestUrl,
version,
arch,
platform,
);
if (!artifact) {
throw new Error(
`manifest-file does not contain version ${version}, arch ${arch}, platform ${platform}.`,
);
}
return ASTRAL_MIRROR_PREFIX + url.slice(GITHUB_RELEASES_PREFIX.length);
return await downloadVersion(
artifact.downloadUrl,
`uv-${arch}-${platform}`,
platform,
arch,
version,
resolveChecksum(checkSum, artifact.checksum),
githubToken,
);
}
async function downloadArtifact(
async function downloadVersion(
downloadUrl: string,
artifactName: string,
platform: Platform,
arch: Architecture,
version: string,
checksum: string | undefined,
githubToken: string | undefined,
githubToken: string,
): Promise<{ version: string; cachedToolDir: string }> {
core.info(`Downloading uv from "${downloadUrl}" ...`);
const downloadPath = await tc.downloadTool(
@@ -144,28 +137,15 @@ async function downloadArtifact(
version,
arch,
);
return { cachedToolDir, version };
}
function getMissingArtifactMessage(
version: string,
arch: Architecture,
platform: Platform,
manifestUrl?: string,
): string {
if (manifestUrl === undefined) {
return `Could not find artifact for version ${version}, arch ${arch}, platform ${platform} in ${VERSIONS_MANIFEST_URL} .`;
}
return `manifest-file does not contain version ${version}, arch ${arch}, platform ${platform}.`;
return { cachedToolDir, version: version };
}
function resolveChecksum(
checksum: string | undefined,
manifestChecksum: string,
): string {
return checksum !== undefined && checksum !== ""
? checksum
checkSum: string | undefined,
manifestChecksum?: string,
): string | undefined {
return checkSum !== undefined && checkSum !== ""
? checkSum
: manifestChecksum;
}
@@ -179,31 +159,46 @@ export async function resolveVersion(
resolutionStrategy: "highest" | "lowest" = "highest",
): Promise<string> {
core.debug(`Resolving version: ${versionInput}`);
let version: string;
const isSimpleMinimumVersionSpecifier =
versionInput.includes(">") && !versionInput.includes(",");
const resolveVersionSpecifierToLatest =
isSimpleMinimumVersionSpecifier && resolutionStrategy === "highest";
if (resolveVersionSpecifierToLatest) {
core.info("Found minimum version specifier, using latest version");
}
const version =
versionInput === "latest" || resolveVersionSpecifierToLatest
? await getLatestVersion(manifestUrl)
: versionInput;
if (manifestUrl !== undefined) {
version =
versionInput === "latest" || resolveVersionSpecifierToLatest
? await getLatestVersionInManifest(manifestUrl)
: versionInput;
} else {
version =
versionInput === "latest" || resolveVersionSpecifierToLatest
? await getLatestVersionFromNdjson()
: versionInput;
}
if (tc.isExplicitVersion(version)) {
core.debug(`Version ${version} is an explicit version.`);
if (
resolveVersionSpecifierToLatest &&
!pep440.satisfies(version, versionInput)
) {
throw new Error(`No version found for ${versionInput}`);
if (resolveVersionSpecifierToLatest) {
if (!pep440.satisfies(version, versionInput)) {
throw new Error(`No version found for ${versionInput}`);
}
}
return version;
}
if (manifestUrl === undefined && resolutionStrategy === "highest") {
const resolvedVersion =
await getHighestSatisfyingVersionFromNdjson(version);
if (resolvedVersion !== undefined) {
core.debug(`Resolved version from NDJSON stream: ${resolvedVersion}`);
return resolvedVersion;
}
throw new Error(`No version found for ${version}`);
}
const availableVersions = await getAvailableVersions(manifestUrl);
core.debug(`Available versions: ${availableVersions}`);
const resolvedVersion =
@@ -225,11 +220,11 @@ async function getAvailableVersions(
core.info(
`Getting available versions from manifest-file ${manifestUrl} ...`,
);
} else {
core.info(`Getting available versions from ${VERSIONS_MANIFEST_URL} ...`);
return await getAllManifestVersions(manifestUrl);
}
return await getAllVersions(manifestUrl);
core.info(`Getting available versions from ${VERSIONS_NDJSON_URL} ...`);
return await getAllVersionsFromNdjson();
}
function maxSatisfying(

View File

@@ -0,0 +1,80 @@
import * as core from "@actions/core";
export interface ManifestEntry {
arch: string;
platform: string;
version: string;
downloadUrl: string;
checksum?: string;
variant?: string;
archiveFormat?: string;
}
interface LegacyManifestEntry {
arch: string;
platform: string;
version: string;
downloadUrl: string;
checksum?: string;
}
const warnedLegacyManifestUrls = new Set<string>();
export function parseLegacyManifestEntries(
parsedEntries: unknown[],
manifestUrl: string,
): ManifestEntry[] {
warnAboutLegacyManifestFormat(manifestUrl);
return parsedEntries.map((entry, index) => {
if (!isLegacyManifestEntry(entry)) {
throw new Error(
`Invalid legacy manifest-file entry at index ${index} in ${manifestUrl}.`,
);
}
return {
arch: entry.arch,
checksum: entry.checksum,
downloadUrl: entry.downloadUrl,
platform: entry.platform,
version: entry.version,
};
});
}
export function clearLegacyManifestWarnings(): void {
warnedLegacyManifestUrls.clear();
}
function warnAboutLegacyManifestFormat(manifestUrl: string): void {
if (warnedLegacyManifestUrls.has(manifestUrl)) {
return;
}
warnedLegacyManifestUrls.add(manifestUrl);
core.warning(
`manifest-file ${manifestUrl} uses the legacy JSON array format, which is deprecated. Please migrate to the astral-sh/versions NDJSON format before the next major release.`,
);
}
function isLegacyManifestEntry(value: unknown): value is LegacyManifestEntry {
if (!isRecord(value)) {
return false;
}
const checksumIsValid =
typeof value.checksum === "string" || value.checksum === undefined;
return (
typeof value.arch === "string" &&
checksumIsValid &&
typeof value.downloadUrl === "string" &&
typeof value.platform === "string" &&
typeof value.version === "string"
);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}

View File

@@ -1,199 +0,0 @@
import * as core from "@actions/core";
import { VERSIONS_MANIFEST_URL } from "../utils/constants";
import { fetch } from "../utils/fetch";
import { selectDefaultVariant } from "./variant-selection";
export interface ManifestArtifact {
platform: string;
variant?: string;
url: string;
archive_format: string;
sha256: string;
}
export interface ManifestVersion {
version: string;
artifacts: ManifestArtifact[];
}
export interface ArtifactResult {
archiveFormat: string;
checksum: string;
downloadUrl: string;
}
const cachedManifestData = new Map<string, ManifestVersion[]>();
export async function fetchManifest(
manifestUrl: string = VERSIONS_MANIFEST_URL,
): Promise<ManifestVersion[]> {
const cachedVersions = cachedManifestData.get(manifestUrl);
if (cachedVersions !== undefined) {
core.debug(`Using cached manifest data from ${manifestUrl}`);
return cachedVersions;
}
core.info(`Fetching manifest data from ${manifestUrl} ...`);
const response = await fetch(manifestUrl, {});
if (!response.ok) {
throw new Error(
`Failed to fetch manifest data: ${response.status} ${response.statusText}`,
);
}
const body = await response.text();
const versions = parseManifest(body, manifestUrl);
cachedManifestData.set(manifestUrl, versions);
return versions;
}
export function parseManifest(
data: string,
sourceDescription: string,
): ManifestVersion[] {
const trimmed = data.trim();
if (trimmed === "") {
throw new Error(`Manifest at ${sourceDescription} is empty.`);
}
if (trimmed.startsWith("[")) {
throw new Error(
`Legacy JSON array manifests are no longer supported in ${sourceDescription}. Use the astral-sh/versions manifest format instead.`,
);
}
const versions: ManifestVersion[] = [];
for (const [index, line] of data.split("\n").entries()) {
const record = line.trim();
if (record === "") {
continue;
}
let parsed: unknown;
try {
parsed = JSON.parse(record);
} catch (error) {
throw new Error(
`Failed to parse manifest data from ${sourceDescription} at line ${index + 1}: ${(error as Error).message}`,
);
}
if (!isManifestVersion(parsed)) {
throw new Error(
`Invalid manifest record in ${sourceDescription} at line ${index + 1}.`,
);
}
versions.push(parsed);
}
if (versions.length === 0) {
throw new Error(`No manifest data found in ${sourceDescription}.`);
}
return versions;
}
export async function getLatestVersion(
manifestUrl: string = VERSIONS_MANIFEST_URL,
): Promise<string> {
const latestVersion = (await fetchManifest(manifestUrl))[0]?.version;
if (latestVersion === undefined) {
throw new Error("No versions found in manifest data");
}
core.debug(`Latest version from manifest: ${latestVersion}`);
return latestVersion;
}
export async function getAllVersions(
manifestUrl: string = VERSIONS_MANIFEST_URL,
): Promise<string[]> {
const versions = await fetchManifest(manifestUrl);
return versions.map((versionData) => versionData.version);
}
export async function getArtifact(
version: string,
arch: string,
platform: string,
manifestUrl: string = VERSIONS_MANIFEST_URL,
): Promise<ArtifactResult | undefined> {
const versions = await fetchManifest(manifestUrl);
const versionData = versions.find(
(candidate) => candidate.version === version,
);
if (!versionData) {
core.debug(`Version ${version} not found in manifest ${manifestUrl}`);
return undefined;
}
const targetPlatform = `${arch}-${platform}`;
const matchingArtifacts = versionData.artifacts.filter(
(candidate) => candidate.platform === targetPlatform,
);
if (matchingArtifacts.length === 0) {
core.debug(
`Artifact for ${targetPlatform} not found in version ${version}. Available platforms: ${versionData.artifacts
.map((candidate) => candidate.platform)
.join(", ")}`,
);
return undefined;
}
const artifact = selectDefaultVariant(
matchingArtifacts,
`Multiple artifacts found for ${targetPlatform} in version ${version}`,
);
return {
archiveFormat: artifact.archive_format,
checksum: artifact.sha256,
downloadUrl: artifact.url,
};
}
export function clearManifestCache(manifestUrl?: string): void {
if (manifestUrl === undefined) {
cachedManifestData.clear();
return;
}
cachedManifestData.delete(manifestUrl);
}
function isManifestVersion(value: unknown): value is ManifestVersion {
if (!isRecord(value)) {
return false;
}
if (typeof value.version !== "string" || !Array.isArray(value.artifacts)) {
return false;
}
return value.artifacts.every(isManifestArtifact);
}
function isManifestArtifact(value: unknown): value is ManifestArtifact {
if (!isRecord(value)) {
return false;
}
const variantIsValid =
typeof value.variant === "string" || value.variant === undefined;
return (
typeof value.archive_format === "string" &&
typeof value.platform === "string" &&
typeof value.sha256 === "string" &&
typeof value.url === "string" &&
variantIsValid
);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}

View File

@@ -0,0 +1,169 @@
import * as core from "@actions/core";
import * as semver from "semver";
import { fetch } from "../utils/fetch";
import {
clearLegacyManifestWarnings,
type ManifestEntry,
parseLegacyManifestEntries,
} from "./legacy-version-manifest";
import { selectDefaultVariant } from "./variant-selection";
import { type NdjsonVersion, parseVersionData } from "./versions-client";
export interface ManifestArtifact {
downloadUrl: string;
checksum?: string;
archiveFormat?: string;
}
const cachedManifestEntries = new Map<string, ManifestEntry[]>();
export async function getLatestKnownVersion(
manifestUrl: string,
): Promise<string> {
const versions = await getAllVersions(manifestUrl);
const latestVersion = versions.reduce((latest, current) =>
semver.gt(current, latest) ? current : latest,
);
return latestVersion;
}
export async function getAllVersions(manifestUrl: string): Promise<string[]> {
const manifestEntries = await getManifestEntries(manifestUrl);
return [...new Set(manifestEntries.map((entry) => entry.version))];
}
export async function getManifestArtifact(
manifestUrl: string,
version: string,
arch: string,
platform: string,
): Promise<ManifestArtifact | undefined> {
const manifestEntries = await getManifestEntries(manifestUrl);
const entry = selectManifestEntry(
manifestEntries,
manifestUrl,
version,
arch,
platform,
);
if (!entry) {
return undefined;
}
return {
archiveFormat: entry.archiveFormat,
checksum: entry.checksum,
downloadUrl: entry.downloadUrl,
};
}
export function clearManifestCache(): void {
cachedManifestEntries.clear();
clearLegacyManifestWarnings();
}
async function getManifestEntries(
manifestUrl: string,
): Promise<ManifestEntry[]> {
const cachedEntries = cachedManifestEntries.get(manifestUrl);
if (cachedEntries !== undefined) {
core.debug(`Using cached manifest-file from: ${manifestUrl}`);
return cachedEntries;
}
core.info(`Fetching manifest-file from: ${manifestUrl}`);
const response = await fetch(manifestUrl, {});
if (!response.ok) {
throw new Error(
`Failed to fetch manifest-file: ${response.status} ${response.statusText}`,
);
}
const data = await response.text();
const parsedEntries = parseManifestEntries(data, manifestUrl);
cachedManifestEntries.set(manifestUrl, parsedEntries);
return parsedEntries;
}
function parseManifestEntries(
data: string,
manifestUrl: string,
): ManifestEntry[] {
const trimmed = data.trim();
if (trimmed === "") {
throw new Error(`manifest-file at ${manifestUrl} is empty.`);
}
const parsedAsJson = tryParseJson(trimmed);
if (Array.isArray(parsedAsJson)) {
return parseLegacyManifestEntries(parsedAsJson, manifestUrl);
}
const versions = parseVersionData(trimmed, manifestUrl);
return mapNdjsonVersionsToManifestEntries(versions, manifestUrl);
}
function mapNdjsonVersionsToManifestEntries(
versions: NdjsonVersion[],
manifestUrl: string,
): ManifestEntry[] {
const manifestEntries: ManifestEntry[] = [];
for (const versionData of versions) {
for (const artifact of versionData.artifacts) {
const [arch, ...platformParts] = artifact.platform.split("-");
if (arch === undefined || platformParts.length === 0) {
throw new Error(
`Invalid artifact platform '${artifact.platform}' in manifest-file ${manifestUrl}.`,
);
}
manifestEntries.push({
arch,
archiveFormat: artifact.archive_format,
checksum: artifact.sha256,
downloadUrl: artifact.url,
platform: platformParts.join("-"),
variant: artifact.variant,
version: versionData.version,
});
}
}
return manifestEntries;
}
function selectManifestEntry(
manifestEntries: ManifestEntry[],
manifestUrl: string,
version: string,
arch: string,
platform: string,
): ManifestEntry | undefined {
const matches = manifestEntries.filter(
(candidate) =>
candidate.version === version &&
candidate.arch === arch &&
candidate.platform === platform,
);
if (matches.length === 0) {
return undefined;
}
return selectDefaultVariant(
matches,
`manifest-file ${manifestUrl} contains multiple artifacts for version ${version}, arch ${arch}, platform ${platform}`,
);
}
function tryParseJson(value: string): unknown {
try {
return JSON.parse(value);
} catch {
return undefined;
}
}

View File

@@ -0,0 +1,380 @@
import * as core from "@actions/core";
import * as pep440 from "@renovatebot/pep440";
import * as semver from "semver";
import { VERSIONS_NDJSON_URL } from "../utils/constants";
import { fetch } from "../utils/fetch";
import { selectDefaultVariant } from "./variant-selection";
export interface NdjsonArtifact {
platform: string;
variant?: string;
url: string;
archive_format: string;
sha256: string;
}
export interface NdjsonVersion {
version: string;
artifacts: NdjsonArtifact[];
}
export interface ArtifactResult {
url: string;
sha256: string;
archiveFormat: string;
}
const cachedVersionData = new Map<string, NdjsonVersion[]>();
const cachedLatestVersionData = new Map<string, NdjsonVersion>();
const cachedVersionLookup = new Map<string, Map<string, NdjsonVersion>>();
export async function fetchVersionData(
url: string = VERSIONS_NDJSON_URL,
): Promise<NdjsonVersion[]> {
const cachedVersions = cachedVersionData.get(url);
if (cachedVersions !== undefined) {
core.debug(`Using cached NDJSON version data from ${url}`);
return cachedVersions;
}
core.info(`Fetching version data from ${url} ...`);
const { versions } = await readVersionData(url);
cacheCompleteVersionData(url, versions);
return versions;
}
export function parseVersionData(
data: string,
sourceDescription: string,
): NdjsonVersion[] {
const versions: NdjsonVersion[] = [];
for (const [index, line] of data.split("\n").entries()) {
const trimmed = line.trim();
if (trimmed === "") {
continue;
}
versions.push(parseVersionLine(trimmed, sourceDescription, index + 1));
}
if (versions.length === 0) {
throw new Error(`No version data found in ${sourceDescription}.`);
}
return versions;
}
export async function getLatestVersion(): Promise<string> {
const cachedVersions = cachedVersionData.get(VERSIONS_NDJSON_URL);
const cachedLatestVersion =
cachedVersions?.[0] ?? cachedLatestVersionData.get(VERSIONS_NDJSON_URL);
if (cachedLatestVersion !== undefined) {
core.debug(
`Latest version from NDJSON cache: ${cachedLatestVersion.version}`,
);
return cachedLatestVersion.version;
}
const latestVersion = await findVersionData(() => true);
if (!latestVersion) {
throw new Error("No versions found in NDJSON data");
}
core.debug(`Latest version from NDJSON: ${latestVersion.version}`);
return latestVersion.version;
}
export async function getAllVersions(): Promise<string[]> {
const versions = await fetchVersionData();
return versions.map((versionData) => versionData.version);
}
export async function getHighestSatisfyingVersion(
versionSpecifier: string,
url: string = VERSIONS_NDJSON_URL,
): Promise<string | undefined> {
const matchedVersion = await findVersionData(
(candidate) => versionSatisfies(candidate.version, versionSpecifier),
url,
);
return matchedVersion?.version;
}
export async function getArtifact(
version: string,
arch: string,
platform: string,
): Promise<ArtifactResult | undefined> {
const versionData = await getVersionData(version);
if (!versionData) {
core.debug(`Version ${version} not found in NDJSON data`);
return undefined;
}
const targetPlatform = `${arch}-${platform}`;
const matchingArtifacts = versionData.artifacts.filter(
(candidate) => candidate.platform === targetPlatform,
);
if (matchingArtifacts.length === 0) {
core.debug(
`Artifact for ${targetPlatform} not found in version ${version}. Available platforms: ${versionData.artifacts
.map((candidate) => candidate.platform)
.join(", ")}`,
);
return undefined;
}
const artifact = selectArtifact(matchingArtifacts, version, targetPlatform);
return {
archiveFormat: artifact.archive_format,
sha256: artifact.sha256,
url: artifact.url,
};
}
export function clearCache(url?: string): void {
if (url === undefined) {
cachedVersionData.clear();
cachedLatestVersionData.clear();
cachedVersionLookup.clear();
return;
}
cachedVersionData.delete(url);
cachedLatestVersionData.delete(url);
cachedVersionLookup.delete(url);
}
function selectArtifact(
artifacts: NdjsonArtifact[],
version: string,
targetPlatform: string,
): NdjsonArtifact {
return selectDefaultVariant(
artifacts,
`Multiple artifacts found for ${targetPlatform} in version ${version}`,
);
}
async function getVersionData(
version: string,
url: string = VERSIONS_NDJSON_URL,
): Promise<NdjsonVersion | undefined> {
const cachedVersions = cachedVersionData.get(url);
if (cachedVersions !== undefined) {
return cachedVersions.find((candidate) => candidate.version === version);
}
const cachedVersion = cachedVersionLookup.get(url)?.get(version);
if (cachedVersion !== undefined) {
return cachedVersion;
}
return await findVersionData(
(candidate) => candidate.version === version,
url,
);
}
async function findVersionData(
predicate: (versionData: NdjsonVersion) => boolean,
url: string = VERSIONS_NDJSON_URL,
): Promise<NdjsonVersion | undefined> {
const cachedVersions = cachedVersionData.get(url);
if (cachedVersions !== undefined) {
return cachedVersions.find(predicate);
}
const { matchedVersion, versions, complete } = await readVersionData(
url,
predicate,
);
if (complete) {
cacheCompleteVersionData(url, versions);
}
return matchedVersion;
}
async function readVersionData(
url: string,
stopWhen?: (versionData: NdjsonVersion) => boolean,
): Promise<{
complete: boolean;
matchedVersion: NdjsonVersion | undefined;
versions: NdjsonVersion[];
}> {
const response = await fetch(url, {});
if (!response.ok) {
throw new Error(
`Failed to fetch version data: ${response.status} ${response.statusText}`,
);
}
if (response.body === null) {
const body = await response.text();
const versions = parseVersionData(body, url);
const matchedVersion = stopWhen
? versions.find((candidate) => stopWhen(candidate))
: undefined;
return { complete: true, matchedVersion, versions };
}
const versions: NdjsonVersion[] = [];
let lineNumber = 0;
let matchedVersion: NdjsonVersion | undefined;
let buffer = "";
const decoder = new TextDecoder();
const reader = response.body.getReader();
const processLine = (line: string): boolean => {
const trimmed = line.trim();
if (trimmed === "") {
return false;
}
lineNumber += 1;
const versionData = parseVersionLine(trimmed, url, lineNumber);
if (versions.length === 0) {
cachedLatestVersionData.set(url, versionData);
}
versions.push(versionData);
cacheVersion(url, versionData);
if (stopWhen?.(versionData) === true) {
matchedVersion = versionData;
return true;
}
return false;
};
while (true) {
const { done, value } = await reader.read();
if (done) {
buffer += decoder.decode();
break;
}
buffer += decoder.decode(value, { stream: true });
let newlineIndex = buffer.indexOf("\n");
while (newlineIndex !== -1) {
const line = buffer.slice(0, newlineIndex);
buffer = buffer.slice(newlineIndex + 1);
if (processLine(line)) {
await reader.cancel();
return { complete: false, matchedVersion, versions };
}
newlineIndex = buffer.indexOf("\n");
}
}
if (buffer.trim() !== "" && processLine(buffer)) {
return { complete: true, matchedVersion, versions };
}
if (versions.length === 0) {
throw new Error(`No version data found in ${url}.`);
}
return { complete: true, matchedVersion, versions };
}
function cacheCompleteVersionData(
url: string,
versions: NdjsonVersion[],
): void {
cachedVersionData.set(url, versions);
if (versions[0] !== undefined) {
cachedLatestVersionData.set(url, versions[0]);
}
const versionLookup = new Map<string, NdjsonVersion>();
for (const versionData of versions) {
versionLookup.set(versionData.version, versionData);
}
cachedVersionLookup.set(url, versionLookup);
}
function cacheVersion(url: string, versionData: NdjsonVersion): void {
let versionLookup = cachedVersionLookup.get(url);
if (versionLookup === undefined) {
versionLookup = new Map<string, NdjsonVersion>();
cachedVersionLookup.set(url, versionLookup);
}
versionLookup.set(versionData.version, versionData);
}
function parseVersionLine(
line: string,
sourceDescription: string,
lineNumber: number,
): NdjsonVersion {
let parsed: unknown;
try {
parsed = JSON.parse(line);
} catch (error) {
throw new Error(
`Failed to parse version data from ${sourceDescription} at line ${lineNumber}: ${(error as Error).message}`,
);
}
if (!isNdjsonVersion(parsed)) {
throw new Error(
`Invalid NDJSON record in ${sourceDescription} at line ${lineNumber}.`,
);
}
return parsed;
}
function versionSatisfies(version: string, versionSpecifier: string): boolean {
return (
semver.satisfies(version, versionSpecifier) ||
pep440.satisfies(version, versionSpecifier)
);
}
function isNdjsonVersion(value: unknown): value is NdjsonVersion {
if (!isRecord(value)) {
return false;
}
if (typeof value.version !== "string" || !Array.isArray(value.artifacts)) {
return false;
}
return value.artifacts.every(isNdjsonArtifact);
}
function isNdjsonArtifact(value: unknown): value is NdjsonArtifact {
if (!isRecord(value)) {
return false;
}
const variantIsValid =
typeof value.variant === "string" || value.variant === undefined;
return (
typeof value.archive_format === "string" &&
typeof value.platform === "string" &&
typeof value.sha256 === "string" &&
typeof value.url === "string" &&
variantIsValid
);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}

View File

@@ -9,14 +9,21 @@ import {
STATE_PYTHON_CACHE_MATCHED_KEY,
} from "./cache/restore-cache";
import { STATE_UV_PATH, STATE_UV_VERSION } from "./utils/constants";
import { loadInputs, type SetupInputs } from "./utils/inputs";
import {
cacheLocalPath,
cachePython,
enableCache,
ignoreNothingToCache,
pythonDir,
pruneCache as shouldPruneCache,
saveCache as shouldSaveCache,
} from "./utils/inputs";
export async function run(): Promise<void> {
try {
const inputs = loadInputs();
if (inputs.enableCache) {
if (inputs.saveCache) {
await saveCache(inputs);
if (enableCache) {
if (shouldSaveCache) {
await saveCache();
} else {
core.info("save-cache is false. Skipping save cache step.");
}
@@ -36,7 +43,7 @@ export async function run(): Promise<void> {
}
}
async function saveCache(inputs: SetupInputs): Promise<void> {
async function saveCache(): Promise<void> {
const cacheKey = core.getState(STATE_CACHE_KEY);
const matchedKey = core.getState(STATE_CACHE_MATCHED_KEY);
@@ -47,13 +54,13 @@ async function saveCache(inputs: SetupInputs): Promise<void> {
if (matchedKey === cacheKey) {
core.info(`Cache hit occurred on key ${cacheKey}, not saving cache.`);
} else {
if (inputs.pruneCache) {
if (shouldPruneCache) {
await pruneCache();
}
const actualCachePath = getUvCachePath(inputs);
const actualCachePath = getUvCachePath();
if (!fs.existsSync(actualCachePath)) {
if (inputs.ignoreNothingToCache) {
if (ignoreNothingToCache) {
core.info(
"No cacheable uv cache paths were found. Ignoring because ignore-nothing-to-cache is enabled.",
);
@@ -72,10 +79,10 @@ async function saveCache(inputs: SetupInputs): Promise<void> {
}
}
if (inputs.cachePython) {
if (!fs.existsSync(inputs.pythonDir)) {
if (cachePython) {
if (!fs.existsSync(pythonDir)) {
core.warning(
`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.`,
`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.`,
);
return;
}
@@ -83,7 +90,7 @@ async function saveCache(inputs: SetupInputs): Promise<void> {
const pythonCacheKey = `${cacheKey}-python`;
await saveCacheToKey(
pythonCacheKey,
inputs.pythonDir,
pythonDir,
STATE_PYTHON_CACHE_MATCHED_KEY,
"Python cache",
);
@@ -106,22 +113,22 @@ async function pruneCache(): Promise<void> {
await exec.exec(uvPath, execArgs, options);
}
function getUvCachePath(inputs: SetupInputs): string {
if (inputs.cacheLocalPath === undefined) {
function getUvCachePath(): string {
if (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 !== inputs.cacheLocalPath.path
process.env.UV_CACHE_DIR !== 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 "${inputs.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 "${cacheLocalPath.path}".`,
);
return process.env.UV_CACHE_DIR;
}
return inputs.cacheLocalPath.path;
return cacheLocalPath.path;
}
async function saveCacheToKey(

View File

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

View File

@@ -6,10 +6,10 @@ import {
updateChecksums,
} from "./download/checksum/update-known-checksums";
import {
fetchManifest,
fetchVersionData,
getLatestVersion,
type ManifestVersion,
} from "./download/manifest";
type NdjsonVersion,
} from "./download/versions-client";
const VERSION_IN_CHECKSUM_KEY_PATTERN =
/-(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)$/;
@@ -32,8 +32,8 @@ async function run(): Promise<void> {
return;
}
const versions = await fetchManifest();
const checksumEntries = extractChecksumsFromManifest(versions);
const versions = await fetchVersionData();
const checksumEntries = extractChecksumsFromNdjson(versions);
await updateChecksums(checksumFilePath, checksumEntries);
core.setOutput("latest-version", latestVersion);
@@ -61,8 +61,8 @@ function extractVersionFromChecksumKey(key: string): string | undefined {
return key.match(VERSION_IN_CHECKSUM_KEY_PATTERN)?.[1];
}
function extractChecksumsFromManifest(
versions: ManifestVersion[],
function extractChecksumsFromNdjson(
versions: NdjsonVersion[],
): ChecksumEntry[] {
const checksums: ChecksumEntry[] = [];

View File

@@ -1,13 +1,5 @@
export const TOOL_CACHE_NAME = "uv";
export const STATE_UV_PATH = "uv-path";
export const STATE_UV_VERSION = "uv-version";
export const VERSIONS_MANIFEST_URL =
export const VERSIONS_NDJSON_URL =
"https://raw.githubusercontent.com/astral-sh/versions/main/v1/uv.ndjson";
/** GitHub Releases URL prefix for uv artifacts. */
export const GITHUB_RELEASES_PREFIX =
"https://github.com/astral-sh/uv/releases/download/";
/** Astral mirror URL prefix that fronts GitHub Releases for uv artifacts. */
export const ASTRAL_MIRROR_PREFIX =
"https://releases.astral.sh/github/uv/releases/download/";

View File

@@ -9,121 +9,53 @@ export enum CacheLocalSource {
Default,
}
export interface CacheLocalPath {
path: string;
source: CacheLocalSource;
}
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 type ResolutionStrategy = "highest" | "lowest";
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: ResolutionStrategy;
}
export function loadInputs(): SetupInputs {
const workingDirectory = core.getInput("working-directory");
const version = core.getInput("version");
const versionFile = getVersionFile(workingDirectory);
const pythonVersion = core.getInput("python-version");
const activateEnvironment = core.getBooleanInput("activate-environment");
const venvPath = getVenvPath(workingDirectory, activateEnvironment);
const checksum = core.getInput("checksum");
const enableCache = getEnableCache();
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);
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);
const toolDir = getToolDir(workingDirectory);
const pythonDir = getUvPythonDir();
const githubToken = core.getInput("github-token");
const manifestFile = getManifestFile();
const addProblemMatchers = core.getInput("add-problem-matchers") === "true";
const resolutionStrategy = getResolutionStrategy();
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): string {
function getVersionFile(): string {
const versionFileInput = core.getInput("version-file");
if (versionFileInput !== "") {
const tildeExpanded = expandTilde(versionFileInput);
return resolveRelativePath(workingDirectory, tildeExpanded);
return resolveRelativePath(tildeExpanded);
}
return versionFileInput;
}
function getVenvPath(
workingDirectory: string,
activateEnvironment: boolean,
): string {
function getVenvPath(): string {
const venvPathInput = core.getInput("venv-path");
if (venvPathInput !== "") {
if (!activateEnvironment) {
core.warning("venv-path is only used when activate-environment is true");
}
const tildeExpanded = expandTilde(venvPathInput);
return normalizePath(resolveRelativePath(workingDirectory, tildeExpanded));
return normalizePath(resolveRelativePath(tildeExpanded));
}
return normalizePath(resolveRelativePath(workingDirectory, ".venv"));
return normalizePath(resolveRelativePath(".venv"));
}
function getEnableCache(): boolean {
@@ -134,11 +66,11 @@ function getEnableCache(): boolean {
return enableCacheInput === "true";
}
function getToolBinDir(workingDirectory: string): string | undefined {
function getToolBinDir(): string | undefined {
const toolBinDirInput = core.getInput("tool-bin-dir");
if (toolBinDirInput !== "") {
const tildeExpanded = expandTilde(toolBinDirInput);
return resolveRelativePath(workingDirectory, tildeExpanded);
return resolveRelativePath(tildeExpanded);
}
if (process.platform === "win32") {
if (process.env.RUNNER_TEMP !== undefined) {
@@ -151,11 +83,11 @@ function getToolBinDir(workingDirectory: string): string | undefined {
return undefined;
}
function getToolDir(workingDirectory: string): string | undefined {
function getToolDir(): string | undefined {
const toolDirInput = core.getInput("tool-dir");
if (toolDirInput !== "") {
const tildeExpanded = expandTilde(toolDirInput);
return resolveRelativePath(workingDirectory, tildeExpanded);
return resolveRelativePath(tildeExpanded);
}
if (process.platform === "win32") {
if (process.env.RUNNER_TEMP !== undefined) {
@@ -168,23 +100,21 @@ function getToolDir(workingDirectory: string): string | undefined {
return undefined;
}
function getCacheLocalPath(
workingDirectory: string,
versionFile: string,
enableCache: boolean,
): CacheLocalPath | undefined {
function getCacheLocalPath():
| {
path: string;
source: CacheLocalSource;
}
| undefined {
const cacheLocalPathInput = core.getInput("cache-local-path");
if (cacheLocalPathInput !== "") {
const tildeExpanded = expandTilde(cacheLocalPathInput);
return {
path: resolveRelativePath(workingDirectory, tildeExpanded),
path: resolveRelativePath(tildeExpanded),
source: CacheLocalSource.Input,
};
}
const cacheDirFromConfig = getCacheDirFromConfig(
workingDirectory,
versionFile,
);
const cacheDirFromConfig = getCacheDirFromConfig();
if (cacheDirFromConfig !== undefined) {
return { path: cacheDirFromConfig, source: CacheLocalSource.Config };
}
@@ -192,7 +122,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 (enableCache) {
if (getEnableCache()) {
if (process.env.RUNNER_ENVIRONMENT === "github-hosted") {
if (process.env.RUNNER_TEMP !== undefined) {
return {
@@ -217,12 +147,9 @@ function getCacheLocalPath(
}
}
function getCacheDirFromConfig(
workingDirectory: string,
versionFile: string,
): string | undefined {
function getCacheDirFromConfig(): string | undefined {
for (const filePath of [versionFile, "uv.toml", "pyproject.toml"]) {
const resolvedPath = resolveRelativePath(workingDirectory, filePath);
const resolvedPath = resolveRelativePath(filePath);
try {
const cacheDir = getConfigValueFromTomlFile(resolvedPath, "cache-dir");
if (cacheDir !== undefined) {
@@ -248,8 +175,9 @@ 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`;
@@ -259,14 +187,14 @@ export function getUvPythonDir(): string {
);
}
function getCacheDependencyGlob(workingDirectory: string): string {
function getCacheDependencyGlob(): string {
const cacheDependencyGlobInput = core.getInput("cache-dependency-glob");
if (cacheDependencyGlobInput !== "") {
return cacheDependencyGlobInput
.split("\n")
.map((part) => part.trim())
.map((part) => expandTilde(part))
.map((part) => resolveRelativePath(workingDirectory, part))
.map((part) => resolveRelativePath(part))
.join("\n");
}
return cacheDependencyGlobInput;
@@ -292,10 +220,7 @@ function normalizePath(inputPath: string): string {
return trimmed;
}
function resolveRelativePath(
workingDirectory: string,
inputPath: string,
): string {
function resolveRelativePath(inputPath: string): string {
const hasNegation = inputPath.startsWith("!");
const pathWithoutNegation = hasNegation ? inputPath.substring(1) : inputPath;
@@ -315,7 +240,7 @@ function getManifestFile(): string | undefined {
return undefined;
}
function getResolutionStrategy(): ResolutionStrategy {
function getResolutionStrategy(): "highest" | "lowest" {
const resolutionStrategyInput = core.getInput("resolution-strategy");
if (resolutionStrategyInput === "lowest") {
return "lowest";