mirror of
https://github.com/astral-sh/setup-uv.git
synced 2026-06-22 20:42:36 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6db51338cf |
@@ -1,48 +0,0 @@
|
||||
---
|
||||
name: dependabot-pr-rollup
|
||||
description: Find open Dependabot PRs for the current GitHub repo, compare each PR head to its base branch, replay only the net dependency changes in a fresh worktree and branch, run npm validation, and optionally commit, push, and open a PR. Use when you want to batch or manually replicate active Dependabot updates.
|
||||
license: MIT
|
||||
compatibility: Requires git, git worktree, gh CLI auth, npm, and a GitHub repo with an origin remote.
|
||||
---
|
||||
|
||||
# Dependabot PR Rollup
|
||||
|
||||
## When to use
|
||||
|
||||
Use this skill when the user wants to:
|
||||
- find all open Dependabot PRs in the current repo
|
||||
- reproduce their net effect in one local branch
|
||||
- validate the result with the repo's standard npm checks
|
||||
- optionally commit, push, and open a PR
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Inspect the current checkout state, but do not reuse a dirty worktree.
|
||||
2. List open Dependabot PRs with `gh pr list --state open --author app/dependabot`.
|
||||
3. For each PR, collect the title, base branch, head branch, changed files, and relevant diffs.
|
||||
4. Compare each PR head against `origin/<base>` instead of trusting the PR title. Dependabot PRs can already be partially merged, superseded by newer versions, or have no remaining net effect.
|
||||
5. Create a new worktree and branch from `origin/<base>`.
|
||||
6. Reproduce only the remaining dependency changes in the new worktree.
|
||||
- Inspect `package.json` before editing.
|
||||
- Run `npm ci --ignore-scripts` before applying updates.
|
||||
- Use `npm install ... --ignore-scripts` for direct dependency changes so `package-lock.json` stays in sync.
|
||||
7. Run `npm run all`.
|
||||
8. If requested, commit the changed source, lockfile, and generated artifacts, then push and open a PR.
|
||||
|
||||
## Repo-specific notes
|
||||
|
||||
- Use `gh` for GitHub operations.
|
||||
- Keep the user's original checkout untouched by working in a separate worktree.
|
||||
- In this repo, `npm run all` is the safest validation command because it runs build, check, package, and test.
|
||||
- If dependency changes affect bundled output, include the regenerated `dist/` files.
|
||||
|
||||
## Report back
|
||||
|
||||
Always report:
|
||||
- open Dependabot PRs found
|
||||
- which PRs required no net changes
|
||||
- new branch name
|
||||
- new worktree path
|
||||
- files changed
|
||||
- `npm run all` result
|
||||
- if applicable, commit SHA and PR URL
|
||||
@@ -7,7 +7,3 @@ self-hosted-runner:
|
||||
# organization. `null` means disabling configuration variables check.
|
||||
# Empty array means no configuration variable is allowed.
|
||||
config-variables: null
|
||||
paths:
|
||||
.github/workflows/test.yml:
|
||||
ignore:
|
||||
- 'invalid runner name.+'
|
||||
|
||||
@@ -4,12 +4,8 @@ updates:
|
||||
directory: /
|
||||
schedule:
|
||||
interval: daily
|
||||
cooldown:
|
||||
default-days: 7
|
||||
|
||||
- package-ecosystem: npm
|
||||
directory: /
|
||||
schedule:
|
||||
interval: daily
|
||||
cooldown:
|
||||
default-days: 7
|
||||
|
||||
@@ -19,7 +19,7 @@ categories:
|
||||
labels:
|
||||
- "maintenance"
|
||||
- "ci"
|
||||
- "update-known-checksums"
|
||||
- "update-known-versions"
|
||||
- title: "📚 Documentation"
|
||||
labels:
|
||||
- "documentation"
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "nodenext",
|
||||
"moduleResolution": "nodenext",
|
||||
"target": "es2022",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["check-all-tests-passed-needs.ts"]
|
||||
}
|
||||
@@ -41,13 +41,13 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
|
||||
uses: github/codeql-action/init@192325c86100d080feab897ff886c34abd4c83a3 # v3.30.3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
source-root: src
|
||||
@@ -59,7 +59,7 @@ jobs:
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
|
||||
uses: github/codeql-action/autobuild@192325c86100d080feab897ff886c34abd4c83a3 # v3.30.3
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 https://git.io/JvXDl
|
||||
@@ -73,4 +73,4 @@ jobs:
|
||||
# make release
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
|
||||
uses: github/codeql-action/analyze@192325c86100d080feab897ff886c34abd4c83a3 # v3.30.3
|
||||
|
||||
@@ -19,8 +19,6 @@ jobs:
|
||||
pull-requests: read
|
||||
steps:
|
||||
- name: 🚀 Run Release Drafter
|
||||
uses: release-drafter/release-drafter@5de93583980a40bd78603b6dfdcda5b4df377b32 # v7.2.0
|
||||
with:
|
||||
commitish: ${{ github.sha }}
|
||||
uses: release-drafter/release-drafter@b1476f6e6eb133afa41ed8589daba6dc69b4d3f5 # v6.1.0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Release version (e.g., 8.1.0)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
validate-release:
|
||||
name: Validate release
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Validate version and draft release
|
||||
env:
|
||||
GH_REPO: ${{ github.repository }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ inputs.version }}
|
||||
TAG: v${{ 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
|
||||
|
||||
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
|
||||
|
||||
release-gate:
|
||||
# N.B. This name should not change, it is used for downstream checks.
|
||||
name: release-gate
|
||||
needs:
|
||||
- validate-release
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: release-gate
|
||||
steps:
|
||||
- run: echo "Release approved"
|
||||
|
||||
create-deployment:
|
||||
name: create-deployment
|
||||
needs:
|
||||
- validate-release
|
||||
- release-gate
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: release
|
||||
steps:
|
||||
- run: echo "Release deployment created"
|
||||
|
||||
release:
|
||||
name: Release
|
||||
needs:
|
||||
- validate-release
|
||||
- release-gate
|
||||
- create-deployment
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Publish release
|
||||
env:
|
||||
GH_REPO: ${{ github.repository }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ inputs.version }}
|
||||
TAG: v${{ 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
|
||||
|
||||
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
|
||||
+303
-562
File diff suppressed because it is too large
Load Diff
@@ -1,69 +0,0 @@
|
||||
name: "Update docs"
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
update-docs:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: true
|
||||
- name: Get tag info
|
||||
id: tag-info
|
||||
run: |
|
||||
TAG_NAME="${GITHUB_REF#refs/tags/}"
|
||||
COMMIT_SHA=$(git rev-list -n 1 "$TAG_NAME")
|
||||
echo "tag=$TAG_NAME" >> "$GITHUB_OUTPUT"
|
||||
echo "sha=$COMMIT_SHA" >> "$GITHUB_OUTPUT"
|
||||
- name: Update references in docs
|
||||
run: |
|
||||
OLD_REF=$(grep -oh 'astral-sh/setup-uv@[a-f0-9]\{40\} # v[0-9][^ ]*' README.md docs/*.md | head -1)
|
||||
OLD_SHA=$(echo "$OLD_REF" | sed 's/astral-sh\/setup-uv@\([a-f0-9]*\) # .*/\1/')
|
||||
OLD_VERSION=$(echo "$OLD_REF" | sed 's/astral-sh\/setup-uv@[a-f0-9]* # \(v[^ ]*\)/\1/')
|
||||
echo "Replacing $OLD_SHA # $OLD_VERSION with $NEW_SHA # $NEW_VERSION"
|
||||
find README.md docs/ -type f \( -name "*.md" \) -exec \
|
||||
sed -i "s|$OLD_SHA # $OLD_VERSION|$NEW_SHA # $NEW_VERSION|g" {} +
|
||||
env:
|
||||
NEW_SHA: ${{ steps.tag-info.outputs.sha }}
|
||||
NEW_VERSION: ${{ steps.tag-info.outputs.tag }}
|
||||
- name: Check for changes
|
||||
id: changes-exist
|
||||
run: |
|
||||
if [ -n "$(git status --porcelain)" ]; then
|
||||
echo "changes-exist=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "changes-exist=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
- name: Commit and push changes
|
||||
if: ${{ steps.changes-exist.outputs.changes-exist == 'true' }}
|
||||
id: commit-and-push
|
||||
continue-on-error: true
|
||||
run: |
|
||||
git config user.name "$GITHUB_ACTOR"
|
||||
git config user.email "$GITHUB_ACTOR@users.noreply.github.com"
|
||||
git add .
|
||||
git commit -m "docs: update version references to $NEW_VERSION"
|
||||
git push origin HEAD:refs/heads/main
|
||||
env:
|
||||
NEW_VERSION: ${{ steps.tag-info.outputs.tag }}
|
||||
- name: Create Pull Request
|
||||
if: ${{ steps.changes-exist.outputs.changes-exist == 'true' && steps.commit-and-push.outcome != 'success' }}
|
||||
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
|
||||
with:
|
||||
commit-message: "docs: update version references to ${{ steps.tag-info.outputs.tag }}"
|
||||
title: "docs: update version references to ${{ steps.tag-info.outputs.tag }}"
|
||||
body: |
|
||||
Update `uses: astral-sh/setup-uv@...` references in documentation to
|
||||
`${{ steps.tag-info.outputs.sha }} # ${{ steps.tag-info.outputs.tag }}`.
|
||||
base: main
|
||||
labels: "automated-pr,update-docs"
|
||||
branch: update-docs-${{ steps.tag-info.outputs.tag }}
|
||||
delete-branch: true
|
||||
@@ -1,69 +0,0 @@
|
||||
name: "Update known checksums"
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: "0 4 * * *" # Run every day at 4am UTC
|
||||
repository_dispatch:
|
||||
types: [ pypi_release ]
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: true
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
- name: Update known checksums
|
||||
id: update-known-checksums
|
||||
run:
|
||||
node dist/update-known-checksums/index.cjs
|
||||
src/download/checksum/known-checksums.ts
|
||||
- name: Check for changes
|
||||
id: changes-exist
|
||||
run: |
|
||||
git status --porcelain
|
||||
if [ -n "$(git status --porcelain)" ]; then
|
||||
echo "changes-exist=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "changes-exist=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
- name: Compile changes
|
||||
if: ${{ steps.changes-exist.outputs.changes-exist == 'true' }}
|
||||
run: npm ci --ignore-scripts && npm run all
|
||||
- name: Commit and push changes
|
||||
if: ${{ steps.changes-exist.outputs.changes-exist == 'true' }}
|
||||
id: commit-and-push
|
||||
continue-on-error: true
|
||||
run: |
|
||||
git config user.name "$GITHUB_ACTOR"
|
||||
git config user.email "$GITHUB_ACTOR@users.noreply.github.com"
|
||||
git add .
|
||||
git commit -m "chore: update known checksums for $LATEST_VERSION"
|
||||
git push origin HEAD:refs/heads/main
|
||||
env:
|
||||
LATEST_VERSION: ${{ steps.update-known-checksums.outputs.latest-version }}
|
||||
|
||||
- name: Create Pull Request
|
||||
if: ${{ steps.changes-exist.outputs.changes-exist == 'true' && steps.commit-and-push.outcome != 'success' }}
|
||||
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
|
||||
with:
|
||||
commit-message: "chore: update known checksums"
|
||||
title:
|
||||
"chore: update known checksums for ${{
|
||||
steps.update-known-checksums.outputs.latest-version }}"
|
||||
body:
|
||||
"chore: update known checksums for ${{
|
||||
steps.update-known-checksums.outputs.latest-version }}"
|
||||
base: main
|
||||
labels: "automated-pr,update-known-checksums"
|
||||
branch: update-known-checksums-pr
|
||||
delete-branch: true
|
||||
@@ -0,0 +1,51 @@
|
||||
name: "Update known versions"
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: "0 4 * * *" # Run every day at 4am UTC
|
||||
repository_dispatch:
|
||||
types: [ pypi_release ]
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
with:
|
||||
node-version: "20"
|
||||
- name: Update known versions
|
||||
id: update-known-versions
|
||||
run:
|
||||
node dist/update-known-versions/index.js
|
||||
src/download/checksum/known-checksums.ts
|
||||
version-manifest.json
|
||||
${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Check for changes
|
||||
id: changes_exist
|
||||
run: |
|
||||
git status --porcelain
|
||||
if [ -n "$(git status --porcelain)" ]; then
|
||||
echo "changes_exist=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "changes_exist=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
- name: Compile changes
|
||||
if: ${{ steps.changes_exist.outputs.changes_exist == 'true' }}
|
||||
run: npm ci && npm run all
|
||||
- name: Commit and push changes
|
||||
if: ${{ steps.changes_exist.outputs.changes_exist == 'true' }}
|
||||
run: |
|
||||
git config user.name "$GITHUB_ACTOR"
|
||||
git config user.email "$GITHUB_ACTOR@users.noreply.github.com"
|
||||
git add .
|
||||
git commit -m "chore: update known versions for $LATEST_VERSION"
|
||||
git push origin HEAD:refs/heads/main
|
||||
env:
|
||||
LATEST_VERSION: ${{ steps.update-known-versions.outputs.latest-version }}
|
||||
@@ -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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
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}"
|
||||
@@ -100,6 +100,3 @@ lib/**/*
|
||||
|
||||
# Idea IDEs (PyCharm, WebStorm, IntelliJ, etc)
|
||||
.idea/
|
||||
|
||||
# Compiled scripts
|
||||
.github/scripts/*.js
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
# setup-uv agent notes
|
||||
|
||||
This repository is a TypeScript-based GitHub Action for installing `uv` in GitHub Actions workflows. It also supports restoring/saving the `uv` cache and optional managed-Python caching.
|
||||
|
||||
- The published action runs the committed bundles in `dist/`, not the TypeScript in `src/`. After any code change, run `npm run package` and commit the resulting `dist/` updates.
|
||||
- Standard local validation is:
|
||||
1. `npm ci --ignore-scripts`
|
||||
2. `npm run all`
|
||||
- `npm run check` uses Biome (not ESLint/Prettier) and rewrites files in place.
|
||||
- User-facing changes are usually multi-file changes. If you add or change inputs, outputs, or behavior, update `action.yml`, `action-types.yml`, the implementation in `src/`, tests in `__tests__/`, relevant docs/README, and then re-package.
|
||||
- The easiest areas to regress are version resolution and caching. When touching them, add or update tests for precedence, cache invalidation, and cross-platform path behavior.
|
||||
- Workflow edits have extra CI-only checks (`actionlint` and `zizmor`); `npm run all` does not cover them.
|
||||
- Source is authored with bundler-friendly TypeScript, but published action artifacts in `dist/` are bundled as CommonJS for maximum GitHub Actions runtime compatibility with `@actions/*` dependencies.
|
||||
- Keep these concerns separate when changing module formats:
|
||||
- `src/` and tests may use modern ESM-friendly TypeScript patterns.
|
||||
- `dist/` should prioritize runtime reliability over format purity.
|
||||
- Do not switch published bundles to ESM without validating the actual committed artifacts under the target Node runtime.
|
||||
- Before finishing, make sure validation does not leave generated or formatting-only diffs behind.
|
||||
@@ -12,11 +12,27 @@ Set up your GitHub Actions workflow with a specific version of [uv](https://docs
|
||||
|
||||
- [Usage](#usage)
|
||||
- [Install a required-version or latest (default)](#install-a-required-version-or-latest-default)
|
||||
- [Inputs](#inputs)
|
||||
- [Outputs](#outputs)
|
||||
- [Install the latest version](#install-the-latest-version)
|
||||
- [Install a specific version](#install-a-specific-version)
|
||||
- [Install a version by supplying a semver range or pep440 specifier](#install-a-version-by-supplying-a-semver-range-or-pep440-specifier)
|
||||
- [Install a version defined in a requirements or config file](#install-a-version-defined-in-a-requirements-or-config-file)
|
||||
- [Python version](#python-version)
|
||||
- [Activate environment](#activate-environment)
|
||||
- [Working directory](#working-directory)
|
||||
- [Advanced Configuration](#advanced-configuration)
|
||||
- [Validate checksum](#validate-checksum)
|
||||
- [Enable Caching](#enable-caching)
|
||||
- [Cache dependency glob](#cache-dependency-glob)
|
||||
- [Restore cache](#restore-cache)
|
||||
- [Save cache](#save-cache)
|
||||
- [Local cache path](#local-cache-path)
|
||||
- [Disable cache pruning](#disable-cache-pruning)
|
||||
- [Ignore nothing to cache](#ignore-nothing-to-cache)
|
||||
- [GitHub authentication token](#github-authentication-token)
|
||||
- [UV_TOOL_DIR](#uv_tool_dir)
|
||||
- [UV_TOOL_BIN_DIR](#uv_tool_bin_dir)
|
||||
- [Tilde Expansion](#tilde-expansion)
|
||||
- [Manifest file](#manifest-file)
|
||||
- [Add problem matchers](#add-problem-matchers)
|
||||
- [How it works](#how-it-works)
|
||||
- [FAQ](#faq)
|
||||
|
||||
@@ -26,7 +42,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@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
uses: astral-sh/setup-uv@v6
|
||||
```
|
||||
|
||||
If you do not specify a version, this action will look for a [required-version](https://docs.astral.sh/uv/reference/settings/#required-version)
|
||||
@@ -35,104 +51,65 @@ in a `uv.toml` or `pyproject.toml` file in the repository root. If none is found
|
||||
For an example workflow, see
|
||||
[here](https://github.com/charliermarsh/autobot/blob/e42c66659bf97b90ca9ff305a19cc99952d0d43f/.github/workflows/ci.yaml).
|
||||
|
||||
### Inputs
|
||||
|
||||
All inputs and their defaults.
|
||||
Have a look under [Advanced Configuration](#advanced-configuration) for detailed documentation on most of them.
|
||||
### Install the latest version
|
||||
|
||||
```yaml
|
||||
- name: Install uv with all available options
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
- name: Install the latest version of uv
|
||||
uses: astral-sh/setup-uv@v6
|
||||
with:
|
||||
# The version of uv to install (default: searches for version in config files, then latest)
|
||||
version: ""
|
||||
|
||||
# Path to a file containing the version of uv to install (default: searches uv.toml then pyproject.toml)
|
||||
version-file: ""
|
||||
|
||||
# Resolution strategy when resolving version ranges: 'highest' or 'lowest'
|
||||
resolution-strategy: "highest"
|
||||
|
||||
# The version of Python to set UV_PYTHON to
|
||||
python-version: ""
|
||||
|
||||
# Use uv venv to activate a venv ready to be used by later steps
|
||||
activate-environment: "false"
|
||||
|
||||
# Custom path for the virtual environment when using activate-environment (default: .venv in the working directory)
|
||||
venv-path: ""
|
||||
|
||||
# Pass --no-project when creating the venv with activate-environment.
|
||||
no-project: "false"
|
||||
|
||||
# The directory to execute all commands in and look for files such as pyproject.toml
|
||||
working-directory: ""
|
||||
|
||||
# The checksum of the uv version to install
|
||||
checksum: ""
|
||||
|
||||
# Used when downloading uv from GitHub releases
|
||||
github-token: ${{ github.token }}
|
||||
|
||||
# Enable uploading of the uv cache: true, false, or auto (enabled on GitHub-hosted runners, disabled on self-hosted runners)
|
||||
enable-cache: "auto"
|
||||
|
||||
# Glob pattern to match files relative to the repository root to control the cache
|
||||
cache-dependency-glob: |
|
||||
**/*requirements*.txt
|
||||
**/*requirements*.in
|
||||
**/*constraints*.txt
|
||||
**/*constraints*.in
|
||||
**/pyproject.toml
|
||||
**/uv.lock
|
||||
**/*.py.lock
|
||||
|
||||
# Whether to restore the cache if found
|
||||
restore-cache: "true"
|
||||
|
||||
# Whether to save the cache after the run
|
||||
save-cache: "true"
|
||||
|
||||
# Suffix for the cache key
|
||||
cache-suffix: ""
|
||||
|
||||
# Local path to store the cache (default: "" - uses system temp directory)
|
||||
cache-local-path: ""
|
||||
|
||||
# Prune cache before saving
|
||||
prune-cache: "true"
|
||||
|
||||
# Upload managed Python installations to the GitHub Actions cache
|
||||
cache-python: "false"
|
||||
|
||||
# Ignore when nothing is found to cache
|
||||
ignore-nothing-to-cache: "false"
|
||||
|
||||
# Ignore when the working directory is empty
|
||||
ignore-empty-workdir: "false"
|
||||
|
||||
# Custom path to set UV_TOOL_DIR to
|
||||
tool-dir: ""
|
||||
|
||||
# Custom path to set UV_TOOL_BIN_DIR to
|
||||
tool-bin-dir: ""
|
||||
|
||||
# URL to a custom manifest file in the astral-sh/versions format
|
||||
manifest-file: ""
|
||||
|
||||
# Add problem matchers
|
||||
add-problem-matchers: "true"
|
||||
version: "latest"
|
||||
```
|
||||
|
||||
### Outputs
|
||||
### Install a specific version
|
||||
|
||||
- `uv-version`: The installed uv version. Useful when using latest.
|
||||
- `uv-path`: The path to the installed uv binary.
|
||||
- `uvx-path`: The path to the installed uvx binary.
|
||||
- `cache-hit`: A boolean value to indicate a cache entry was found.
|
||||
- `venv`: Path to the activated venv if activate-environment is true.
|
||||
- `python-version`: The Python version that was set.
|
||||
- `python-cache-hit`: A boolean value to indicate the Python cache entry was found.
|
||||
```yaml
|
||||
- name: Install a specific version of uv
|
||||
uses: astral-sh/setup-uv@v6
|
||||
with:
|
||||
version: "0.4.4"
|
||||
```
|
||||
|
||||
### Install a version by supplying a semver range or pep440 specifier
|
||||
|
||||
You can specify a [semver range](https://github.com/npm/node-semver?tab=readme-ov-file#ranges)
|
||||
or [pep440 specifier](https://peps.python.org/pep-0440/#version-specifiers)
|
||||
to install the latest version that satisfies the range.
|
||||
|
||||
```yaml
|
||||
- name: Install a semver range of uv
|
||||
uses: astral-sh/setup-uv@v6
|
||||
with:
|
||||
version: ">=0.4.0"
|
||||
```
|
||||
|
||||
```yaml
|
||||
- name: Pinning a minor version of uv
|
||||
uses: astral-sh/setup-uv@v6
|
||||
with:
|
||||
version: "0.4.x"
|
||||
```
|
||||
|
||||
```yaml
|
||||
- name: Install a pep440-specifier-satisfying version of uv
|
||||
uses: astral-sh/setup-uv@v6
|
||||
with:
|
||||
version: ">=0.4.25,<0.5"
|
||||
```
|
||||
|
||||
### Install a version defined in a requirements or config file
|
||||
|
||||
You can use the `version-file` input to specify a file that contains the version of uv to install.
|
||||
This can either be a `pyproject.toml` or `uv.toml` file which defines a `required-version` or
|
||||
uv defined as a dependency in `pyproject.toml` or `requirements.txt`.
|
||||
|
||||
[asdf](https://asdf-vm.com/) `.tool-versions` is also supported, but without the `ref` syntax.
|
||||
|
||||
```yaml
|
||||
- name: Install uv based on the version defined in pyproject.toml
|
||||
uses: astral-sh/setup-uv@v6
|
||||
with:
|
||||
version-file: "pyproject.toml"
|
||||
```
|
||||
|
||||
### Python version
|
||||
|
||||
@@ -142,13 +119,13 @@ 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@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
uses: astral-sh/setup-uv@v6
|
||||
with:
|
||||
python-version: 3.13t
|
||||
- run: uv pip install --python=3.13t pip
|
||||
```
|
||||
|
||||
You can combine this with a matrix to test multiple Python versions:
|
||||
You can combine this with a matrix to test multiple python versions:
|
||||
|
||||
```yaml
|
||||
jobs:
|
||||
@@ -156,49 +133,380 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
||||
python-version: ["3.9", "3.10", "3.11", "3.12"]
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install the latest version of uv and set the python version
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
uses: astral-sh/setup-uv@v6
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Test with python ${{ matrix.python-version }}
|
||||
run: uv run --frozen pytest
|
||||
```
|
||||
|
||||
### Activate environment
|
||||
|
||||
You can set `activate-environment` to `true` to automatically activate a venv.
|
||||
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@v6
|
||||
with:
|
||||
activate-environment: true
|
||||
- run: uv pip install pip
|
||||
```
|
||||
|
||||
> [!WARNING]
|
||||
>
|
||||
> Activating the environment adds your dependencies to the `PATH`, which could break some workflows.
|
||||
> For example, if you have a dependency which requires uv, e.g., `hatch`, activating the
|
||||
> environment will shadow the `uv` binary installed by this action and may result in a different uv
|
||||
> version being used.
|
||||
>
|
||||
> We do not recommend using this setting for most use-cases. Instead, use `uv run` to execute
|
||||
> commands in the environment.
|
||||
|
||||
### Working directory
|
||||
|
||||
You can set the working directory with the `working-directory` input.
|
||||
This controls where we look for `pyproject.toml`, `uv.toml` and `.python-version` files
|
||||
which are used to determine the version of uv and python to install.
|
||||
|
||||
It also controls where [the venv gets created](#activate-environment), unless `venv-path` is set.
|
||||
It also controls where [the venv gets created](#activate-environment).
|
||||
|
||||
```yaml
|
||||
- name: Install uv based on the config files in the working-directory
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
uses: astral-sh/setup-uv@v6
|
||||
with:
|
||||
working-directory: my/subproject/dir
|
||||
```
|
||||
|
||||
## Advanced Configuration
|
||||
### Validate checksum
|
||||
|
||||
For more advanced configuration options, see our detailed documentation:
|
||||
You can specify a checksum to validate the downloaded executable. Checksums up to the default version
|
||||
are automatically verified by this action. The sha256 hashes can be found on the
|
||||
[releases page](https://github.com/astral-sh/uv/releases) of the uv repo.
|
||||
|
||||
- **[Advanced Version Configuration](docs/advanced-version-configuration.md)** - Resolution strategies and version files
|
||||
- **[Caching](docs/caching.md)** - Complete guide to caching configuration
|
||||
- **[Environment and Tools](docs/environment-and-tools.md)** - Environment activation, tool directories, authentication, and environment variables
|
||||
- **[Customization](docs/customization.md)** - Checksum validation, custom manifests, and problem matchers
|
||||
```yaml
|
||||
- name: Install a specific version and validate the checksum
|
||||
uses: astral-sh/setup-uv@v6
|
||||
with:
|
||||
version: "0.3.1"
|
||||
checksum: "e11b01402ab645392c7ad6044db63d37e4fd1e745e015306993b07695ea5f9f8"
|
||||
```
|
||||
|
||||
### Enable caching
|
||||
|
||||
> [!NOTE]
|
||||
> The cache is pruned before it is uploaded to the GitHub Actions cache. This can lead to
|
||||
> a small or empty cache. See [Disable cache pruning](#disable-cache-pruning) for more details.
|
||||
|
||||
If you enable caching, the [uv cache](https://docs.astral.sh/uv/concepts/cache/) will be uploaded to
|
||||
the GitHub Actions cache. This can speed up runs that reuse the cache by several minutes.
|
||||
Caching is enabled by default on GitHub-hosted runners.
|
||||
|
||||
> [!TIP]
|
||||
>
|
||||
> On self-hosted runners this is usually not needed since the cache generated by uv on the runner's
|
||||
> filesystem is not removed after a run. For more details see [Local cache path](#local-cache-path).
|
||||
|
||||
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@v6
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-suffix: "optional-suffix"
|
||||
```
|
||||
|
||||
When the cache was successfully restored, the output `cache-hit` will be set to `true` and you can
|
||||
use it in subsequent steps. For example, to use the cache in the above case:
|
||||
|
||||
```yaml
|
||||
- name: Do something if the cache was restored
|
||||
if: steps.setup-uv.outputs.cache-hit == 'true'
|
||||
run: echo "Cache was restored"
|
||||
```
|
||||
|
||||
#### Cache dependency glob
|
||||
|
||||
If you want to control when the GitHub Actions cache is invalidated, specify a glob pattern with the
|
||||
`cache-dependency-glob` input. The GitHub Actions cache will be invalidated if any file matching the glob pattern
|
||||
changes. If you use relative paths, they are relative to the repository root.
|
||||
|
||||
> [!NOTE]
|
||||
>
|
||||
> You can look up supported patterns [here](https://github.com/actions/toolkit/tree/main/packages/glob#patterns)
|
||||
>
|
||||
> The default is
|
||||
> ```yaml
|
||||
> cache-dependency-glob: |
|
||||
> **/*requirements*.txt
|
||||
> **/*requirements*.in
|
||||
> **/*constraints*.txt
|
||||
> **/*constraints*.in
|
||||
> **/pyproject.toml
|
||||
> **/uv.lock
|
||||
> ```
|
||||
|
||||
```yaml
|
||||
- name: Define a cache dependency glob
|
||||
uses: astral-sh/setup-uv@v6
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: "**/pyproject.toml"
|
||||
```
|
||||
|
||||
```yaml
|
||||
- name: Define a list of cache dependency globs
|
||||
uses: astral-sh/setup-uv@v6
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: |
|
||||
**/requirements*.txt
|
||||
**/pyproject.toml
|
||||
```
|
||||
|
||||
```yaml
|
||||
- name: Define an absolute cache dependency glob
|
||||
uses: astral-sh/setup-uv@v6
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: "/tmp/my-folder/requirements*.txt"
|
||||
```
|
||||
|
||||
```yaml
|
||||
- name: Never invalidate the cache
|
||||
uses: astral-sh/setup-uv@v6
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: ""
|
||||
```
|
||||
|
||||
#### Restore cache
|
||||
|
||||
Restoring an existing cache can be enabled or disabled with the `restore-cache` input.
|
||||
By default, the cache will be restored.
|
||||
|
||||
```yaml
|
||||
- name: Don't restore an existing cache
|
||||
uses: astral-sh/setup-uv@v6
|
||||
with:
|
||||
enable-cache: true
|
||||
restore-cache: false
|
||||
```
|
||||
|
||||
#### Save cache
|
||||
|
||||
You can also disable saving the cache after the run with the `save-cache` input.
|
||||
This can be useful to save cache storage when you know you will not use the cache of the run again.
|
||||
By default, the cache will be saved.
|
||||
|
||||
```yaml
|
||||
- name: Don't save the cache after the run
|
||||
uses: astral-sh/setup-uv@v6
|
||||
with:
|
||||
enable-cache: true
|
||||
save-cache: false
|
||||
```
|
||||
|
||||
### Local cache path
|
||||
|
||||
This action controls where uv stores its cache on the runner's filesystem by setting `UV_CACHE_DIR`.
|
||||
It defaults to `setup-uv-cache` in the `TMP` dir, `D:\a\_temp\uv-tool-dir` on Windows and
|
||||
`/tmp/setup-uv-cache` on Linux/macOS. You can change the default by specifying the path with the
|
||||
`cache-local-path` input.
|
||||
|
||||
```yaml
|
||||
- name: Define a custom uv cache path
|
||||
uses: astral-sh/setup-uv@v6
|
||||
with:
|
||||
cache-local-path: "/path/to/cache"
|
||||
```
|
||||
|
||||
### Disable cache pruning
|
||||
|
||||
By default, the uv cache is pruned after every run, removing pre-built wheels, but retaining any
|
||||
wheels that were built from source. On GitHub-hosted runners, it's typically faster to omit those
|
||||
pre-built wheels from the cache (and instead re-download them from the registry on each run).
|
||||
However, on self-hosted or local runners, preserving the cache may be more efficient. See
|
||||
the [documentation](https://docs.astral.sh/uv/concepts/cache/#caching-in-continuous-integration) for
|
||||
more information.
|
||||
|
||||
If you want to persist the entire cache across runs, disable cache pruning with the `prune-cache`
|
||||
input.
|
||||
|
||||
```yaml
|
||||
- name: Don't prune the cache before saving it
|
||||
uses: astral-sh/setup-uv@v6
|
||||
with:
|
||||
enable-cache: true
|
||||
prune-cache: false
|
||||
```
|
||||
|
||||
### 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).
|
||||
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@v6
|
||||
with:
|
||||
enable-cache: true
|
||||
ignore-nothing-to-cache: true
|
||||
```
|
||||
|
||||
### Ignore empty workdir
|
||||
|
||||
By default, the action will warn if the workdir is empty, because this is usually the case when
|
||||
`actions/checkout` is configured to run after `setup-uv`, which is not supported.
|
||||
|
||||
If you want to ignore this, set the `ignore-empty-workdir` input to `true`.
|
||||
|
||||
```yaml
|
||||
- name: Ignore empty workdir
|
||||
uses: astral-sh/setup-uv@v6
|
||||
with:
|
||||
ignore-empty-workdir: true
|
||||
```
|
||||
|
||||
### GitHub authentication token
|
||||
|
||||
This action uses the GitHub API to fetch the uv release artifacts. To avoid hitting the GitHub API
|
||||
rate limit too quickly, an authentication token can be provided via the `github-token` input. By
|
||||
default, the `GITHUB_TOKEN` secret is used, which is automatically provided by GitHub Actions.
|
||||
|
||||
If the default
|
||||
[permissions for the GitHub token](https://docs.github.com/en/actions/security-for-github-actions/security-guides/automatic-token-authentication#permissions-for-the-github_token)
|
||||
are not sufficient, you can provide a custom GitHub token with the necessary permissions.
|
||||
|
||||
```yaml
|
||||
- name: Install the latest version of uv with a custom GitHub token
|
||||
uses: astral-sh/setup-uv@v6
|
||||
with:
|
||||
github-token: ${{ secrets.CUSTOM_GITHUB_TOKEN }}
|
||||
```
|
||||
|
||||
### UV_TOOL_DIR
|
||||
|
||||
On Windows `UV_TOOL_DIR` is set to `uv-tool-dir` in the `TMP` dir (e.g. `D:\a\_temp\uv-tool-dir`).
|
||||
On GitHub hosted runners this is on the much faster `D:` drive.
|
||||
|
||||
On all other platforms the tool environments are placed in the
|
||||
[default location](https://docs.astral.sh/uv/concepts/tools/#tools-directory).
|
||||
|
||||
If you want to change this behaviour (especially on self-hosted runners) you can use the `tool-dir`
|
||||
input:
|
||||
|
||||
```yaml
|
||||
- name: Install the latest version of uv with a custom tool dir
|
||||
uses: astral-sh/setup-uv@v6
|
||||
with:
|
||||
tool-dir: "/path/to/tool/dir"
|
||||
```
|
||||
|
||||
### UV_TOOL_BIN_DIR
|
||||
|
||||
On Windows `UV_TOOL_BIN_DIR` is set to `uv-tool-bin-dir` in the `TMP` dir (e.g.
|
||||
`D:\a\_temp\uv-tool-bin-dir`). On GitHub hosted runners this is on the much faster `D:` drive. This
|
||||
path is also automatically added to the PATH.
|
||||
|
||||
On all other platforms the tool binaries get installed to the
|
||||
[default location](https://docs.astral.sh/uv/concepts/tools/#the-bin-directory).
|
||||
|
||||
If you want to change this behaviour (especially on self-hosted runners) you can use the
|
||||
`tool-bin-dir` input:
|
||||
|
||||
```yaml
|
||||
- name: Install the latest version of uv with a custom tool bin dir
|
||||
uses: astral-sh/setup-uv@v6
|
||||
with:
|
||||
tool-bin-dir: "/path/to/tool-bin/dir"
|
||||
```
|
||||
|
||||
### Tilde Expansion
|
||||
|
||||
This action supports expanding the `~` character to the user's home directory for the following inputs:
|
||||
|
||||
- `version-file`
|
||||
- `cache-local-path`
|
||||
- `tool-dir`
|
||||
- `tool-bin-dir`
|
||||
- `cache-dependency-glob`
|
||||
|
||||
```yaml
|
||||
- name: Expand the tilde character
|
||||
uses: astral-sh/setup-uv@v6
|
||||
with:
|
||||
cache-local-path: "~/path/to/cache"
|
||||
tool-dir: "~/path/to/tool/dir"
|
||||
tool-bin-dir: "~/path/to/tool-bin/dir"
|
||||
cache-dependency-glob: "~/my-cache-buster"
|
||||
```
|
||||
|
||||
### Manifest file
|
||||
|
||||
The `manifest-file` input allows you to specify a JSON manifest that lists available uv versions,
|
||||
architectures, and their download URLs. By default, this action uses the manifest file contained
|
||||
in this repository, which is automatically updated with each release of uv.
|
||||
|
||||
The manifest file contains an array of objects, each describing a version,
|
||||
architecture, platform, and the corresponding download URL. For example:
|
||||
|
||||
```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"
|
||||
},
|
||||
...
|
||||
]
|
||||
```
|
||||
|
||||
You can supply a custom manifest file URL to define additional versions,
|
||||
architectures, or different download URLs.
|
||||
This is useful if you maintain your own uv builds or want to override the default sources.
|
||||
|
||||
```yaml
|
||||
- name: Use a custom manifest file
|
||||
uses: astral-sh/setup-uv@v6
|
||||
with:
|
||||
manifest-file: "https://example.com/my-custom-manifest.json"
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> When you use a custom manifest file and do not set the `version` input, its default value is `latest`.
|
||||
> This means the action will install the latest version available in the custom manifest file.
|
||||
> This is different from the default behavior of installing the latest version from the official uv releases.
|
||||
|
||||
### Add problem matchers
|
||||
|
||||
This action automatically adds
|
||||
[problem matchers](https://github.com/actions/toolkit/blob/main/docs/problem-matchers.md)
|
||||
for python errors.
|
||||
|
||||
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@v6
|
||||
with:
|
||||
add-problem-matchers: false
|
||||
```
|
||||
|
||||
## 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
|
||||
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
|
||||
tool to speed up consecutive runs on self-hosted runners.
|
||||
This action downloads uv from the uv repo's official
|
||||
[GitHub Releases](https://github.com/astral-sh/uv) and uses the
|
||||
[GitHub Actions Toolkit](https://github.com/actions/toolkit) to cache it as a tool to speed up
|
||||
consecutive runs on self-hosted runners.
|
||||
|
||||
The installed version of uv is then added to the runner PATH, enabling later steps to invoke it
|
||||
by name (`uv`).
|
||||
@@ -210,8 +518,7 @@ by name (`uv`).
|
||||
With `setup-uv`, you can install a specific version of Python using `uv python install` rather than
|
||||
relying on `actions/setup-python`.
|
||||
|
||||
Using `actions/setup-python` can be faster (~1s), because GitHub includes several Python versions in the runner image
|
||||
which are available to get activated by `actions/setup-python` without having to download them.
|
||||
Using `actions/setup-python` can be faster, because GitHub caches the Python versions alongside the runner.
|
||||
|
||||
For example:
|
||||
|
||||
@@ -219,7 +526,7 @@ For example:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@main
|
||||
- name: Install the latest version of uv
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
uses: astral-sh/setup-uv@v6
|
||||
with:
|
||||
enable-cache: true
|
||||
- name: Test
|
||||
@@ -231,7 +538,7 @@ To install a specific version of Python, use
|
||||
|
||||
```yaml
|
||||
- name: Install the latest version of uv
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
uses: astral-sh/setup-uv@v6
|
||||
with:
|
||||
enable-cache: true
|
||||
- name: Install Python 3.12
|
||||
@@ -250,7 +557,7 @@ output:
|
||||
uses: actions/checkout@main
|
||||
- name: Install the default version of uv
|
||||
id: setup-uv
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
uses: astral-sh/setup-uv@v6
|
||||
- name: Print the installed version
|
||||
run: echo "Installed uv version is ${{ steps.setup-uv.outputs.uv-version }}"
|
||||
```
|
||||
@@ -259,13 +566,14 @@ output:
|
||||
|
||||
**Yes!**
|
||||
|
||||
The cache key gets computed by using the cache-dependency-glob (see [Caching documentation](docs/caching.md)).
|
||||
The cache key gets computed by using the [cache-dependency-glob](#cache-dependency-glob).
|
||||
|
||||
If you have jobs which use the same dependency definitions from `requirements.txt` or
|
||||
If you
|
||||
have jobs which use the same dependency definitions from `requirements.txt` or
|
||||
`pyproject.toml` but different
|
||||
[resolution strategies](https://docs.astral.sh/uv/concepts/resolution/#resolution-strategy),
|
||||
each job will have different dependencies or dependency versions.
|
||||
But if you do not add the resolution strategy as a cache-suffix (see [Caching documentation](docs/caching.md)),
|
||||
But if you do not add the resolution strategy as a [cache-suffix](#enable-caching),
|
||||
they will have the same cache key.
|
||||
|
||||
This means the first job which starts uploading its cache will win and all other job will fail
|
||||
@@ -278,15 +586,15 @@ You might see errors like
|
||||
### Why do I see warnings like `No GitHub Actions cache found for key`
|
||||
|
||||
When a workflow runs for the first time on a branch and has a new cache key, because the
|
||||
cache-dependency-glob (see [Caching documentation](docs/caching.md)) found changed files (changed dependencies),
|
||||
[cache-dependency-glob](#cache-dependency-glob) found changed files (changed dependencies),
|
||||
the cache will not be found and the warning `No GitHub Actions cache found for key` will be printed.
|
||||
|
||||
While this might be irritating at first, it is expected behaviour and the cache will be created
|
||||
and reused in later workflows.
|
||||
|
||||
The reason for the warning is that we have to way to know if this is the first run of a new
|
||||
cache key or the user accidentally misconfigured the cache-dependency-glob
|
||||
or cache-suffix (see [Caching documentation](docs/caching.md)) and the cache never gets used.
|
||||
The reason for the warning is, that we have to way to know if this is the first run of a new
|
||||
cache key or the user accidentally misconfigured the [cache-dependency-glob](#cache-dependency-glob)
|
||||
or [cache-suffix](#enable-caching) and the cache never gets used.
|
||||
|
||||
### Do I have to run `actions/checkout` before or after `setup-uv`?
|
||||
|
||||
@@ -297,7 +605,7 @@ Running `actions/checkout` after `setup-uv` **is not supported**.
|
||||
|
||||
### Does `setup-uv` also install my project or its dependencies automatically?
|
||||
|
||||
No, `setup-uv` alone won't install any libraries from your `pyproject.toml` or `requirements.txt`, it only sets up `uv`.
|
||||
No, `setup-uv` alone wont install any libraries from your `pyproject.toml` or `requirements.txt`, it only sets up `uv`.
|
||||
You should run `uv sync` or `uv pip install .` separately, or use `uv run ...` to ensure necessary dependencies are installed.
|
||||
|
||||
### Why is a changed cache not detected and not the full cache uploaded?
|
||||
@@ -311,11 +619,11 @@ if an uploaded cache exists for this key.
|
||||
If yes (e.g. contents of `uv.lock` did not change since last run) the dependencies in the cache
|
||||
are up to date and the cache will be downloaded and used.
|
||||
|
||||
Details on determining which files will lead to different caches can be read in the
|
||||
[Caching documentation](docs/caching.md).
|
||||
Details on determining which files will lead to different caches can be read under
|
||||
[cache-dependency-glob](#cache-dependency-glob)
|
||||
|
||||
Some dependencies will never be uploaded to the cache and will be downloaded again on each run
|
||||
as described in the [Caching documentation](docs/caching.md).
|
||||
as described in [disable-cache-pruning](#disable-cache-pruning)
|
||||
|
||||
## Acknowledgements
|
||||
|
||||
|
||||
@@ -4,11 +4,10 @@ import {
|
||||
validateChecksum,
|
||||
} from "../../../src/download/checksum/checksum";
|
||||
|
||||
const validChecksum =
|
||||
"f3da96ec7e995debee7f5d52ecd034dfb7074309a1da42f76429ecb814d813a3";
|
||||
const filePath = "__tests__/fixtures/checksumfile";
|
||||
|
||||
test("checksum should match", async () => {
|
||||
const validChecksum =
|
||||
"f3da96ec7e995debee7f5d52ecd034dfb7074309a1da42f76429ecb814d813a3";
|
||||
const filePath = "__tests__/fixtures/checksumfile";
|
||||
// string params don't matter only test the checksum mechanism, not known checksums
|
||||
await validateChecksum(
|
||||
validChecksum,
|
||||
@@ -19,16 +18,6 @@ test("checksum should match", async () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("provided checksum beats known checksums", async () => {
|
||||
await validateChecksum(
|
||||
validChecksum,
|
||||
filePath,
|
||||
"x86_64",
|
||||
"unknown-linux-gnu",
|
||||
"0.3.0",
|
||||
);
|
||||
});
|
||||
|
||||
type KnownVersionFixture = { version: string; known: boolean };
|
||||
|
||||
it.each<KnownVersionFixture>([
|
||||
@@ -40,9 +29,9 @@ it.each<KnownVersionFixture>([
|
||||
known: false,
|
||||
version: "0.0.15",
|
||||
},
|
||||
])("isknownVersion should return $known for version $version", ({
|
||||
version,
|
||||
known,
|
||||
}) => {
|
||||
expect(isknownVersion(version)).toBe(known);
|
||||
});
|
||||
])(
|
||||
"isknownVersion should return $known for version $version",
|
||||
({ version, known }) => {
|
||||
expect(isknownVersion(version)).toBe(known);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
@@ -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"}]}
|
||||
@@ -1,382 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, jest } from "@jest/globals";
|
||||
import * as semver from "semver";
|
||||
|
||||
const mockInfo = jest.fn();
|
||||
const mockWarning = jest.fn();
|
||||
|
||||
jest.unstable_mockModule("@actions/core", () => ({
|
||||
debug: jest.fn(),
|
||||
info: mockInfo,
|
||||
warning: mockWarning,
|
||||
}));
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Mock requires flexible typing in tests.
|
||||
const mockDownloadTool = jest.fn<any>();
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Mock requires flexible typing in tests.
|
||||
const mockExtractTar = jest.fn<any>();
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Mock requires flexible typing in tests.
|
||||
const mockExtractZip = jest.fn<any>();
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Mock requires flexible typing in tests.
|
||||
const mockCacheDir = jest.fn<any>();
|
||||
|
||||
jest.unstable_mockModule("@actions/tool-cache", () => ({
|
||||
cacheDir: mockCacheDir,
|
||||
downloadTool: mockDownloadTool,
|
||||
evaluateVersions: (versions: string[], range: string) =>
|
||||
semver.maxSatisfying(versions, range) ?? "",
|
||||
extractTar: mockExtractTar,
|
||||
extractZip: mockExtractZip,
|
||||
find: () => "",
|
||||
findAllVersions: () => [],
|
||||
isExplicitVersion: (version: string) => semver.valid(version) !== null,
|
||||
}));
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Mock requires flexible typing in tests.
|
||||
const mockGetLatestVersion = jest.fn<any>();
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Mock requires flexible typing in tests.
|
||||
const mockGetAllVersions = jest.fn<any>();
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Mock requires flexible typing in tests.
|
||||
const mockGetArtifact = jest.fn<any>();
|
||||
|
||||
jest.unstable_mockModule("../../src/download/manifest", () => ({
|
||||
getAllVersions: mockGetAllVersions,
|
||||
getArtifact: mockGetArtifact,
|
||||
getLatestVersion: mockGetLatestVersion,
|
||||
}));
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Mock requires flexible typing in tests.
|
||||
const mockValidateChecksum = jest.fn<any>();
|
||||
|
||||
jest.unstable_mockModule("../../src/download/checksum/checksum", () => ({
|
||||
validateChecksum: mockValidateChecksum,
|
||||
}));
|
||||
|
||||
const { downloadVersion, resolveVersion, rewriteToMirror } = await import(
|
||||
"../../src/download/download-version"
|
||||
);
|
||||
|
||||
describe("download-version", () => {
|
||||
beforeEach(() => {
|
||||
mockInfo.mockReset();
|
||||
mockWarning.mockReset();
|
||||
mockDownloadTool.mockReset();
|
||||
mockExtractTar.mockReset();
|
||||
mockExtractZip.mockReset();
|
||||
mockCacheDir.mockReset();
|
||||
mockGetLatestVersion.mockReset();
|
||||
mockGetAllVersions.mockReset();
|
||||
mockGetArtifact.mockReset();
|
||||
mockValidateChecksum.mockReset();
|
||||
|
||||
mockDownloadTool.mockResolvedValue("/tmp/downloaded");
|
||||
mockExtractTar.mockResolvedValue("/tmp/extracted");
|
||||
mockExtractZip.mockResolvedValue("/tmp/extracted");
|
||||
mockCacheDir.mockResolvedValue("/tmp/cached");
|
||||
});
|
||||
|
||||
describe("resolveVersion", () => {
|
||||
it("uses the default manifest to resolve latest", async () => {
|
||||
mockGetLatestVersion.mockResolvedValue("0.9.26");
|
||||
|
||||
const version = await resolveVersion("latest", undefined);
|
||||
|
||||
expect(version).toBe("0.9.26");
|
||||
expect(mockGetLatestVersion).toHaveBeenCalledTimes(1);
|
||||
expect(mockGetLatestVersion).toHaveBeenCalledWith(undefined);
|
||||
});
|
||||
|
||||
it("uses the default manifest to resolve available versions", async () => {
|
||||
mockGetAllVersions.mockResolvedValue(["0.9.26", "0.9.25"]);
|
||||
|
||||
const version = await resolveVersion("^0.9.0", undefined);
|
||||
|
||||
expect(version).toBe("0.9.26");
|
||||
expect(mockGetAllVersions).toHaveBeenCalledTimes(1);
|
||||
expect(mockGetAllVersions).toHaveBeenCalledWith(undefined);
|
||||
});
|
||||
|
||||
it("treats == exact pins as explicit versions", async () => {
|
||||
const version = await resolveVersion("==0.9.26", undefined);
|
||||
|
||||
expect(version).toBe("0.9.26");
|
||||
expect(mockGetAllVersions).not.toHaveBeenCalled();
|
||||
expect(mockGetLatestVersion).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses latest for minimum-only ranges when using the highest strategy", async () => {
|
||||
mockGetLatestVersion.mockResolvedValue("0.9.26");
|
||||
|
||||
const version = await resolveVersion(">=0.9.0", undefined, "highest");
|
||||
|
||||
expect(version).toBe("0.9.26");
|
||||
expect(mockGetLatestVersion).toHaveBeenCalledTimes(1);
|
||||
expect(mockGetLatestVersion).toHaveBeenCalledWith(undefined);
|
||||
expect(mockGetAllVersions).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses the lowest compatible version when requested", async () => {
|
||||
mockGetAllVersions.mockResolvedValue(["0.9.26", "0.9.25"]);
|
||||
|
||||
const version = await resolveVersion("^0.9.0", undefined, "lowest");
|
||||
|
||||
expect(version).toBe("0.9.25");
|
||||
expect(mockGetAllVersions).toHaveBeenCalledTimes(1);
|
||||
expect(mockGetAllVersions).toHaveBeenCalledWith(undefined);
|
||||
});
|
||||
|
||||
it("uses manifest-file when provided", async () => {
|
||||
mockGetAllVersions.mockResolvedValue(["0.9.26", "0.9.25"]);
|
||||
|
||||
const version = await resolveVersion(
|
||||
"^0.9.0",
|
||||
"https://example.com/custom.ndjson",
|
||||
);
|
||||
|
||||
expect(version).toBe("0.9.26");
|
||||
expect(mockGetAllVersions).toHaveBeenCalledWith(
|
||||
"https://example.com/custom.ndjson",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("downloadVersion", () => {
|
||||
it("fails when manifest lookup fails", async () => {
|
||||
mockGetArtifact.mockRejectedValue(new Error("manifest unavailable"));
|
||||
|
||||
await expect(
|
||||
downloadVersion(
|
||||
"unknown-linux-gnu",
|
||||
"x86_64",
|
||||
"0.9.26",
|
||||
undefined,
|
||||
"token",
|
||||
),
|
||||
).rejects.toThrow("manifest unavailable");
|
||||
|
||||
expect(mockDownloadTool).not.toHaveBeenCalled();
|
||||
expect(mockValidateChecksum).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails when no matching artifact exists in the default manifest", async () => {
|
||||
mockGetArtifact.mockResolvedValue(undefined);
|
||||
|
||||
await expect(
|
||||
downloadVersion(
|
||||
"unknown-linux-gnu",
|
||||
"x86_64",
|
||||
"0.9.26",
|
||||
undefined,
|
||||
"token",
|
||||
),
|
||||
).rejects.toThrow(
|
||||
"Could not find artifact for version 0.9.26, arch x86_64, platform unknown-linux-gnu in https://raw.githubusercontent.com/astral-sh/versions/main/v1/uv.ndjson .",
|
||||
);
|
||||
|
||||
expect(mockDownloadTool).not.toHaveBeenCalled();
|
||||
expect(mockValidateChecksum).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses built-in checksums for default manifest downloads", async () => {
|
||||
mockGetArtifact.mockResolvedValue({
|
||||
archiveFormat: "tar.gz",
|
||||
checksum: "manifest-checksum-that-should-be-ignored",
|
||||
downloadUrl: "https://example.com/uv.tar.gz",
|
||||
});
|
||||
|
||||
await downloadVersion(
|
||||
"unknown-linux-gnu",
|
||||
"x86_64",
|
||||
"0.9.26",
|
||||
undefined,
|
||||
"token",
|
||||
);
|
||||
|
||||
expect(mockValidateChecksum).toHaveBeenCalledWith(
|
||||
undefined,
|
||||
"/tmp/downloaded",
|
||||
"x86_64",
|
||||
"unknown-linux-gnu",
|
||||
"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);
|
||||
});
|
||||
|
||||
it("uses manifest-file checksum metadata when checksum input is unset", async () => {
|
||||
mockGetArtifact.mockResolvedValue({
|
||||
archiveFormat: "tar.gz",
|
||||
checksum: "manifest-checksum",
|
||||
downloadUrl: "https://example.com/custom-uv.tar.gz",
|
||||
});
|
||||
|
||||
await downloadVersion(
|
||||
"unknown-linux-gnu",
|
||||
"x86_64",
|
||||
"0.9.26",
|
||||
"",
|
||||
"token",
|
||||
"https://example.com/custom.ndjson",
|
||||
);
|
||||
|
||||
expect(mockValidateChecksum).toHaveBeenCalledWith(
|
||||
"manifest-checksum",
|
||||
"/tmp/downloaded",
|
||||
"x86_64",
|
||||
"unknown-linux-gnu",
|
||||
"0.9.26",
|
||||
);
|
||||
});
|
||||
|
||||
it("prefers checksum input over manifest-file checksum metadata", async () => {
|
||||
mockGetArtifact.mockResolvedValue({
|
||||
archiveFormat: "tar.gz",
|
||||
checksum: "manifest-checksum",
|
||||
downloadUrl: "https://example.com/custom-uv.tar.gz",
|
||||
});
|
||||
|
||||
await downloadVersion(
|
||||
"unknown-linux-gnu",
|
||||
"x86_64",
|
||||
"0.9.26",
|
||||
"user-checksum",
|
||||
"token",
|
||||
"https://example.com/custom.ndjson",
|
||||
);
|
||||
|
||||
expect(mockValidateChecksum).toHaveBeenCalledWith(
|
||||
"user-checksum",
|
||||
"/tmp/downloaded",
|
||||
"x86_64",
|
||||
"unknown-linux-gnu",
|
||||
"0.9.26",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
[project]
|
||||
name = "uv-project"
|
||||
version = "0.1.0"
|
||||
description = "Add your description here"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"ruff>=0.6.2",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.uv]
|
||||
cache-dir = "/tmp/pyproject-toml-defined-cache-path"
|
||||
@@ -1,2 +0,0 @@
|
||||
def hello() -> str:
|
||||
return "Hello from uv-project!"
|
||||
@@ -1,38 +0,0 @@
|
||||
version = 1
|
||||
requires-python = ">=3.12"
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.6.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/23/f4/279d044f66b79261fd37df76bf72b64471afab5d3b7906a01499c4451910/ruff-0.6.2.tar.gz", hash = "sha256:239ee6beb9e91feb8e0ec384204a763f36cb53fb895a1a364618c6abb076b3be", size = 2460281 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/72/4b/47dd7a69287afb4069fa42c198e899463605460a58120196711bfcf0446b/ruff-0.6.2-py3-none-linux_armv6l.whl", hash = "sha256:5c8cbc6252deb3ea840ad6a20b0f8583caab0c5ef4f9cca21adc5a92b8f79f3c", size = 9695871 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/c3/8aac62ac4638c14a740ee76a755a925f2d0d04580ab790a9887accb729f6/ruff-0.6.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:17002fe241e76544448a8e1e6118abecbe8cd10cf68fde635dad480dba594570", size = 9459354 },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/cf/77fbd8d4617b9b9c503f9bffb8552c4e3ea1a58dc36975e7a9104ffb0f85/ruff-0.6.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3dbeac76ed13456f8158b8f4fe087bf87882e645c8e8b606dd17b0b66c2c1158", size = 9163871 },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/1c/765192bab32b79efbb498b06f0b9dcb3629112b53b8777ae1d19b8209e09/ruff-0.6.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:094600ee88cda325988d3f54e3588c46de5c18dae09d683ace278b11f9d4d534", size = 10096250 },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/d0/86f3cb0f6934c99f759c232984a5204d67a26745cad2d9edff6248adf7d2/ruff-0.6.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:316d418fe258c036ba05fbf7dfc1f7d3d4096db63431546163b472285668132b", size = 9475376 },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/cc/4c8d0e225b559a3fae6092ec310d7150d3b02b4669e9223f783ef64d82c0/ruff-0.6.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d72b8b3abf8a2d51b7b9944a41307d2f442558ccb3859bbd87e6ae9be1694a5d", size = 10295634 },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/96/d2699cfb1bb5a01c68122af43454c76c31331e1c8a9bd97d653d7c82524b/ruff-0.6.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:2aed7e243be68487aa8982e91c6e260982d00da3f38955873aecd5a9204b1d66", size = 11024941 },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/a9/6ecd66af8929e0f2a1ed308a4137f3521789f28f0eb97d32c2ca3aa7000c/ruff-0.6.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d371f7fc9cec83497fe7cf5eaf5b76e22a8efce463de5f775a1826197feb9df8", size = 10606894 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/73/2ee4cd19f44992fedac1cc6db9e3d825966072f6dcbd4032f21cbd063170/ruff-0.6.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a8f310d63af08f583363dfb844ba8f9417b558199c58a5999215082036d795a1", size = 11552886 },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/4c/c0f1cd35ce4a93c54a6bb1ee6934a3a205fa02198dd076678193853ceea1/ruff-0.6.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7db6880c53c56addb8638fe444818183385ec85eeada1d48fc5abe045301b2f1", size = 10264945 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/89/e45c9359b9cdd4245512ea2b9f2bb128a997feaa5f726fc9e8c7a66afadf/ruff-0.6.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:1175d39faadd9a50718f478d23bfc1d4da5743f1ab56af81a2b6caf0a2394f23", size = 10100007 },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/74/0bd4e0a7ed5f6908df87892f9bf60a2356c0fd74102d8097298bd9b4f346/ruff-0.6.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:5b939f9c86d51635fe486585389f54582f0d65b8238e08c327c1534844b3bb9a", size = 9559267 },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/03/3dc6dc9419f276f05805bf888c279e3e0b631284abd548d9e87cebb93aec/ruff-0.6.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d0d62ca91219f906caf9b187dea50d17353f15ec9bb15aae4a606cd697b49b4c", size = 9905304 },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/5b/d6a72a6a6bbf097c09de468326ef5fa1c9e7aa5e6e45979bc0d984b0dbe7/ruff-0.6.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:7438a7288f9d67ed3c8ce4d059e67f7ed65e9fe3aa2ab6f5b4b3610e57e3cb56", size = 10341480 },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/a9/0f2f21fe15ba537c46598f96aa9ae4a3d4b9ec64926664617ca6a8c772f4/ruff-0.6.2-py3-none-win32.whl", hash = "sha256:279d5f7d86696df5f9549b56b9b6a7f6c72961b619022b5b7999b15db392a4da", size = 7961901 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/80/fff12ffe11853d9f4ea3e5221e6dd2e93640a161c05c9579833e09ad40a7/ruff-0.6.2-py3-none-win_amd64.whl", hash = "sha256:d9f3469c7dd43cd22eb1c3fc16926fb8258d50cb1b216658a07be95dd117b0f2", size = 8783320 },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/91/577cdd64cce5e74d3f8b5ecb93f29566def569c741eb008aed4f331ef821/ruff-0.6.2-py3-none-win_arm64.whl", hash = "sha256:f28fcd2cd0e02bdf739297516d5643a945cc7caf09bd9bcb4d932540a5ea4fa9", size = 8225886 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uv-project"
|
||||
version = "0.1.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "ruff" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [{ name = "ruff" }]
|
||||
@@ -9,5 +9,5 @@ dependencies = [
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["uv_build>=0.9.22,<0.10.0"]
|
||||
build-backend = "uv_build"
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
+28
-30
@@ -1,9 +1,33 @@
|
||||
version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.8, <=3.9"
|
||||
requires-python = ">=3.12"
|
||||
|
||||
[[package]]
|
||||
name = "old-python-constraint-project"
|
||||
name = "ruff"
|
||||
version = "0.6.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/23/f4/279d044f66b79261fd37df76bf72b64471afab5d3b7906a01499c4451910/ruff-0.6.2.tar.gz", hash = "sha256:239ee6beb9e91feb8e0ec384204a763f36cb53fb895a1a364618c6abb076b3be", size = 2460281 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/72/4b/47dd7a69287afb4069fa42c198e899463605460a58120196711bfcf0446b/ruff-0.6.2-py3-none-linux_armv6l.whl", hash = "sha256:5c8cbc6252deb3ea840ad6a20b0f8583caab0c5ef4f9cca21adc5a92b8f79f3c", size = 9695871 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/c3/8aac62ac4638c14a740ee76a755a925f2d0d04580ab790a9887accb729f6/ruff-0.6.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:17002fe241e76544448a8e1e6118abecbe8cd10cf68fde635dad480dba594570", size = 9459354 },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/cf/77fbd8d4617b9b9c503f9bffb8552c4e3ea1a58dc36975e7a9104ffb0f85/ruff-0.6.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3dbeac76ed13456f8158b8f4fe087bf87882e645c8e8b606dd17b0b66c2c1158", size = 9163871 },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/1c/765192bab32b79efbb498b06f0b9dcb3629112b53b8777ae1d19b8209e09/ruff-0.6.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:094600ee88cda325988d3f54e3588c46de5c18dae09d683ace278b11f9d4d534", size = 10096250 },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/d0/86f3cb0f6934c99f759c232984a5204d67a26745cad2d9edff6248adf7d2/ruff-0.6.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:316d418fe258c036ba05fbf7dfc1f7d3d4096db63431546163b472285668132b", size = 9475376 },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/cc/4c8d0e225b559a3fae6092ec310d7150d3b02b4669e9223f783ef64d82c0/ruff-0.6.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d72b8b3abf8a2d51b7b9944a41307d2f442558ccb3859bbd87e6ae9be1694a5d", size = 10295634 },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/96/d2699cfb1bb5a01c68122af43454c76c31331e1c8a9bd97d653d7c82524b/ruff-0.6.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:2aed7e243be68487aa8982e91c6e260982d00da3f38955873aecd5a9204b1d66", size = 11024941 },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/a9/6ecd66af8929e0f2a1ed308a4137f3521789f28f0eb97d32c2ca3aa7000c/ruff-0.6.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d371f7fc9cec83497fe7cf5eaf5b76e22a8efce463de5f775a1826197feb9df8", size = 10606894 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/73/2ee4cd19f44992fedac1cc6db9e3d825966072f6dcbd4032f21cbd063170/ruff-0.6.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a8f310d63af08f583363dfb844ba8f9417b558199c58a5999215082036d795a1", size = 11552886 },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/4c/c0f1cd35ce4a93c54a6bb1ee6934a3a205fa02198dd076678193853ceea1/ruff-0.6.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7db6880c53c56addb8638fe444818183385ec85eeada1d48fc5abe045301b2f1", size = 10264945 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/89/e45c9359b9cdd4245512ea2b9f2bb128a997feaa5f726fc9e8c7a66afadf/ruff-0.6.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:1175d39faadd9a50718f478d23bfc1d4da5743f1ab56af81a2b6caf0a2394f23", size = 10100007 },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/74/0bd4e0a7ed5f6908df87892f9bf60a2356c0fd74102d8097298bd9b4f346/ruff-0.6.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:5b939f9c86d51635fe486585389f54582f0d65b8238e08c327c1534844b3bb9a", size = 9559267 },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/03/3dc6dc9419f276f05805bf888c279e3e0b631284abd548d9e87cebb93aec/ruff-0.6.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d0d62ca91219f906caf9b187dea50d17353f15ec9bb15aae4a606cd697b49b4c", size = 9905304 },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/5b/d6a72a6a6bbf097c09de468326ef5fa1c9e7aa5e6e45979bc0d984b0dbe7/ruff-0.6.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:7438a7288f9d67ed3c8ce4d059e67f7ed65e9fe3aa2ab6f5b4b3610e57e3cb56", size = 10341480 },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/a9/0f2f21fe15ba537c46598f96aa9ae4a3d4b9ec64926664617ca6a8c772f4/ruff-0.6.2-py3-none-win32.whl", hash = "sha256:279d5f7d86696df5f9549b56b9b6a7f6c72961b619022b5b7999b15db392a4da", size = 7961901 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/80/fff12ffe11853d9f4ea3e5221e6dd2e93640a161c05c9579833e09ad40a7/ruff-0.6.2-py3-none-win_amd64.whl", hash = "sha256:d9f3469c7dd43cd22eb1c3fc16926fb8258d50cb1b216658a07be95dd117b0f2", size = 8783320 },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/91/577cdd64cce5e74d3f8b5ecb93f29566def569c741eb008aed4f331ef821/ruff-0.6.2-py3-none-win_arm64.whl", hash = "sha256:f28fcd2cd0e02bdf739297516d5643a945cc7caf09bd9bcb4d932540a5ea4fa9", size = 8225886 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uv-project"
|
||||
version = "0.1.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
@@ -11,30 +35,4 @@ dependencies = [
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [{ name = "ruff", specifier = ">=0.6.2" }]
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.14.10"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/57/08/52232a877978dd8f9cf2aeddce3e611b40a63287dfca29b6b8da791f5e8d/ruff-0.14.10.tar.gz", hash = "sha256:9a2e830f075d1a42cd28420d7809ace390832a490ed0966fe373ba288e77aaf4", size = 5859763, upload-time = "2025-12-18T19:28:57.98Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/60/01/933704d69f3f05ee16ef11406b78881733c186fe14b6a46b05cfcaf6d3b2/ruff-0.14.10-py3-none-linux_armv6l.whl", hash = "sha256:7a3ce585f2ade3e1f29ec1b92df13e3da262178df8c8bdf876f48fa0e8316c49", size = 13527080, upload-time = "2025-12-18T19:29:25.642Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/58/a0349197a7dfa603ffb7f5b0470391efa79ddc327c1e29c4851e85b09cc5/ruff-0.14.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:674f9be9372907f7257c51f1d4fc902cb7cf014b9980152b802794317941f08f", size = 13797320, upload-time = "2025-12-18T19:29:02.571Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/82/36be59f00a6082e38c23536df4e71cdbc6af8d7c707eade97fcad5c98235/ruff-0.14.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d85713d522348837ef9df8efca33ccb8bd6fcfc86a2cde3ccb4bc9d28a18003d", size = 12918434, upload-time = "2025-12-18T19:28:51.202Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/00/45c62a7f7e34da92a25804f813ebe05c88aa9e0c25e5cb5a7d23dd7450e3/ruff-0.14.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6987ebe0501ae4f4308d7d24e2d0fe3d7a98430f5adfd0f1fead050a740a3a77", size = 13371961, upload-time = "2025-12-18T19:29:04.991Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/31/a5906d60f0405f7e57045a70f2d57084a93ca7425f22e1d66904769d1628/ruff-0.14.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:16a01dfb7b9e4eee556fbfd5392806b1b8550c9b4a9f6acd3dbe6812b193c70a", size = 13275629, upload-time = "2025-12-18T19:29:21.381Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/60/61c0087df21894cf9d928dc04bcd4fb10e8b2e8dca7b1a276ba2155b2002/ruff-0.14.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7165d31a925b7a294465fa81be8c12a0e9b60fb02bf177e79067c867e71f8b1f", size = 14029234, upload-time = "2025-12-18T19:29:00.132Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/84/77d911bee3b92348b6e5dab5a0c898d87084ea03ac5dc708f46d88407def/ruff-0.14.10-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c561695675b972effb0c0a45db233f2c816ff3da8dcfbe7dfc7eed625f218935", size = 15449890, upload-time = "2025-12-18T19:28:53.573Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/36/480206eaefa24a7ec321582dda580443a8f0671fdbf6b1c80e9c3e93a16a/ruff-0.14.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4bb98fcbbc61725968893682fd4df8966a34611239c9fd07a1f6a07e7103d08e", size = 15123172, upload-time = "2025-12-18T19:29:23.453Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/38/68e414156015ba80cef5473d57919d27dfb62ec804b96180bafdeaf0e090/ruff-0.14.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f24b47993a9d8cb858429e97bdf8544c78029f09b520af615c1d261bf827001d", size = 14460260, upload-time = "2025-12-18T19:29:27.808Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/19/9e050c0dca8aba824d67cc0db69fb459c28d8cd3f6855b1405b3f29cc91d/ruff-0.14.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59aabd2e2c4fd614d2862e7939c34a532c04f1084476d6833dddef4afab87e9f", size = 14229978, upload-time = "2025-12-18T19:29:11.32Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/eb/e8dd1dd6e05b9e695aa9dd420f4577debdd0f87a5ff2fedda33c09e9be8c/ruff-0.14.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:213db2b2e44be8625002dbea33bb9c60c66ea2c07c084a00d55732689d697a7f", size = 14338036, upload-time = "2025-12-18T19:29:09.184Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/12/f3e3a505db7c19303b70af370d137795fcfec136d670d5de5391e295c134/ruff-0.14.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b914c40ab64865a17a9a5b67911d14df72346a634527240039eb3bd650e5979d", size = 13264051, upload-time = "2025-12-18T19:29:13.431Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/64/8c3a47eaccfef8ac20e0484e68e0772013eb85802f8a9f7603ca751eb166/ruff-0.14.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1484983559f026788e3a5c07c81ef7d1e97c1c78ed03041a18f75df104c45405", size = 13283998, upload-time = "2025-12-18T19:29:06.994Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/84/534a5506f4074e5cc0529e5cd96cfc01bb480e460c7edf5af70d2bcae55e/ruff-0.14.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c70427132db492d25f982fffc8d6c7535cc2fd2c83fc8888f05caaa248521e60", size = 13601891, upload-time = "2025-12-18T19:28:55.811Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/1e/14c916087d8598917dbad9b2921d340f7884824ad6e9c55de948a93b106d/ruff-0.14.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5bcf45b681e9f1ee6445d317ce1fa9d6cba9a6049542d1c3d5b5958986be8830", size = 14336660, upload-time = "2025-12-18T19:29:16.531Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/1c/d7b67ab43f30013b47c12b42d1acd354c195351a3f7a1d67f59e54227ede/ruff-0.14.10-py3-none-win32.whl", hash = "sha256:104c49fc7ab73f3f3a758039adea978869a918f31b73280db175b43a2d9b51d6", size = 13196187, upload-time = "2025-12-18T19:29:19.006Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/9c/896c862e13886fae2af961bef3e6312db9ebc6adc2b156fe95e615dee8c1/ruff-0.14.10-py3-none-win_amd64.whl", hash = "sha256:466297bd73638c6bdf06485683e812db1c00c7ac96d4ddd0294a338c62fdc154", size = 14661283, upload-time = "2025-12-18T19:29:30.16Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/31/b0e29d572670dca3674eeee78e418f20bdf97fa8aa9ea71380885e175ca0/ruff-0.14.10-py3-none-win_arm64.whl", hash = "sha256:e51d046cf6dda98a4633b8a8a771451107413b0f07183b2bef03f075599e44e6", size = 13729839, upload-time = "2025-12-18T19:28:48.636Z" },
|
||||
]
|
||||
requires-dist = [{ name = "ruff" }]
|
||||
|
||||
+56
-263
@@ -1,6 +1,13 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
jest.mock("@actions/core", () => {
|
||||
return {
|
||||
debug: jest.fn(),
|
||||
getBooleanInput: jest.fn(
|
||||
(name: string) => (mockInputs[name] ?? "") === "true",
|
||||
),
|
||||
getInput: jest.fn((name: string) => mockInputs[name] ?? ""),
|
||||
};
|
||||
});
|
||||
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
@@ -10,284 +17,70 @@ 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(
|
||||
(name: string) => (mockInputs[name] ?? "") === "true",
|
||||
);
|
||||
const mockGetInput = jest.fn((name: string) => mockInputs[name] ?? "");
|
||||
const mockInfo = jest.fn();
|
||||
const mockWarning = jest.fn();
|
||||
|
||||
jest.unstable_mockModule("@actions/core", () => ({
|
||||
debug: mockDebug,
|
||||
getBooleanInput: mockGetBooleanInput,
|
||||
getInput: mockGetInput,
|
||||
info: mockInfo,
|
||||
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;
|
||||
}
|
||||
|
||||
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();
|
||||
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 import("../../src/utils/inputs");
|
||||
expect(cacheDependencyGlob).toBe("");
|
||||
});
|
||||
|
||||
it("resolves a single relative path", async () => {
|
||||
mockInputs["working-directory"] = "/workspace";
|
||||
mockInputs["cache-dependency-glob"] = "requirements.txt";
|
||||
const { cacheDependencyGlob } = await import("../../src/utils/inputs");
|
||||
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 import("../../src/utils/inputs");
|
||||
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 import("../../src/utils/inputs");
|
||||
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",
|
||||
it("keeps absolute path unchanged in multiline input", async () => {
|
||||
mockInputs["working-directory"] = "/workspace";
|
||||
mockInputs["cache-dependency-glob"] = "/abs/path.lock\nrelative.lock";
|
||||
const { cacheDependencyGlob } = await import("../../src/utils/inputs");
|
||||
expect(cacheDependencyGlob).toBe(
|
||||
["/abs/path.lock", "/workspace/relative.lock"].join("\n"),
|
||||
],
|
||||
[
|
||||
"!/abs/path.lock\n!relative.lock",
|
||||
);
|
||||
});
|
||||
|
||||
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 import("../../src/utils/inputs");
|
||||
expect(cacheDependencyGlob).toBe(
|
||||
["!/abs/path.lock", "!/workspace/relative.lock"].join("\n"),
|
||||
],
|
||||
])("normalizes multiline glob %s", (globInput, expected) => {
|
||||
mockInputs["working-directory"] = "/workspace";
|
||||
mockInputs["cache-dependency-glob"] = globInput;
|
||||
|
||||
const inputs = loadInputs();
|
||||
|
||||
expect(inputs.cacheDependencyGlob).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("tool directories", () => {
|
||||
it("expands tilde for tool-bin-dir and tool-dir", () => {
|
||||
mockInputs["working-directory"] = "/workspace";
|
||||
mockInputs["tool-bin-dir"] = "~/tool-bin-dir";
|
||||
mockInputs["tool-dir"] = "~/tool-dir";
|
||||
|
||||
const inputs = loadInputs();
|
||||
|
||||
expect(inputs.toolBinDir).toBe("/home/testuser/tool-bin-dir");
|
||||
expect(inputs.toolDir).toBe("/home/testuser/tool-dir");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cacheLocalPath", () => {
|
||||
it("expands tilde in cache-local-path", () => {
|
||||
mockInputs["working-directory"] = "/workspace";
|
||||
mockInputs["cache-local-path"] = "~/uv-cache/cache-local-path";
|
||||
|
||||
const inputs = loadInputs();
|
||||
|
||||
expect(inputs.cacheLocalPath).toEqual({
|
||||
path: "/home/testuser/uv-cache/cache-local-path",
|
||||
source: CacheLocalSource.Input,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("venvPath", () => {
|
||||
it("defaults to .venv in the working directory", () => {
|
||||
mockInputs["working-directory"] = "/workspace";
|
||||
|
||||
const inputs = loadInputs();
|
||||
|
||||
expect(inputs.venvPath).toBe("/workspace/.venv");
|
||||
});
|
||||
|
||||
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) => {
|
||||
mockInputs["working-directory"] = "/workspace";
|
||||
mockInputs["activate-environment"] = "true";
|
||||
mockInputs["venv-path"] = venvPathInput;
|
||||
|
||||
const inputs = loadInputs();
|
||||
|
||||
expect(inputs.venvPath).toBe(expected);
|
||||
});
|
||||
|
||||
it("warns when venv-path is set but activate-environment is false", () => {
|
||||
mockInputs["working-directory"] = "/workspace";
|
||||
mockInputs["venv-path"] = "custom-venv";
|
||||
|
||||
const inputs = loadInputs();
|
||||
|
||||
expect(inputs.activateEnvironment).toBe(false);
|
||||
expect(inputs.venvPath).toBe("/workspace/custom-venv");
|
||||
expect(mockWarning).toHaveBeenCalledWith(
|
||||
"venv-path is only used when activate-environment is true",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, test } from "@jest/globals";
|
||||
import { getUvVersionFromFile } from "../../src/version/file-parser";
|
||||
import { getUvVersionFromFile } from "../../src/version/resolve";
|
||||
|
||||
test("ignores dependencies starting with uv", async () => {
|
||||
const parsedVersion = getUvVersionFromFile(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, test } from "@jest/globals";
|
||||
import { getUvVersionFromFile } from "../../src/version/file-parser";
|
||||
import { getUvVersionFromFile } from "../../src/version/resolve";
|
||||
|
||||
test("ignores dependencies starting with uv", async () => {
|
||||
const parsedVersion = getUvVersionFromFile(
|
||||
|
||||
@@ -1,121 +1,113 @@
|
||||
jest.mock("node:fs");
|
||||
jest.mock("@actions/core", () => ({
|
||||
warning: jest.fn(),
|
||||
}));
|
||||
|
||||
import fs from "node:fs";
|
||||
import * as core from "@actions/core";
|
||||
import { beforeEach, describe, expect, it, jest } from "@jest/globals";
|
||||
import { getUvVersionFromToolVersions } from "../../src/version/tool-versions-file";
|
||||
|
||||
const mockReadFileSync = jest.fn();
|
||||
const mockWarning = jest.fn();
|
||||
|
||||
jest.unstable_mockModule("node:fs", () => ({
|
||||
default: {
|
||||
readFileSync: mockReadFileSync,
|
||||
},
|
||||
}));
|
||||
|
||||
jest.unstable_mockModule("@actions/core", () => ({
|
||||
warning: mockWarning,
|
||||
}));
|
||||
|
||||
async function getVersionFromToolVersions(filePath: string) {
|
||||
const { getUvVersionFromToolVersions } = await import(
|
||||
"../../src/version/tool-versions-file"
|
||||
);
|
||||
|
||||
return getUvVersionFromToolVersions(filePath);
|
||||
}
|
||||
const mockedFs = fs as jest.Mocked<typeof fs>;
|
||||
const mockedCore = core as jest.Mocked<typeof core>;
|
||||
|
||||
describe("getUvVersionFromToolVersions", () => {
|
||||
beforeEach(() => {
|
||||
jest.resetModules();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should return undefined for non-.tool-versions files", async () => {
|
||||
const result = await getVersionFromToolVersions("package.json");
|
||||
it("should return undefined for non-.tool-versions files", () => {
|
||||
const result = getUvVersionFromToolVersions("package.json");
|
||||
expect(result).toBeUndefined();
|
||||
expect(mockReadFileSync).not.toHaveBeenCalled();
|
||||
expect(mockedFs.readFileSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should return version for valid uv entry", async () => {
|
||||
it("should return version for valid uv entry", () => {
|
||||
const fileContent = "python 3.11.0\nuv 0.1.0\nnodejs 18.0.0";
|
||||
mockReadFileSync.mockReturnValue(fileContent);
|
||||
mockedFs.readFileSync.mockReturnValue(fileContent);
|
||||
|
||||
const result = await getVersionFromToolVersions(".tool-versions");
|
||||
const result = getUvVersionFromToolVersions(".tool-versions");
|
||||
|
||||
expect(result).toBe("0.1.0");
|
||||
expect(mockReadFileSync).toHaveBeenCalledWith(".tool-versions", "utf8");
|
||||
expect(mockedFs.readFileSync).toHaveBeenCalledWith(
|
||||
".tool-versions",
|
||||
"utf8",
|
||||
);
|
||||
});
|
||||
|
||||
it("should return version for uv entry with v prefix", async () => {
|
||||
it("should return version for uv entry with v prefix", () => {
|
||||
const fileContent = "uv v0.2.0";
|
||||
mockReadFileSync.mockReturnValue(fileContent);
|
||||
mockedFs.readFileSync.mockReturnValue(fileContent);
|
||||
|
||||
const result = await getVersionFromToolVersions(".tool-versions");
|
||||
const result = getUvVersionFromToolVersions(".tool-versions");
|
||||
|
||||
expect(result).toBe("0.2.0");
|
||||
});
|
||||
|
||||
it("should handle whitespace around uv entry", async () => {
|
||||
it("should handle whitespace around uv entry", () => {
|
||||
const fileContent = " uv 0.3.0 ";
|
||||
mockReadFileSync.mockReturnValue(fileContent);
|
||||
mockedFs.readFileSync.mockReturnValue(fileContent);
|
||||
|
||||
const result = await getVersionFromToolVersions(".tool-versions");
|
||||
const result = getUvVersionFromToolVersions(".tool-versions");
|
||||
|
||||
expect(result).toBe("0.3.0");
|
||||
});
|
||||
|
||||
it("should skip commented lines", async () => {
|
||||
it("should skip commented lines", () => {
|
||||
const fileContent = "# uv 0.1.0\npython 3.11.0\nuv 0.2.0";
|
||||
mockReadFileSync.mockReturnValue(fileContent);
|
||||
mockedFs.readFileSync.mockReturnValue(fileContent);
|
||||
|
||||
const result = await getVersionFromToolVersions(".tool-versions");
|
||||
const result = getUvVersionFromToolVersions(".tool-versions");
|
||||
|
||||
expect(result).toBe("0.2.0");
|
||||
});
|
||||
|
||||
it("should return first matching uv version", async () => {
|
||||
it("should return first matching uv version", () => {
|
||||
const fileContent = "uv 0.1.0\npython 3.11.0\nuv 0.2.0";
|
||||
mockReadFileSync.mockReturnValue(fileContent);
|
||||
mockedFs.readFileSync.mockReturnValue(fileContent);
|
||||
|
||||
const result = await getVersionFromToolVersions(".tool-versions");
|
||||
const result = getUvVersionFromToolVersions(".tool-versions");
|
||||
|
||||
expect(result).toBe("0.1.0");
|
||||
});
|
||||
|
||||
it("should return undefined when no uv entry found", async () => {
|
||||
it("should return undefined when no uv entry found", () => {
|
||||
const fileContent = "python 3.11.0\nnodejs 18.0.0";
|
||||
mockReadFileSync.mockReturnValue(fileContent);
|
||||
mockedFs.readFileSync.mockReturnValue(fileContent);
|
||||
|
||||
const result = await getVersionFromToolVersions(".tool-versions");
|
||||
const result = getUvVersionFromToolVersions(".tool-versions");
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should return undefined for empty file", async () => {
|
||||
mockReadFileSync.mockReturnValue("");
|
||||
it("should return undefined for empty file", () => {
|
||||
mockedFs.readFileSync.mockReturnValue("");
|
||||
|
||||
const result = await getVersionFromToolVersions(".tool-versions");
|
||||
const result = getUvVersionFromToolVersions(".tool-versions");
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should warn and return undefined for ref syntax", async () => {
|
||||
it("should warn and return undefined for ref syntax", () => {
|
||||
const fileContent = "uv ref:main";
|
||||
mockReadFileSync.mockReturnValue(fileContent);
|
||||
mockedFs.readFileSync.mockReturnValue(fileContent);
|
||||
|
||||
const result = await getVersionFromToolVersions(".tool-versions");
|
||||
const result = getUvVersionFromToolVersions(".tool-versions");
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(mockWarning).toHaveBeenCalledWith(
|
||||
expect(mockedCore.warning).toHaveBeenCalledWith(
|
||||
"The ref syntax of .tool-versions is not supported. Please use a released version instead.",
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle file path with .tool-versions extension", async () => {
|
||||
it("should handle file path with .tool-versions extension", () => {
|
||||
const fileContent = "uv 0.1.0";
|
||||
mockReadFileSync.mockReturnValue(fileContent);
|
||||
mockedFs.readFileSync.mockReturnValue(fileContent);
|
||||
|
||||
const result = await getVersionFromToolVersions("path/to/.tool-versions");
|
||||
const result = getUvVersionFromToolVersions("path/to/.tool-versions");
|
||||
|
||||
expect(result).toBe("0.1.0");
|
||||
expect(mockReadFileSync).toHaveBeenCalledWith(
|
||||
expect(mockedFs.readFileSync).toHaveBeenCalledWith(
|
||||
"path/to/.tool-versions",
|
||||
"utf8",
|
||||
);
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "@jest/globals";
|
||||
import { resolveVersionRequest } from "../../src/version/version-request-resolver";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
function createTempProject(files: Record<string, string> = {}): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "setup-uv-version-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;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
fs.rmSync(dir, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("resolveVersionRequest", () => {
|
||||
it("prefers explicit input over version-file and workspace config", () => {
|
||||
const workingDirectory = createTempProject({
|
||||
".tool-versions": "uv 0.4.0\n",
|
||||
"pyproject.toml": `[tool.uv]\nrequired-version = "==0.5.14"\n`,
|
||||
"uv.toml": `required-version = "==0.5.15"\n`,
|
||||
});
|
||||
|
||||
const request = resolveVersionRequest({
|
||||
version: "==0.6.0",
|
||||
versionFile: path.join(workingDirectory, ".tool-versions"),
|
||||
workingDirectory,
|
||||
});
|
||||
|
||||
expect(request).toEqual({
|
||||
source: "input",
|
||||
specifier: "0.6.0",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses .tool-versions when it is passed via version-file", () => {
|
||||
const workingDirectory = createTempProject({
|
||||
".tool-versions": "uv 0.5.15\n",
|
||||
});
|
||||
|
||||
const request = resolveVersionRequest({
|
||||
versionFile: path.join(workingDirectory, ".tool-versions"),
|
||||
workingDirectory,
|
||||
});
|
||||
|
||||
expect(request).toEqual({
|
||||
format: ".tool-versions",
|
||||
source: "version-file",
|
||||
sourcePath: path.join(workingDirectory, ".tool-versions"),
|
||||
specifier: "0.5.15",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses requirements.txt when it is passed via version-file", () => {
|
||||
const workingDirectory = createTempProject({
|
||||
"requirements.txt": "uv==0.6.17\nuvicorn==0.35.0\n",
|
||||
});
|
||||
|
||||
const request = resolveVersionRequest({
|
||||
versionFile: path.join(workingDirectory, "requirements.txt"),
|
||||
workingDirectory,
|
||||
});
|
||||
|
||||
expect(request).toEqual({
|
||||
format: "requirements",
|
||||
source: "version-file",
|
||||
sourcePath: path.join(workingDirectory, "requirements.txt"),
|
||||
specifier: "0.6.17",
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers uv.toml over pyproject.toml during workspace discovery", () => {
|
||||
const workingDirectory = createTempProject({
|
||||
"pyproject.toml": `[tool.uv]\nrequired-version = "==0.5.14"\n`,
|
||||
"uv.toml": `required-version = "==0.5.15"\n`,
|
||||
});
|
||||
|
||||
const request = resolveVersionRequest({ workingDirectory });
|
||||
|
||||
expect(request).toEqual({
|
||||
format: "uv.toml",
|
||||
source: "uv.toml",
|
||||
sourcePath: path.join(workingDirectory, "uv.toml"),
|
||||
specifier: "0.5.15",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to latest when no version source is found", () => {
|
||||
const workingDirectory = createTempProject({});
|
||||
|
||||
const request = resolveVersionRequest({ workingDirectory });
|
||||
|
||||
expect(request).toEqual({
|
||||
source: "default",
|
||||
specifier: "latest",
|
||||
});
|
||||
});
|
||||
|
||||
it("throws when version-file does not resolve a version", () => {
|
||||
const workingDirectory = createTempProject({
|
||||
"requirements.txt": "uvicorn==0.35.0\n",
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
resolveVersionRequest({
|
||||
versionFile: path.join(workingDirectory, "requirements.txt"),
|
||||
workingDirectory,
|
||||
}),
|
||||
).toThrow(
|
||||
`Could not determine uv version from file: ${path.join(workingDirectory, "requirements.txt")}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,79 +0,0 @@
|
||||
# See https://github.com/typesafegithub/github-actions-typing
|
||||
|
||||
inputs:
|
||||
version:
|
||||
type: string
|
||||
version-file:
|
||||
type: string
|
||||
python-version:
|
||||
type: string
|
||||
activate-environment:
|
||||
type: boolean
|
||||
venv-path:
|
||||
type: string
|
||||
no-project:
|
||||
type: boolean
|
||||
working-directory:
|
||||
type: string
|
||||
checksum:
|
||||
type: string
|
||||
github-token:
|
||||
type: string
|
||||
enable-cache:
|
||||
type: enum
|
||||
allowed-values:
|
||||
- "true"
|
||||
- "false"
|
||||
- auto
|
||||
cache-dependency-glob:
|
||||
type: list
|
||||
separator: "\n"
|
||||
list-item:
|
||||
type: string
|
||||
restore-cache:
|
||||
type: boolean
|
||||
save-cache:
|
||||
type: boolean
|
||||
cache-suffix:
|
||||
type: string
|
||||
cache-local-path:
|
||||
type: string
|
||||
prune-cache:
|
||||
type: boolean
|
||||
cache-python:
|
||||
type: boolean
|
||||
ignore-nothing-to-cache:
|
||||
type: boolean
|
||||
ignore-empty-workdir:
|
||||
type: boolean
|
||||
tool-dir:
|
||||
type: string
|
||||
tool-bin-dir:
|
||||
type: string
|
||||
manifest-file:
|
||||
type: string
|
||||
add-problem-matchers:
|
||||
type: boolean
|
||||
resolution-strategy:
|
||||
type: enum
|
||||
allowed-values:
|
||||
- highest
|
||||
- lowest
|
||||
|
||||
outputs:
|
||||
uv-version:
|
||||
type: string
|
||||
uv-path:
|
||||
type: string
|
||||
uvx-path:
|
||||
type: string
|
||||
cache-hit:
|
||||
type: boolean
|
||||
cache-key:
|
||||
type: string
|
||||
venv:
|
||||
type: string
|
||||
python-version:
|
||||
type: string
|
||||
python-cache-hit:
|
||||
type: boolean
|
||||
+10
-27
@@ -15,21 +15,19 @@ inputs:
|
||||
activate-environment:
|
||||
description: "Use uv venv to activate a venv ready to be used by later steps. "
|
||||
default: "false"
|
||||
venv-path:
|
||||
description: "Custom path for the virtual environment when using activate-environment. Defaults to '.venv' in the working directory."
|
||||
default: ""
|
||||
no-project:
|
||||
description: "Pass --no-project when creating the venv with activate-environment."
|
||||
default: "false"
|
||||
working-directory:
|
||||
description: "The directory to execute all commands in and look for files such as pyproject.toml"
|
||||
default: ${{ github.workspace }}
|
||||
checksum:
|
||||
description: "The checksum of the uv version to install"
|
||||
required: false
|
||||
server-url:
|
||||
description: "(Deprecated) The server url to use when downloading uv"
|
||||
required: false
|
||||
default: "https://github.com"
|
||||
github-token:
|
||||
description:
|
||||
"Used when downloading uv from GitHub releases."
|
||||
"Used to increase the rate limit when retrieving versions and downloading uv."
|
||||
required: false
|
||||
default: ${{ github.token }}
|
||||
enable-cache:
|
||||
@@ -37,7 +35,7 @@ inputs:
|
||||
default: "auto"
|
||||
cache-dependency-glob:
|
||||
description:
|
||||
"Glob pattern to match files relative to the working directory to control
|
||||
"Glob pattern to match files relative to the repository root to control
|
||||
the cache."
|
||||
default: |
|
||||
**/*requirements*.txt
|
||||
@@ -46,7 +44,6 @@ inputs:
|
||||
**/*constraints*.in
|
||||
**/pyproject.toml
|
||||
**/uv.lock
|
||||
**/*.py.lock
|
||||
restore-cache:
|
||||
description: "Whether to restore the cache if found."
|
||||
default: "true"
|
||||
@@ -62,9 +59,6 @@ inputs:
|
||||
prune-cache:
|
||||
description: "Prune cache before saving."
|
||||
default: "true"
|
||||
cache-python:
|
||||
description: "Upload managed Python installations to the Github Actions cache."
|
||||
default: "false"
|
||||
ignore-nothing-to-cache:
|
||||
description: "Ignore when nothing is found to cache."
|
||||
default: "false"
|
||||
@@ -78,14 +72,11 @@ 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 the manifest file containing available versions and download URLs."
|
||||
required: false
|
||||
add-problem-matchers:
|
||||
description: "Add problem matchers."
|
||||
default: "true"
|
||||
resolution-strategy:
|
||||
description: "Resolution strategy to use when resolving version ranges. 'highest' uses the latest compatible version, 'lowest' uses the oldest compatible version."
|
||||
default: "highest"
|
||||
outputs:
|
||||
uv-version:
|
||||
description: "The installed uv version. Useful when using latest."
|
||||
@@ -95,18 +86,10 @@ outputs:
|
||||
description: "The path to the installed uvx binary."
|
||||
cache-hit:
|
||||
description: "A boolean value to indicate a cache entry was found"
|
||||
cache-key:
|
||||
description: "The cache key used for storing/restoring the cache"
|
||||
venv:
|
||||
description: "Path to the activated venv if activate-environment is true"
|
||||
python-version:
|
||||
description: "The Python version that was set."
|
||||
python-cache-hit:
|
||||
description: "A boolean value to indicate the Python cache entry was found"
|
||||
runs:
|
||||
using: "node24"
|
||||
main: "dist/setup/index.cjs"
|
||||
post: "dist/save-cache/index.cjs"
|
||||
using: "node20"
|
||||
main: "dist/setup/index.js"
|
||||
post: "dist/save-cache/index.js"
|
||||
post-if: success()
|
||||
branding:
|
||||
icon: "package"
|
||||
|
||||
+2
-3
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.7/schema.json",
|
||||
"$schema": "https://biomejs.dev/schemas/2.2.4/schema.json",
|
||||
"assist": {
|
||||
"actions": {
|
||||
"source": {
|
||||
@@ -18,8 +18,7 @@
|
||||
"!**/node_modules",
|
||||
"!**/package*.json",
|
||||
"!**/known-checksums.*"
|
||||
],
|
||||
"maxSize": 2097152
|
||||
]
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
|
||||
-63368
File diff suppressed because one or more lines are too long
+92334
File diff suppressed because one or more lines are too long
-97583
File diff suppressed because one or more lines are too long
+137121
File diff suppressed because one or more lines are too long
-49830
File diff suppressed because one or more lines are too long
+71834
File diff suppressed because one or more lines are too long
@@ -1,82 +0,0 @@
|
||||
# Advanced Version Configuration
|
||||
|
||||
This document covers advanced options for configuring which version of uv to install.
|
||||
|
||||
## Install the latest version
|
||||
|
||||
```yaml
|
||||
- name: Install the latest version of uv
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
version: "latest"
|
||||
```
|
||||
|
||||
## Install a specific version
|
||||
|
||||
```yaml
|
||||
- name: Install a specific version of uv
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
version: "0.4.4"
|
||||
```
|
||||
|
||||
## Install a version by supplying a semver range or pep440 specifier
|
||||
|
||||
You can specify a [semver range](https://github.com/npm/node-semver?tab=readme-ov-file#ranges)
|
||||
or [pep440 specifier](https://peps.python.org/pep-0440/#version-specifiers)
|
||||
to install the latest version that satisfies the range.
|
||||
|
||||
```yaml
|
||||
- name: Install a semver range of uv
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
version: ">=0.4.0"
|
||||
```
|
||||
|
||||
```yaml
|
||||
- name: Pinning a minor version of uv
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
version: "0.4.x"
|
||||
```
|
||||
|
||||
```yaml
|
||||
- name: Install a pep440-specifier-satisfying version of uv
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
version: ">=0.4.25,<0.5"
|
||||
```
|
||||
|
||||
## Resolution strategy
|
||||
|
||||
By default, when resolving version ranges, setup-uv will install the highest compatible version.
|
||||
You can change this behavior using the `resolution-strategy` input:
|
||||
|
||||
```yaml
|
||||
- name: Install the lowest compatible version of uv
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
version: ">=0.4.0"
|
||||
resolution-strategy: "lowest"
|
||||
```
|
||||
|
||||
The supported resolution strategies are:
|
||||
- `highest` (default): Install the latest version that satisfies the constraints
|
||||
- `lowest`: Install the oldest version that satisfies the constraints
|
||||
|
||||
This can be useful for testing compatibility with older versions of uv, similar to uv's own `--resolution-strategy` option.
|
||||
|
||||
## Install a version defined in a requirements or config file
|
||||
|
||||
You can use the `version-file` input to specify a file that contains the version of uv to install.
|
||||
This can either be a `pyproject.toml` or `uv.toml` file which defines a `required-version` or
|
||||
uv defined as a dependency in `pyproject.toml` or `requirements.txt`.
|
||||
|
||||
[asdf](https://asdf-vm.com/) `.tool-versions` is also supported, but without the `ref` syntax.
|
||||
|
||||
```yaml
|
||||
- name: Install uv based on the version defined in pyproject.toml
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
version-file: "pyproject.toml"
|
||||
```
|
||||
-230
@@ -1,230 +0,0 @@
|
||||
# Caching
|
||||
|
||||
This document covers all caching-related configuration options for setup-uv.
|
||||
|
||||
## Cache key
|
||||
|
||||
The cache key is automatically generated based on:
|
||||
|
||||
- **Architecture**: CPU architecture (e.g., `x86_64`, `aarch64`)
|
||||
- **Platform**: OS platform type (e.g., `unknown-linux-gnu`, `unknown-linux-musl`, `apple-darwin`,
|
||||
`pc-windows-msvc`)
|
||||
- **OS version**: OS name and version (e.g., `ubuntu-22.04`, `macos-14`, `windows-2022`)
|
||||
- **Python version**: The Python version in use
|
||||
- **Cache options**: Whether pruning and Python caching are enabled
|
||||
- **Dependency hash**: Hash of files matching `cache-dependency-glob`
|
||||
- **Suffix**: Optional `cache-suffix` if provided
|
||||
|
||||
Including the OS version ensures that caches are not shared between different OS versions,
|
||||
preventing binary incompatibility issues when runner images change.
|
||||
|
||||
The computed cache key is available as the `cache-key` output:
|
||||
|
||||
```yaml
|
||||
- name: Setup uv
|
||||
id: setup-uv
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
- name: Print cache key
|
||||
run: echo "Cache key: ${{ steps.setup-uv.outputs.cache-key }}"
|
||||
```
|
||||
|
||||
## Enable caching
|
||||
|
||||
> [!NOTE]
|
||||
> The cache is pruned before it is uploaded to the GitHub Actions cache. This can lead to
|
||||
> a small or empty cache. See [Disable cache pruning](#disable-cache-pruning) for more details.
|
||||
|
||||
If you enable caching, the [uv cache](https://docs.astral.sh/uv/concepts/cache/) will be uploaded to
|
||||
the GitHub Actions cache. This can speed up runs that reuse the cache by several minutes.
|
||||
Caching is enabled by default on GitHub-hosted runners.
|
||||
|
||||
> [!TIP]
|
||||
>
|
||||
> On self-hosted runners this is usually not needed since the cache generated by uv on the runner's
|
||||
> filesystem is not removed after a run. For more details see [Local cache path](#local-cache-path).
|
||||
|
||||
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@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-suffix: "optional-suffix"
|
||||
```
|
||||
|
||||
When the cache was successfully restored, the output `cache-hit` will be set to `true` and you can
|
||||
use it in subsequent steps. For example, to use the cache in the above case:
|
||||
|
||||
```yaml
|
||||
- name: Do something if the cache was restored
|
||||
if: steps.setup-uv.outputs.cache-hit == 'true'
|
||||
run: echo "Cache was restored"
|
||||
```
|
||||
|
||||
## Cache dependency glob
|
||||
|
||||
If you want to control when the GitHub Actions cache is invalidated, specify a glob pattern with the
|
||||
`cache-dependency-glob` input. The GitHub Actions cache will be invalidated if any file matching the glob pattern
|
||||
changes. If you use relative paths, they are relative to the working directory.
|
||||
|
||||
> [!NOTE]
|
||||
>
|
||||
> You can look up supported patterns [here](https://github.com/actions/toolkit/tree/main/packages/glob#patterns)
|
||||
>
|
||||
> The default is
|
||||
> ```yaml
|
||||
> cache-dependency-glob: |
|
||||
> **/*requirements*.txt
|
||||
> **/*requirements*.in
|
||||
> **/*constraints*.txt
|
||||
> **/*constraints*.in
|
||||
> **/pyproject.toml
|
||||
> **/uv.lock
|
||||
> **/*.py.lock
|
||||
> ```
|
||||
|
||||
```yaml
|
||||
- name: Define a cache dependency glob
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: "**/pyproject.toml"
|
||||
```
|
||||
|
||||
```yaml
|
||||
- name: Define a list of cache dependency globs
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: |
|
||||
**/requirements*.txt
|
||||
**/pyproject.toml
|
||||
```
|
||||
|
||||
```yaml
|
||||
- name: Define an absolute cache dependency glob
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: "/tmp/my-folder/requirements*.txt"
|
||||
```
|
||||
|
||||
```yaml
|
||||
- name: Never invalidate the cache
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: ""
|
||||
```
|
||||
|
||||
## Restore cache
|
||||
|
||||
Restoring an existing cache can be enabled or disabled with the `restore-cache` input.
|
||||
By default, the cache will be restored.
|
||||
|
||||
```yaml
|
||||
- name: Don't restore an existing cache
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
restore-cache: false
|
||||
```
|
||||
|
||||
## Save cache
|
||||
|
||||
You can also disable saving the cache after the run with the `save-cache` input.
|
||||
This can be useful to save cache storage when you know you will not use the cache of the run again.
|
||||
By default, the cache will be saved.
|
||||
|
||||
```yaml
|
||||
- name: Don't save the cache after the run
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
save-cache: false
|
||||
```
|
||||
|
||||
## Local cache path
|
||||
|
||||
If caching is enabled, this action controls where uv stores its cache on the runner's filesystem
|
||||
by setting `UV_CACHE_DIR`.
|
||||
|
||||
It defaults to `setup-uv-cache` in the `TMP` dir, `D:\a\_temp\setup-uv-cache` on Windows and
|
||||
`/tmp/setup-uv-cache` on Linux/macOS. You can change the default by specifying the path with the
|
||||
`cache-local-path` input.
|
||||
|
||||
> [!NOTE]
|
||||
> If the environment variable `UV_CACHE_DIR` is already set this action will not override it.
|
||||
> If you configured [cache-dir](https://docs.astral.sh/uv/reference/settings/#cache-dir) in your
|
||||
> config file then it is also respected and this action will not set `UV_CACHE_DIR`.
|
||||
|
||||
> [!NOTE]
|
||||
> If caching is disabled, you can still use `cache-local-path` so this action sets `UV_CACHE_DIR`
|
||||
> to your desired path.
|
||||
|
||||
```yaml
|
||||
- name: Define a custom uv cache path
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
cache-local-path: "/path/to/cache"
|
||||
```
|
||||
|
||||
## Disable cache pruning
|
||||
|
||||
By default, the uv cache is pruned after every run, removing pre-built wheels, but retaining any
|
||||
wheels that were built from source. On GitHub-hosted runners, it's typically faster to omit those
|
||||
pre-built wheels from the cache (and instead re-download them from the registry on each run).
|
||||
However, on self-hosted or local runners, preserving the cache may be more efficient. See
|
||||
the [documentation](https://docs.astral.sh/uv/concepts/cache/#caching-in-continuous-integration) for
|
||||
more information.
|
||||
|
||||
If you want to persist the entire cache across runs, disable cache pruning with the `prune-cache`
|
||||
input.
|
||||
|
||||
```yaml
|
||||
- name: Don't prune the cache before saving it
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
prune-cache: false
|
||||
```
|
||||
|
||||
## Cache Python installs
|
||||
|
||||
By default, the Python install dir (`uv python dir` / `UV_PYTHON_INSTALL_DIR`) is not cached,
|
||||
for the same reason that the dependency cache is pruned.
|
||||
If you want to cache Python installs along with your dependencies, set the `cache-python` input to `true`.
|
||||
|
||||
Note that this only caches Python versions that uv actually installs into `UV_PYTHON_INSTALL_DIR`
|
||||
(i.e. managed Python installs). If uv uses a system Python, there may be nothing to cache.
|
||||
To force managed Python installs, set `UV_PYTHON_PREFERENCE=only-managed`.
|
||||
|
||||
```yaml
|
||||
- name: Cache Python installs
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-python: true
|
||||
```
|
||||
|
||||
## 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.
|
||||
```
|
||||
|
||||
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@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
ignore-nothing-to-cache: true
|
||||
```
|
||||
@@ -1,64 +0,0 @@
|
||||
# Customization
|
||||
|
||||
This document covers advanced customization options including checksum validation, custom manifests, and problem matchers.
|
||||
|
||||
## Validate checksum
|
||||
|
||||
You can specify a checksum to validate the downloaded executable. Checksums up to the default version
|
||||
are automatically verified by this action. The sha256 hashes can be found on the
|
||||
[releases page](https://github.com/astral-sh/uv/releases) of the uv repo.
|
||||
|
||||
```yaml
|
||||
- name: Install a specific version and validate the checksum
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
version: "0.3.1"
|
||||
checksum: "e11b01402ab645392c7ad6044db63d37e4fd1e745e015306993b07695ea5f9f8"
|
||||
```
|
||||
|
||||
## Manifest file
|
||||
|
||||
By default, setup-uv reads version metadata from
|
||||
[`astral-sh/versions`](https://github.com/astral-sh/versions).
|
||||
|
||||
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:
|
||||
|
||||
```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":"..."}]}
|
||||
{"version":"0.10.6","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":"..."}]}
|
||||
```
|
||||
|
||||
setup-uv currently only supports `default` as the `variant`.
|
||||
|
||||
The `archive_format` field is currently ignored.
|
||||
|
||||
```yaml
|
||||
- name: Use a custom manifest file
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
manifest-file: "https://example.com/my-custom-manifest.ndjson"
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> When you use a custom manifest file and do not set the `version` input, setup-uv installs the
|
||||
> latest version from that custom manifest.
|
||||
|
||||
## Add problem matchers
|
||||
|
||||
This action automatically adds
|
||||
[problem matchers](https://github.com/actions/toolkit/blob/main/docs/problem-matchers.md)
|
||||
for python errors.
|
||||
|
||||
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@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
add-problem-matchers: false
|
||||
```
|
||||
@@ -1,160 +0,0 @@
|
||||
# Environment and Tools
|
||||
|
||||
This document covers environment activation, tool directory configuration, and authentication options.
|
||||
|
||||
## Activate environment
|
||||
|
||||
You can set `activate-environment` to `true` to automatically activate a venv.
|
||||
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@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
activate-environment: true
|
||||
- run: uv pip install pip
|
||||
```
|
||||
|
||||
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@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
activate-environment: true
|
||||
venv-path: ${{ runner.temp }}/custom-venv
|
||||
```
|
||||
|
||||
> [!WARNING]
|
||||
>
|
||||
> Activating the environment adds your dependencies to the `PATH`, which could break some workflows.
|
||||
> For example, if you have a dependency which requires uv, e.g., `hatch`, activating the
|
||||
> environment will shadow the `uv` binary installed by this action and may result in a different uv
|
||||
> version being used.
|
||||
>
|
||||
> We do not recommend using this setting for most use-cases. Instead, use `uv run` to execute
|
||||
> commands in the environment.
|
||||
|
||||
## GitHub authentication token
|
||||
|
||||
By default, this action resolves available uv versions from
|
||||
[`astral-sh/versions`](https://github.com/astral-sh/versions), then downloads uv artifacts from
|
||||
GitHub Releases.
|
||||
|
||||
You can provide a token via `github-token` to authenticate those downloads. By default, the
|
||||
`GITHUB_TOKEN` secret is used, which is automatically provided by GitHub Actions.
|
||||
|
||||
If the default
|
||||
[permissions for the GitHub token](https://docs.github.com/en/actions/security-for-github-actions/security-guides/automatic-token-authentication#permissions-for-the-github_token)
|
||||
are not sufficient, you can provide a custom GitHub token with the necessary permissions.
|
||||
|
||||
```yaml
|
||||
- name: Install the latest version of uv with a custom GitHub token
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
github-token: ${{ secrets.CUSTOM_GITHUB_TOKEN }}
|
||||
```
|
||||
|
||||
## UV_TOOL_DIR
|
||||
|
||||
On Windows `UV_TOOL_DIR` is set to `uv-tool-dir` in the `TMP` dir (e.g. `D:\a\_temp\uv-tool-dir`).
|
||||
On GitHub hosted runners this is on the much faster `D:` drive.
|
||||
|
||||
On all other platforms the tool environments are placed in the
|
||||
[default location](https://docs.astral.sh/uv/concepts/tools/#tools-directory).
|
||||
|
||||
If you want to change this behaviour (especially on self-hosted runners) you can use the `tool-dir`
|
||||
input:
|
||||
|
||||
```yaml
|
||||
- name: Install the latest version of uv with a custom tool dir
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
tool-dir: "/path/to/tool/dir"
|
||||
```
|
||||
|
||||
## UV_TOOL_BIN_DIR
|
||||
|
||||
On Windows `UV_TOOL_BIN_DIR` is set to `uv-tool-bin-dir` in the `TMP` dir (e.g.
|
||||
`D:\a\_temp\uv-tool-bin-dir`). On GitHub hosted runners this is on the much faster `D:` drive. This
|
||||
path is also automatically added to the PATH.
|
||||
|
||||
On all other platforms the tool binaries get installed to the
|
||||
[default location](https://docs.astral.sh/uv/concepts/tools/#the-bin-directory).
|
||||
|
||||
If you want to change this behaviour (especially on self-hosted runners) you can use the
|
||||
`tool-bin-dir` input:
|
||||
|
||||
```yaml
|
||||
- name: Install the latest version of uv with a custom tool bin dir
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
tool-bin-dir: "/path/to/tool-bin/dir"
|
||||
```
|
||||
|
||||
## Tilde Expansion
|
||||
|
||||
This action supports expanding the `~` character to the user's home directory for the following inputs:
|
||||
|
||||
- `version-file`
|
||||
- `cache-local-path`
|
||||
- `tool-dir`
|
||||
- `tool-bin-dir`
|
||||
- `cache-dependency-glob`
|
||||
|
||||
```yaml
|
||||
- name: Expand the tilde character
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
cache-local-path: "~/path/to/cache"
|
||||
tool-dir: "~/path/to/tool/dir"
|
||||
tool-bin-dir: "~/path/to/tool-bin/dir"
|
||||
cache-dependency-glob: "~/my-cache-buster"
|
||||
```
|
||||
|
||||
## Ignore empty workdir
|
||||
|
||||
By default, the action will warn if the workdir is empty, because this is usually the case when
|
||||
`actions/checkout` is configured to run after `setup-uv`, which is not supported.
|
||||
|
||||
If you want to ignore this, set the `ignore-empty-workdir` input to `true`.
|
||||
|
||||
```yaml
|
||||
- name: Ignore empty workdir
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
ignore-empty-workdir: true
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
This action sets several environment variables that influence uv's behavior and can be used by subsequent steps:
|
||||
|
||||
- `UV_PYTHON`: Set when `python-version` input is specified. Controls which Python version uv uses.
|
||||
- `UV_CACHE_DIR`: Set when caching is enabled (unless already configured in uv config files). Controls where uv stores its cache.
|
||||
- `UV_TOOL_DIR`: Set when `tool-dir` input is specified. Controls where uv installs tool environments.
|
||||
- `UV_TOOL_BIN_DIR`: Set when `tool-bin-dir` input is specified. Controls where uv installs tool binaries.
|
||||
- `UV_PYTHON_INSTALL_DIR`: Always set. Controls where uv installs Python versions.
|
||||
- `VIRTUAL_ENV`: Set when `activate-environment` is true. Points to the activated virtual environment.
|
||||
|
||||
**Environment variables that affect the action behavior:**
|
||||
|
||||
- `UV_NO_MODIFY_PATH`: If set, prevents the action from modifying PATH. Cannot be used with `activate-environment`.
|
||||
- `UV_CACHE_DIR`: If already set, the action will respect it instead of setting its own cache directory.
|
||||
|
||||
```yaml
|
||||
- name: Example using environment variables
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
tool-dir: "/custom/tool/dir"
|
||||
enable-cache: true
|
||||
|
||||
- name: Check environment variables
|
||||
run: |
|
||||
echo "UV_PYTHON: $UV_PYTHON"
|
||||
echo "UV_CACHE_DIR: $UV_CACHE_DIR"
|
||||
echo "UV_TOOL_DIR: $UV_TOOL_DIR"
|
||||
echo "UV_PYTHON_INSTALL_DIR: $UV_PYTHON_INSTALL_DIR"
|
||||
```
|
||||
@@ -1,81 +0,0 @@
|
||||
# setup-uv Repository Threat Model
|
||||
|
||||
## Overview
|
||||
|
||||
`setup-uv` is a GitHub Action that installs or reuses `uv`, changes later-step paths and environment, may discover and execute a Python interpreter, may create or clear a virtual environment, and may restore or save caches. It runs with the workflow job's filesystem, network, token, secrets, OIDC, artifact, and release authority.
|
||||
|
||||
The consumer runtime is the selected ref's committed action metadata, bundles, and runner-interpreted companion files; source alone is not evidence of shipped behavior. Privileged automation that generates, updates, or publishes those artifacts is also in scope.
|
||||
|
||||
The assets are job credentials; integrity of installed executables, interpreter, environment, checkout, runner, artifacts, and caches; isolation between jobs sharing caches or persistent runners; integrity of published action refs; and workflow compute/storage availability.
|
||||
|
||||
Material failures are unauthorized executable selection, credential disclosure, premature execution of lower-authority content, filesystem escape or destructive path use, cross-authority cache/runner persistence, and unauthorized publication.
|
||||
|
||||
## Threat Model, Trust Boundaries, and Assumptions
|
||||
|
||||
### Authority and trust boundaries
|
||||
|
||||
| Actor or input | Trust decision |
|
||||
|---|---|
|
||||
| Maintainers, repository/configuration administrators, and GitHub infrastructure | Trusted roots for source, bundles, workflows, refs, rulesets, environments, runner protocol, hosted isolation, and cache service. A lower-authority path into these roots is in scope; their compromise alone is not a repository bug. |
|
||||
| Consumer workflow authors and runner operators | Control the action ref, trigger, runner, permissions, secrets, proxy, environment, inputs, paths, globs, and custom sources. These are trusted choices unless derived from lower-authority event data. Selecting a custom manifest delegates metadata and executable authority; selecting a path authorizes normal operations on it and intended referents. |
|
||||
| Selected checkout, project authors, and pull-request contributors | The consumer delegates project/version files, interpreter discovery state, virtual environments, symlinks, cache inputs, and code execution within `setup-uv`'s process environment. Checkout-controlled behavior is trusted unless it overrides an explicit workflow choice or crosses an independent cache, runner, remote, or publication boundary. |
|
||||
| Remote metadata and artifacts | Default official endpoints, TLS roots, and an operator proxy are trusted mutable authorities. A custom manifest authorizes its URLs and hashes; a hash supplied by that same authority detects corruption, not malice. |
|
||||
| Cache and runner-state producers/consumers | Same-principal state is trusted by default. Integrity attacks require a lower-authority producer and higher-authority consumer. Confidentiality can flow the opposite way because lower-authority refs may read eligible higher-authority caches. Shared self-hosted state creates a boundary only when principals and authority differ. |
|
||||
| GitHub-managed automation | Dependency, coding-agent, and review workflows may exist outside the committed tree. Treat them as external principals and obtain their effective trigger, actor, token, environment, ref, and write/secret authority from live evidence. |
|
||||
|
||||
### Assumptions
|
||||
|
||||
- Running the selected `uv` and checkout-selected Python interpreters is intended. Project execution is out of scope unless it bypasses an explicit workflow choice or crosses an independent cache, runner, remote, or publication boundary.
|
||||
- Mutable official manifests, ranges, `latest`, and unprotected refs are not attacker control. A protected ref or independent checksum matters only if the selected bundle actually enforces it.
|
||||
- Same-user changes to paths, environment, proxies, or tool/cache state are not separate attacks. Demonstrate a cross-principal or lower-to-higher boundary.
|
||||
- Content merged through a trust path that can also merge executable code is not a lower-authority source; require a narrower writer or post-review mutation path.
|
||||
- Running `setup-uv` on an untrusted checkout with higher authority is a consumer trust decision; checkout-selected code may inherit the action environment.
|
||||
- Authorized paths include expected symlink/junction referents. Absolute paths and paths outside the workspace are supported; an escape requires independent control crossing an unauthorized boundary.
|
||||
- Hosted runners are assumed ephemeral and isolated. Persistence or hostile co-tenancy on self-hosted runners must be demonstrated.
|
||||
- Branch/tag rules, environments, token defaults, cache visibility, fork policy, dynamic workflows, and runner allocation are external state. Re-query required approvals/checks, bypass actors, tag movement, deployment reviewers/principals, release targets, and effective permissions for each attack path.
|
||||
- Web-application classes such as sessions, CSRF, XSS, SQL injection, and tenant isolation are not applicable.
|
||||
|
||||
### Security invariants
|
||||
|
||||
1. **Published runtime:** review `action.yml`, committed `dist/*.cjs`, and runner-interpreted shipped files; source-only fixes do not protect consumers.
|
||||
2. **Executable identity:** precedence is workflow version, version file, project configuration, then `latest`. Manifest authority, platform, variant, URL, checksum, mirror fallback, extraction, and cache placement must bind the intended artifact. A tool-cache hit bypasses download validation and depends on cache provenance.
|
||||
3. **Credential recipients:** tokens and URL credentials may reach only workflow-authorized origins, redirects, paths, and logs. Metadata authority does not imply token-recipient authority.
|
||||
4. **Executable boundaries:** checkout-selected interpreters are authorized by default. Explicit workflow selections must win, and independent cache, runner, or remote state must not substitute executables or gain additional authority.
|
||||
5. **Paths and action channels:** path/environment changes, virtual-environment clearing, outputs, state, and problem matchers must affect only authorized targets and keep untrusted values as data.
|
||||
6. **Cache boundaries:** keys, scope, restore paths, and executable content must prevent lower-to-higher poisoning; cache contents and post-action path re-resolution must prevent higher-to-lower disclosure, destructive pruning, or persistence.
|
||||
7. **Workflow and release authority:** unreviewed code or mutable tooling must not acquire write, secret, OIDC, artifact, deployment, tag, or publication authority. Only the intended reviewed bundles and commit may be released.
|
||||
8. **Availability:** independently controlled manifests, archives, globs, traversal, and caches must stay within the accepted one-job resource-failure model.
|
||||
|
||||
### Finding gate
|
||||
|
||||
Before reporting, identify the attacker and victim principals; exact controlled input; scanned action and checkout refs; runtime reachability in committed bundles; effective token, secrets/OIDC, environment gates, cache scope, and runner persistence; applicable defaults and opt-ins; validation performed or skipped; declared trust roots; baseline versus incremental capability; and concrete impact. Reproduce platform-specific behavior and distinguish the scanned ref from other versions.
|
||||
|
||||
Missing independent attacker control, a violated guarantee, committed-runtime reachability, incremental capability, or practical impact is `NOT_APPLICABLE`, `INTENDED_BEHAVIOR`, `CORRECTNESS`, `DEFENSE_IN_DEPTH`, or `NEEDS_EVIDENCE`, not a security severity.
|
||||
|
||||
## Attack Surface, Mitigations, and Attacker Stories
|
||||
|
||||
| Surface | Security-relevant behavior and controls | Reportable attacker story |
|
||||
|---|---|---|
|
||||
| Published action and build/release supply chain | Consumers execute committed bundles and embedded dependencies. Verify source/bundle alignment, lockfile integrity, dependency-install policy, reproducible/generated-diff checks, immutable action pins, branch enforcement, and publication target checks. | A lower-authority contributor or dependency changes shipped code, or release automation publishes a different commit, by bypassing an effective review, branch, or release control. |
|
||||
| Version, manifest, proxy, and network selection | Project files may select an official version by documented precedence. Custom manifests may select URLs, hashes, variants, and platforms and may reach arbitrary network locations. Parsing should reject malformed, ambiguous, unsupported, or incorrectly typed records; verify HTTPS, time/size bounds, proxy behavior, and selected-ref defaults. | Lower-authority event/project data violates a promised fixed version, escapes the selected manifest, probes runner-only services, causes material resource use, selects attacker bytes, or redirects later credentials. Operator selection of a custom authority is not itself a finding. |
|
||||
| Artifact URL, token, checksum, extraction, and tool cache | Mirror fallback must preserve identity and checksum policy. Origin gating should restrict tokens; redirect handling should strip authorization across unauthorized hosts and reject downgrade. Verify checksum precedence and reject missing/empty hashes when policy requires validation. Independent hashes must precede extraction. Native helpers come from `PATH`; tool-cache hits skip network/hash validation. | An attacker receives a usable token outside delegated authority, bypasses an independent pin, exploits archive/link traversal, substitutes the cached executable, or poisons shared tool state later executed with higher authority. Same-authority manifest hashes and same-user cache changes do not establish the boundary. |
|
||||
| Interpreter, PATH, virtual environment, and action channels | Checkout-selected interpreters, virtual environments, paths, symlinks, and helpers are delegated project authority. Explicit workflow choices must bind; the action also changes later-step paths/environment, emits state/outputs, invokes native helpers, and consumes cache/runner state. | Independent cache, runner, or remote content substitutes an executable; an explicit workflow choice is bypassed; or action channels cross an authority boundary. Same-checkout interpreter, path, and helper effects are not findings. |
|
||||
| GitHub uv/Python caches and post action | Cache keys should partition platform, interpreter, dependency, and policy state and restore without unsafe fallback. Determine cache defaults, visibility, and the exact hit/miss path from the selected ref and GitHub policy; an exact hit may suppress post save/prune. Post processing re-reads inputs/config/environment and may save re-resolved uv or Python paths. | A lower producer supplies executable content to a higher consumer; a higher producer exposes private data to a lower cache reader; or a later successful step retargets a cache miss toward sensitive files, destructive pruning, or cross-job persistence. Existing equal-authority code with the same secrets often gains no new confidentiality. |
|
||||
| CI, updater, dynamic automation, and release workflows | PR workflows intentionally execute contributor code. Verify effective permissions, fork behavior, credential persistence, mutable tooling, security-upload authority, and whether checks are required. Updaters convert remote data into source under write authority. Distinguish ruleset-required deployment from human review present only in a workflow DAG. | Unreviewed code gains write/secret/OIDC/artifact authority; remote metadata becomes executable generated source; a dynamic workflow has unexpected authority; or an actor satisfies a deployment/tag rule without the intended review and publishes a malicious ref. |
|
||||
| Availability and logging | Manifests, version enumeration, archives, globs, hashing, caches, and remote strings can consume resources or influence logs. Verify size/count/expansion bounds, timeouts, retries, top-level error handling, and that parsing never executes data. | Independently controlled input causes reliable material workflow cost, disk/memory exhaustion, or meaningful log/output manipulation. A bounded one-job failure or operator-selected broad input is usually Low or correctness. |
|
||||
| Lower-priority classes | Shell injection is constrained where child execution uses argv, but workflow shell blocks still require quoting review. Prototype pollution requires a dangerous merge/sink. Secret-shaped strings require proof of a genuine usable secret. Documentation drift, range surprises, malformed trusted config, and test-only code normally lack a security boundary. | Report only when a concrete lower-authority value reaches an execution, credential, persistent-state, publication, or material-availability sink. |
|
||||
|
||||
## Severity Calibration (Critical, High, Medium, Low)
|
||||
|
||||
Severity follows the complete attack graph and incremental capability, not the presence of words such as token, checksum, cache, manifest, archive, Python, PATH, release, or OIDC.
|
||||
|
||||
| Severity | Threshold | Representative examples |
|
||||
|---|---|---|
|
||||
| **Critical** | A low-prerequisite remote/lower-authority attacker compromises default distribution or installation across many consumers, publishes trusted malicious action artifacts, or gains broad credentials/runner control under safe defaults without first compromising a declared trust root. | Bypass an effective hash/origin control to distribute an automatically executed malicious binary at scale; reach publication authority to ship malicious bundles or move trusted refs without required approval; exploit default-accepted archive content for host overwrite or cross-job execution across hosted runners. |
|
||||
| **High** | A demonstrated lower-authority input crosses an execution, confidentiality, integrity, or persistence boundary in a privileged job and gains substantial capability. | Independent shared-state interpreter substitution in a write/OIDC release job; shared cache poisoning later executed with secrets; high-value cache disclosure to an untrusted ref; usable write-token disclosure; independent-pin bypass; archive/cache escape into sensitive state. |
|
||||
| **Medium** | A real but constrained crossing causes limited credential/filesystem impact, reliable remote denial of service, scoped persistence, or premature execution in a realistic uncommon configuration. | Limited executable substitution from independent cache/runner state in a read-only job; same-repository cache confusion or disclosure; reliable hosted-runner exhaustion; disclosure of a usable read-only private token; output manipulation without publication or high-value credentials. |
|
||||
| **Low** | A genuine weak boundary causes narrow disclosure, log/annotation spoofing, defense-in-depth weakness, exotic cache aliasing without a privileged consumer, or limited waste. | Confusing logs with no execution effect; bounded job failure; limited overwrite of nonexecuted cache data; disclosure of a path/URL without private data or follow-on capability. |
|
||||
|
||||
Trust-root compromise may have Critical impact but is not a repository Critical without a lower-authority path into that root or an independent control that should have survived. High requires exact trigger, refs, effective authority, sink, and committed runtime; it cannot rely only on a trusted operator choosing malicious inputs, same-user state changes, or code already intentionally executed with equal authority. A separate privileged consumer, broad secret, persistent trusted state, publication path, or cross-repository boundary can raise Medium to High.
|
||||
|
||||
Normally non-reportable without additional evidence: expected mutability of ranges, `latest`, official/custom sources, or unprotected refs; documented project version selection; checkout-selected interpreters, paths, virtual environments, symlinks, and helpers; deliberate operator selection of manifests, proxies, checksums, or paths; same-principal cache/path changes; requested `uv` or dependency execution; trusted-runner `PATH` lookup; test/developer-only code without a shipped or privileged-workflow path; behavior fixed in the scanned ref; and correctness/compatibility/documentation issues without incremental confidentiality, integrity, persistence, or availability impact.
|
||||
@@ -0,0 +1,9 @@
|
||||
module.exports = {
|
||||
clearMocks: true,
|
||||
moduleFileExtensions: ["js", "ts"],
|
||||
testMatch: ["**/*.test.ts"],
|
||||
transform: {
|
||||
"^.+\\.ts$": "ts-jest",
|
||||
},
|
||||
verbose: true,
|
||||
};
|
||||
@@ -1,14 +0,0 @@
|
||||
import { createDefaultEsmPreset } from "ts-jest";
|
||||
|
||||
const esmPreset = createDefaultEsmPreset({
|
||||
tsconfig: "./tsconfig.json",
|
||||
});
|
||||
|
||||
export default {
|
||||
...esmPreset,
|
||||
clearMocks: true,
|
||||
moduleFileExtensions: ["js", "mjs", "ts"],
|
||||
testEnvironment: "node",
|
||||
testMatch: ["**/*.test.ts"],
|
||||
verbose: true,
|
||||
};
|
||||
Generated
+2416
-2164
File diff suppressed because it is too large
Load Diff
+26
-31
@@ -2,52 +2,47 @@
|
||||
"name": "setup-uv",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Set up your GitHub Actions workflow with a specific version of uv",
|
||||
"main": "dist/setup/index.cjs",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"build": "tsc --noEmit",
|
||||
"build": "tsc",
|
||||
"check": "biome check --write",
|
||||
"package": "node scripts/build-dist.mjs",
|
||||
"test:unit": "node --experimental-vm-modules ./node_modules/jest/bin/jest.js",
|
||||
"test": "npm run build && npm run test:unit",
|
||||
"package": "ncc build -o dist/setup src/setup-uv.ts && ncc build -o dist/save-cache src/save-cache.ts && ncc build -o dist/update-known-versions src/update-known-versions.ts",
|
||||
"test": "jest",
|
||||
"act": "act pull_request -W .github/workflows/test.yml --container-architecture linux/amd64 -s GITHUB_TOKEN=\"$(gh auth token)\"",
|
||||
"update-known-checksums": "RUNNER_TEMP=known_versions node dist/update-known-checksums/index.cjs src/download/checksum/known-checksums.ts",
|
||||
"all": "npm run build && npm run check && npm run package && npm run test:unit"
|
||||
"update-known-versions": "RUNNER_TEMP=known_versions node dist/update-known-versions/index.js src/download/checksum/known-versions.ts \"$(gh auth token)\"",
|
||||
"all": "npm run build && npm run check && npm run package && npm test"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/astral-sh/setup-uv.git"
|
||||
},
|
||||
"keywords": [
|
||||
"actions",
|
||||
"python",
|
||||
"setup",
|
||||
"uv"
|
||||
],
|
||||
"keywords": ["actions", "python", "setup", "uv"],
|
||||
"author": "@eifinger",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@actions/cache": "^6.0.0",
|
||||
"@actions/core": "^3.0.0",
|
||||
"@actions/exec": "^3.0.0",
|
||||
"@actions/glob": "^0.6.1",
|
||||
"@actions/io": "^3.0.2",
|
||||
"@actions/tool-cache": "^4.0.0",
|
||||
"@renovatebot/pep440": "^4.2.2",
|
||||
"smol-toml": "^1.6.0",
|
||||
"undici": "^7.24.2"
|
||||
"@actions/cache": "^4.0.5",
|
||||
"@actions/core": "^1.11.1",
|
||||
"@actions/exec": "^1.1.1",
|
||||
"@actions/glob": "^0.5.0",
|
||||
"@actions/io": "^1.1.3",
|
||||
"@actions/tool-cache": "^2.0.2",
|
||||
"@octokit/core": "^7.0.3",
|
||||
"@octokit/plugin-paginate-rest": "^13.1.1",
|
||||
"@octokit/plugin-rest-endpoint-methods": "^16.0.0",
|
||||
"@renovatebot/pep440": "^4.2.0",
|
||||
"smol-toml": "^1.4.2",
|
||||
"undici": "^7.16.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.7",
|
||||
"@biomejs/biome": "2.2.4",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "^25.5.0",
|
||||
"@types/node": "^24.3.1",
|
||||
"@types/semver": "^7.7.1",
|
||||
"@vercel/ncc": "^0.38.4",
|
||||
"esbuild": "^0.27.4",
|
||||
"jest": "^30.3.0",
|
||||
"js-yaml": "^4.1.1",
|
||||
"ts-jest": "^29.4.6",
|
||||
"typescript": "^5.9.3"
|
||||
"@vercel/ncc": "^0.38.3",
|
||||
"jest": "^30.1.3",
|
||||
"js-yaml": "^4.1.0",
|
||||
"ts-jest": "^29.4.1",
|
||||
"typescript": "^5.9.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { rm } from "node:fs/promises";
|
||||
import { build } from "esbuild";
|
||||
|
||||
const builds = [
|
||||
{
|
||||
entryPoints: ["src/setup-uv.ts"],
|
||||
outfile: "dist/setup/index.cjs",
|
||||
staleOutfiles: ["dist/setup/index.mjs"],
|
||||
},
|
||||
{
|
||||
entryPoints: ["src/save-cache.ts"],
|
||||
outfile: "dist/save-cache/index.cjs",
|
||||
staleOutfiles: ["dist/save-cache/index.mjs"],
|
||||
},
|
||||
{
|
||||
entryPoints: ["src/update-known-checksums.ts"],
|
||||
outfile: "dist/update-known-checksums/index.cjs",
|
||||
staleOutfiles: ["dist/update-known-checksums/index.mjs"],
|
||||
},
|
||||
];
|
||||
|
||||
for (const { staleOutfiles, ...options } of builds) {
|
||||
await Promise.all(
|
||||
staleOutfiles.map((outfile) => rm(outfile, { force: true })),
|
||||
);
|
||||
await build({
|
||||
bundle: true,
|
||||
format: "cjs",
|
||||
platform: "node",
|
||||
target: "node24",
|
||||
...options,
|
||||
});
|
||||
}
|
||||
Vendored
+67
-73
@@ -1,120 +1,114 @@
|
||||
import * as cache from "@actions/cache";
|
||||
import * as core from "@actions/core";
|
||||
import * as exec from "@actions/exec";
|
||||
import { hashFiles } from "../hash/hash-files";
|
||||
import type { SetupInputs } from "../utils/inputs";
|
||||
import { getArch, getOSNameVersion, getPlatform } from "../utils/platforms";
|
||||
import {
|
||||
cacheDependencyGlob,
|
||||
cacheLocalPath,
|
||||
cacheSuffix,
|
||||
pruneCache,
|
||||
pythonVersion as pythonVersionInput,
|
||||
restoreCache as shouldRestoreCache,
|
||||
workingDirectory,
|
||||
} from "../utils/inputs";
|
||||
import { getArch, getPlatform } from "../utils/platforms";
|
||||
|
||||
export const STATE_CACHE_KEY = "cache-key";
|
||||
export const STATE_CACHE_MATCHED_KEY = "cache-matched-key";
|
||||
export const STATE_PYTHON_CACHE_MATCHED_KEY = "python-cache-matched-key";
|
||||
const CACHE_VERSION = "1";
|
||||
|
||||
const CACHE_VERSION = "2";
|
||||
|
||||
export async function restoreCache(
|
||||
inputs: SetupInputs,
|
||||
pythonVersion?: string,
|
||||
): Promise<void> {
|
||||
const cacheKey = await computeKeys(inputs, pythonVersion);
|
||||
export async function restoreCache(): Promise<void> {
|
||||
const cacheKey = await computeKeys();
|
||||
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) {
|
||||
throw new Error(
|
||||
"cache-local-path is not set. Cannot restore cache without a valid cache path.",
|
||||
);
|
||||
}
|
||||
|
||||
await restoreCacheFromKey(
|
||||
cacheKey,
|
||||
inputs.cacheLocalPath.path,
|
||||
STATE_CACHE_MATCHED_KEY,
|
||||
"cache-hit",
|
||||
);
|
||||
|
||||
if (inputs.cachePython) {
|
||||
await restoreCacheFromKey(
|
||||
`${cacheKey}-python`,
|
||||
inputs.pythonDir,
|
||||
STATE_PYTHON_CACHE_MATCHED_KEY,
|
||||
"python-cache-hit",
|
||||
);
|
||||
} else {
|
||||
core.setOutput("python-cache-hit", false);
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreCacheFromKey(
|
||||
cacheKey: string,
|
||||
cachePath: string,
|
||||
stateKey: string,
|
||||
outputKey: string,
|
||||
): Promise<void> {
|
||||
core.info(
|
||||
`Trying to restore cache from GitHub Actions cache with key: ${cacheKey}`,
|
||||
);
|
||||
let matchedKey: string | undefined;
|
||||
core.info(
|
||||
`Trying to restore uv cache from GitHub Actions cache with key: ${cacheKey}`,
|
||||
);
|
||||
try {
|
||||
matchedKey = await cache.restoreCache([cachePath], cacheKey);
|
||||
matchedKey = await cache.restoreCache([cacheLocalPath], cacheKey);
|
||||
} catch (err) {
|
||||
const message = (err as Error).message;
|
||||
core.warning(message);
|
||||
core.setOutput(outputKey, false);
|
||||
core.setOutput("cache-hit", false);
|
||||
return;
|
||||
}
|
||||
|
||||
handleMatchResult(matchedKey, cacheKey, stateKey, outputKey);
|
||||
handleMatchResult(matchedKey, cacheKey);
|
||||
}
|
||||
|
||||
async function computeKeys(
|
||||
inputs: SetupInputs,
|
||||
pythonVersion?: string,
|
||||
): Promise<string> {
|
||||
async function computeKeys(): 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 version = pythonVersion ?? "unknown";
|
||||
const suffix = cacheSuffix ? `-${cacheSuffix}` : "";
|
||||
const pythonVersion = await getPythonVersion();
|
||||
const platform = await getPlatform();
|
||||
const osNameVersion = getOSNameVersion();
|
||||
const pruned = inputs.pruneCache ? "-pruned" : "";
|
||||
const python = inputs.cachePython ? "-py" : "";
|
||||
return `setup-uv-${CACHE_VERSION}-${getArch()}-${platform}-${osNameVersion}-${version}${pruned}${python}${cacheDependencyPathHash}${suffix}`;
|
||||
const pruned = pruneCache ? "-pruned" : "";
|
||||
return `setup-uv-${CACHE_VERSION}-${getArch()}-${platform}-${pythonVersion}${pruned}${cacheDependencyPathHash}${suffix}`;
|
||||
}
|
||||
|
||||
async function getPythonVersion(): Promise<string> {
|
||||
if (pythonVersionInput !== "") {
|
||||
return pythonVersionInput;
|
||||
}
|
||||
|
||||
let output = "";
|
||||
const options: exec.ExecOptions = {
|
||||
listeners: {
|
||||
stdout: (data: Buffer) => {
|
||||
output += data.toString();
|
||||
},
|
||||
},
|
||||
silent: !core.isDebug(),
|
||||
};
|
||||
|
||||
try {
|
||||
const execArgs = ["python", "find", "--directory", workingDirectory];
|
||||
await exec.exec("uv", execArgs, options);
|
||||
const pythonPath = output.trim();
|
||||
|
||||
output = "";
|
||||
await exec.exec(pythonPath, ["--version"], options);
|
||||
// output is like "Python 3.8.10"
|
||||
return output.split(" ")[1].trim();
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
core.debug(`Failed to get python version from uv. Error: ${err.message}`);
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
function handleMatchResult(
|
||||
matchedKey: string | undefined,
|
||||
primaryKey: string,
|
||||
stateKey: string,
|
||||
outputKey: string,
|
||||
): void {
|
||||
if (!matchedKey) {
|
||||
core.info(`No GitHub Actions cache found for key: ${primaryKey}`);
|
||||
core.setOutput(outputKey, false);
|
||||
core.setOutput("cache-hit", false);
|
||||
return;
|
||||
}
|
||||
|
||||
core.saveState(stateKey, matchedKey);
|
||||
core.info(`cache restored from GitHub Actions cache with key: ${matchedKey}`);
|
||||
core.setOutput(outputKey, true);
|
||||
core.saveState(STATE_CACHE_MATCHED_KEY, matchedKey);
|
||||
core.info(
|
||||
`uv cache restored from GitHub Actions cache with key: ${matchedKey}`,
|
||||
);
|
||||
core.setOutput("cache-hit", true);
|
||||
}
|
||||
|
||||
@@ -6,35 +6,33 @@ import type { Architecture, Platform } from "../../utils/platforms";
|
||||
import { KNOWN_CHECKSUMS } from "./known-checksums";
|
||||
|
||||
export async function validateChecksum(
|
||||
checksum: string | undefined,
|
||||
checkSum: string | undefined,
|
||||
downloadPath: string,
|
||||
arch: Architecture,
|
||||
platform: Platform,
|
||||
version: string,
|
||||
): Promise<void> {
|
||||
const key = `${arch}-${platform}-${version}`;
|
||||
const hasProvidedChecksum = checksum !== undefined && checksum !== "";
|
||||
const checksumToUse = hasProvidedChecksum ? checksum : KNOWN_CHECKSUMS[key];
|
||||
|
||||
if (checksumToUse === undefined) {
|
||||
core.debug(`No checksum found for ${key}.`);
|
||||
return;
|
||||
let isValid: boolean | undefined;
|
||||
if (checkSum !== undefined && checkSum !== "") {
|
||||
isValid = await validateFileCheckSum(downloadPath, checkSum);
|
||||
} else {
|
||||
core.debug("Checksum not provided. Checking known checksums.");
|
||||
const key = `${arch}-${platform}-${version}`;
|
||||
if (key in KNOWN_CHECKSUMS) {
|
||||
const knownChecksum = KNOWN_CHECKSUMS[`${arch}-${platform}-${version}`];
|
||||
core.debug(`Checking checksum for ${arch}-${platform}-${version}.`);
|
||||
isValid = await validateFileCheckSum(downloadPath, knownChecksum);
|
||||
} else {
|
||||
core.debug(`No known checksum found for ${key}.`);
|
||||
}
|
||||
}
|
||||
|
||||
const checksumSource = hasProvidedChecksum
|
||||
? "provided checksum"
|
||||
: `KNOWN_CHECKSUMS entry for ${key}`;
|
||||
|
||||
core.debug(`Validating checksum using ${checksumSource}.`);
|
||||
const isValid = await validateFileCheckSum(downloadPath, checksumToUse);
|
||||
|
||||
if (!isValid) {
|
||||
throw new Error(
|
||||
`Checksum for ${downloadPath} did not match ${checksumToUse}.`,
|
||||
);
|
||||
if (isValid === false) {
|
||||
throw new Error(`Checksum for ${downloadPath} did not match ${checkSum}.`);
|
||||
}
|
||||
if (isValid === true) {
|
||||
core.debug(`Checksum for ${downloadPath} is valid.`);
|
||||
}
|
||||
|
||||
core.debug(`Checksum for ${downloadPath} is valid.`);
|
||||
}
|
||||
|
||||
async function validateFileCheckSum(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,34 +1,59 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
|
||||
export interface ChecksumEntry {
|
||||
key: string;
|
||||
checksum: string;
|
||||
}
|
||||
|
||||
import * as tc from "@actions/tool-cache";
|
||||
import { KNOWN_CHECKSUMS } from "./known-checksums";
|
||||
export async function updateChecksums(
|
||||
filePath: string,
|
||||
checksumEntries: ChecksumEntry[],
|
||||
downloadUrls: string[],
|
||||
): Promise<void> {
|
||||
const deduplicatedEntries = new Map<string, string>();
|
||||
|
||||
for (const entry of checksumEntries) {
|
||||
if (deduplicatedEntries.has(entry.key)) {
|
||||
await fs.rm(filePath);
|
||||
await fs.appendFile(
|
||||
filePath,
|
||||
"// AUTOGENERATED_DO_NOT_EDIT\nexport const KNOWN_CHECKSUMS: { [key: string]: string } = {\n",
|
||||
);
|
||||
let firstLine = true;
|
||||
for (const downloadUrl of downloadUrls) {
|
||||
const key = getKey(downloadUrl);
|
||||
if (key === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
deduplicatedEntries.set(entry.key, entry.checksum);
|
||||
const checksum = await getOrDownloadChecksum(key, downloadUrl);
|
||||
if (!firstLine) {
|
||||
await fs.appendFile(filePath, ",\n");
|
||||
}
|
||||
await fs.appendFile(filePath, ` "${key}":\n "${checksum}"`);
|
||||
firstLine = false;
|
||||
}
|
||||
|
||||
const body = [...deduplicatedEntries.entries()]
|
||||
.map(([key, checksum]) => ` "${key}":\n "${checksum}"`)
|
||||
.join(",\n");
|
||||
|
||||
const content =
|
||||
"// AUTOGENERATED_DO_NOT_EDIT\n" +
|
||||
"export const KNOWN_CHECKSUMS: { [key: string]: string } = {\n" +
|
||||
body +
|
||||
(body === "" ? "" : ",\n") +
|
||||
"};\n";
|
||||
|
||||
await fs.writeFile(filePath, content);
|
||||
await fs.appendFile(filePath, ",\n};\n");
|
||||
}
|
||||
|
||||
function getKey(downloadUrl: string): string | undefined {
|
||||
// https://github.com/astral-sh/uv/releases/download/0.3.2/uv-aarch64-apple-darwin.tar.gz.sha256
|
||||
const parts = downloadUrl.split("/");
|
||||
const fileName = parts[parts.length - 1];
|
||||
if (fileName.startsWith("source")) {
|
||||
return undefined;
|
||||
}
|
||||
const name = fileName.split(".")[0].split("uv-")[1];
|
||||
const version = parts[parts.length - 2];
|
||||
return `${name}-${version}`;
|
||||
}
|
||||
|
||||
async function getOrDownloadChecksum(
|
||||
key: string,
|
||||
downloadUrl: string,
|
||||
): Promise<string> {
|
||||
let checksum = "";
|
||||
if (key in KNOWN_CHECKSUMS) {
|
||||
checksum = KNOWN_CHECKSUMS[key];
|
||||
} else {
|
||||
const content = await downloadAssetContent(downloadUrl);
|
||||
checksum = content.split(" ")[0].trim();
|
||||
}
|
||||
return checksum;
|
||||
}
|
||||
|
||||
async function downloadAssetContent(downloadUrl: string): Promise<string> {
|
||||
const downloadPath = await tc.downloadTool(downloadUrl);
|
||||
const content = await fs.readFile(downloadPath, "utf8");
|
||||
return content;
|
||||
}
|
||||
|
||||
+186
-107
@@ -2,17 +2,15 @@ import { promises as fs } from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import * as core from "@actions/core";
|
||||
import * as tc from "@actions/tool-cache";
|
||||
import {
|
||||
ASTRAL_MIRROR_PREFIX,
|
||||
GITHUB_RELEASES_PREFIX,
|
||||
TOOL_CACHE_NAME,
|
||||
VERSIONS_MANIFEST_URL,
|
||||
} from "../utils/constants";
|
||||
import * as pep440 from "@renovatebot/pep440";
|
||||
import { OWNER, REPO, TOOL_CACHE_NAME } from "../utils/constants";
|
||||
import { Octokit } from "../utils/octokit";
|
||||
import type { Architecture, Platform } from "../utils/platforms";
|
||||
import { validateChecksum } from "./checksum/checksum";
|
||||
import { getArtifact } from "./manifest";
|
||||
|
||||
export { resolveVersion } from "../version/resolve";
|
||||
import {
|
||||
getDownloadUrl,
|
||||
getLatestKnownVersion as getLatestVersionInManifest,
|
||||
} from "./version-manifest";
|
||||
|
||||
export function tryGetFromToolCache(
|
||||
arch: Architecture,
|
||||
@@ -29,85 +27,74 @@ export function tryGetFromToolCache(
|
||||
return { installedPath, version: resolvedVersion };
|
||||
}
|
||||
|
||||
export async function downloadVersion(
|
||||
export async function downloadVersionFromGithub(
|
||||
serverUrl: string,
|
||||
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 = `uv-${arch}-${platform}`;
|
||||
const extension = getExtension(platform);
|
||||
const downloadUrl = `${serverUrl}/${OWNER}/${REPO}/releases/download/${version}/${artifact}${extension}`;
|
||||
return await downloadVersion(
|
||||
downloadUrl,
|
||||
artifact,
|
||||
platform,
|
||||
arch,
|
||||
version,
|
||||
checkSum,
|
||||
githubToken,
|
||||
);
|
||||
}
|
||||
|
||||
if (!artifact) {
|
||||
throw new Error(
|
||||
getMissingArtifactMessage(version, arch, platform, manifestUrl),
|
||||
export async function downloadVersionFromManifest(
|
||||
manifestUrl: string | undefined,
|
||||
platform: Platform,
|
||||
arch: Architecture,
|
||||
version: string,
|
||||
checkSum: string | undefined,
|
||||
githubToken: string,
|
||||
): Promise<{ version: string; cachedToolDir: string }> {
|
||||
const downloadUrl = await getDownloadUrl(
|
||||
manifestUrl,
|
||||
version,
|
||||
arch,
|
||||
platform,
|
||||
);
|
||||
if (!downloadUrl) {
|
||||
core.info(
|
||||
`manifest-file does not contain version ${version}, arch ${arch}, platform ${platform}. Falling back to GitHub releases.`,
|
||||
);
|
||||
}
|
||||
|
||||
// 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}`,
|
||||
return await downloadVersionFromGithub(
|
||||
"https://github.com",
|
||||
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,
|
||||
checkSum,
|
||||
githubToken,
|
||||
);
|
||||
}
|
||||
return await downloadVersion(
|
||||
downloadUrl,
|
||||
`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;
|
||||
}
|
||||
|
||||
return ASTRAL_MIRROR_PREFIX + url.slice(GITHUB_RELEASES_PREFIX.length);
|
||||
}
|
||||
|
||||
async function downloadArtifact(
|
||||
async function downloadVersion(
|
||||
downloadUrl: string,
|
||||
artifactName: string,
|
||||
platform: Platform,
|
||||
arch: Architecture,
|
||||
version: string,
|
||||
checksum: string | undefined,
|
||||
githubToken: string | undefined,
|
||||
checkSum: string | undefined,
|
||||
githubToken: string,
|
||||
): Promise<{ version: string; cachedToolDir: string }> {
|
||||
core.info(`Downloading uv from "${downloadUrl}" ...`);
|
||||
const downloadPath = await tc.downloadTool(
|
||||
@@ -115,60 +102,152 @@ async function downloadArtifact(
|
||||
undefined,
|
||||
githubToken,
|
||||
);
|
||||
await validateChecksum(checksum, downloadPath, arch, platform, version);
|
||||
await validateChecksum(checkSum, downloadPath, arch, platform, version);
|
||||
|
||||
let uvDir: string;
|
||||
const extension = getExtension(platform);
|
||||
if (platform === "pc-windows-msvc") {
|
||||
// On windows extracting the zip does not create an intermediate directory.
|
||||
try {
|
||||
// Try tar first as it's much faster, but only bsdtar supports zip files,
|
||||
// so this may fail if another tar, like gnu tar, ends up being used.
|
||||
uvDir = await tc.extractTar(downloadPath, undefined, "x");
|
||||
} catch (err) {
|
||||
core.info(
|
||||
`Extracting with tar failed, falling back to zip extraction: ${(err as Error).message}`,
|
||||
);
|
||||
const extension = getExtension(platform);
|
||||
const fullPathWithExtension = `${downloadPath}${extension}`;
|
||||
await fs.copyFile(downloadPath, fullPathWithExtension);
|
||||
uvDir = await tc.extractZip(fullPathWithExtension);
|
||||
}
|
||||
const fullPathWithExtension = `${downloadPath}${extension}`;
|
||||
await fs.copyFile(downloadPath, fullPathWithExtension);
|
||||
uvDir = await tc.extractZip(fullPathWithExtension);
|
||||
// On windows extracting the zip does not create an intermediate directory
|
||||
} else {
|
||||
const extractedDir = await tc.extractTar(downloadPath);
|
||||
uvDir = path.join(extractedDir, artifactName);
|
||||
}
|
||||
|
||||
const cachedToolDir = await tc.cacheDir(
|
||||
uvDir,
|
||||
TOOL_CACHE_NAME,
|
||||
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}.`;
|
||||
}
|
||||
|
||||
function resolveChecksum(
|
||||
checksum: string | undefined,
|
||||
manifestChecksum: string,
|
||||
): string {
|
||||
return checksum !== undefined && checksum !== ""
|
||||
? checksum
|
||||
: manifestChecksum;
|
||||
return { cachedToolDir, version: version };
|
||||
}
|
||||
|
||||
function getExtension(platform: Platform): string {
|
||||
return platform === "pc-windows-msvc" ? ".zip" : ".tar.gz";
|
||||
}
|
||||
|
||||
export async function resolveVersion(
|
||||
versionInput: string,
|
||||
manifestFile: string | undefined,
|
||||
githubToken: string,
|
||||
): Promise<string> {
|
||||
core.debug(`Resolving version: ${versionInput}`);
|
||||
let version: string;
|
||||
if (manifestFile) {
|
||||
version =
|
||||
versionInput === "latest"
|
||||
? await getLatestVersionInManifest(manifestFile)
|
||||
: versionInput;
|
||||
} else {
|
||||
version =
|
||||
versionInput === "latest"
|
||||
? await getLatestVersion(githubToken)
|
||||
: versionInput;
|
||||
}
|
||||
if (tc.isExplicitVersion(version)) {
|
||||
core.debug(`Version ${version} is an explicit version.`);
|
||||
return version;
|
||||
}
|
||||
const availableVersions = await getAvailableVersions(githubToken);
|
||||
core.debug(`Available versions: ${availableVersions}`);
|
||||
const resolvedVersion = maxSatisfying(availableVersions, version);
|
||||
if (resolvedVersion === undefined) {
|
||||
throw new Error(`No version found for ${version}`);
|
||||
}
|
||||
return resolvedVersion;
|
||||
}
|
||||
|
||||
async function getAvailableVersions(githubToken: string): Promise<string[]> {
|
||||
core.info("Getting available versions from GitHub API...");
|
||||
try {
|
||||
const octokit = new Octokit({
|
||||
auth: githubToken,
|
||||
});
|
||||
return await getReleaseTagNames(octokit);
|
||||
} catch (err) {
|
||||
if ((err as Error).message.includes("Bad credentials")) {
|
||||
core.info(
|
||||
"No (valid) GitHub token provided. Falling back to anonymous. Requests might be rate limited.",
|
||||
);
|
||||
const octokit = new Octokit();
|
||||
return await getReleaseTagNames(octokit);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function getReleaseTagNames(
|
||||
octokit: InstanceType<typeof Octokit>,
|
||||
): Promise<string[]> {
|
||||
const response = await octokit.paginate(octokit.rest.repos.listReleases, {
|
||||
owner: OWNER,
|
||||
repo: REPO,
|
||||
});
|
||||
const releaseTagNames = response.map((release) => release.tag_name);
|
||||
if (releaseTagNames.length === 0) {
|
||||
throw Error(
|
||||
"Github API request failed while getting releases. Check the GitHub status page for outages. Try again later.",
|
||||
);
|
||||
}
|
||||
return releaseTagNames;
|
||||
}
|
||||
|
||||
async function getLatestVersion(githubToken: string) {
|
||||
core.info("Getting latest version from GitHub API...");
|
||||
const octokit = new Octokit({
|
||||
auth: githubToken,
|
||||
});
|
||||
|
||||
let latestRelease: { tag_name: string } | undefined;
|
||||
try {
|
||||
latestRelease = await getLatestRelease(octokit);
|
||||
} catch (err) {
|
||||
if ((err as Error).message.includes("Bad credentials")) {
|
||||
core.info(
|
||||
"No (valid) GitHub token provided. Falling back to anonymous. Requests might be rate limited.",
|
||||
);
|
||||
const octokit = new Octokit();
|
||||
latestRelease = await getLatestRelease(octokit);
|
||||
} else {
|
||||
core.error(
|
||||
"Github API request failed while getting latest release. Check the GitHub status page for outages. Try again later.",
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
if (!latestRelease) {
|
||||
throw new Error("Could not determine latest release.");
|
||||
}
|
||||
core.debug(`Latest version: ${latestRelease.tag_name}`);
|
||||
return latestRelease.tag_name;
|
||||
}
|
||||
|
||||
async function getLatestRelease(octokit: InstanceType<typeof Octokit>) {
|
||||
const { data: latestRelease } = await octokit.rest.repos.getLatestRelease({
|
||||
owner: OWNER,
|
||||
repo: REPO,
|
||||
});
|
||||
return latestRelease;
|
||||
}
|
||||
|
||||
function maxSatisfying(
|
||||
versions: string[],
|
||||
version: string,
|
||||
): string | undefined {
|
||||
const maxSemver = tc.evaluateVersions(versions, version);
|
||||
if (maxSemver !== "") {
|
||||
core.debug(`Found a version that satisfies the semver range: ${maxSemver}`);
|
||||
return maxSemver;
|
||||
}
|
||||
const maxPep440 = pep440.maxSatisfying(versions, version);
|
||||
if (maxPep440 !== null) {
|
||||
core.debug(
|
||||
`Found a version that satisfies the pep440 specifier: ${maxPep440}`,
|
||||
);
|
||||
return maxPep440;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -1,210 +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[]> {
|
||||
core.info(
|
||||
`Getting available versions from ${manifestSource(manifestUrl)} ...`,
|
||||
);
|
||||
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 manifestSource(manifestUrl: string): string {
|
||||
if (manifestUrl === VERSIONS_MANIFEST_URL) {
|
||||
return VERSIONS_MANIFEST_URL;
|
||||
}
|
||||
|
||||
return `manifest-file ${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;
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
interface VariantAwareEntry {
|
||||
variant?: string;
|
||||
}
|
||||
|
||||
export function selectDefaultVariant<T extends VariantAwareEntry>(
|
||||
entries: T[],
|
||||
duplicateEntryDescription: string,
|
||||
): T {
|
||||
const firstEntry = entries[0];
|
||||
if (firstEntry === undefined) {
|
||||
throw new Error("selectDefaultVariant requires at least one candidate.");
|
||||
}
|
||||
|
||||
if (entries.length === 1) {
|
||||
return firstEntry;
|
||||
}
|
||||
|
||||
const defaultEntries = entries.filter((entry) =>
|
||||
isDefaultVariant(entry.variant),
|
||||
);
|
||||
if (defaultEntries.length === 1) {
|
||||
return defaultEntries[0];
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`${duplicateEntryDescription} with variants ${formatVariants(entries)}. setup-uv currently requires a single default variant for duplicate platform entries.`,
|
||||
);
|
||||
}
|
||||
|
||||
function isDefaultVariant(variant: string | undefined): boolean {
|
||||
return variant === undefined || variant === "default";
|
||||
}
|
||||
|
||||
function formatVariants<T extends VariantAwareEntry>(entries: T[]): string {
|
||||
return entries
|
||||
.map((entry) => entry.variant ?? "default")
|
||||
.sort((left, right) => left.localeCompare(right))
|
||||
.join(", ");
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import * as core from "@actions/core";
|
||||
import * as semver from "semver";
|
||||
import { fetch } from "../utils/fetch";
|
||||
|
||||
const localManifestFile = join(__dirname, "..", "..", "version-manifest.json");
|
||||
|
||||
interface ManifestEntry {
|
||||
version: string;
|
||||
artifactName: string;
|
||||
arch: string;
|
||||
platform: string;
|
||||
downloadUrl: string;
|
||||
}
|
||||
|
||||
export async function getLatestKnownVersion(
|
||||
manifestUrl: string | undefined,
|
||||
): Promise<string> {
|
||||
const manifestEntries = await getManifestEntries(manifestUrl);
|
||||
return manifestEntries.reduce((a, b) =>
|
||||
semver.gt(a.version, b.version) ? a : b,
|
||||
).version;
|
||||
}
|
||||
|
||||
export async function getDownloadUrl(
|
||||
manifestUrl: string | undefined,
|
||||
version: string,
|
||||
arch: string,
|
||||
platform: string,
|
||||
): Promise<string | undefined> {
|
||||
const manifestEntries = await getManifestEntries(manifestUrl);
|
||||
const entry = manifestEntries.find(
|
||||
(entry) =>
|
||||
entry.version === version &&
|
||||
entry.arch === arch &&
|
||||
entry.platform === platform,
|
||||
);
|
||||
return entry ? entry.downloadUrl : undefined;
|
||||
}
|
||||
|
||||
async function getManifestEntries(
|
||||
manifestUrl: string | undefined,
|
||||
): Promise<ManifestEntry[]> {
|
||||
let data: string;
|
||||
if (manifestUrl !== undefined) {
|
||||
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}`,
|
||||
);
|
||||
}
|
||||
data = await response.text();
|
||||
} else {
|
||||
core.info("manifest-file not provided, reading from local file.");
|
||||
const fileContent = await fs.readFile(localManifestFile);
|
||||
data = fileContent.toString();
|
||||
}
|
||||
|
||||
return JSON.parse(data);
|
||||
}
|
||||
|
||||
export async function updateVersionManifest(
|
||||
manifestUrl: string,
|
||||
downloadUrls: string[],
|
||||
): Promise<void> {
|
||||
const manifest: ManifestEntry[] = [];
|
||||
|
||||
for (const downloadUrl of downloadUrls) {
|
||||
const urlParts = downloadUrl.split("/");
|
||||
const version = urlParts[urlParts.length - 2];
|
||||
const artifactName = urlParts[urlParts.length - 1];
|
||||
if (!artifactName.startsWith("uv-")) {
|
||||
continue;
|
||||
}
|
||||
if (artifactName.startsWith("uv-installer")) {
|
||||
continue;
|
||||
}
|
||||
const artifactParts = artifactName.split(".")[0].split("-");
|
||||
manifest.push({
|
||||
arch: artifactParts[1],
|
||||
artifactName: artifactName,
|
||||
downloadUrl: downloadUrl,
|
||||
platform: artifactName.split(`uv-${artifactParts[1]}-`)[1].split(".")[0],
|
||||
version: version,
|
||||
});
|
||||
}
|
||||
core.debug(`Updating manifest-file: ${JSON.stringify(manifest)}`);
|
||||
await fs.writeFile(manifestUrl, JSON.stringify(manifest));
|
||||
}
|
||||
+54
-105
@@ -2,41 +2,47 @@ import * as fs from "node:fs";
|
||||
import * as cache from "@actions/cache";
|
||||
import * as core from "@actions/core";
|
||||
import * as exec from "@actions/exec";
|
||||
import * as pep440 from "@renovatebot/pep440";
|
||||
import {
|
||||
STATE_CACHE_KEY,
|
||||
STATE_CACHE_MATCHED_KEY,
|
||||
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,
|
||||
enableCache,
|
||||
ignoreNothingToCache,
|
||||
pruneCache as shouldPruneCache,
|
||||
saveCache as shouldSaveCache,
|
||||
} from "./utils/inputs";
|
||||
|
||||
export async function run(): Promise<void> {
|
||||
if (!enableCache) {
|
||||
return;
|
||||
}
|
||||
if (!shouldSaveCache) {
|
||||
core.info("save-cache is false. Skipping save cache step.");
|
||||
return;
|
||||
}
|
||||
if (process.env.SETUP_UV_SAVE_CACHE === "false") {
|
||||
core.info(
|
||||
"Environment variable SETUP_UV_SAVE_CACHE is set to false. Skipping save cache step.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const inputs = loadInputs();
|
||||
if (inputs.enableCache) {
|
||||
if (inputs.saveCache) {
|
||||
await saveCache(inputs);
|
||||
} else {
|
||||
core.info("save-cache is false. Skipping save cache step.");
|
||||
}
|
||||
// https://github.com/nodejs/node/issues/56645#issuecomment-3077594952
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
// node will stay alive if any promises are not resolved,
|
||||
// which is a possibility if HTTP requests are dangling
|
||||
// due to retries or timeouts. We know that if we got here
|
||||
// that all promises that we care about have successfully
|
||||
// resolved, so simply exit with success.
|
||||
process.exit(0);
|
||||
}
|
||||
await saveCache();
|
||||
// node will stay alive if any promises are not resolved,
|
||||
// which is a possibility if HTTP requests are dangling
|
||||
// due to retries or timeouts. We know that if we got here
|
||||
// that all promises that we care about have successfully
|
||||
// resolved, so simply exit with success.
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
core.setFailed(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -46,102 +52,45 @@ 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) {
|
||||
await pruneCache();
|
||||
}
|
||||
|
||||
const actualCachePath = getUvCachePath(inputs);
|
||||
if (!fs.existsSync(actualCachePath)) {
|
||||
if (inputs.ignoreNothingToCache) {
|
||||
core.info(
|
||||
"No cacheable uv cache paths were found. Ignoring because ignore-nothing-to-cache is enabled.",
|
||||
);
|
||||
} else {
|
||||
throw new Error(
|
||||
`Cache path ${actualCachePath} 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.`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
await saveCacheToKey(
|
||||
cacheKey,
|
||||
actualCachePath,
|
||||
STATE_CACHE_MATCHED_KEY,
|
||||
"uv cache",
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (inputs.cachePython) {
|
||||
if (!fs.existsSync(inputs.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.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (shouldPruneCache) {
|
||||
await pruneCache();
|
||||
}
|
||||
|
||||
const pythonCacheKey = `${cacheKey}-python`;
|
||||
await saveCacheToKey(
|
||||
pythonCacheKey,
|
||||
inputs.pythonDir,
|
||||
STATE_PYTHON_CACHE_MATCHED_KEY,
|
||||
"Python cache",
|
||||
core.info(`Saving cache path: ${cacheLocalPath}`);
|
||||
if (!fs.existsSync(cacheLocalPath) && !ignoreNothingToCache) {
|
||||
throw new Error(
|
||||
`Cache path ${cacheLocalPath} 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.`,
|
||||
);
|
||||
}
|
||||
try {
|
||||
await cache.saveCache([cacheLocalPath], cacheKey);
|
||||
core.info(`cache saved with the key: ${cacheKey}`);
|
||||
} catch (e) {
|
||||
if (
|
||||
e instanceof Error &&
|
||||
e.message ===
|
||||
"Path Validation Error: Path(s) specified in the action for caching do(es) not exist, hence no cache is being saved."
|
||||
) {
|
||||
core.info(
|
||||
"No cacheable paths were found. Ignoring because ignore-nothing-to-save is enabled.",
|
||||
);
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function pruneCache(): Promise<void> {
|
||||
const forceSupported = pep440.gte(core.getState(STATE_UV_VERSION), "0.8.24");
|
||||
|
||||
const options: exec.ExecOptions = {
|
||||
silent: false,
|
||||
silent: !core.isDebug(),
|
||||
};
|
||||
const execArgs = ["cache", "prune", "--ci"];
|
||||
if (forceSupported) {
|
||||
execArgs.push("--force");
|
||||
}
|
||||
|
||||
core.info("Pruning cache...");
|
||||
const uvPath = core.getState(STATE_UV_PATH);
|
||||
await exec.exec(uvPath, execArgs, options);
|
||||
}
|
||||
|
||||
function getUvCachePath(inputs: SetupInputs): string {
|
||||
if (inputs.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
|
||||
) {
|
||||
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}".`,
|
||||
);
|
||||
return process.env.UV_CACHE_DIR;
|
||||
}
|
||||
return inputs.cacheLocalPath.path;
|
||||
}
|
||||
|
||||
async function saveCacheToKey(
|
||||
cacheKey: string,
|
||||
cachePath: string,
|
||||
stateKey: string,
|
||||
cacheName: string,
|
||||
): Promise<void> {
|
||||
const matchedKey = core.getState(stateKey);
|
||||
|
||||
if (matchedKey === cacheKey) {
|
||||
core.info(
|
||||
`${cacheName} hit occurred on key ${cacheKey}, not saving cache.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
core.info(`Including ${cacheName} path: ${cachePath}`);
|
||||
await cache.saveCache([cachePath], cacheKey);
|
||||
core.info(`${cacheName} saved with key: ${cacheKey}`);
|
||||
await exec.exec("uv", execArgs, options);
|
||||
}
|
||||
|
||||
run();
|
||||
|
||||
+131
-158
@@ -4,97 +4,73 @@ import * as core from "@actions/core";
|
||||
import * as exec from "@actions/exec";
|
||||
import { restoreCache } from "./cache/restore-cache";
|
||||
import {
|
||||
downloadVersion,
|
||||
downloadVersionFromGithub,
|
||||
downloadVersionFromManifest,
|
||||
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,
|
||||
cacheLocalPath,
|
||||
checkSum,
|
||||
enableCache,
|
||||
githubToken,
|
||||
ignoreEmptyWorkdir,
|
||||
manifestFile,
|
||||
pythonVersion,
|
||||
serverUrl,
|
||||
toolBinDir,
|
||||
toolDir,
|
||||
versionFile as versionFileInput,
|
||||
version as versionInput,
|
||||
workingDirectory,
|
||||
} from "./utils/inputs";
|
||||
import {
|
||||
type Architecture,
|
||||
getArch,
|
||||
getPlatform,
|
||||
type Platform,
|
||||
} from "./utils/platforms";
|
||||
import { resolveUvVersion } from "./version/resolve";
|
||||
|
||||
const sourceDir = __dirname;
|
||||
|
||||
async function getPythonVersion(inputs: SetupInputs): Promise<string> {
|
||||
if (inputs.pythonVersion !== "") {
|
||||
return inputs.pythonVersion;
|
||||
}
|
||||
|
||||
let output = "";
|
||||
const options: exec.ExecOptions = {
|
||||
listeners: {
|
||||
stdout: (data: Buffer) => {
|
||||
output += data.toString();
|
||||
},
|
||||
},
|
||||
silent: !core.isDebug(),
|
||||
};
|
||||
|
||||
try {
|
||||
const execArgs = ["python", "find", "--directory", inputs.workingDirectory];
|
||||
await exec.exec("uv", execArgs, options);
|
||||
const pythonPath = output.trim();
|
||||
|
||||
output = "";
|
||||
await exec.exec(pythonPath, ["--version"], options);
|
||||
// output is like "Python 3.8.10"
|
||||
return output.split(" ")[1].trim();
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
core.debug(`Failed to get python version from uv. Error: ${err.message}`);
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
import { getUvVersionFromFile } from "./version/resolve";
|
||||
|
||||
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();
|
||||
setupPython();
|
||||
await activateEnvironment();
|
||||
addMatchers();
|
||||
setCacheDir(cacheLocalPath);
|
||||
|
||||
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);
|
||||
|
||||
if (inputs.enableCache) {
|
||||
await restoreCache(inputs, detectedPythonVersion);
|
||||
if (enableCache) {
|
||||
await restoreCache();
|
||||
}
|
||||
// https://github.com/nodejs/node/issues/56645#issuecomment-3077594952
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
process.exit(0);
|
||||
} catch (err) {
|
||||
core.setFailed((err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
function detectEmptyWorkdir(inputs: SetupInputs): void {
|
||||
if (fs.readdirSync(inputs.workingDirectory).length === 0) {
|
||||
if (inputs.ignoreEmptyWorkdir) {
|
||||
function detectEmptyWorkdir(): void {
|
||||
if (fs.readdirSync(".").length === 0) {
|
||||
if (ignoreEmptyWorkdir) {
|
||||
core.info(
|
||||
"Empty workdir detected. Ignoring because ignore-empty-workdir is enabled",
|
||||
);
|
||||
@@ -107,17 +83,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 resolveUvVersion({
|
||||
manifestFile: inputs.manifestFile,
|
||||
resolutionStrategy: inputs.resolutionStrategy,
|
||||
version: inputs.version,
|
||||
versionFile: inputs.versionFile,
|
||||
workingDirectory: inputs.workingDirectory,
|
||||
});
|
||||
const resolvedVersion = await determineVersion(manifestFile);
|
||||
const toolCacheResult = tryGetFromToolCache(arch, resolvedVersion);
|
||||
if (toolCacheResult.installedPath) {
|
||||
core.info(`Found uv in tool-cache for ${toolCacheResult.version}`);
|
||||
@@ -127,50 +98,83 @@ async function setupUv(
|
||||
};
|
||||
}
|
||||
|
||||
const downloadResult = await downloadVersion(
|
||||
platform,
|
||||
arch,
|
||||
resolvedVersion,
|
||||
inputs.checksum,
|
||||
inputs.githubToken,
|
||||
inputs.manifestFile,
|
||||
);
|
||||
let downloadVersionResult: { version: string; cachedToolDir: string };
|
||||
if (serverUrl !== "https://github.com") {
|
||||
core.warning(
|
||||
"The input server-url is deprecated. Please use manifest-file instead.",
|
||||
);
|
||||
downloadVersionResult = await downloadVersionFromGithub(
|
||||
serverUrl,
|
||||
platform,
|
||||
arch,
|
||||
resolvedVersion,
|
||||
checkSum,
|
||||
githubToken,
|
||||
);
|
||||
} else {
|
||||
downloadVersionResult = await downloadVersionFromManifest(
|
||||
manifestFile,
|
||||
platform,
|
||||
arch,
|
||||
resolvedVersion,
|
||||
checkSum,
|
||||
githubToken,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
uvDir: downloadResult.cachedToolDir,
|
||||
version: downloadResult.version,
|
||||
uvDir: downloadVersionResult.cachedToolDir,
|
||||
version: downloadVersionResult.version,
|
||||
};
|
||||
}
|
||||
|
||||
async function determineVersion(
|
||||
manifestFile: string | undefined,
|
||||
): Promise<string> {
|
||||
if (versionInput !== "") {
|
||||
return await resolveVersion(versionInput, manifestFile, githubToken);
|
||||
}
|
||||
if (versionFileInput !== "") {
|
||||
const versionFromFile = getUvVersionFromFile(versionFileInput);
|
||||
if (versionFromFile === undefined) {
|
||||
throw new Error(
|
||||
`Could not determine uv version from file: ${versionFileInput}`,
|
||||
);
|
||||
}
|
||||
return await resolveVersion(versionFromFile, manifestFile, githubToken);
|
||||
}
|
||||
const versionFromUvToml = getUvVersionFromFile(
|
||||
`${workingDirectory}${path.sep}uv.toml`,
|
||||
);
|
||||
const versionFromPyproject = getUvVersionFromFile(
|
||||
`${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 await resolveVersion(
|
||||
versionFromUvToml || versionFromPyproject || "latest",
|
||||
manifestFile,
|
||||
githubToken,
|
||||
);
|
||||
}
|
||||
|
||||
function addUvToPathAndOutput(cachedPath: string): void {
|
||||
core.setOutput("uv-path", `${cachedPath}${path.sep}uv`);
|
||||
core.saveState(STATE_UV_PATH, `${cachedPath}${path.sep}uv`);
|
||||
core.setOutput("uvx-path", `${cachedPath}${path.sep}uvx`);
|
||||
if (process.env.UV_NO_MODIFY_PATH !== undefined) {
|
||||
core.info("UV_NO_MODIFY_PATH is set, not modifying PATH");
|
||||
} else {
|
||||
core.addPath(cachedPath);
|
||||
core.info(`Added ${cachedPath} to the path`);
|
||||
}
|
||||
core.addPath(cachedPath);
|
||||
core.info(`Added ${cachedPath} to the path`);
|
||||
}
|
||||
|
||||
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}`);
|
||||
if (process.env.UV_NO_MODIFY_PATH !== undefined) {
|
||||
core.info(
|
||||
`UV_NO_MODIFY_PATH is set, not adding ${inputs.toolBinDir} to path`,
|
||||
);
|
||||
} else {
|
||||
core.addPath(inputs.toolBinDir);
|
||||
core.info(`Added ${inputs.toolBinDir} to the path`);
|
||||
}
|
||||
function addToolBinToPath(): void {
|
||||
if (toolBinDir !== undefined) {
|
||||
core.exportVariable("UV_TOOL_BIN_DIR", toolBinDir);
|
||||
core.info(`Set UV_TOOL_BIN_DIR to ${toolBinDir}`);
|
||||
core.addPath(toolBinDir);
|
||||
core.info(`Added ${toolBinDir} to the path`);
|
||||
} else {
|
||||
if (process.env.UV_NO_MODIFY_PATH !== undefined) {
|
||||
core.info("UV_NO_MODIFY_PATH is set, not adding user local bin to path");
|
||||
return;
|
||||
}
|
||||
if (process.env.XDG_BIN_HOME !== undefined) {
|
||||
core.addPath(process.env.XDG_BIN_HOME);
|
||||
core.info(`Added ${process.env.XDG_BIN_HOME} to the path`);
|
||||
@@ -184,78 +188,47 @@ 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}`);
|
||||
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`);
|
||||
function setupPython(): void {
|
||||
if (pythonVersion !== "") {
|
||||
core.exportVariable("UV_PYTHON", pythonVersion);
|
||||
core.info(`Set UV_PYTHON to ${pythonVersion}`);
|
||||
}
|
||||
}
|
||||
|
||||
function setupPython(inputs: SetupInputs): void {
|
||||
if (inputs.pythonVersion !== "") {
|
||||
core.exportVariable("UV_PYTHON", inputs.pythonVersion);
|
||||
core.info(`Set UV_PYTHON to ${inputs.pythonVersion}`);
|
||||
}
|
||||
}
|
||||
async function activateEnvironment(): Promise<void> {
|
||||
if (activateEnvironmentInput) {
|
||||
const execArgs = ["venv", ".venv", "--directory", workingDirectory];
|
||||
|
||||
async function activateEnvironment(inputs: SetupInputs): Promise<void> {
|
||||
if (inputs.activateEnvironment) {
|
||||
if (process.env.UV_NO_MODIFY_PATH !== undefined) {
|
||||
throw new Error(
|
||||
"UV_NO_MODIFY_PATH and activate-environment cannot be used together.",
|
||||
);
|
||||
}
|
||||
core.info("Activating python venv...");
|
||||
await exec.exec("uv", execArgs);
|
||||
|
||||
core.info(`Creating and activating python venv at ${inputs.venvPath}...`);
|
||||
const venvArgs = [
|
||||
"venv",
|
||||
inputs.venvPath,
|
||||
"--directory",
|
||||
inputs.workingDirectory,
|
||||
"--clear",
|
||||
];
|
||||
if (inputs.noProject) {
|
||||
venvArgs.push("--no-project");
|
||||
}
|
||||
await exec.exec("uv", venvArgs);
|
||||
|
||||
let venvBinPath = `${inputs.venvPath}${path.sep}bin`;
|
||||
let venvBinPath = `${workingDirectory}${path.sep}.venv${path.sep}bin`;
|
||||
if (process.platform === "win32") {
|
||||
venvBinPath = `${inputs.venvPath}${path.sep}Scripts`;
|
||||
venvBinPath = `${workingDirectory}${path.sep}.venv${path.sep}Scripts`;
|
||||
}
|
||||
core.addPath(path.resolve(venvBinPath));
|
||||
core.exportVariable("VIRTUAL_ENV", inputs.venvPath);
|
||||
core.setOutput("venv", inputs.venvPath);
|
||||
core.exportVariable(
|
||||
"VIRTUAL_ENV",
|
||||
path.resolve(`${workingDirectory}${path.sep}.venv`),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function setCacheDir(inputs: SetupInputs): void {
|
||||
if (inputs.cacheLocalPath !== undefined) {
|
||||
if (inputs.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}`);
|
||||
}
|
||||
function setCacheDir(cacheLocalPath: string): void {
|
||||
core.exportVariable("UV_CACHE_DIR", cacheLocalPath);
|
||||
core.info(`Set UV_CACHE_DIR to ${cacheLocalPath}`);
|
||||
}
|
||||
|
||||
function addMatchers(inputs: SetupInputs): void {
|
||||
if (inputs.addProblemMatchers) {
|
||||
const matchersPath = path.join(sourceDir, "..", "..", ".github");
|
||||
function addMatchers(): void {
|
||||
if (addProblemMatchers) {
|
||||
const matchersPath = path.join(__dirname, `..${path.sep}..`, ".github");
|
||||
core.info(`##[add-matcher]${path.join(matchersPath, "python.json")}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
import * as core from "@actions/core";
|
||||
import * as semver from "semver";
|
||||
import { KNOWN_CHECKSUMS } from "./download/checksum/known-checksums";
|
||||
import {
|
||||
type ChecksumEntry,
|
||||
updateChecksums,
|
||||
} from "./download/checksum/update-known-checksums";
|
||||
import {
|
||||
fetchManifest,
|
||||
getLatestVersion,
|
||||
type ManifestVersion,
|
||||
} from "./download/manifest";
|
||||
|
||||
const VERSION_IN_CHECKSUM_KEY_PATTERN =
|
||||
/-(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)$/;
|
||||
|
||||
async function run(): Promise<void> {
|
||||
const checksumFilePath = process.argv.slice(2)[0];
|
||||
if (!checksumFilePath) {
|
||||
throw new Error(
|
||||
"Missing checksum file path. Usage: node dist/update-known-checksums/index.cjs <checksum-file-path>",
|
||||
);
|
||||
}
|
||||
|
||||
const latestVersion = await getLatestVersion();
|
||||
const latestKnownVersion = getLatestKnownVersionFromChecksums();
|
||||
|
||||
if (semver.lte(latestVersion, latestKnownVersion)) {
|
||||
core.info(
|
||||
`Latest release (${latestVersion}) is not newer than the latest known version (${latestKnownVersion}). Skipping update.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const versions = await fetchManifest();
|
||||
const checksumEntries = extractChecksumsFromManifest(versions);
|
||||
await updateChecksums(checksumFilePath, checksumEntries);
|
||||
|
||||
core.setOutput("latest-version", latestVersion);
|
||||
}
|
||||
|
||||
function getLatestKnownVersionFromChecksums(): string {
|
||||
const versions = new Set<string>();
|
||||
|
||||
for (const key of Object.keys(KNOWN_CHECKSUMS)) {
|
||||
const version = extractVersionFromChecksumKey(key);
|
||||
if (version !== undefined) {
|
||||
versions.add(version);
|
||||
}
|
||||
}
|
||||
|
||||
const latestVersion = [...versions].sort(semver.rcompare)[0];
|
||||
if (!latestVersion) {
|
||||
throw new Error("Could not determine latest known version from checksums.");
|
||||
}
|
||||
|
||||
return latestVersion;
|
||||
}
|
||||
|
||||
function extractVersionFromChecksumKey(key: string): string | undefined {
|
||||
return key.match(VERSION_IN_CHECKSUM_KEY_PATTERN)?.[1];
|
||||
}
|
||||
|
||||
function extractChecksumsFromManifest(
|
||||
versions: ManifestVersion[],
|
||||
): ChecksumEntry[] {
|
||||
const checksums: ChecksumEntry[] = [];
|
||||
|
||||
for (const version of versions) {
|
||||
for (const artifact of version.artifacts) {
|
||||
checksums.push({
|
||||
checksum: artifact.sha256,
|
||||
key: `${artifact.platform}-${version.version}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return checksums;
|
||||
}
|
||||
|
||||
run();
|
||||
@@ -0,0 +1,56 @@
|
||||
import * as core from "@actions/core";
|
||||
import * as semver from "semver";
|
||||
import { updateChecksums } from "./download/checksum/update-known-checksums";
|
||||
import {
|
||||
getLatestKnownVersion,
|
||||
updateVersionManifest,
|
||||
} from "./download/version-manifest";
|
||||
import { OWNER, REPO } from "./utils/constants";
|
||||
import { Octokit } from "./utils/octokit";
|
||||
|
||||
async function run(): Promise<void> {
|
||||
const checksumFilePath = process.argv.slice(2)[0];
|
||||
const versionsManifestFile = process.argv.slice(2)[1];
|
||||
const githubToken = process.argv.slice(2)[2];
|
||||
|
||||
const octokit = new Octokit({
|
||||
auth: githubToken,
|
||||
});
|
||||
|
||||
const { data: latestRelease } = await octokit.rest.repos.getLatestRelease({
|
||||
owner: OWNER,
|
||||
repo: REPO,
|
||||
});
|
||||
|
||||
const latestKnownVersion = await getLatestKnownVersion(undefined);
|
||||
|
||||
if (semver.lte(latestRelease.tag_name, latestKnownVersion)) {
|
||||
core.info(
|
||||
`Latest release (${latestRelease.tag_name}) is not newer than the latest known version (${latestKnownVersion}). Skipping update.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const releases = await octokit.paginate(octokit.rest.repos.listReleases, {
|
||||
owner: OWNER,
|
||||
repo: REPO,
|
||||
});
|
||||
const checksumDownloadUrls: string[] = releases.flatMap((release) =>
|
||||
release.assets
|
||||
.filter((asset) => asset.name.endsWith(".sha256"))
|
||||
.map((asset) => asset.browser_download_url),
|
||||
);
|
||||
await updateChecksums(checksumFilePath, checksumDownloadUrls);
|
||||
|
||||
const artifactDownloadUrls: string[] = releases.flatMap((release) =>
|
||||
release.assets
|
||||
.filter((asset) => !asset.name.endsWith(".sha256"))
|
||||
.map((asset) => asset.browser_download_url),
|
||||
);
|
||||
|
||||
await updateVersionManifest(versionsManifestFile, artifactDownloadUrls);
|
||||
|
||||
core.setOutput("latest-version", latestRelease.tag_name);
|
||||
}
|
||||
|
||||
run();
|
||||
@@ -1,37 +0,0 @@
|
||||
import fs from "node:fs";
|
||||
import * as toml from "smol-toml";
|
||||
|
||||
export function getConfigValueFromTomlFile(
|
||||
filePath: string,
|
||||
key: string,
|
||||
): string | undefined {
|
||||
if (!fs.existsSync(filePath) || !filePath.endsWith(".toml")) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const fileContent = fs.readFileSync(filePath, "utf-8");
|
||||
return getConfigValueFromTomlContent(filePath, fileContent, key);
|
||||
}
|
||||
|
||||
export function getConfigValueFromTomlContent(
|
||||
filePath: string,
|
||||
fileContent: string,
|
||||
key: string,
|
||||
): string | undefined {
|
||||
if (!filePath.endsWith(".toml")) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (filePath.endsWith("pyproject.toml")) {
|
||||
const tomlContent = toml.parse(fileContent) as {
|
||||
tool?: { uv?: Record<string, string | undefined> };
|
||||
};
|
||||
return tomlContent?.tool?.uv?.[key];
|
||||
}
|
||||
|
||||
const tomlContent = toml.parse(fileContent) as Record<
|
||||
string,
|
||||
string | undefined
|
||||
>;
|
||||
return tomlContent[key];
|
||||
}
|
||||
+2
-12
@@ -1,13 +1,3 @@
|
||||
export const REPO = "uv";
|
||||
export const OWNER = "astral-sh";
|
||||
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 =
|
||||
"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/";
|
||||
|
||||
+43
-237
@@ -1,134 +1,40 @@
|
||||
import path from "node:path";
|
||||
import * as core from "@actions/core";
|
||||
import { getConfigValueFromTomlFile } from "./config-file";
|
||||
|
||||
export enum CacheLocalSource {
|
||||
Input,
|
||||
Config,
|
||||
Env,
|
||||
Default,
|
||||
}
|
||||
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 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 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 serverUrl = core.getInput("server-url");
|
||||
export const githubToken = core.getInput("github-token");
|
||||
export const manifestFile = getManifestFile();
|
||||
export const addProblemMatchers =
|
||||
core.getInput("add-problem-matchers") === "true";
|
||||
|
||||
export interface CacheLocalPath {
|
||||
path: string;
|
||||
source: CacheLocalSource;
|
||||
}
|
||||
|
||||
export type ResolutionStrategy = "highest" | "lowest";
|
||||
|
||||
export interface SetupInputs {
|
||||
workingDirectory: string;
|
||||
version: string;
|
||||
versionFile: string;
|
||||
pythonVersion: string;
|
||||
activateEnvironment: boolean;
|
||||
noProject: 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 noProject = core.getBooleanInput("no-project");
|
||||
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,
|
||||
noProject,
|
||||
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 {
|
||||
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(workingDirectory, ".venv"));
|
||||
}
|
||||
|
||||
function getEnableCache(): boolean {
|
||||
const enableCacheInput = core.getInput("enable-cache");
|
||||
if (enableCacheInput === "auto") {
|
||||
@@ -137,11 +43,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) {
|
||||
@@ -154,11 +60,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) {
|
||||
@@ -171,105 +77,34 @@ function getToolDir(workingDirectory: string): string | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getCacheLocalPath(
|
||||
workingDirectory: string,
|
||||
versionFile: string,
|
||||
enableCache: boolean,
|
||||
): CacheLocalPath | undefined {
|
||||
function getCacheLocalPath(): string {
|
||||
const cacheLocalPathInput = core.getInput("cache-local-path");
|
||||
if (cacheLocalPathInput !== "") {
|
||||
const tildeExpanded = expandTilde(cacheLocalPathInput);
|
||||
return {
|
||||
path: resolveRelativePath(workingDirectory, tildeExpanded),
|
||||
source: CacheLocalSource.Input,
|
||||
};
|
||||
return resolveRelativePath(tildeExpanded);
|
||||
}
|
||||
const cacheDirFromConfig = getCacheDirFromConfig(
|
||||
workingDirectory,
|
||||
versionFile,
|
||||
);
|
||||
if (cacheDirFromConfig !== undefined) {
|
||||
return { path: cacheDirFromConfig, source: CacheLocalSource.Config };
|
||||
}
|
||||
if (process.env.UV_CACHE_DIR !== undefined) {
|
||||
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 (process.env.RUNNER_ENVIRONMENT === "github-hosted") {
|
||||
if (process.env.RUNNER_TEMP !== undefined) {
|
||||
return {
|
||||
path: `${process.env.RUNNER_TEMP}${path.sep}setup-uv-cache`,
|
||||
source: CacheLocalSource.Default,
|
||||
};
|
||||
}
|
||||
throw Error(
|
||||
"Could not determine UV_CACHE_DIR. Please make sure RUNNER_TEMP is set or provide the cache-local-path input",
|
||||
);
|
||||
if (process.env.RUNNER_ENVIRONMENT === "github-hosted") {
|
||||
if (process.env.RUNNER_TEMP !== undefined) {
|
||||
return `${process.env.RUNNER_TEMP}${path.sep}setup-uv-cache`;
|
||||
}
|
||||
if (process.platform === "win32") {
|
||||
return {
|
||||
path: `${process.env.APPDATA}${path.sep}uv${path.sep}cache`,
|
||||
source: CacheLocalSource.Default,
|
||||
};
|
||||
}
|
||||
return {
|
||||
path: `${process.env.HOME}${path.sep}.cache${path.sep}uv`,
|
||||
source: CacheLocalSource.Default,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function getCacheDirFromConfig(
|
||||
workingDirectory: string,
|
||||
versionFile: string,
|
||||
): string | undefined {
|
||||
for (const filePath of [versionFile, "uv.toml", "pyproject.toml"]) {
|
||||
const resolvedPath = resolveRelativePath(workingDirectory, filePath);
|
||||
try {
|
||||
const cacheDir = getConfigValueFromTomlFile(resolvedPath, "cache-dir");
|
||||
if (cacheDir !== undefined) {
|
||||
core.info(`Found cache-dir in ${resolvedPath}: ${cacheDir}`);
|
||||
return cacheDir;
|
||||
}
|
||||
} catch (err) {
|
||||
const message = (err as Error).message;
|
||||
core.warning(`Error while parsing ${filePath}: ${message}`);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function getUvPythonDir(): string {
|
||||
if (process.env.UV_PYTHON_INSTALL_DIR !== undefined) {
|
||||
core.info(
|
||||
`UV_PYTHON_INSTALL_DIR is already set to ${process.env.UV_PYTHON_INSTALL_DIR}`,
|
||||
throw Error(
|
||||
"Could not determine UV_CACHE_DIR. Please make sure RUNNER_TEMP is set or provide the cache-local-path input",
|
||||
);
|
||||
return process.env.UV_PYTHON_INSTALL_DIR;
|
||||
}
|
||||
if (process.env.RUNNER_ENVIRONMENT !== "github-hosted") {
|
||||
if (process.platform === "win32") {
|
||||
return `${process.env.APPDATA}${path.sep}uv${path.sep}python`;
|
||||
}
|
||||
return `${process.env.HOME}${path.sep}.local${path.sep}share${path.sep}uv${path.sep}python`;
|
||||
if (process.platform === "win32") {
|
||||
return `${process.env.APPDATA}${path.sep}uv${path.sep}cache`;
|
||||
}
|
||||
if (process.env.RUNNER_TEMP !== undefined) {
|
||||
return `${process.env.RUNNER_TEMP}${path.sep}uv-python-dir`;
|
||||
}
|
||||
throw Error(
|
||||
"Could not determine UV_PYTHON_INSTALL_DIR. Please make sure RUNNER_TEMP is set or provide the UV_PYTHON_INSTALL_DIR environment variable",
|
||||
);
|
||||
return `${process.env.HOME}${path.sep}.cache${path.sep}uv`;
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -282,23 +117,7 @@ function expandTilde(input: string): string {
|
||||
return input;
|
||||
}
|
||||
|
||||
function normalizePath(inputPath: string): string {
|
||||
const normalized = path.normalize(inputPath);
|
||||
const root = path.parse(normalized).root;
|
||||
|
||||
// Remove any trailing path separators, except when the whole path is the root.
|
||||
let trimmed = normalized;
|
||||
while (trimmed.length > root.length && trimmed.endsWith(path.sep)) {
|
||||
trimmed = trimmed.slice(0, -1);
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@@ -317,16 +136,3 @@ function getManifestFile(): string | undefined {
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getResolutionStrategy(): ResolutionStrategy {
|
||||
const resolutionStrategyInput = core.getInput("resolution-strategy");
|
||||
if (resolutionStrategyInput === "lowest") {
|
||||
return "lowest";
|
||||
}
|
||||
if (resolutionStrategyInput === "highest" || resolutionStrategyInput === "") {
|
||||
return "highest";
|
||||
}
|
||||
throw new Error(
|
||||
`Invalid resolution-strategy: ${resolutionStrategyInput}. Must be 'highest' or 'lowest'.`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Octokit as Core } from "@octokit/core";
|
||||
import type {
|
||||
Constructor,
|
||||
OctokitOptions,
|
||||
} from "@octokit/core/dist-types/types";
|
||||
import {
|
||||
type PaginateInterface,
|
||||
paginateRest,
|
||||
} from "@octokit/plugin-paginate-rest";
|
||||
import { legacyRestEndpointMethods } from "@octokit/plugin-rest-endpoint-methods";
|
||||
import { fetch as customFetch } from "./fetch";
|
||||
|
||||
export type { RestEndpointMethodTypes } from "@octokit/plugin-rest-endpoint-methods";
|
||||
|
||||
const DEFAULTS = {
|
||||
baseUrl: "https://api.github.com",
|
||||
userAgent: "setup-uv",
|
||||
};
|
||||
|
||||
export const Octokit: typeof Core &
|
||||
Constructor<
|
||||
{
|
||||
paginate: PaginateInterface;
|
||||
} & ReturnType<typeof legacyRestEndpointMethods>
|
||||
> = Core.plugin(paginateRest, legacyRestEndpointMethods).defaults(
|
||||
function buildDefaults(options: OctokitOptions): OctokitOptions {
|
||||
return {
|
||||
...DEFAULTS,
|
||||
...options,
|
||||
request: {
|
||||
fetch: customFetch,
|
||||
...options.request,
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
export type Octokit = InstanceType<typeof Octokit>;
|
||||
@@ -1,5 +1,3 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import * as core from "@actions/core";
|
||||
import * as exec from "@actions/exec";
|
||||
export type Platform =
|
||||
@@ -13,7 +11,6 @@ export type Architecture =
|
||||
| "x86_64"
|
||||
| "aarch64"
|
||||
| "s390x"
|
||||
| "riscv64gc"
|
||||
| "powerpc64le";
|
||||
|
||||
export function getArch(): Architecture | undefined {
|
||||
@@ -22,7 +19,6 @@ export function getArch(): Architecture | undefined {
|
||||
arm64: "aarch64",
|
||||
ia32: "i686",
|
||||
ppc64: "powerpc64le",
|
||||
riscv64: "riscv64gc",
|
||||
s390x: "s390x",
|
||||
x64: "x86_64",
|
||||
};
|
||||
@@ -78,77 +74,3 @@ async function isMuslOs(): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns OS name and version for cache key differentiation.
|
||||
* Examples: "ubuntu-22.04", "macos-14", "windows-2022"
|
||||
* Throws if OS detection fails.
|
||||
*/
|
||||
export function getOSNameVersion(): string {
|
||||
const platform = process.platform;
|
||||
|
||||
if (platform === "linux") {
|
||||
return getLinuxOSNameVersion();
|
||||
}
|
||||
if (platform === "darwin") {
|
||||
return getMacOSNameVersion();
|
||||
}
|
||||
if (platform === "win32") {
|
||||
return getWindowsNameVersion();
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported platform: ${platform}`);
|
||||
}
|
||||
|
||||
function getLinuxOSNameVersion(): string {
|
||||
const files = ["/etc/os-release", "/usr/lib/os-release"];
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
const content = fs.readFileSync(file, "utf8");
|
||||
const id = parseOsReleaseValue(content, "ID");
|
||||
const versionId = parseOsReleaseValue(content, "VERSION_ID");
|
||||
// Fallback for rolling releases (debian:unstable/testing, arch, etc.)
|
||||
// that don't have VERSION_ID but have VERSION_CODENAME
|
||||
const versionCodename = parseOsReleaseValue(content, "VERSION_CODENAME");
|
||||
|
||||
if (id && versionId) {
|
||||
return `${id}-${versionId}`;
|
||||
}
|
||||
if (id && versionCodename) {
|
||||
return `${id}-${versionCodename}`;
|
||||
}
|
||||
} catch {
|
||||
// Try next file
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
"Failed to determine Linux distribution. " +
|
||||
"Could not read /etc/os-release or /usr/lib/os-release",
|
||||
);
|
||||
}
|
||||
|
||||
function parseOsReleaseValue(content: string, key: string): string | undefined {
|
||||
const regex = new RegExp(`^${key}=["']?([^"'\\n]*)["']?$`, "m");
|
||||
const match = content.match(regex);
|
||||
return match?.[1];
|
||||
}
|
||||
|
||||
function getMacOSNameVersion(): string {
|
||||
const darwinVersion = Number.parseInt(os.release().split(".")[0], 10);
|
||||
if (Number.isNaN(darwinVersion)) {
|
||||
throw new Error(`Failed to parse macOS version from: ${os.release()}`);
|
||||
}
|
||||
const macosVersion = darwinVersion - 9;
|
||||
return `macos-${macosVersion}`;
|
||||
}
|
||||
|
||||
function getWindowsNameVersion(): string {
|
||||
const version = os.version();
|
||||
const match = version.match(/Windows(?: Server)? (\d+)/);
|
||||
if (!match) {
|
||||
throw new Error(`Failed to parse Windows version from: ${version}`);
|
||||
}
|
||||
return `windows-${match[1]}`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import fs from "node:fs";
|
||||
import * as toml from "smol-toml";
|
||||
|
||||
export function getRequiredVersionFromConfigFile(
|
||||
filePath: string,
|
||||
): string | undefined {
|
||||
if (!filePath.endsWith(".toml")) {
|
||||
return undefined;
|
||||
}
|
||||
const fileContent = fs.readFileSync(filePath, "utf-8");
|
||||
|
||||
if (filePath.endsWith("pyproject.toml")) {
|
||||
const tomlContent = toml.parse(fileContent) as {
|
||||
tool?: { uv?: { "required-version"?: string } };
|
||||
};
|
||||
return tomlContent?.tool?.uv?.["required-version"];
|
||||
}
|
||||
const tomlContent = toml.parse(fileContent) as {
|
||||
"required-version"?: string;
|
||||
};
|
||||
return tomlContent["required-version"];
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
import fs from "node:fs";
|
||||
import * as core from "@actions/core";
|
||||
import { getConfigValueFromTomlContent } from "../utils/config-file";
|
||||
import {
|
||||
getUvVersionFromParsedPyproject,
|
||||
getUvVersionFromRequirementsText,
|
||||
parsePyprojectContent,
|
||||
} from "./requirements-file";
|
||||
import { normalizeVersionSpecifier } from "./specifier";
|
||||
import { getUvVersionFromToolVersions } from "./tool-versions-file";
|
||||
import type { ParsedVersionFile, VersionFileFormat } from "./types";
|
||||
|
||||
interface VersionFileParser {
|
||||
format: VersionFileFormat;
|
||||
parse(filePath: string): string | undefined;
|
||||
supports(filePath: string): boolean;
|
||||
}
|
||||
|
||||
const VERSION_FILE_PARSERS: VersionFileParser[] = [
|
||||
{
|
||||
format: ".tool-versions",
|
||||
parse: (filePath) => getUvVersionFromToolVersions(filePath),
|
||||
supports: (filePath) => filePath.endsWith(".tool-versions"),
|
||||
},
|
||||
{
|
||||
format: "uv.toml",
|
||||
parse: (filePath) => {
|
||||
const fileContent = fs.readFileSync(filePath, "utf-8");
|
||||
return getConfigValueFromTomlContent(
|
||||
filePath,
|
||||
fileContent,
|
||||
"required-version",
|
||||
);
|
||||
},
|
||||
supports: (filePath) => filePath.endsWith("uv.toml"),
|
||||
},
|
||||
{
|
||||
format: "pyproject.toml",
|
||||
parse: (filePath) => {
|
||||
const fileContent = fs.readFileSync(filePath, "utf-8");
|
||||
const pyproject = parsePyprojectContent(fileContent);
|
||||
const requiredVersion = pyproject.tool?.uv?.["required-version"];
|
||||
|
||||
if (requiredVersion !== undefined) {
|
||||
return requiredVersion;
|
||||
}
|
||||
|
||||
return getUvVersionFromParsedPyproject(pyproject);
|
||||
},
|
||||
supports: (filePath) => filePath.endsWith("pyproject.toml"),
|
||||
},
|
||||
{
|
||||
format: "requirements",
|
||||
parse: (filePath) => {
|
||||
const fileContent = fs.readFileSync(filePath, "utf-8");
|
||||
return getUvVersionFromRequirementsText(fileContent);
|
||||
},
|
||||
supports: (filePath) => filePath.endsWith(".txt"),
|
||||
},
|
||||
];
|
||||
|
||||
export function getParsedVersionFile(
|
||||
filePath: string,
|
||||
): ParsedVersionFile | undefined {
|
||||
core.info(`Trying to find version for uv in: ${filePath}`);
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
core.info(`Could not find file: ${filePath}`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const parser = getVersionFileParser(filePath);
|
||||
if (parser === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const specifier = parser.parse(filePath);
|
||||
if (specifier === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalizedSpecifier = normalizeVersionSpecifier(specifier);
|
||||
core.info(`Found version for uv in ${filePath}: ${normalizedSpecifier}`);
|
||||
return {
|
||||
format: parser.format,
|
||||
specifier: normalizedSpecifier,
|
||||
};
|
||||
} catch (error) {
|
||||
core.warning(
|
||||
`Error while parsing ${filePath}: ${(error as Error).message}`,
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function getUvVersionFromFile(filePath: string): string | undefined {
|
||||
return getParsedVersionFile(filePath)?.specifier;
|
||||
}
|
||||
|
||||
function getVersionFileParser(filePath: string): VersionFileParser | undefined {
|
||||
return VERSION_FILE_PARSERS.find((parser) => parser.supports(filePath));
|
||||
}
|
||||
@@ -5,23 +5,31 @@ export function getUvVersionFromRequirementsFile(
|
||||
filePath: string,
|
||||
): string | undefined {
|
||||
const fileContent = fs.readFileSync(filePath, "utf-8");
|
||||
|
||||
if (filePath.endsWith(".txt")) {
|
||||
return getUvVersionFromRequirementsText(fileContent);
|
||||
return getUvVersionFromAllDependencies(fileContent.split("\n"));
|
||||
}
|
||||
|
||||
return getUvVersionFromPyprojectContent(fileContent);
|
||||
const dependencies = parsePyprojectDependencies(fileContent);
|
||||
return getUvVersionFromAllDependencies(dependencies);
|
||||
}
|
||||
function getUvVersionFromAllDependencies(
|
||||
allDependencies: string[],
|
||||
): string | undefined {
|
||||
return allDependencies
|
||||
.find((dep: string) => dep.match(/^uv[=<>~!]/))
|
||||
?.match(/^uv([=<>~!]+\S*)/)?.[1]
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function getUvVersionFromRequirementsText(
|
||||
fileContent: string,
|
||||
): string | undefined {
|
||||
return getUvVersionFromAllDependencies(fileContent.split("\n"));
|
||||
interface Pyproject {
|
||||
project?: {
|
||||
dependencies?: string[];
|
||||
"optional-dependencies"?: Record<string, string[]>;
|
||||
};
|
||||
"dependency-groups"?: Record<string, Array<string | object>>;
|
||||
}
|
||||
|
||||
export function getUvVersionFromParsedPyproject(
|
||||
pyproject: Pyproject,
|
||||
): string | undefined {
|
||||
function parsePyprojectDependencies(pyprojectContent: string): string[] {
|
||||
const pyproject: Pyproject = toml.parse(pyprojectContent);
|
||||
const dependencies: string[] = pyproject?.project?.dependencies || [];
|
||||
const optionalDependencies: string[] = Object.values(
|
||||
pyproject?.project?.["optional-dependencies"] || {},
|
||||
@@ -31,39 +39,5 @@ export function getUvVersionFromParsedPyproject(
|
||||
)
|
||||
.flat()
|
||||
.filter((item: string | object) => typeof item === "string");
|
||||
|
||||
return getUvVersionFromAllDependencies(
|
||||
dependencies.concat(optionalDependencies, devDependencies),
|
||||
);
|
||||
}
|
||||
|
||||
export function getUvVersionFromPyprojectContent(
|
||||
pyprojectContent: string,
|
||||
): string | undefined {
|
||||
const pyproject = parsePyprojectContent(pyprojectContent);
|
||||
return getUvVersionFromParsedPyproject(pyproject);
|
||||
}
|
||||
|
||||
export interface Pyproject {
|
||||
project?: {
|
||||
dependencies?: string[];
|
||||
"optional-dependencies"?: Record<string, string[]>;
|
||||
};
|
||||
"dependency-groups"?: Record<string, Array<string | object>>;
|
||||
tool?: {
|
||||
uv?: Record<string, string | undefined>;
|
||||
};
|
||||
}
|
||||
|
||||
export function parsePyprojectContent(pyprojectContent: string): Pyproject {
|
||||
return toml.parse(pyprojectContent) as Pyproject;
|
||||
}
|
||||
|
||||
function getUvVersionFromAllDependencies(
|
||||
allDependencies: string[],
|
||||
): string | undefined {
|
||||
return allDependencies
|
||||
.find((dep: string) => dep.match(/^uv[=<>~!]/))
|
||||
?.match(/^uv([=<>~!]+\S*)/)?.[1]
|
||||
.trim();
|
||||
return dependencies.concat(optionalDependencies, devDependencies);
|
||||
}
|
||||
|
||||
+28
-177
@@ -1,183 +1,34 @@
|
||||
import fs from "node:fs";
|
||||
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 { getAllVersions, getLatestVersion } from "../download/manifest";
|
||||
import type { ResolutionStrategy } from "../utils/inputs";
|
||||
import {
|
||||
type ParsedVersionSpecifier,
|
||||
parseVersionSpecifier,
|
||||
} from "./specifier";
|
||||
import type { ResolveUvVersionOptions } from "./types";
|
||||
import { resolveVersionRequest } from "./version-request-resolver";
|
||||
import { getRequiredVersionFromConfigFile } from "./config-file";
|
||||
import { getUvVersionFromRequirementsFile } from "./requirements-file";
|
||||
import { getUvVersionFromToolVersions } from "./tool-versions-file";
|
||||
|
||||
interface ConcreteVersionResolutionContext {
|
||||
manifestUrl?: string;
|
||||
parsedSpecifier: ParsedVersionSpecifier;
|
||||
resolutionStrategy: ResolutionStrategy;
|
||||
}
|
||||
|
||||
interface ConcreteVersionResolver {
|
||||
resolve(
|
||||
context: ConcreteVersionResolutionContext,
|
||||
): Promise<string | undefined>;
|
||||
}
|
||||
|
||||
class ExactVersionResolver implements ConcreteVersionResolver {
|
||||
async resolve(
|
||||
context: ConcreteVersionResolutionContext,
|
||||
): Promise<string | undefined> {
|
||||
if (context.parsedSpecifier.kind !== "exact") {
|
||||
return undefined;
|
||||
export function getUvVersionFromFile(filePath: string): string | undefined {
|
||||
core.info(`Trying to find version for uv in: ${filePath}`);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
core.info(`Could not find file: ${filePath}`);
|
||||
return undefined;
|
||||
}
|
||||
let uvVersion: string | undefined;
|
||||
try {
|
||||
uvVersion = getUvVersionFromToolVersions(filePath);
|
||||
if (uvVersion === undefined) {
|
||||
uvVersion = getRequiredVersionFromConfigFile(filePath);
|
||||
}
|
||||
|
||||
core.debug(
|
||||
`Version ${context.parsedSpecifier.normalized} is an explicit version.`,
|
||||
);
|
||||
return context.parsedSpecifier.normalized;
|
||||
}
|
||||
}
|
||||
|
||||
class LatestVersionResolver implements ConcreteVersionResolver {
|
||||
async resolve(
|
||||
context: ConcreteVersionResolutionContext,
|
||||
): Promise<string | undefined> {
|
||||
const shouldUseLatestVersion =
|
||||
context.parsedSpecifier.kind === "latest" ||
|
||||
(context.parsedSpecifier.kind === "range" &&
|
||||
context.parsedSpecifier.isSimpleMinimumVersionSpecifier &&
|
||||
context.resolutionStrategy === "highest");
|
||||
|
||||
if (!shouldUseLatestVersion) {
|
||||
return undefined;
|
||||
if (uvVersion === undefined) {
|
||||
uvVersion = getUvVersionFromRequirementsFile(filePath);
|
||||
}
|
||||
|
||||
if (
|
||||
context.parsedSpecifier.kind === "range" &&
|
||||
context.parsedSpecifier.isSimpleMinimumVersionSpecifier
|
||||
) {
|
||||
core.info("Found minimum version specifier, using latest version");
|
||||
}
|
||||
|
||||
const latestVersion = await getLatestVersion(context.manifestUrl);
|
||||
|
||||
if (
|
||||
context.parsedSpecifier.kind === "range" &&
|
||||
context.parsedSpecifier.isSimpleMinimumVersionSpecifier &&
|
||||
!pep440.satisfies(latestVersion, context.parsedSpecifier.raw)
|
||||
) {
|
||||
throw new Error(`No version found for ${context.parsedSpecifier.raw}`);
|
||||
}
|
||||
|
||||
return latestVersion;
|
||||
} catch (err) {
|
||||
const message = (err as Error).message;
|
||||
core.warning(`Error while parsing ${filePath}: ${message}`);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
class RangeVersionResolver implements ConcreteVersionResolver {
|
||||
async resolve(
|
||||
context: ConcreteVersionResolutionContext,
|
||||
): Promise<string | undefined> {
|
||||
if (context.parsedSpecifier.kind !== "range") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const availableVersions = await getAllVersions(context.manifestUrl);
|
||||
core.debug(`Available versions: ${availableVersions}`);
|
||||
|
||||
const resolvedVersion =
|
||||
context.resolutionStrategy === "lowest"
|
||||
? minSatisfying(availableVersions, context.parsedSpecifier.normalized)
|
||||
: maxSatisfying(availableVersions, context.parsedSpecifier.normalized);
|
||||
|
||||
if (resolvedVersion === undefined) {
|
||||
throw new Error(`No version found for ${context.parsedSpecifier.raw}`);
|
||||
}
|
||||
|
||||
return resolvedVersion;
|
||||
}
|
||||
}
|
||||
|
||||
const CONCRETE_VERSION_RESOLVERS: ConcreteVersionResolver[] = [
|
||||
new ExactVersionResolver(),
|
||||
new LatestVersionResolver(),
|
||||
new RangeVersionResolver(),
|
||||
];
|
||||
|
||||
export async function resolveUvVersion(
|
||||
options: ResolveUvVersionOptions,
|
||||
): Promise<string> {
|
||||
const request = resolveVersionRequest(options);
|
||||
const resolutionStrategy = options.resolutionStrategy ?? "highest";
|
||||
const version = await resolveVersion(
|
||||
request.specifier,
|
||||
options.manifestFile,
|
||||
resolutionStrategy,
|
||||
);
|
||||
|
||||
return version;
|
||||
}
|
||||
|
||||
export async function resolveVersion(
|
||||
versionInput: string,
|
||||
manifestUrl: string | undefined,
|
||||
resolutionStrategy: ResolutionStrategy = "highest",
|
||||
): Promise<string> {
|
||||
core.debug(`Resolving version: ${versionInput}`);
|
||||
|
||||
const context: ConcreteVersionResolutionContext = {
|
||||
manifestUrl,
|
||||
parsedSpecifier: parseVersionSpecifier(versionInput),
|
||||
resolutionStrategy,
|
||||
};
|
||||
|
||||
for (const resolver of CONCRETE_VERSION_RESOLVERS) {
|
||||
const version = await resolver.resolve(context);
|
||||
if (version !== undefined) {
|
||||
return version;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`No version found for ${versionInput}`);
|
||||
}
|
||||
|
||||
function maxSatisfying(
|
||||
versions: string[],
|
||||
version: string,
|
||||
): string | undefined {
|
||||
const maxSemver = tc.evaluateVersions(versions, version);
|
||||
if (maxSemver !== "") {
|
||||
core.debug(`Found a version that satisfies the semver range: ${maxSemver}`);
|
||||
return maxSemver;
|
||||
}
|
||||
|
||||
const maxPep440 = pep440.maxSatisfying(versions, version);
|
||||
if (maxPep440 !== null) {
|
||||
core.debug(
|
||||
`Found a version that satisfies the pep440 specifier: ${maxPep440}`,
|
||||
);
|
||||
return maxPep440;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function minSatisfying(
|
||||
versions: string[],
|
||||
version: string,
|
||||
): string | undefined {
|
||||
const minSemver = semver.minSatisfying(versions, version);
|
||||
if (minSemver !== null) {
|
||||
core.debug(`Found a version that satisfies the semver range: ${minSemver}`);
|
||||
return minSemver;
|
||||
}
|
||||
|
||||
const minPep440 = pep440.minSatisfying(versions, version);
|
||||
if (minPep440 !== null) {
|
||||
core.debug(
|
||||
`Found a version that satisfies the pep440 specifier: ${minPep440}`,
|
||||
);
|
||||
return minPep440;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
if (uvVersion?.startsWith("==")) {
|
||||
uvVersion = uvVersion.slice(2);
|
||||
}
|
||||
if (uvVersion !== undefined) {
|
||||
core.info(`Found version for uv in ${filePath}: ${uvVersion}`);
|
||||
}
|
||||
return uvVersion;
|
||||
}
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
import * as tc from "@actions/tool-cache";
|
||||
|
||||
export type ParsedVersionSpecifier =
|
||||
| {
|
||||
kind: "exact";
|
||||
normalized: string;
|
||||
raw: string;
|
||||
}
|
||||
| {
|
||||
kind: "latest";
|
||||
normalized: "latest";
|
||||
raw: string;
|
||||
}
|
||||
| {
|
||||
isSimpleMinimumVersionSpecifier: boolean;
|
||||
kind: "range";
|
||||
normalized: string;
|
||||
raw: string;
|
||||
};
|
||||
|
||||
export function normalizeVersionSpecifier(specifier: string): string {
|
||||
const trimmedSpecifier = specifier.trim();
|
||||
|
||||
if (trimmedSpecifier.startsWith("==")) {
|
||||
return trimmedSpecifier.slice(2);
|
||||
}
|
||||
|
||||
return trimmedSpecifier;
|
||||
}
|
||||
|
||||
export function parseVersionSpecifier(
|
||||
specifier: string,
|
||||
): ParsedVersionSpecifier {
|
||||
const raw = specifier.trim();
|
||||
const normalized = normalizeVersionSpecifier(raw);
|
||||
|
||||
if (normalized === "latest") {
|
||||
return {
|
||||
kind: "latest",
|
||||
normalized: "latest",
|
||||
raw,
|
||||
};
|
||||
}
|
||||
|
||||
if (tc.isExplicitVersion(normalized)) {
|
||||
return {
|
||||
kind: "exact",
|
||||
normalized,
|
||||
raw,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
isSimpleMinimumVersionSpecifier: raw.includes(">") && !raw.includes(","),
|
||||
kind: "range",
|
||||
normalized,
|
||||
raw,
|
||||
};
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import type { ResolutionStrategy } from "../utils/inputs";
|
||||
|
||||
export type VersionSource =
|
||||
| "input"
|
||||
| "version-file"
|
||||
| "uv.toml"
|
||||
| "pyproject.toml"
|
||||
| "default";
|
||||
|
||||
export type VersionFileFormat =
|
||||
| ".tool-versions"
|
||||
| "pyproject.toml"
|
||||
| "requirements"
|
||||
| "uv.toml";
|
||||
|
||||
export interface ParsedVersionFile {
|
||||
format: VersionFileFormat;
|
||||
specifier: string;
|
||||
}
|
||||
|
||||
export interface ResolveUvVersionOptions {
|
||||
manifestFile?: string;
|
||||
resolutionStrategy?: ResolutionStrategy;
|
||||
version?: string;
|
||||
versionFile?: string;
|
||||
workingDirectory: string;
|
||||
}
|
||||
|
||||
export interface VersionRequest {
|
||||
format?: VersionFileFormat;
|
||||
source: VersionSource;
|
||||
sourcePath?: string;
|
||||
specifier: string;
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
import * as path from "node:path";
|
||||
import * as core from "@actions/core";
|
||||
import { getParsedVersionFile } from "./file-parser";
|
||||
import { normalizeVersionSpecifier } from "./specifier";
|
||||
import type {
|
||||
ParsedVersionFile,
|
||||
ResolveUvVersionOptions,
|
||||
VersionRequest,
|
||||
} from "./types";
|
||||
|
||||
export interface VersionRequestResolver {
|
||||
resolve(context: VersionRequestContext): VersionRequest | undefined;
|
||||
}
|
||||
|
||||
export class VersionRequestContext {
|
||||
readonly version: string | undefined;
|
||||
readonly versionFile: string | undefined;
|
||||
readonly workingDirectory: string;
|
||||
|
||||
private readonly parsedFiles = new Map<
|
||||
string,
|
||||
ParsedVersionFile | undefined
|
||||
>();
|
||||
|
||||
constructor(
|
||||
version: string | undefined,
|
||||
versionFile: string | undefined,
|
||||
workingDirectory: string,
|
||||
) {
|
||||
this.version = version;
|
||||
this.versionFile = versionFile;
|
||||
this.workingDirectory = workingDirectory;
|
||||
}
|
||||
|
||||
getVersionFile(filePath: string): ParsedVersionFile | undefined {
|
||||
const cachedResult = this.parsedFiles.get(filePath);
|
||||
if (cachedResult !== undefined || this.parsedFiles.has(filePath)) {
|
||||
return cachedResult;
|
||||
}
|
||||
|
||||
const result = getParsedVersionFile(filePath);
|
||||
this.parsedFiles.set(filePath, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
getWorkspaceCandidates(): Array<{
|
||||
source: "pyproject.toml" | "uv.toml";
|
||||
sourcePath: string;
|
||||
}> {
|
||||
return [
|
||||
{
|
||||
source: "uv.toml",
|
||||
sourcePath: path.join(this.workingDirectory, "uv.toml"),
|
||||
},
|
||||
{
|
||||
source: "pyproject.toml",
|
||||
sourcePath: path.join(this.workingDirectory, "pyproject.toml"),
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
export class ExplicitInputVersionResolver implements VersionRequestResolver {
|
||||
resolve(context: VersionRequestContext): VersionRequest | undefined {
|
||||
if (context.version === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
source: "input",
|
||||
specifier: normalizeVersionSpecifier(context.version),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class VersionFileVersionResolver implements VersionRequestResolver {
|
||||
resolve(context: VersionRequestContext): VersionRequest | undefined {
|
||||
if (context.versionFile === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const versionFile = context.getVersionFile(context.versionFile);
|
||||
if (versionFile === undefined) {
|
||||
throw new Error(
|
||||
`Could not determine uv version from file: ${context.versionFile}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
format: versionFile.format,
|
||||
source: "version-file",
|
||||
sourcePath: context.versionFile,
|
||||
specifier: versionFile.specifier,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class WorkspaceVersionResolver implements VersionRequestResolver {
|
||||
resolve(context: VersionRequestContext): VersionRequest | undefined {
|
||||
for (const candidate of context.getWorkspaceCandidates()) {
|
||||
const versionFile = context.getVersionFile(candidate.sourcePath);
|
||||
if (versionFile === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return {
|
||||
format: versionFile.format,
|
||||
source: candidate.source,
|
||||
sourcePath: candidate.sourcePath,
|
||||
specifier: versionFile.specifier,
|
||||
};
|
||||
}
|
||||
|
||||
core.info(
|
||||
"Could not determine uv version from uv.toml or pyproject.toml. Falling back to latest.",
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export class LatestVersionResolver implements VersionRequestResolver {
|
||||
resolve(): VersionRequest {
|
||||
return {
|
||||
source: "default",
|
||||
specifier: "latest",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const VERSION_REQUEST_RESOLVERS: VersionRequestResolver[] = [
|
||||
new ExplicitInputVersionResolver(),
|
||||
new VersionFileVersionResolver(),
|
||||
new WorkspaceVersionResolver(),
|
||||
new LatestVersionResolver(),
|
||||
];
|
||||
|
||||
export function resolveVersionRequest(
|
||||
options: ResolveUvVersionOptions,
|
||||
): VersionRequest {
|
||||
const context = new VersionRequestContext(
|
||||
emptyToUndefined(options.version),
|
||||
emptyToUndefined(options.versionFile),
|
||||
options.workingDirectory,
|
||||
);
|
||||
|
||||
for (const resolver of VERSION_REQUEST_RESOLVERS) {
|
||||
const request = resolver.resolve(context);
|
||||
if (request !== undefined) {
|
||||
return request;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Could not resolve a requested uv version.");
|
||||
}
|
||||
|
||||
function emptyToUndefined(value: string | undefined): string | undefined {
|
||||
return value === undefined || value === "" ? undefined : value;
|
||||
}
|
||||
+8
-8
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"esModuleInterop": true,
|
||||
"isolatedModules": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"noImplicitAny": true,
|
||||
"strict": true,
|
||||
"target": "ES2022"
|
||||
"esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */,
|
||||
"module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */,
|
||||
"noImplicitAny": true /* Raise error on expressions and declarations with an implied 'any' type. */,
|
||||
"outDir": "./lib" /* Redirect output structure to the directory. */,
|
||||
"rootDir": "./src" /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */,
|
||||
"strict": true /* Enable all strict type-checking options. */,
|
||||
"target": "ES2022" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
"exclude": ["node_modules", "**/*.test.ts"]
|
||||
}
|
||||
|
||||
+25020
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user