mirror of
https://github.com/astral-sh/ruff-action.git
synced 2026-08-08 18:27:03 +00:00
Compare commits
69
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
28ec38735d | ||
|
|
2957f9e73d | ||
|
|
5b168050b9 | ||
|
|
3bb5cd7c3c | ||
|
|
8d2a42ddf5 | ||
|
|
278981a28c | ||
|
|
c35f46c92b | ||
|
|
f3db229c84 | ||
|
|
270e80dfe7 | ||
|
|
5fedeebdb3 | ||
|
|
99fe79f472 | ||
|
|
32ad1b993e | ||
|
|
f78e087041 | ||
|
|
2f88679623 | ||
|
|
b79a7ac759 | ||
|
|
30c0929868 | ||
|
|
b4bf7655a0 | ||
|
|
7f78e88b21 | ||
|
|
8c228c72ff | ||
|
|
248b6d6da3 | ||
|
|
ecaa98070e | ||
|
|
a9cfed68e4 | ||
|
|
eb44a0e99b | ||
|
|
2915b93f0d | ||
|
|
0ce1b0bf8b | ||
|
|
9b8caf6c41 | ||
|
|
535554df96 | ||
|
|
2186c6ecae | ||
|
|
26892dbe43 | ||
|
|
d7f6ad639a | ||
|
|
5b5935861b | ||
|
|
0be154b683 | ||
|
|
f611dfc122 | ||
|
|
d40baf4d10 | ||
|
|
18ddc929c7 | ||
|
|
25445a5bce | ||
|
|
cb58d827d0 | ||
|
|
845ce6a88f | ||
|
|
48f37fab2d | ||
|
|
53288957fd | ||
|
|
bab84a8f49 | ||
|
|
4919ec5cf1 | ||
|
|
1977806bc6 | ||
|
|
aedff8d295 | ||
|
|
191187a20a | ||
|
|
ecac2cc03c | ||
|
|
ddb8c29960 | ||
|
|
5eee2a4332 | ||
|
|
1d756c4b80 | ||
|
|
fde82cb611 | ||
|
|
deb632007b | ||
|
|
90ea8a399c | ||
|
|
7840fe30a7 | ||
|
|
56403b4f7c | ||
|
|
eacaa6bf71 | ||
|
|
e85c033ebc | ||
|
|
8b935c8722 | ||
|
|
c03f335068 | ||
|
|
7b97a6d66e | ||
|
|
5960f93ec0 | ||
|
|
1e133b7ccc | ||
|
|
cd51da8b6c | ||
|
|
27c6fcea11 | ||
|
|
b08f7b47e5 | ||
|
|
e7baf99fcc | ||
|
|
4f45dfa46b | ||
|
|
4ac3c5ec56 | ||
|
|
7f084f337e | ||
|
|
87990efc6b |
@@ -0,0 +1,53 @@
|
|||||||
|
---
|
||||||
|
name: dependabot-pr-rollup
|
||||||
|
description: Find open Dependabot PRs for ruff-action, compare each PR head to its base branch, replay only the remaining npm and GitHub Actions changes in a fresh worktree and branch, run repository 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 this repo
|
||||||
|
- reproduce their net effect in one local branch
|
||||||
|
- validate the result with the repo's standard 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 npm dependencies.
|
||||||
|
- Run `npm ci --ignore-scripts` before applying npm updates so dependency commands run from a clean install.
|
||||||
|
- Use `npm install ... --ignore-scripts` for direct npm dependency changes so `package-lock.json` stays in sync.
|
||||||
|
- Preserve whether an npm package is a `dependency` or `devDependency`.
|
||||||
|
- When updating `@biomejs/biome`, also update the Biome schema URL version in `biome.json` to match the installed Biome version.
|
||||||
|
- For GitHub Actions updates, apply the net workflow-file changes from the Dependabot PR heads after confirming they still apply cleanly to the selected base.
|
||||||
|
7. Run `npm run all`.
|
||||||
|
8. If requested, commit the changed source, lockfile, workflow files, 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.
|
||||||
|
- This repo has Dependabot configured for both `npm` and `github-actions`.
|
||||||
|
- In this repo, `npm run all` is the safest validation command because it runs build, check, package, and unit tests.
|
||||||
|
- `npm run check` may rewrite files with Biome; include those intentional formatting changes.
|
||||||
|
- If dependency changes affect bundled output, include the regenerated `dist/` files from `npm run package`.
|
||||||
|
|
||||||
|
## 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
|
||||||
@@ -4,9 +4,15 @@
|
|||||||
"owner": "ruff-format",
|
"owner": "ruff-format",
|
||||||
"pattern": [
|
"pattern": [
|
||||||
{
|
{
|
||||||
"file": 2,
|
"code": 2,
|
||||||
"message": 1,
|
"column": 5,
|
||||||
"regexp": "^(Would reformat):\\s*(.+)$"
|
"endColumn": 7,
|
||||||
|
"endLine": 6,
|
||||||
|
"file": 3,
|
||||||
|
"line": 4,
|
||||||
|
"message": 8,
|
||||||
|
"regexp": "^(?:(error|warning|notice))\\s+title=([^,]+),file=([^,]+),line=(\\d+),col=(\\d+)(?:,endLine=(\\d+))?(?:,endColumn=(\\d+))?::(.+)$",
|
||||||
|
"severity": 1
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "nodenext",
|
||||||
|
"moduleResolution": "nodenext",
|
||||||
|
"target": "es2022",
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["check-all-tests-passed-needs.ts"]
|
||||||
|
}
|
||||||
@@ -39,13 +39,13 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
# Initializes the CodeQL tools for scanning.
|
# Initializes the CodeQL tools for scanning.
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@3599b3baa15b485a2e49ef411a7a4bb2452e7f93 # v3.30.5
|
uses: github/codeql-action/init@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
|
||||||
with:
|
with:
|
||||||
languages: ${{ matrix.language }}
|
languages: ${{ matrix.language }}
|
||||||
source-root: src
|
source-root: src
|
||||||
@@ -57,7 +57,7 @@ jobs:
|
|||||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
# 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)
|
# If this step fails, then you should remove it and run the build manually (see below)
|
||||||
- name: Autobuild
|
- name: Autobuild
|
||||||
uses: github/codeql-action/autobuild@3599b3baa15b485a2e49ef411a7a4bb2452e7f93 # v3.30.5
|
uses: github/codeql-action/autobuild@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
|
||||||
|
|
||||||
# ℹ️ Command-line programs to run using the OS shell.
|
# ℹ️ Command-line programs to run using the OS shell.
|
||||||
# 📚 https://git.io/JvXDl
|
# 📚 https://git.io/JvXDl
|
||||||
@@ -71,4 +71,4 @@ jobs:
|
|||||||
# make release
|
# make release
|
||||||
|
|
||||||
- name: Perform CodeQL Analysis
|
- name: Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@3599b3baa15b485a2e49ef411a7a4bb2452e7f93 # v3.30.5
|
uses: github/codeql-action/analyze@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ jobs:
|
|||||||
pull-requests: read
|
pull-requests: read
|
||||||
steps:
|
steps:
|
||||||
- name: 🚀 Run Release Drafter
|
- name: 🚀 Run Release Drafter
|
||||||
uses: release-drafter/release-drafter@b1476f6e6eb133afa41ed8589daba6dc69b4d3f5 # v6.1.0
|
uses: release-drafter/release-drafter@c2e2804cc59f45f57076a99af580d0fedb697927 # v7.3.0
|
||||||
|
with:
|
||||||
|
commitish: ${{ github.sha }}
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
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
|
||||||
+166
-24
@@ -15,22 +15,27 @@ permissions: {}
|
|||||||
jobs:
|
jobs:
|
||||||
lint:
|
lint:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
security-events: write # for zizmor
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Actionlint
|
- name: Actionlint
|
||||||
uses: eifinger/actionlint-action@03ff1f78c0670b71017616a37170f327df932030 # v1.9.2
|
uses: eifinger/actionlint-action@1fc89649be682d16ec5cf65ea16e269eb88d3982 # v1.10.2
|
||||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
- name: Run zizmor
|
||||||
|
uses: zizmorcore/zizmor-action@5f14fd08f7cf1cb1609c1e344975f152c7ee938d # v0.5.6
|
||||||
|
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||||
with:
|
with:
|
||||||
node-version: "20"
|
node-version-file: .nvmrc
|
||||||
|
cache: npm
|
||||||
- run: |
|
- run: |
|
||||||
npm install
|
npm ci --ignore-scripts
|
||||||
- run: |
|
- run: |
|
||||||
npm run all
|
npm run all
|
||||||
- name: Check all jobs are in all-tests-passed.needs
|
- name: Check all jobs are in all-tests-passed.needs
|
||||||
run: |
|
run: |
|
||||||
tsc check-all-tests-passed-needs.ts
|
tsc -p tsconfig.json
|
||||||
node check-all-tests-passed-needs.js
|
node check-all-tests-passed-needs.js
|
||||||
working-directory: .github/scripts
|
working-directory: .github/scripts
|
||||||
- name: Make sure no changes from linters are detected
|
- name: Make sure no changes from linters are detected
|
||||||
@@ -42,7 +47,7 @@ jobs:
|
|||||||
matrix:
|
matrix:
|
||||||
os: [ubuntu-latest, macos-latest, macos-14, windows-latest]
|
os: [ubuntu-latest, macos-latest, macos-14, windows-latest]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Use latest version
|
- name: Use latest version
|
||||||
@@ -57,7 +62,7 @@ jobs:
|
|||||||
ruff-version: ["0.1.7", "0.1.8", "0.4.7", "0.4.10", "0.7", "0.7.x", ">=0.7.0"]
|
ruff-version: ["0.1.7", "0.1.8", "0.4.7", "0.4.10", "0.7", "0.7.x", ">=0.7.0"]
|
||||||
os: [ ubuntu-latest, ubuntu-24.04-arm, macos-latest, macos-14, windows-latest ]
|
os: [ ubuntu-latest, ubuntu-24.04-arm, macos-latest, macos-14, windows-latest ]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Use version ${{ matrix.ruff-version }}
|
- name: Use version ${{ matrix.ruff-version }}
|
||||||
@@ -68,7 +73,7 @@ jobs:
|
|||||||
test-unsupported-version:
|
test-unsupported-version:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Try install old version
|
- name: Try install old version
|
||||||
@@ -88,7 +93,7 @@ jobs:
|
|||||||
test-version-from-version-file-pyproject:
|
test-version-from-version-file-pyproject:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Use version from pyproject.toml
|
- name: Use version from pyproject.toml
|
||||||
@@ -107,7 +112,7 @@ jobs:
|
|||||||
test-default-version-from-pyproject:
|
test-default-version-from-pyproject:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Use default version from pyproject.toml
|
- name: Use default version from pyproject.toml
|
||||||
@@ -125,7 +130,7 @@ jobs:
|
|||||||
test-default-version-from-pyproject-dev-group:
|
test-default-version-from-pyproject-dev-group:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Use default version from pyproject.toml dev group
|
- name: Use default version from pyproject.toml dev group
|
||||||
@@ -144,7 +149,7 @@ jobs:
|
|||||||
test-default-version-from-pyproject-dependency-groups:
|
test-default-version-from-pyproject-dependency-groups:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Use default version from pyproject.toml dependency groups
|
- name: Use default version from pyproject.toml dependency groups
|
||||||
@@ -160,10 +165,30 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
env:
|
env:
|
||||||
RUFF_VERSION: ${{ steps.ruff-action.outputs.ruff-version }}
|
RUFF_VERSION: ${{ steps.ruff-action.outputs.ruff-version }}
|
||||||
|
test-default-version-from-pyproject-dependency-groups-with-env-marker:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- name: Use default version from pyproject.toml with environment marker
|
||||||
|
id: ruff-action
|
||||||
|
uses: ./
|
||||||
|
with:
|
||||||
|
src: __tests__/fixtures/python-project
|
||||||
|
version-file: __tests__/fixtures/pyproject-dependency-groups-with-env-marker/pyproject.toml
|
||||||
|
- name: Correct version gets installed
|
||||||
|
run: |
|
||||||
|
if [ "$RUFF_VERSION" != "0.13.3" ]; then
|
||||||
|
echo "Expected version 0.13.3 but got $RUFF_VERSION"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
env:
|
||||||
|
RUFF_VERSION: ${{ steps.ruff-action.outputs.ruff-version }}
|
||||||
test-default-version-from-pyproject-poetry-groups:
|
test-default-version-from-pyproject-poetry-groups:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Use default version from pyproject.toml poetry group dependencies
|
- name: Use default version from pyproject.toml poetry group dependencies
|
||||||
@@ -182,7 +207,7 @@ jobs:
|
|||||||
test-default-version-from-pyproject-poetry:
|
test-default-version-from-pyproject-poetry:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Use default version from pyproject.toml poetry dependencies
|
- name: Use default version from pyproject.toml poetry dependencies
|
||||||
@@ -201,7 +226,7 @@ jobs:
|
|||||||
test-default-version-from-pyproject-optional-dependencies:
|
test-default-version-from-pyproject-optional-dependencies:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Use default version from pyproject.toml optional dependencies
|
- name: Use default version from pyproject.toml optional dependencies
|
||||||
@@ -220,7 +245,7 @@ jobs:
|
|||||||
test-default-version-from-requirements:
|
test-default-version-from-requirements:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Use default version from requirements.txt
|
- name: Use default version from requirements.txt
|
||||||
@@ -236,10 +261,48 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
env:
|
env:
|
||||||
RUFF_VERSION: ${{ steps.ruff-action.outputs.ruff-version }}
|
RUFF_VERSION: ${{ steps.ruff-action.outputs.ruff-version }}
|
||||||
|
test-default-version-from-requirements-with-hash:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- name: Use default version from requirements-with-hash.txt
|
||||||
|
id: ruff-action
|
||||||
|
uses: ./
|
||||||
|
with:
|
||||||
|
src: __tests__/fixtures/python-project
|
||||||
|
version-file: __tests__/fixtures/requirements-with-hash.txt
|
||||||
|
- name: Correct version gets installed
|
||||||
|
run: |
|
||||||
|
if [ "$RUFF_VERSION" != "0.9.0" ]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
env:
|
||||||
|
RUFF_VERSION: ${{ steps.ruff-action.outputs.ruff-version }}
|
||||||
|
test-default-version-from-uv-lock:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- name: Use default version from uv.lock
|
||||||
|
id: ruff-action
|
||||||
|
uses: ./
|
||||||
|
with:
|
||||||
|
src: __tests__/fixtures/python-project
|
||||||
|
version-file: __tests__/fixtures/uv.lock
|
||||||
|
- name: Correct version gets installed
|
||||||
|
run: |
|
||||||
|
if [ "$RUFF_VERSION" != "0.9.5" ]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
env:
|
||||||
|
RUFF_VERSION: ${{ steps.ruff-action.outputs.ruff-version }}
|
||||||
test-semver-range:
|
test-semver-range:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Use version 0.7
|
- name: Use version 0.7
|
||||||
@@ -258,7 +321,7 @@ jobs:
|
|||||||
test-pep440-version-specifier:
|
test-pep440-version-specifier:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Install version 0.9.10
|
- name: Install version 0.9.10
|
||||||
@@ -284,7 +347,7 @@ jobs:
|
|||||||
- os: macos-latest
|
- os: macos-latest
|
||||||
checksum: "af9583bff12afbca5d5670334e0187dd60c4d91bc71317d1b2dde70cb1200ba9"
|
checksum: "af9583bff12afbca5d5670334e0187dd60c4d91bc71317d1b2dde70cb1200ba9"
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Checksum matches expected
|
- name: Checksum matches expected
|
||||||
@@ -296,7 +359,7 @@ jobs:
|
|||||||
test-with-explicit-token:
|
test-with-explicit-token:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Use default version
|
- name: Use default version
|
||||||
@@ -310,7 +373,7 @@ jobs:
|
|||||||
matrix:
|
matrix:
|
||||||
os: [ubuntu-latest, windows-latest]
|
os: [ubuntu-latest, windows-latest]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Use args
|
- name: Use args
|
||||||
@@ -321,7 +384,7 @@ jobs:
|
|||||||
test-failure:
|
test-failure:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Format should fail
|
- name: Format should fail
|
||||||
@@ -351,13 +414,41 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
env:
|
env:
|
||||||
CHECK_SHOULD_FAIL_OUTCOME: ${{ steps.check-should-fail.outcome }}
|
CHECK_SHOULD_FAIL_OUTCOME: ${{ steps.check-should-fail.outcome }}
|
||||||
|
test-glob-src:
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
os: [ ubuntu-latest, windows-latest ]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- name: Use glob pattern in src
|
||||||
|
uses: ./
|
||||||
|
with:
|
||||||
|
src: __tests__/fixtures/glob-project/**/*.py
|
||||||
|
|
||||||
|
test-glob-single-star-src:
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
os: [ ubuntu-latest, windows-latest ]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- name: Use single-star glob pattern in src
|
||||||
|
uses: ./
|
||||||
|
with:
|
||||||
|
src: __tests__/fixtures/glob-single-star-project/foo/bar/*.py
|
||||||
|
|
||||||
test-multiple-src:
|
test-multiple-src:
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
os: [ ubuntu-latest, windows-latest ]
|
os: [ ubuntu-latest, windows-latest ]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Use args
|
- name: Use args
|
||||||
@@ -367,6 +458,50 @@ jobs:
|
|||||||
src: >-
|
src: >-
|
||||||
__tests__/fixtures/python-project/src/python_project/__init__.py
|
__tests__/fixtures/python-project/src/python_project/__init__.py
|
||||||
__tests__/fixtures/python-project/src/python_project/hello_world.py
|
__tests__/fixtures/python-project/src/python_project/hello_world.py
|
||||||
|
test-parent-directory-pyproject:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- name: Use version from parent directory pyproject.toml
|
||||||
|
id: ruff-action
|
||||||
|
uses: ./
|
||||||
|
with:
|
||||||
|
src: __tests__/fixtures/parent-config-project/subproject
|
||||||
|
- name: Correct version gets installed
|
||||||
|
run: |
|
||||||
|
if [ "$RUFF_VERSION" != "0.10.0" ]; then
|
||||||
|
echo "Expected version 0.10.0 but got $RUFF_VERSION"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
env:
|
||||||
|
RUFF_VERSION: ${{ steps.ruff-action.outputs.ruff-version }}
|
||||||
|
|
||||||
|
test-custom-manifest-file:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- name: Create test src
|
||||||
|
run: |
|
||||||
|
mkdir -p "${{ runner.temp }}/ruff-manifest-test"
|
||||||
|
printf 'print("hello")\n' > "${{ runner.temp }}/ruff-manifest-test/hello.py"
|
||||||
|
- name: Install from custom manifest file
|
||||||
|
id: ruff-action
|
||||||
|
uses: ./
|
||||||
|
with:
|
||||||
|
src: ${{ runner.temp }}/ruff-manifest-test
|
||||||
|
manifest-file: "https://raw.githubusercontent.com/astral-sh/ruff-action/${{ github.ref }}/__tests__/download/custom-manifest.ndjson"
|
||||||
|
- name: Correct version gets installed
|
||||||
|
run: |
|
||||||
|
if [ "$RUFF_VERSION" != "0.15.10" ]; then
|
||||||
|
echo "Wrong ruff version: $RUFF_VERSION"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
env:
|
||||||
|
RUFF_VERSION: ${{ steps.ruff-action.outputs.ruff-version }}
|
||||||
|
|
||||||
all-tests-passed:
|
all-tests-passed:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -379,17 +514,24 @@ jobs:
|
|||||||
- test-default-version-from-pyproject
|
- test-default-version-from-pyproject
|
||||||
- test-default-version-from-pyproject-dev-group
|
- test-default-version-from-pyproject-dev-group
|
||||||
- test-default-version-from-pyproject-dependency-groups
|
- test-default-version-from-pyproject-dependency-groups
|
||||||
|
- test-default-version-from-pyproject-dependency-groups-with-env-marker
|
||||||
- test-default-version-from-pyproject-poetry-groups
|
- test-default-version-from-pyproject-poetry-groups
|
||||||
- test-default-version-from-pyproject-poetry
|
- test-default-version-from-pyproject-poetry
|
||||||
- test-default-version-from-pyproject-optional-dependencies
|
- test-default-version-from-pyproject-optional-dependencies
|
||||||
- test-default-version-from-requirements
|
- test-default-version-from-requirements
|
||||||
|
- test-default-version-from-requirements-with-hash
|
||||||
|
- test-default-version-from-uv-lock
|
||||||
- test-semver-range
|
- test-semver-range
|
||||||
- test-pep440-version-specifier
|
- test-pep440-version-specifier
|
||||||
- test-checksum
|
- test-checksum
|
||||||
- test-with-explicit-token
|
- test-with-explicit-token
|
||||||
- test-args
|
- test-args
|
||||||
- test-failure
|
- test-failure
|
||||||
|
- test-glob-src
|
||||||
|
- test-glob-single-star-src
|
||||||
- test-multiple-src
|
- test-multiple-src
|
||||||
|
- test-parent-directory-pyproject
|
||||||
|
- test-custom-manifest-file
|
||||||
if: always()
|
if: always()
|
||||||
steps:
|
steps:
|
||||||
- name: All tests passed
|
- name: All tests passed
|
||||||
|
|||||||
@@ -11,20 +11,21 @@ jobs:
|
|||||||
contents: write
|
contents: write
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||||
with:
|
with:
|
||||||
node-version: "20"
|
node-version-file: .nvmrc
|
||||||
|
cache: npm
|
||||||
- name: Update known checksums
|
- name: Update known checksums
|
||||||
id: update-known-checksums
|
id: update-known-checksums
|
||||||
run:
|
run:
|
||||||
node dist/update-known-checksums/index.js
|
node dist/update-known-checksums/index.cjs
|
||||||
src/download/checksum/known-checksums.ts ${{ secrets.GITHUB_TOKEN }}
|
src/download/checksum/known-checksums.ts ${{ secrets.GITHUB_TOKEN }}
|
||||||
- run: npm install && npm run all
|
- run: npm ci --ignore-scripts && npm run all
|
||||||
- name: Create Pull Request
|
- name: Create Pull Request
|
||||||
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
|
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
|
||||||
with:
|
with:
|
||||||
commit-message: "chore: update known checksums"
|
commit-message: "chore: update known checksums"
|
||||||
title:
|
title:
|
||||||
|
|||||||
@@ -16,9 +16,9 @@ jobs:
|
|||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: true
|
||||||
- name: Update Major Minor Tags
|
- name: Update Major Minor Tags
|
||||||
run: |
|
run: |
|
||||||
set -x
|
set -x
|
||||||
|
|||||||
@@ -20,51 +20,84 @@ anything `ruff` can (ex, fix).
|
|||||||
- [Install a specific version](#install-a-specific-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 by supplying a semver range or pep440 specifier](#install-a-version-by-supplying-a-semver-range-or-pep440-specifier)
|
||||||
- [Install a version from a specified version file](#install-a-version-from-a-specified-version-file)
|
- [Install a version from a specified version file](#install-a-version-from-a-specified-version-file)
|
||||||
|
- [Install using a custom manifest URL](#install-using-a-custom-manifest-url)
|
||||||
- [Validate checksum](#validate-checksum)
|
- [Validate checksum](#validate-checksum)
|
||||||
- [GitHub authentication token](#github-authentication-token)
|
- [GitHub authentication token](#github-authentication-token)
|
||||||
- [Outputs](#outputs)
|
- [Outputs](#outputs)
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
| Input | Description | Default |
|
| Input | Description | Default |
|
||||||
|----------------|--------------------------------------------------------------------------------------------------------------------------------------------|--------------------|
|
|-----------------|--------------------------------------------------------------------------------------------------------------------------------------------|--------------------|
|
||||||
| `version` | The version of Ruff to install. See [Install specific versions](#install-specific-versions) | `latest` |
|
| `version` | The version of Ruff to install. See [Install specific versions](#install-specific-versions) | discovered from `pyproject.toml`, else `latest` |
|
||||||
| `version-file` | The file to read the version from. See [Install a version from a specified version file](#install-a-version-from-a-specified-version-file) | None |
|
| `version-file` | The file to read the version from. See [Install a version from a specified version file](#install-a-version-from-a-specified-version-file) | None |
|
||||||
| `args` | The arguments to pass to the `ruff` command. See [Configuring Ruff] | `check` |
|
| `manifest-file` | URL to a custom Ruff manifest in the `astral-sh/versions` format. | None |
|
||||||
| `src` | The directory or single files to run `ruff` on. | [github.workspace] |
|
| `download-from-astral-mirror` | Download Ruff from the Astral mirror instead of directly from GitHub Releases. | `true` |
|
||||||
| `checksum` | The sha256 checksum of the downloaded executable. | None |
|
| `args` | The arguments to pass to the `ruff` command. See [Configuring Ruff] | `check` |
|
||||||
| `github-token` | The GitHub token to use for authentication. | `GITHUB_TOKEN` |
|
| `src` | Source path(s) to run `ruff` on. Supports glob patterns. | [github.workspace] |
|
||||||
|
| `checksum` | The sha256 checksum of the downloaded artifact. | None |
|
||||||
|
| `github-token` | The GitHub token to use when downloading Ruff release artifacts from GitHub. | `GITHUB_TOKEN` |
|
||||||
|
|
||||||
|
By default, Ruff version metadata is resolved from the
|
||||||
|
[`astral-sh/versions` Ruff manifest](https://github.com/astral-sh/versions/blob/main/v1/ruff.ndjson).
|
||||||
|
|
||||||
### Basic
|
### Basic
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
- uses: astral-sh/ruff-action@v3
|
- uses: astral-sh/ruff-action@v4.1.0
|
||||||
```
|
```
|
||||||
|
|
||||||
### Specify a different source directory
|
### Specify a different source directory
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
- uses: astral-sh/ruff-action@v3
|
- uses: astral-sh/ruff-action@v4.1.0
|
||||||
with:
|
with:
|
||||||
src: "./src"
|
src: "./src"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Specify multiple files
|
### Specify multiple files
|
||||||
|
|
||||||
|
Separate multiple `src` values with whitespace.
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
- uses: astral-sh/ruff-action@v3
|
- uses: astral-sh/ruff-action@v4.1.0
|
||||||
with:
|
with:
|
||||||
src: >-
|
src: >-
|
||||||
path/to/file1.py
|
path/to/file1.py
|
||||||
path/to/file2.py
|
path/to/file2.py
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Use glob patterns
|
||||||
|
|
||||||
|
Glob patterns (`*`, `?`, `[...]`, and `**`) are expanded by the action using
|
||||||
|
[`@actions/glob`](https://github.com/actions/toolkit/tree/main/packages/glob),
|
||||||
|
so they work consistently across Linux, macOS, and Windows runners. This action
|
||||||
|
does not emulate Bash, PowerShell, or other shell-specific glob expansion.
|
||||||
|
|
||||||
|
Hidden files and directories are skipped by default; target them explicitly, for
|
||||||
|
example with `.venv/**/*.py`, to include them. If a pattern does not match any
|
||||||
|
files, the original pattern is passed to Ruff so Ruff can report the missing
|
||||||
|
path. Literal glob metacharacters in file or directory names must be escaped
|
||||||
|
using `@actions/glob` syntax.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- uses: astral-sh/ruff-action@v4.1.0
|
||||||
|
with:
|
||||||
|
src: "src/**/*.py"
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> When using multiple patterns, only the first is used to search for
|
||||||
|
> `pyproject.toml` to determine the Ruff version. Use the `version` input
|
||||||
|
> for explicit control. Large glob expansions may hit command-line length limits,
|
||||||
|
> especially on Windows.
|
||||||
|
|
||||||
### Use to install ruff
|
### Use to install ruff
|
||||||
|
|
||||||
This action adds ruff to the PATH, so you can use it in subsequent steps.
|
This action adds ruff to the PATH, so you can use it in subsequent steps.
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
- uses: astral-sh/ruff-action@v3
|
- uses: astral-sh/ruff-action@v4.1.0
|
||||||
- run: ruff check --fix
|
- run: ruff check --fix
|
||||||
- run: ruff format
|
- run: ruff format
|
||||||
```
|
```
|
||||||
@@ -75,7 +108,7 @@ you can use the `args` input to overwrite the default value (`check`):
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
- name: Install ruff without running check or format
|
- name: Install ruff without running check or format
|
||||||
uses: astral-sh/ruff-action@v3
|
uses: astral-sh/ruff-action@v4.1.0
|
||||||
with:
|
with:
|
||||||
args: "--version"
|
args: "--version"
|
||||||
```
|
```
|
||||||
@@ -83,17 +116,17 @@ you can use the `args` input to overwrite the default value (`check`):
|
|||||||
### Use `ruff format`
|
### Use `ruff format`
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
- uses: astral-sh/ruff-action@v3
|
- uses: astral-sh/ruff-action@v4.1.0
|
||||||
with:
|
with:
|
||||||
args: "format --check --diff"
|
args: "format --check --diff"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Install specific versions
|
### Install specific versions
|
||||||
|
|
||||||
By default this action looks for a pyproject.toml file in the root of the repository to determine
|
By default this action searches upward from `src` until the workspace root to find the nearest
|
||||||
the ruff version to install. If no pyproject.toml file is found, or no ruff version is defined in
|
`pyproject.toml` and determine the Ruff version to install. If no `pyproject.toml` file is found,
|
||||||
`project.dependencies`, `project.optional-dependencies`, or `dependency-groups`,
|
or no Ruff version is defined in `project.dependencies`, `project.optional-dependencies`,
|
||||||
the latest version is installed.
|
`dependency-groups`, or supported Poetry dependency tables, the latest version is installed.
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> This action does only support ruff versions v0.0.247 and above.
|
> This action does only support ruff versions v0.0.247 and above.
|
||||||
@@ -102,7 +135,7 @@ the latest version is installed.
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
- name: Install the latest version of ruff
|
- name: Install the latest version of ruff
|
||||||
uses: astral-sh/ruff-action@v3
|
uses: astral-sh/ruff-action@v4.1.0
|
||||||
with:
|
with:
|
||||||
version: "latest"
|
version: "latest"
|
||||||
```
|
```
|
||||||
@@ -111,7 +144,7 @@ the latest version is installed.
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
- name: Install a specific version of ruff
|
- name: Install a specific version of ruff
|
||||||
uses: astral-sh/ruff-action@v3
|
uses: astral-sh/ruff-action@v4.1.0
|
||||||
with:
|
with:
|
||||||
version: "0.4.4"
|
version: "0.4.4"
|
||||||
```
|
```
|
||||||
@@ -124,21 +157,21 @@ to install the latest version that satisfies the range.
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
- name: Install a semver range of ruff
|
- name: Install a semver range of ruff
|
||||||
uses: astral-sh/ruff-action@v3
|
uses: astral-sh/ruff-action@v4.1.0
|
||||||
with:
|
with:
|
||||||
version: ">=0.4.0"
|
version: ">=0.4.0"
|
||||||
```
|
```
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
- name: Pinning a minor version of ruff
|
- name: Pinning a minor version of ruff
|
||||||
uses: astral-sh/ruff-action@v3
|
uses: astral-sh/ruff-action@v4.1.0
|
||||||
with:
|
with:
|
||||||
version: "0.4.x"
|
version: "0.4.x"
|
||||||
```
|
```
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
- name: Install a pep440-specifier-satisfying version of ruff
|
- name: Install a pep440-specifier-satisfying version of ruff
|
||||||
uses: astral-sh/ruff-action@v3
|
uses: astral-sh/ruff-action@v4.1.0
|
||||||
with:
|
with:
|
||||||
version: ">=0.11.10,<0.12.0"
|
version: ">=0.11.10,<0.12.0"
|
||||||
```
|
```
|
||||||
@@ -146,13 +179,34 @@ to install the latest version that satisfies the range.
|
|||||||
#### Install a version from a specified version file
|
#### Install a version from a specified version file
|
||||||
|
|
||||||
You can specify a file to read the version from.
|
You can specify a file to read the version from.
|
||||||
Currently `pyproject.toml` and `requirements.txt` are supported.
|
Currently `pyproject.toml`, `requirements.txt` and `uv.lock` are supported. If the file cannot
|
||||||
|
be parsed or does not contain a Ruff version, the action warns and falls back to `latest`.
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
- name: Install a version from a specified version file
|
- name: Install a version from a specified version file
|
||||||
uses: astral-sh/ruff-action@v3
|
uses: astral-sh/ruff-action@v4.1.0
|
||||||
with:
|
with:
|
||||||
version-file: "my-path/to/pyproject.toml-or-requirements.txt"
|
version-file: "my-path/to/pyproject.toml-requirements.txt-or-uv.lock"
|
||||||
|
```
|
||||||
|
|
||||||
|
Version resolution precedence is:
|
||||||
|
|
||||||
|
1. `version`
|
||||||
|
2. `version-file`
|
||||||
|
3. nearest discoverable `pyproject.toml` found by searching upward from `src`
|
||||||
|
4. `latest`
|
||||||
|
|
||||||
|
#### Install using a custom manifest URL
|
||||||
|
|
||||||
|
You can override the default `astral-sh/versions` manifest with `manifest-file`.
|
||||||
|
This affects both version resolution and artifact selection.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- name: Install Ruff from a custom manifest
|
||||||
|
uses: astral-sh/ruff-action@v4.1.0
|
||||||
|
with:
|
||||||
|
version: "latest"
|
||||||
|
manifest-file: "https://example.com/ruff.ndjson"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Validate checksum
|
### Validate checksum
|
||||||
@@ -163,7 +217,7 @@ are automatically verified by this action. The sha256 hashes can be found on the
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
- name: Install a specific version and validate the checksum
|
- name: Install a specific version and validate the checksum
|
||||||
uses: astral-sh/ruff-action@v3
|
uses: astral-sh/ruff-action@v4.1.0
|
||||||
with:
|
with:
|
||||||
version: "0.7.4"
|
version: "0.7.4"
|
||||||
checksum: "0de731c669b9ece77e799ac3f4a160c30849752714d9775c94cc4cfaf326860c"
|
checksum: "0de731c669b9ece77e799ac3f4a160c30849752714d9775c94cc4cfaf326860c"
|
||||||
@@ -171,9 +225,13 @@ are automatically verified by this action. The sha256 hashes can be found on the
|
|||||||
|
|
||||||
### GitHub authentication token
|
### GitHub authentication token
|
||||||
|
|
||||||
This action uses the GitHub API to fetch the ruff release artifacts. To avoid hitting the GitHub API
|
By default, this action resolves available Ruff versions from
|
||||||
rate limit too quickly, an authentication token can be provided via the `github-token` input. By
|
[`astral-sh/versions`](https://github.com/astral-sh/versions) and downloads release artifacts from `https://releases.astral.sh`. If this fails this action falls back to downloading from the GitHub releases page of the Ruff repository.
|
||||||
default, the `GITHUB_TOKEN` secret is used, which is automatically provided by GitHub Actions.
|
|
||||||
|
Set `download-from-astral-mirror` to `false` to skip the Astral mirror and download directly from GitHub Releases.
|
||||||
|
|
||||||
|
You can provide a token via `github-token` to authenticate GitHub Releases downloads. By default, the
|
||||||
|
`GITHUB_TOKEN` secret is used, which is automatically provided by GitHub Actions.
|
||||||
|
|
||||||
If the default
|
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)
|
[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)
|
||||||
@@ -181,7 +239,7 @@ are not sufficient, you can provide a custom GitHub token with the necessary per
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
- name: Install the latest version of ruff with a custom GitHub token
|
- name: Install the latest version of ruff with a custom GitHub token
|
||||||
uses: astral-sh/ruff-action@v3
|
uses: astral-sh/ruff-action@v4.1.0
|
||||||
with:
|
with:
|
||||||
github-token: ${{ secrets.CUSTOM_GITHUB_TOKEN }}
|
github-token: ${{ secrets.CUSTOM_GITHUB_TOKEN }}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
{"version":"0.15.10","artifacts":[{"platform":"x86_64-unknown-linux-gnu","variant":"default","url":"https://github.com/astral-sh/ruff/releases/download/0.15.10/ruff-x86_64-unknown-linux-gnu.tar.gz","archive_format":"tar.gz","sha256":"e3e9e5c791542f00d95edc74a506e1ac24efc0af9574de01ab338187bf1ff9f6"}]}
|
||||||
@@ -0,0 +1,537 @@
|
|||||||
|
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,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockDownloadTool = jest.fn();
|
||||||
|
const mockExtractTar = jest.fn();
|
||||||
|
const mockExtractZip = jest.fn();
|
||||||
|
const mockCacheDir = jest.fn();
|
||||||
|
|
||||||
|
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,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockGetLatestVersion = jest.fn();
|
||||||
|
const mockGetAllVersions = jest.fn();
|
||||||
|
const mockGetArtifact = jest.fn();
|
||||||
|
|
||||||
|
jest.unstable_mockModule("../../src/download/manifest", () => ({
|
||||||
|
getAllVersions: mockGetAllVersions,
|
||||||
|
getArtifact: mockGetArtifact,
|
||||||
|
getLatestVersion: mockGetLatestVersion,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockValidateChecksum = jest.fn();
|
||||||
|
|
||||||
|
jest.unstable_mockModule("../../src/download/checksum/checksum", () => ({
|
||||||
|
validateChecksum: mockValidateChecksum,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockCopyFile = jest.fn();
|
||||||
|
const mockReaddir = jest.fn();
|
||||||
|
|
||||||
|
jest.unstable_mockModule("node:fs", () => ({
|
||||||
|
default: {},
|
||||||
|
promises: {
|
||||||
|
copyFile: mockCopyFile,
|
||||||
|
readdir: mockReaddir,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { downloadVersion, rewriteToMirror } = await import(
|
||||||
|
"../../src/download/download-version"
|
||||||
|
);
|
||||||
|
const { resolveVersion } = await import("../../src/version/resolve");
|
||||||
|
|
||||||
|
describe("download-version", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockInfo.mockReset();
|
||||||
|
mockWarning.mockReset();
|
||||||
|
mockDownloadTool.mockReset();
|
||||||
|
mockExtractTar.mockReset();
|
||||||
|
mockExtractZip.mockReset();
|
||||||
|
mockCacheDir.mockReset();
|
||||||
|
mockGetLatestVersion.mockReset();
|
||||||
|
mockGetAllVersions.mockReset();
|
||||||
|
mockGetArtifact.mockReset();
|
||||||
|
mockValidateChecksum.mockReset();
|
||||||
|
mockCopyFile.mockReset();
|
||||||
|
mockReaddir.mockReset();
|
||||||
|
|
||||||
|
mockDownloadTool.mockResolvedValue("/tmp/downloaded");
|
||||||
|
mockExtractTar.mockResolvedValue("/tmp/extracted");
|
||||||
|
mockExtractZip.mockResolvedValue("/tmp/extracted");
|
||||||
|
mockCacheDir.mockResolvedValue("/tmp/cached");
|
||||||
|
mockReaddir.mockResolvedValue(["ruff"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveVersion", () => {
|
||||||
|
it("uses the default manifest to resolve latest", async () => {
|
||||||
|
mockGetLatestVersion.mockResolvedValue("0.15.8");
|
||||||
|
|
||||||
|
const version = await resolveVersion("latest", undefined);
|
||||||
|
|
||||||
|
expect(version).toBe("0.15.8");
|
||||||
|
expect(mockGetLatestVersion).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockGetLatestVersion).toHaveBeenCalledWith(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the default manifest to resolve available versions", async () => {
|
||||||
|
mockGetAllVersions.mockResolvedValue(["0.15.8", "0.15.7"]);
|
||||||
|
|
||||||
|
const version = await resolveVersion("0.15.x", undefined);
|
||||||
|
|
||||||
|
expect(version).toBe("0.15.8");
|
||||||
|
expect(mockGetAllVersions).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockGetAllVersions).toHaveBeenCalledWith(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses manifest-file when provided", async () => {
|
||||||
|
mockGetAllVersions.mockResolvedValue(["0.15.8", "0.15.7"]);
|
||||||
|
|
||||||
|
const version = await resolveVersion(
|
||||||
|
"0.15.x",
|
||||||
|
"https://example.com/custom.ndjson",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(version).toBe("0.15.8");
|
||||||
|
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.15.8",
|
||||||
|
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.15.8",
|
||||||
|
undefined,
|
||||||
|
"token",
|
||||||
|
),
|
||||||
|
).rejects.toThrow(
|
||||||
|
"Could not find artifact for version 0.15.8, arch x86_64, platform unknown-linux-gnu in https://raw.githubusercontent.com/astral-sh/versions/main/v1/ruff.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/ruff.tar.gz",
|
||||||
|
});
|
||||||
|
|
||||||
|
await downloadVersion(
|
||||||
|
"unknown-linux-gnu",
|
||||||
|
"x86_64",
|
||||||
|
"0.15.8",
|
||||||
|
undefined,
|
||||||
|
"token",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockValidateChecksum).toHaveBeenCalledWith(
|
||||||
|
undefined,
|
||||||
|
"/tmp/downloaded",
|
||||||
|
"x86_64",
|
||||||
|
"unknown-linux-gnu",
|
||||||
|
"0.15.8",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rewrites GitHub Releases URLs to the Astral mirror", async () => {
|
||||||
|
mockGetArtifact.mockResolvedValue({
|
||||||
|
archiveFormat: "tar.gz",
|
||||||
|
checksum: "abc123",
|
||||||
|
downloadUrl:
|
||||||
|
"https://github.com/astral-sh/ruff/releases/download/0.15.8/ruff-x86_64-unknown-linux-gnu.tar.gz",
|
||||||
|
});
|
||||||
|
|
||||||
|
await downloadVersion(
|
||||||
|
"unknown-linux-gnu",
|
||||||
|
"x86_64",
|
||||||
|
"0.15.8",
|
||||||
|
undefined,
|
||||||
|
"token",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockDownloadTool).toHaveBeenCalledWith(
|
||||||
|
"https://releases.astral.sh/github/ruff/releases/download/0.15.8/ruff-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/ruff.tar.gz",
|
||||||
|
});
|
||||||
|
|
||||||
|
await downloadVersion(
|
||||||
|
"unknown-linux-gnu",
|
||||||
|
"x86_64",
|
||||||
|
"0.15.8",
|
||||||
|
undefined,
|
||||||
|
"token",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockDownloadTool).toHaveBeenCalledWith(
|
||||||
|
"https://example.com/ruff.tar.gz",
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to GitHub Releases when the mirror download fails", async () => {
|
||||||
|
mockGetArtifact.mockResolvedValue({
|
||||||
|
archiveFormat: "tar.gz",
|
||||||
|
checksum: "abc123",
|
||||||
|
downloadUrl:
|
||||||
|
"https://github.com/astral-sh/ruff/releases/download/0.15.8/ruff-x86_64-unknown-linux-gnu.tar.gz",
|
||||||
|
});
|
||||||
|
|
||||||
|
mockDownloadTool
|
||||||
|
.mockRejectedValueOnce(new Error("mirror unavailable"))
|
||||||
|
.mockResolvedValueOnce("/tmp/downloaded");
|
||||||
|
|
||||||
|
await downloadVersion(
|
||||||
|
"unknown-linux-gnu",
|
||||||
|
"x86_64",
|
||||||
|
"0.15.8",
|
||||||
|
undefined,
|
||||||
|
"token",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockDownloadTool).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mockDownloadTool).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
"https://releases.astral.sh/github/ruff/releases/download/0.15.8/ruff-x86_64-unknown-linux-gnu.tar.gz",
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
expect(mockDownloadTool).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
"https://github.com/astral-sh/ruff/releases/download/0.15.8/ruff-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("falls back to the canonical old GitHub Releases URL", async () => {
|
||||||
|
mockGetArtifact.mockResolvedValue({
|
||||||
|
archiveFormat: "tar.gz",
|
||||||
|
checksum: "abc123",
|
||||||
|
downloadUrl:
|
||||||
|
"https://github.com/astral-sh/ruff/releases/download/0.4.7/ruff-x86_64-unknown-linux-gnu.tar.gz",
|
||||||
|
});
|
||||||
|
|
||||||
|
mockDownloadTool
|
||||||
|
.mockRejectedValueOnce(new Error("mirror unavailable"))
|
||||||
|
.mockResolvedValueOnce("/tmp/downloaded");
|
||||||
|
|
||||||
|
await downloadVersion(
|
||||||
|
"unknown-linux-gnu",
|
||||||
|
"x86_64",
|
||||||
|
"0.4.7",
|
||||||
|
undefined,
|
||||||
|
"token",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockDownloadTool).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
"https://github.com/astral-sh/ruff/releases/download/v0.4.7/ruff-0.4.7-x86_64-unknown-linux-gnu.tar.gz",
|
||||||
|
undefined,
|
||||||
|
"token",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not fall back when checksum validation fails", async () => {
|
||||||
|
mockGetArtifact.mockResolvedValue({
|
||||||
|
archiveFormat: "tar.gz",
|
||||||
|
checksum: "abc123",
|
||||||
|
downloadUrl:
|
||||||
|
"https://github.com/astral-sh/ruff/releases/download/0.15.8/ruff-x86_64-unknown-linux-gnu.tar.gz",
|
||||||
|
});
|
||||||
|
mockValidateChecksum.mockRejectedValue(new Error("bad checksum"));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
downloadVersion(
|
||||||
|
"unknown-linux-gnu",
|
||||||
|
"x86_64",
|
||||||
|
"0.15.8",
|
||||||
|
undefined,
|
||||||
|
"token",
|
||||||
|
),
|
||||||
|
).rejects.toThrow("bad checksum");
|
||||||
|
|
||||||
|
expect(mockDownloadTool).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockWarning).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not fall back when extraction fails", async () => {
|
||||||
|
mockGetArtifact.mockResolvedValue({
|
||||||
|
archiveFormat: "tar.gz",
|
||||||
|
checksum: "abc123",
|
||||||
|
downloadUrl:
|
||||||
|
"https://github.com/astral-sh/ruff/releases/download/0.15.8/ruff-x86_64-unknown-linux-gnu.tar.gz",
|
||||||
|
});
|
||||||
|
mockExtractTar.mockRejectedValue(new Error("extract failed"));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
downloadVersion(
|
||||||
|
"unknown-linux-gnu",
|
||||||
|
"x86_64",
|
||||||
|
"0.15.8",
|
||||||
|
undefined,
|
||||||
|
"token",
|
||||||
|
),
|
||||||
|
).rejects.toThrow("extract failed");
|
||||||
|
|
||||||
|
expect(mockDownloadTool).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockWarning).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not fall back for non-GitHub URLs", async () => {
|
||||||
|
mockGetArtifact.mockResolvedValue({
|
||||||
|
archiveFormat: "tar.gz",
|
||||||
|
checksum: "abc123",
|
||||||
|
downloadUrl: "https://example.com/ruff.tar.gz",
|
||||||
|
});
|
||||||
|
|
||||||
|
mockDownloadTool.mockRejectedValue(new Error("download failed"));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
downloadVersion(
|
||||||
|
"unknown-linux-gnu",
|
||||||
|
"x86_64",
|
||||||
|
"0.15.8",
|
||||||
|
undefined,
|
||||||
|
"token",
|
||||||
|
),
|
||||||
|
).rejects.toThrow("download failed");
|
||||||
|
|
||||||
|
expect(mockDownloadTool).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips the Astral mirror when downloadFromAstralMirror is false", async () => {
|
||||||
|
mockGetArtifact.mockResolvedValue({
|
||||||
|
archiveFormat: "tar.gz",
|
||||||
|
checksum: "abc123",
|
||||||
|
downloadUrl:
|
||||||
|
"https://github.com/astral-sh/ruff/releases/download/0.15.8/ruff-x86_64-unknown-linux-gnu.tar.gz",
|
||||||
|
});
|
||||||
|
|
||||||
|
await downloadVersion(
|
||||||
|
"unknown-linux-gnu",
|
||||||
|
"x86_64",
|
||||||
|
"0.15.8",
|
||||||
|
undefined,
|
||||||
|
"token",
|
||||||
|
undefined,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockDownloadTool).toHaveBeenCalledWith(
|
||||||
|
"https://github.com/astral-sh/ruff/releases/download/0.15.8/ruff-x86_64-unknown-linux-gnu.tar.gz",
|
||||||
|
undefined,
|
||||||
|
"token",
|
||||||
|
);
|
||||||
|
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-ruff.tar.gz",
|
||||||
|
});
|
||||||
|
|
||||||
|
await downloadVersion(
|
||||||
|
"unknown-linux-gnu",
|
||||||
|
"x86_64",
|
||||||
|
"0.15.8",
|
||||||
|
"",
|
||||||
|
"token",
|
||||||
|
"https://example.com/custom.ndjson",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockValidateChecksum).toHaveBeenCalledWith(
|
||||||
|
"manifest-checksum",
|
||||||
|
"/tmp/downloaded",
|
||||||
|
"x86_64",
|
||||||
|
"unknown-linux-gnu",
|
||||||
|
"0.15.8",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers checksum input over manifest-file checksum metadata", async () => {
|
||||||
|
mockGetArtifact.mockResolvedValue({
|
||||||
|
archiveFormat: "tar.gz",
|
||||||
|
checksum: "manifest-checksum",
|
||||||
|
downloadUrl: "https://example.com/custom-ruff.tar.gz",
|
||||||
|
});
|
||||||
|
|
||||||
|
await downloadVersion(
|
||||||
|
"unknown-linux-gnu",
|
||||||
|
"x86_64",
|
||||||
|
"0.15.8",
|
||||||
|
"user-checksum",
|
||||||
|
"token",
|
||||||
|
"https://example.com/custom.ndjson",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockValidateChecksum).toHaveBeenCalledWith(
|
||||||
|
"user-checksum",
|
||||||
|
"/tmp/downloaded",
|
||||||
|
"x86_64",
|
||||||
|
"unknown-linux-gnu",
|
||||||
|
"0.15.8",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves tar extraction behavior for newer versions", async () => {
|
||||||
|
mockGetArtifact.mockResolvedValue({
|
||||||
|
archiveFormat: "tar.gz",
|
||||||
|
checksum: "abc123",
|
||||||
|
downloadUrl: "https://example.com/ruff.tar.gz",
|
||||||
|
});
|
||||||
|
|
||||||
|
await downloadVersion(
|
||||||
|
"unknown-linux-gnu",
|
||||||
|
"x86_64",
|
||||||
|
"0.15.8",
|
||||||
|
"user-checksum",
|
||||||
|
"token",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockExtractTar).toHaveBeenCalledWith("/tmp/downloaded");
|
||||||
|
expect(mockCacheDir).toHaveBeenCalledWith(
|
||||||
|
"/tmp/extracted/ruff-x86_64-unknown-linux-gnu",
|
||||||
|
"ruff",
|
||||||
|
"0.15.8",
|
||||||
|
"x86_64",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves tar extraction behavior for older versions", async () => {
|
||||||
|
mockGetArtifact.mockResolvedValue({
|
||||||
|
archiveFormat: "tar.gz",
|
||||||
|
checksum: "abc123",
|
||||||
|
downloadUrl: "https://example.com/ruff.tar.gz",
|
||||||
|
});
|
||||||
|
|
||||||
|
await downloadVersion(
|
||||||
|
"unknown-linux-gnu",
|
||||||
|
"x86_64",
|
||||||
|
"0.4.10",
|
||||||
|
undefined,
|
||||||
|
"token",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockCacheDir).toHaveBeenCalledWith(
|
||||||
|
"/tmp/extracted",
|
||||||
|
"ruff",
|
||||||
|
"0.4.10",
|
||||||
|
"x86_64",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves zip extraction behavior on Windows", async () => {
|
||||||
|
mockGetArtifact.mockResolvedValue({
|
||||||
|
archiveFormat: "zip",
|
||||||
|
checksum: "abc123",
|
||||||
|
downloadUrl: "https://example.com/ruff.zip",
|
||||||
|
});
|
||||||
|
|
||||||
|
await downloadVersion(
|
||||||
|
"pc-windows-msvc",
|
||||||
|
"x86_64",
|
||||||
|
"0.15.8",
|
||||||
|
undefined,
|
||||||
|
"token",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockCopyFile).toHaveBeenCalledWith(
|
||||||
|
"/tmp/downloaded",
|
||||||
|
"/tmp/downloaded.zip",
|
||||||
|
);
|
||||||
|
expect(mockExtractZip).toHaveBeenCalledWith("/tmp/downloaded.zip");
|
||||||
|
expect(mockCacheDir).toHaveBeenCalledWith(
|
||||||
|
"/tmp/extracted",
|
||||||
|
"ruff",
|
||||||
|
"0.15.8",
|
||||||
|
"x86_64",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("rewriteToMirror", () => {
|
||||||
|
it("rewrites a GitHub Releases URL to the Astral mirror", () => {
|
||||||
|
expect(
|
||||||
|
rewriteToMirror(
|
||||||
|
"https://github.com/astral-sh/ruff/releases/download/0.15.8/ruff-x86_64-unknown-linux-gnu.tar.gz",
|
||||||
|
),
|
||||||
|
).toBe(
|
||||||
|
"https://releases.astral.sh/github/ruff/releases/download/0.15.8/ruff-x86_64-unknown-linux-gnu.tar.gz",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined for non-GitHub URLs", () => {
|
||||||
|
expect(
|
||||||
|
rewriteToMirror("https://example.com/ruff.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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import { beforeEach, describe, expect, it, jest } from "@jest/globals";
|
||||||
|
|
||||||
|
const mockFetch = jest.fn();
|
||||||
|
|
||||||
|
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.15.8","artifacts":[{"platform":"aarch64-apple-darwin","variant":"default","url":"https://github.com/astral-sh/ruff/releases/download/0.15.8/ruff-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/ruff/releases/download/0.15.8/ruff-x86_64-pc-windows-msvc.zip","archive_format":"zip","sha256":"eb02fd95d8e0eed462b4a67ecdd320d865b38c560bffcda9a0b87ec944bdf036"}]}
|
||||||
|
{"version":"0.15.7","artifacts":[{"platform":"aarch64-apple-darwin","variant":"default","url":"https://github.com/astral-sh/ruff/releases/download/0.15.7/ruff-aarch64-apple-darwin.tar.gz","archive_format":"tar.gz","sha256":"606b3c6949d971709f2526fa0d9f0fd23ccf60e09f117999b406b424af18a6a6"}]}`;
|
||||||
|
|
||||||
|
const multiVariantManifestResponse = `{"version":"0.15.8","artifacts":[{"platform":"aarch64-apple-darwin","variant":"python-managed","url":"https://github.com/astral-sh/ruff/releases/download/0.15.8/ruff-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/ruff/releases/download/0.15.8/ruff-aarch64-apple-darwin.zip","archive_format":"zip","sha256":"default-checksum"}]}`;
|
||||||
|
|
||||||
|
const oldVersionManifestResponse = `{"version":"v0.4.7","artifacts":[{"platform":"0.4.7-aarch64-apple-darwin","variant":"default","url":"https://github.com/astral-sh/ruff/releases/download/v0.4.7/ruff-0.4.7-aarch64-apple-darwin.tar.gz","archive_format":"tar.gz","sha256":"old-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.15.8");
|
||||||
|
expect(versions[1]?.version).toBe("0.15.7");
|
||||||
|
});
|
||||||
|
|
||||||
|
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.15.8", "0.15.7"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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.15.8");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getArtifact", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockFetch.mockResolvedValue(
|
||||||
|
createMockResponse(true, 200, "OK", sampleManifestResponse),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("finds an artifact by version and platform", async () => {
|
||||||
|
const artifact = await getArtifact("0.15.8", "aarch64", "apple-darwin");
|
||||||
|
|
||||||
|
expect(artifact).toEqual({
|
||||||
|
archiveFormat: "tar.gz",
|
||||||
|
checksum:
|
||||||
|
"fcf0a9ea6599c6ae28a4c854ac6da76f2c889354d7c36ce136ef071f7ab9721f",
|
||||||
|
downloadUrl:
|
||||||
|
"https://github.com/astral-sh/ruff/releases/download/0.15.8/ruff-aarch64-apple-darwin.tar.gz",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("finds a windows artifact", async () => {
|
||||||
|
const artifact = await getArtifact("0.15.8", "x86_64", "pc-windows-msvc");
|
||||||
|
|
||||||
|
expect(artifact).toEqual({
|
||||||
|
archiveFormat: "zip",
|
||||||
|
checksum:
|
||||||
|
"eb02fd95d8e0eed462b4a67ecdd320d865b38c560bffcda9a0b87ec944bdf036",
|
||||||
|
downloadUrl:
|
||||||
|
"https://github.com/astral-sh/ruff/releases/download/0.15.8/ruff-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.15.8", "aarch64", "apple-darwin");
|
||||||
|
|
||||||
|
expect(artifact).toEqual({
|
||||||
|
archiveFormat: "zip",
|
||||||
|
checksum: "default-checksum",
|
||||||
|
downloadUrl:
|
||||||
|
"https://github.com/astral-sh/ruff/releases/download/0.15.8/ruff-aarch64-apple-darwin.zip",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("finds an old artifact when the manifest version has a v prefix", async () => {
|
||||||
|
mockFetch.mockResolvedValue(
|
||||||
|
createMockResponse(true, 200, "OK", oldVersionManifestResponse),
|
||||||
|
);
|
||||||
|
|
||||||
|
const artifact = await getArtifact("0.4.7", "aarch64", "apple-darwin");
|
||||||
|
|
||||||
|
expect(artifact).toEqual({
|
||||||
|
archiveFormat: "tar.gz",
|
||||||
|
checksum: "old-checksum",
|
||||||
|
downloadUrl:
|
||||||
|
"https://github.com/astral-sh/ruff/releases/download/v0.4.7/ruff-0.4.7-aarch64-apple-darwin.tar.gz",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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.15.8",
|
||||||
|
"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",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
[project]
|
||||||
|
name = "pyython-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"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
print("hello")
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
print("deeper")
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
print("check me")
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# This file should be ignored when using foo/bar/*.py glob
|
||||||
|
print("ignore me")
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
[project]
|
||||||
|
name = "glob-single-star-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"
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
[project]
|
||||||
|
name = "parent-config-project"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Test fixture for parent directory pyproject.toml search"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
dependencies = [
|
||||||
|
"ruff==0.10.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
"""Hello world example."""
|
||||||
|
|
||||||
|
print("Hello, world!")
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
[project]
|
||||||
|
name = "pyproject-dependency-groups-with-env-marker"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Test fixture for issue #256 - environment markers"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
dev = [
|
||||||
|
"ruff~=0.13 ; python_version >= '3.11'",
|
||||||
|
]
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ruff==0.9.0 \
|
||||||
|
--hash=sha256:badc0de
|
||||||
Generated
+20
@@ -0,0 +1,20 @@
|
|||||||
|
version = 1
|
||||||
|
revision = 1
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "example-project"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = { editable = "." }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "ruff" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata]
|
||||||
|
requires-dist = [{ name = "ruff", specifier = ">=0.9.0" }]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ruff"
|
||||||
|
version = "0.9.5"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/source/r/ruff/ruff-0.9.5.tar.gz", hash = "sha256:11aecd7a633932875ab3cb05a484c99970b9d52606ce9ea912b690b02653d56c", size = 3634177 }
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { beforeEach, describe, expect, it, jest } from "@jest/globals";
|
||||||
|
|
||||||
|
const debug = jest.fn();
|
||||||
|
const info = jest.fn();
|
||||||
|
|
||||||
|
jest.unstable_mockModule("@actions/core", () => ({
|
||||||
|
debug,
|
||||||
|
info,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { findPyprojectToml } = await import("../../src/utils/pyproject-finder");
|
||||||
|
|
||||||
|
const testFilePath = fileURLToPath(import.meta.url);
|
||||||
|
const testDir = path.dirname(testFilePath);
|
||||||
|
const repoRoot = path.resolve(testDir, "..", "..");
|
||||||
|
const fixturesDir = path.join(repoRoot, "__tests__", "fixtures");
|
||||||
|
|
||||||
|
describe("findPyprojectToml", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
debug.mockReset();
|
||||||
|
info.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("when pyproject.toml exists in src directory", () => {
|
||||||
|
it("should return the exact path", () => {
|
||||||
|
const result = findPyprojectToml(fixturesDir, repoRoot);
|
||||||
|
|
||||||
|
expect(result).toContain("pyproject.toml");
|
||||||
|
expect(result).toContain("fixtures");
|
||||||
|
expect(info).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("when pyproject.toml exists only in parent directory", () => {
|
||||||
|
it("should search upwards and find the parent's pyproject.toml", () => {
|
||||||
|
const subprojectDir = path.join(
|
||||||
|
fixturesDir,
|
||||||
|
"parent-config-project",
|
||||||
|
"subproject",
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = findPyprojectToml(subprojectDir, repoRoot);
|
||||||
|
|
||||||
|
expect(result).toBeTruthy();
|
||||||
|
expect(result).toContain("pyproject.toml");
|
||||||
|
expect(result).toContain("parent-config-project");
|
||||||
|
expect(info).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("boundary conditions", () => {
|
||||||
|
it("should stop searching at workspace root and return undefined when not found", () => {
|
||||||
|
const nodeModulesDir = path.join(repoRoot, "node_modules", "@actions");
|
||||||
|
|
||||||
|
const result = findPyprojectToml(nodeModulesDir, repoRoot);
|
||||||
|
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
expect(info).not.toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("Found pyproject.toml"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should find pyproject.toml when it exists at workspace root", () => {
|
||||||
|
const parentConfigProjectDir = path.join(
|
||||||
|
fixturesDir,
|
||||||
|
"parent-config-project",
|
||||||
|
);
|
||||||
|
const subprojectDir = path.join(parentConfigProjectDir, "subproject");
|
||||||
|
|
||||||
|
const result = findPyprojectToml(subprojectDir, parentConfigProjectDir);
|
||||||
|
|
||||||
|
expect(result).toBeTruthy();
|
||||||
|
expect(result).toContain("pyproject.toml");
|
||||||
|
expect(result).toContain("parent-config-project");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should stop at workspace root even if searching from it", () => {
|
||||||
|
const result = findPyprojectToml(fixturesDir, fixturesDir);
|
||||||
|
|
||||||
|
expect(result).toBeTruthy();
|
||||||
|
expect(result).toContain("pyproject.toml");
|
||||||
|
expect(result).toContain("fixtures");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("edge cases", () => {
|
||||||
|
it("should handle relative paths", () => {
|
||||||
|
const result = findPyprojectToml("./__tests__/fixtures", ".");
|
||||||
|
|
||||||
|
expect(result).toBeTruthy();
|
||||||
|
expect(result).toContain("pyproject.toml");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle when src equals workspace root", () => {
|
||||||
|
const result = findPyprojectToml(fixturesDir, fixturesDir);
|
||||||
|
|
||||||
|
expect(result).toBeTruthy();
|
||||||
|
expect(result).toContain("pyproject.toml");
|
||||||
|
expect(result).toContain("fixtures");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should log debug messages for each checked path", () => {
|
||||||
|
const pythonProjectDir = path.join(fixturesDir, "python-project");
|
||||||
|
|
||||||
|
findPyprojectToml(pythonProjectDir, repoRoot);
|
||||||
|
|
||||||
|
expect(debug).toHaveBeenCalled();
|
||||||
|
expect(debug.mock.calls.length).toBeGreaterThan(0);
|
||||||
|
expect(debug.mock.calls[0][0]).toContain("Checking for");
|
||||||
|
expect(debug.mock.calls[0][0]).toContain("python-project");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle paths with trailing slashes", () => {
|
||||||
|
const result = findPyprojectToml(`${fixturesDir}/`, repoRoot);
|
||||||
|
|
||||||
|
expect(result).toBeTruthy();
|
||||||
|
expect(result).toContain("pyproject.toml");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,374 @@
|
|||||||
|
import * as fs from "node:fs/promises";
|
||||||
|
import * as os from "node:os";
|
||||||
|
import * as path from "node:path";
|
||||||
|
import { afterEach, describe, expect, it, jest } from "@jest/globals";
|
||||||
|
|
||||||
|
jest.unstable_mockModule("@actions/core", () => ({
|
||||||
|
debug: jest.fn(),
|
||||||
|
info: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const {
|
||||||
|
expandSourceInput,
|
||||||
|
getSourceBasePath,
|
||||||
|
getVersionSourceDirectory,
|
||||||
|
splitInput,
|
||||||
|
} = await import("../../src/utils/source-input");
|
||||||
|
const { findPyprojectToml } = await import("../../src/utils/pyproject-finder");
|
||||||
|
|
||||||
|
let tempDir: string | undefined;
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (tempDir !== undefined) {
|
||||||
|
await fs.rm(tempDir, { force: true, recursive: true });
|
||||||
|
tempDir = undefined;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createTempProject(): Promise<string> {
|
||||||
|
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "ruff-action-source-"));
|
||||||
|
await fs.mkdir(path.join(tempDir, "src", "package", "nested"), {
|
||||||
|
recursive: true,
|
||||||
|
});
|
||||||
|
await fs.writeFile(path.join(tempDir, "src", "package", "__init__.py"), "");
|
||||||
|
await fs.writeFile(path.join(tempDir, "src", "package", "main.py"), "");
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(tempDir, "src", "package", "nested", "mod.py"),
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
await fs.writeFile(path.join(tempDir, "src", "package", "notes.txt"), "");
|
||||||
|
await fs.mkdir(path.join(tempDir, "src", "pkg[legacy]"), {
|
||||||
|
recursive: true,
|
||||||
|
});
|
||||||
|
await fs.writeFile(path.join(tempDir, "src", "pkg[legacy]", "main.py"), "");
|
||||||
|
await fs.mkdir(path.join(tempDir, "src", "pkgl"), { recursive: true });
|
||||||
|
await fs.writeFile(path.join(tempDir, "src", "pkgl", "main.py"), "");
|
||||||
|
|
||||||
|
return tempDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("splitInput", () => {
|
||||||
|
it("splits whitespace-delimited inputs", () => {
|
||||||
|
expect(splitInput(" check --fix\n--diff ")).toEqual([
|
||||||
|
"check",
|
||||||
|
"--fix",
|
||||||
|
"--diff",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an empty array for empty input", () => {
|
||||||
|
expect(splitInput(" \n\t ")).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("expandSourceInput", () => {
|
||||||
|
it("leaves non-glob sources unchanged", async () => {
|
||||||
|
await expect(expandSourceInput("src file.py")).resolves.toEqual([
|
||||||
|
"src",
|
||||||
|
"file.py",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("expands recursive globs without relying on shell behavior", async () => {
|
||||||
|
const projectDir = await createTempProject();
|
||||||
|
const pattern = path.join(projectDir, "src", "**", "*.py");
|
||||||
|
|
||||||
|
await expect(expandSourceInput(pattern)).resolves.toEqual([
|
||||||
|
path.join(projectDir, "src", "package", "__init__.py"),
|
||||||
|
path.join(projectDir, "src", "package", "main.py"),
|
||||||
|
path.join(projectDir, "src", "package", "nested", "mod.py"),
|
||||||
|
path.join(projectDir, "src", "pkg[legacy]", "main.py"),
|
||||||
|
path.join(projectDir, "src", "pkgl", "main.py"),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves relative source patterns as relative paths", async () => {
|
||||||
|
const projectDir = await createTempProject();
|
||||||
|
const originalCwd = process.cwd();
|
||||||
|
|
||||||
|
try {
|
||||||
|
process.chdir(projectDir);
|
||||||
|
await expect(expandSourceInput("src/**/*.py")).resolves.toEqual([
|
||||||
|
path.join("src", "package", "__init__.py"),
|
||||||
|
path.join("src", "package", "main.py"),
|
||||||
|
path.join("src", "package", "nested", "mod.py"),
|
||||||
|
path.join("src", "pkg[legacy]", "main.py"),
|
||||||
|
path.join("src", "pkgl", "main.py"),
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
process.chdir(originalCwd);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps relative root glob matches to . instead of an empty argument", async () => {
|
||||||
|
const projectDir = await createTempProject();
|
||||||
|
const originalCwd = process.cwd();
|
||||||
|
|
||||||
|
try {
|
||||||
|
process.chdir(projectDir);
|
||||||
|
for (const pattern of ["**", "**/", "./**"]) {
|
||||||
|
const sources = await expandSourceInput(pattern);
|
||||||
|
|
||||||
|
expect(sources).toContain(".");
|
||||||
|
expect(sources).not.toContain("");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
process.chdir(originalCwd);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("excludes hidden paths by default while allowing explicit hidden patterns", async () => {
|
||||||
|
const projectDir = await createTempProject();
|
||||||
|
const originalCwd = process.cwd();
|
||||||
|
await fs.mkdir(path.join(projectDir, ".venv"));
|
||||||
|
await fs.writeFile(path.join(projectDir, ".venv", "ignored.py"), "");
|
||||||
|
await fs.mkdir(path.join(projectDir, ".tox"));
|
||||||
|
await fs.writeFile(path.join(projectDir, ".tox", "ignored.py"), "");
|
||||||
|
|
||||||
|
try {
|
||||||
|
process.chdir(projectDir);
|
||||||
|
const defaultSources = await expandSourceInput("**/*.py");
|
||||||
|
|
||||||
|
expect(defaultSources).not.toContain(path.join(".venv", "ignored.py"));
|
||||||
|
expect(defaultSources).not.toContain(path.join(".tox", "ignored.py"));
|
||||||
|
await expect(expandSourceInput(".venv/**/*.py")).resolves.toEqual([
|
||||||
|
path.join(".venv", "ignored.py"),
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
process.chdir(originalCwd);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves unmatched globs for Ruff to report", async () => {
|
||||||
|
const projectDir = await createTempProject();
|
||||||
|
const pattern = path.join(projectDir, "src", "**", "missing-*.py");
|
||||||
|
|
||||||
|
await expect(expandSourceInput(pattern)).resolves.toEqual([pattern]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("expands multiple sources in input order", async () => {
|
||||||
|
const projectDir = await createTempProject();
|
||||||
|
const first = path.join(projectDir, "src", "package", "main.py");
|
||||||
|
const second = path.join(projectDir, "src", "package", "nested", "*.py");
|
||||||
|
|
||||||
|
await expect(expandSourceInput(`${first} ${second}`)).resolves.toEqual([
|
||||||
|
first,
|
||||||
|
path.join(projectDir, "src", "package", "nested", "mod.py"),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses @actions/glob syntax for paths that contain glob metacharacters", async () => {
|
||||||
|
const projectDir = await createTempProject();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
expandSourceInput(path.join(projectDir, "src", "pkg[legacy]")),
|
||||||
|
).resolves.toEqual([path.join(projectDir, "src", "pkgl")]);
|
||||||
|
await expect(
|
||||||
|
expandSourceInput(path.join(projectDir, "src", "pkg[legacy]", "*.py")),
|
||||||
|
).resolves.toEqual([path.join(projectDir, "src", "pkgl", "main.py")]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports @actions/glob escaping for literal glob metacharacters", async () => {
|
||||||
|
const projectDir = await createTempProject();
|
||||||
|
const pattern = path.join(projectDir, "src", "pkg[[]legacy]", "*.py");
|
||||||
|
|
||||||
|
await expect(expandSourceInput(pattern)).resolves.toEqual([
|
||||||
|
path.join(projectDir, "src", "pkg[legacy]", "main.py"),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not traverse symlinked directories", async () => {
|
||||||
|
const projectDir = await createTempProject();
|
||||||
|
const externalDir = path.join(projectDir, "external");
|
||||||
|
const symlinkPath = path.join(projectDir, "src", "linked");
|
||||||
|
await fs.mkdir(externalDir);
|
||||||
|
await fs.writeFile(path.join(externalDir, "external.py"), "");
|
||||||
|
await fs.symlink(externalDir, symlinkPath, "dir");
|
||||||
|
|
||||||
|
const sources = await expandSourceInput(
|
||||||
|
path.join(projectDir, "src", "**", "*.py"),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(sources).not.toContain(path.join(symlinkPath, "external.py"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still expands missing paths that contain glob metacharacters", async () => {
|
||||||
|
const projectDir = await createTempProject();
|
||||||
|
const pattern = path.join(projectDir, "src", "pkg[l]");
|
||||||
|
|
||||||
|
await expect(expandSourceInput(pattern)).resolves.toEqual([
|
||||||
|
path.join(projectDir, "src", "pkgl"),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getVersionSourceDirectory", () => {
|
||||||
|
it("does not scan src globs when version input is explicit", async () => {
|
||||||
|
await expect(
|
||||||
|
getVersionSourceDirectory("../**/*.py", "0.1.0", undefined),
|
||||||
|
).resolves.toBe(".");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not scan src globs when version-file input is explicit", async () => {
|
||||||
|
await expect(
|
||||||
|
getVersionSourceDirectory("../**/*.py", undefined, "pyproject.toml"),
|
||||||
|
).resolves.toBe(".");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses source discovery when no version input is explicit", async () => {
|
||||||
|
await expect(
|
||||||
|
getVersionSourceDirectory("../**/*.py", undefined, undefined),
|
||||||
|
).rejects.toThrow("Invalid pattern '../**/*.py'");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getSourceBasePath", () => {
|
||||||
|
it("returns the directory for a plain path", async () => {
|
||||||
|
await expect(getSourceBasePath("my-project")).resolves.toBe("my-project");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the directory for a dotted path", async () => {
|
||||||
|
await expect(getSourceBasePath("./src")).resolves.toBe("./src");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves filesystem root paths", async () => {
|
||||||
|
const root = path.parse(process.cwd()).root;
|
||||||
|
|
||||||
|
await expect(getSourceBasePath(root)).resolves.toBe(root);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips glob pattern and returns parent directory", async () => {
|
||||||
|
await expect(getSourceBasePath("src/**/*.py")).resolves.toBe("src");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns . for a top-level glob", async () => {
|
||||||
|
await expect(getSourceBasePath("*.py")).resolves.toBe(".");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns . for empty string", async () => {
|
||||||
|
await expect(getSourceBasePath("")).resolves.toBe(".");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses only the first whitespace-delimited token", async () => {
|
||||||
|
await expect(getSourceBasePath("sub1/*.py sub2/")).resolves.toBe("sub1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles trailing slash in prefix", async () => {
|
||||||
|
await expect(getSourceBasePath("sub/*.py")).resolves.toBe("sub");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the parent directory when a glob appears in a filename", async () => {
|
||||||
|
await expect(getSourceBasePath("src/file*.py")).resolves.toBe("src");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles ? glob", async () => {
|
||||||
|
await expect(getSourceBasePath("file?.py")).resolves.toBe(".");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles bracket glob", async () => {
|
||||||
|
await expect(getSourceBasePath("file[0-9].py")).resolves.toBe(".");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses @actions/glob syntax for base paths with glob metacharacters", async () => {
|
||||||
|
const projectDir = await createTempProject();
|
||||||
|
const originalCwd = process.cwd();
|
||||||
|
|
||||||
|
try {
|
||||||
|
process.chdir(projectDir);
|
||||||
|
await expect(getSourceBasePath("src/pkg[legacy]")).resolves.toBe(
|
||||||
|
path.join("src", "pkgl"),
|
||||||
|
);
|
||||||
|
await expect(getSourceBasePath("src/pkg[legacy]/*.py")).resolves.toBe(
|
||||||
|
path.join("src", "pkgl", "main.py"),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
process.chdir(originalCwd);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports @actions/glob escaping for version discovery from literal glob metacharacters", async () => {
|
||||||
|
const projectDir = await createTempProject();
|
||||||
|
const originalCwd = process.cwd();
|
||||||
|
const pyprojectPath = path.join(
|
||||||
|
projectDir,
|
||||||
|
"src",
|
||||||
|
"pkg[legacy]",
|
||||||
|
"pyproject.toml",
|
||||||
|
);
|
||||||
|
await fs.writeFile(
|
||||||
|
pyprojectPath,
|
||||||
|
"[project]\ndependencies = ['ruff==0.6.2']\n",
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
process.chdir(projectDir);
|
||||||
|
const sourceBasePath = await getSourceBasePath(
|
||||||
|
"src/pkg[[]legacy]/main.py",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(findPyprojectToml(sourceBasePath, projectDir)).toBe(
|
||||||
|
await fs.realpath(pyprojectPath),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
process.chdir(originalCwd);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the first expanded glob match for version discovery", async () => {
|
||||||
|
const projectDir = await createTempProject();
|
||||||
|
const originalCwd = process.cwd();
|
||||||
|
const pyprojectPath = path.join(
|
||||||
|
projectDir,
|
||||||
|
"packages",
|
||||||
|
"foo",
|
||||||
|
"pyproject.toml",
|
||||||
|
);
|
||||||
|
await fs.mkdir(path.join(projectDir, "packages", "foo", "src"), {
|
||||||
|
recursive: true,
|
||||||
|
});
|
||||||
|
await fs.writeFile(
|
||||||
|
pyprojectPath,
|
||||||
|
"[project]\ndependencies = ['ruff==0.10.0']\n",
|
||||||
|
);
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(projectDir, "packages", "foo", "src", "module.py"),
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
process.chdir(projectDir);
|
||||||
|
const sourceBasePath = await getSourceBasePath("packages/*/src/**/*.py");
|
||||||
|
|
||||||
|
expect(sourceBasePath).toBe(
|
||||||
|
path.join("packages", "foo", "src", "module.py"),
|
||||||
|
);
|
||||||
|
const workspaceRoot = await fs.realpath(projectDir);
|
||||||
|
expect(findPyprojectToml(sourceBasePath, workspaceRoot)).toBe(
|
||||||
|
await fs.realpath(pyprojectPath),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
process.chdir(originalCwd);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the unmatched glob fallback when version-discovery glob has no matches", async () => {
|
||||||
|
const projectDir = await createTempProject();
|
||||||
|
const originalCwd = process.cwd();
|
||||||
|
await fs.mkdir(path.join(projectDir, "packages"));
|
||||||
|
|
||||||
|
try {
|
||||||
|
process.chdir(projectDir);
|
||||||
|
await expect(getSourceBasePath("packages/*/src/**/*.py")).resolves.toBe(
|
||||||
|
"packages",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
process.chdir(originalCwd);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not treat brace expansion as glob syntax", async () => {
|
||||||
|
await expect(getSourceBasePath("src/{a,b}/*.py")).resolves.toBe(
|
||||||
|
"src/{a,b}",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import { beforeEach, describe, expect, it, jest } from "@jest/globals";
|
||||||
|
|
||||||
|
const info = jest.fn();
|
||||||
|
const warning = jest.fn();
|
||||||
|
|
||||||
|
jest.unstable_mockModule("@actions/core", () => ({
|
||||||
|
debug: jest.fn(),
|
||||||
|
info,
|
||||||
|
warning,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const {
|
||||||
|
findRuffVersionInSpec,
|
||||||
|
getRuffVersionFromFile,
|
||||||
|
getRuffVersionFromUvLockContent,
|
||||||
|
} = await import("../../src/version/file-parser");
|
||||||
|
|
||||||
|
describe("file-parser", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
info.mockReset();
|
||||||
|
warning.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("findRuffVersionInSpec", () => {
|
||||||
|
it("extracts version from 'ruff==0.9.3'", () => {
|
||||||
|
const result = findRuffVersionInSpec("ruff==0.9.3");
|
||||||
|
expect(result).toBe("0.9.3");
|
||||||
|
expect(info).toHaveBeenCalledWith(
|
||||||
|
"Found ruff version in requirements file: 0.9.3",
|
||||||
|
);
|
||||||
|
expect(warning).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("extracts version from 'ruff>=0.14'", () => {
|
||||||
|
const result = findRuffVersionInSpec("ruff>=0.14");
|
||||||
|
expect(result).toBe(">=0.14");
|
||||||
|
expect(warning).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("extracts version from 'ruff ~=1.0.0'", () => {
|
||||||
|
const result = findRuffVersionInSpec("ruff ~=1.0.0");
|
||||||
|
expect(result).toBe("~=1.0.0");
|
||||||
|
expect(warning).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("extracts version from 'ruff>=0.14,<1.0'", () => {
|
||||||
|
const result = findRuffVersionInSpec("ruff>=0.14,<1.0");
|
||||||
|
expect(result).toBe(">=0.14,<1.0");
|
||||||
|
expect(warning).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("extracts version from 'ruff>=0.14,<2.0,!=1.5.0'", () => {
|
||||||
|
const result = findRuffVersionInSpec("ruff>=0.14,<2.0,!=1.5.0");
|
||||||
|
expect(result).toBe(">=0.14,<2.0,!=1.5.0");
|
||||||
|
expect(warning).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined for non-ruff dependencies", () => {
|
||||||
|
const result = findRuffVersionInSpec("another-dep==0.1.6");
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
expect(info).not.toHaveBeenCalled();
|
||||||
|
expect(warning).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips trailing backslash", () => {
|
||||||
|
const result = findRuffVersionInSpec("ruff==0.9.3 \\");
|
||||||
|
expect(result).toBe("0.9.3");
|
||||||
|
expect(info).toHaveBeenCalledWith(
|
||||||
|
"Found ruff version in requirements file: 0.9.3",
|
||||||
|
);
|
||||||
|
expect(warning).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips environment markers and warns", () => {
|
||||||
|
const result = findRuffVersionInSpec(
|
||||||
|
'ruff>=0.14 ; python_version >= "3.11"',
|
||||||
|
);
|
||||||
|
expect(result).toBe(">=0.14");
|
||||||
|
expect(info).toHaveBeenCalledWith(
|
||||||
|
"Found ruff version in requirements file: >=0.14",
|
||||||
|
);
|
||||||
|
expect(warning).toHaveBeenCalledWith(
|
||||||
|
"Environment markers are ignored. ruff is a standalone tool that works independently of Python version.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles whitespace", () => {
|
||||||
|
const result = findRuffVersionInSpec(" ruff >=0.14 ");
|
||||||
|
expect(result).toBe(">=0.14");
|
||||||
|
expect(warning).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined for empty strings", () => {
|
||||||
|
const result = findRuffVersionInSpec("");
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
expect(info).not.toHaveBeenCalled();
|
||||||
|
expect(warning).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getRuffVersionFromFile", () => {
|
||||||
|
it("reads the version from requirements.txt", () => {
|
||||||
|
const result = getRuffVersionFromFile(
|
||||||
|
"__tests__/fixtures/requirements.txt",
|
||||||
|
);
|
||||||
|
expect(result).toBe("0.9.0");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads the version from requirements files with hashes", () => {
|
||||||
|
const result = getRuffVersionFromFile(
|
||||||
|
"__tests__/fixtures/requirements-with-hash.txt",
|
||||||
|
);
|
||||||
|
expect(result).toBe("0.9.0");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads the version from pyproject.toml dependencies", () => {
|
||||||
|
const result = getRuffVersionFromFile(
|
||||||
|
"__tests__/fixtures/pyproject.toml",
|
||||||
|
);
|
||||||
|
expect(result).toBe("0.9.3");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads the version from Poetry dependencies", () => {
|
||||||
|
const result = getRuffVersionFromFile(
|
||||||
|
"__tests__/fixtures/pyproject-dependency-poetry-project/pyproject.toml",
|
||||||
|
);
|
||||||
|
expect(result).toBe("~0.8.2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads the version from uv.lock", () => {
|
||||||
|
const result = getRuffVersionFromFile("__tests__/fixtures/uv.lock");
|
||||||
|
expect(result).toBe("0.9.5");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getRuffVersionFromUvLockContent", () => {
|
||||||
|
it("extracts the ruff version from uv.lock content", () => {
|
||||||
|
const result = getRuffVersionFromUvLockContent(
|
||||||
|
'[[package]]\nname = "other"\nversion = "1.0.0"\n\n[[package]]\nname = "ruff"\nversion = "0.9.5"\n',
|
||||||
|
);
|
||||||
|
expect(result).toBe("0.9.5");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined when ruff is not in uv.lock", () => {
|
||||||
|
const result = getRuffVersionFromUvLockContent(
|
||||||
|
'[[package]]\nname = "other"\nversion = "1.0.0"\n',
|
||||||
|
);
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import {
|
||||||
|
afterEach,
|
||||||
|
beforeEach,
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it,
|
||||||
|
jest,
|
||||||
|
} from "@jest/globals";
|
||||||
|
|
||||||
|
const debug = jest.fn();
|
||||||
|
const info = jest.fn();
|
||||||
|
const warning = jest.fn();
|
||||||
|
|
||||||
|
jest.unstable_mockModule("@actions/core", () => ({
|
||||||
|
debug,
|
||||||
|
info,
|
||||||
|
warning,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { resolveVersionRequest } = await import(
|
||||||
|
"../../src/version/version-request-resolver"
|
||||||
|
);
|
||||||
|
|
||||||
|
const tempDirs: string[] = [];
|
||||||
|
|
||||||
|
function createTempProject(files: Record<string, string> = {}): string {
|
||||||
|
const dir = fs.mkdtempSync(
|
||||||
|
path.join(os.tmpdir(), "ruff-action-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", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
debug.mockReset();
|
||||||
|
info.mockReset();
|
||||||
|
warning.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers explicit input over workspace discovery", () => {
|
||||||
|
const workspaceRoot = createTempProject({
|
||||||
|
"pyproject.toml": `[project]\ndependencies = ["ruff==0.5.14"]\n`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const request = resolveVersionRequest({
|
||||||
|
sourceDirectory: workspaceRoot,
|
||||||
|
version: "==0.6.0",
|
||||||
|
workspaceRoot,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(request).toEqual({
|
||||||
|
source: "input",
|
||||||
|
specifier: "0.6.0",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses requirements.txt when it is passed via version-file", () => {
|
||||||
|
const workspaceRoot = createTempProject({
|
||||||
|
"requirements.txt": "ruff==0.6.17\nruff-api==0.1.0\n",
|
||||||
|
});
|
||||||
|
|
||||||
|
const request = resolveVersionRequest({
|
||||||
|
sourceDirectory: workspaceRoot,
|
||||||
|
versionFile: path.join(workspaceRoot, "requirements.txt"),
|
||||||
|
workspaceRoot,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(request).toEqual({
|
||||||
|
format: "requirements",
|
||||||
|
source: "version-file",
|
||||||
|
sourcePath: path.join(workspaceRoot, "requirements.txt"),
|
||||||
|
specifier: "0.6.17",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("warns and falls back to latest when version-file does not resolve a version", () => {
|
||||||
|
const workspaceRoot = createTempProject({
|
||||||
|
"requirements.txt": "ruff-api==0.1.0\n",
|
||||||
|
});
|
||||||
|
|
||||||
|
const request = resolveVersionRequest({
|
||||||
|
sourceDirectory: workspaceRoot,
|
||||||
|
versionFile: path.join(workspaceRoot, "requirements.txt"),
|
||||||
|
workspaceRoot,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(request).toEqual({
|
||||||
|
source: "default",
|
||||||
|
specifier: "latest",
|
||||||
|
});
|
||||||
|
expect(warning).toHaveBeenCalledWith(
|
||||||
|
`Could not parse version from ${path.join(workspaceRoot, "requirements.txt")}. Using latest version.`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("discovers pyproject.toml by searching upward from src", () => {
|
||||||
|
const workspaceRoot = createTempProject({
|
||||||
|
"pyproject.toml": `[project]\ndependencies = ["ruff==0.10.0"]\n`,
|
||||||
|
"subproject/nested/example.py": 'print("hello")\n',
|
||||||
|
});
|
||||||
|
const sourceDirectory = path.join(workspaceRoot, "subproject", "nested");
|
||||||
|
|
||||||
|
const request = resolveVersionRequest({
|
||||||
|
sourceDirectory,
|
||||||
|
workspaceRoot,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(request).toEqual({
|
||||||
|
format: "pyproject.toml",
|
||||||
|
source: "pyproject.toml",
|
||||||
|
sourcePath: path.join(workspaceRoot, "pyproject.toml"),
|
||||||
|
specifier: "0.10.0",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to latest when no workspace version source is found", () => {
|
||||||
|
const workspaceRoot = createTempProject({
|
||||||
|
"subproject/example.py": 'print("hello")\n',
|
||||||
|
});
|
||||||
|
|
||||||
|
const request = resolveVersionRequest({
|
||||||
|
sourceDirectory: path.join(workspaceRoot, "subproject"),
|
||||||
|
workspaceRoot,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(request).toEqual({
|
||||||
|
source: "default",
|
||||||
|
specifier: "latest",
|
||||||
|
});
|
||||||
|
expect(info).toHaveBeenCalledWith(
|
||||||
|
"Could not find pyproject.toml. Using latest version.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when both version and version-file are specified", () => {
|
||||||
|
const workspaceRoot = createTempProject();
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
resolveVersionRequest({
|
||||||
|
sourceDirectory: workspaceRoot,
|
||||||
|
version: "0.6.0",
|
||||||
|
versionFile: path.join(workspaceRoot, "requirements.txt"),
|
||||||
|
workspaceRoot,
|
||||||
|
}),
|
||||||
|
).toThrow("It is not allowed to specify both version and version-file");
|
||||||
|
});
|
||||||
|
});
|
||||||
+13
-7
@@ -7,31 +7,37 @@ inputs:
|
|||||||
required: false
|
required: false
|
||||||
default: "check"
|
default: "check"
|
||||||
src:
|
src:
|
||||||
description: "Source to run Ruff. Defaults to the current directory."
|
description: "Source path(s) or @actions/glob pattern(s) to run Ruff on. Defaults to the current workspace."
|
||||||
required: false
|
required: false
|
||||||
default: ${{ github.workspace }}
|
default: ${{ github.workspace }}
|
||||||
version:
|
version:
|
||||||
description: "The version of Ruff to use, e.g., `0.6.0` Defaults to the version in pyproject.toml or 'latest'."
|
description: "The version of Ruff to use, e.g., `0.6.0`. Defaults to the first discoverable version in pyproject.toml searched upward from `src`, or `latest`."
|
||||||
required: false
|
required: false
|
||||||
default: ""
|
default: ""
|
||||||
version-file:
|
version-file:
|
||||||
description: "Path to a pyproject.toml or requirements.txt file to read the version from."
|
description: "Path to a pyproject.toml, requirements.txt or uv.lock file to read the version from. If parsing fails, the action warns and falls back to `latest`."
|
||||||
required: false
|
required: false
|
||||||
checksum:
|
checksum:
|
||||||
description: "The checksum of the ruff version to install"
|
description: "The checksum of the ruff version to install"
|
||||||
required: false
|
required: false
|
||||||
|
manifest-file:
|
||||||
|
description: "URL to a custom manifest file in the astral-sh/versions format."
|
||||||
|
required: false
|
||||||
|
download-from-astral-mirror:
|
||||||
|
description: "Download Ruff from the Astral mirror instead of directly from GitHub Releases."
|
||||||
|
required: false
|
||||||
|
default: "true"
|
||||||
github-token:
|
github-token:
|
||||||
description:
|
description:
|
||||||
"Used to increase the rate limit when retrieving versions and downloading
|
"Used for authenticated downloads of Ruff release artifacts from GitHub."
|
||||||
ruff."
|
|
||||||
required: false
|
required: false
|
||||||
default: ${{ github.token }}
|
default: ${{ github.token }}
|
||||||
outputs:
|
outputs:
|
||||||
ruff-version:
|
ruff-version:
|
||||||
description: "The installed ruff version. Useful when using latest."
|
description: "The installed ruff version. Useful when using latest."
|
||||||
runs:
|
runs:
|
||||||
using: "node20"
|
using: "node24"
|
||||||
main: "dist/ruff-action/index.js"
|
main: "dist/ruff-action/index.cjs"
|
||||||
branding:
|
branding:
|
||||||
icon: "code"
|
icon: "code"
|
||||||
color: "black"
|
color: "black"
|
||||||
|
|||||||
+2
-1
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://biomejs.dev/schemas/2.1.4/schema.json",
|
"$schema": "https://biomejs.dev/schemas/2.4.9/schema.json",
|
||||||
"assist": {
|
"assist": {
|
||||||
"actions": {
|
"actions": {
|
||||||
"source": {
|
"source": {
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
"ignoreUnknown": false,
|
"ignoreUnknown": false,
|
||||||
"includes": [
|
"includes": [
|
||||||
"**",
|
"**",
|
||||||
|
"!**/coverage",
|
||||||
"!**/dist",
|
"!**/dist",
|
||||||
"!**/lib",
|
"!**/lib",
|
||||||
"!**/node_modules",
|
"!**/node_modules",
|
||||||
|
|||||||
+32251
File diff suppressed because one or more lines are too long
-39035
File diff suppressed because one or more lines are too long
+30126
File diff suppressed because one or more lines are too long
-36895
File diff suppressed because one or more lines are too long
@@ -0,0 +1,12 @@
|
|||||||
|
import { createDefaultEsmPreset } from "ts-jest";
|
||||||
|
|
||||||
|
const preset = createDefaultEsmPreset();
|
||||||
|
|
||||||
|
/** @type {import('jest').Config} */
|
||||||
|
export default {
|
||||||
|
...preset,
|
||||||
|
collectCoverageFrom: ["src/**/*.ts", "!src/**/*.d.ts"],
|
||||||
|
moduleFileExtensions: ["ts", "js", "mjs"],
|
||||||
|
testEnvironment: "node",
|
||||||
|
testMatch: ["<rootDir>/__tests__/**/*.test.ts"],
|
||||||
|
};
|
||||||
Generated
+8032
-158
File diff suppressed because it is too large
Load Diff
+32
-19
@@ -2,40 +2,53 @@
|
|||||||
"name": "ruff-action",
|
"name": "ruff-action",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
"description": "A GitHub Action to run Ruff, an extremely fast Python linter and code formatter.",
|
"description": "A GitHub Action to run Ruff, an extremely fast Python linter and code formatter.",
|
||||||
"main": "dist/ruff-action/index.js",
|
"main": "dist/ruff-action/index.cjs",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "tsc --noEmit",
|
||||||
"check": "biome check --write",
|
"check": "biome check --write",
|
||||||
"package": "ncc build -o dist/ruff-action src/ruff-action.ts && ncc build -o dist/update-known-checksums src/update-known-checksums.ts",
|
"package": "node scripts/build-dist.mjs",
|
||||||
"act": "act pull_request -W .github/workflows/test.yml --container-architecture linux/amd64 -s GITHUB_TOKEN=\"$(gh auth token)\"",
|
"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_checksums node dist/update-known-checksums/index.js src/download/checksum/known-checksums.ts \"$(gh auth token)\"",
|
"update-known-checksums": "RUNNER_TEMP=known_checksums node dist/update-known-checksums/index.cjs src/download/checksum/known-checksums.ts \"$(gh auth token)\"",
|
||||||
"all": "npm install && npm run build && npm run check && npm run package"
|
"test:unit": "node --experimental-vm-modules ./node_modules/jest/bin/jest.js",
|
||||||
|
"test": "npm run build && npm run test:unit",
|
||||||
|
"all": "npm run build && npm run check && npm run package && npm run test:unit"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "git+https://github.com/astral-sh/ruff-action.git"
|
"url": "git+https://github.com/astral-sh/ruff-action.git"
|
||||||
},
|
},
|
||||||
"keywords": ["actions", "python", "ruff", "action"],
|
"keywords": [
|
||||||
|
"actions",
|
||||||
|
"python",
|
||||||
|
"ruff",
|
||||||
|
"action"
|
||||||
|
],
|
||||||
"author": "@eifinger",
|
"author": "@eifinger",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@actions/core": "^1.11.1",
|
"@actions/core": "^3.0.0",
|
||||||
"@actions/exec": "^1.1.1",
|
"@actions/exec": "^3.0.0",
|
||||||
"@actions/tool-cache": "^2.0.2",
|
"@actions/glob": "^0.7.0",
|
||||||
"@octokit/core": "^7.0.3",
|
"@actions/tool-cache": "^4.0.0",
|
||||||
|
"@octokit/core": "^7.0.6",
|
||||||
"@octokit/plugin-paginate-rest": "^13.1.1",
|
"@octokit/plugin-paginate-rest": "^13.1.1",
|
||||||
"@octokit/plugin-rest-endpoint-methods": "^16.0.0",
|
"@octokit/plugin-rest-endpoint-methods": "^17.0.0",
|
||||||
"@renovatebot/pep440": "^4.1.0",
|
"@renovatebot/pep440": "^4.2.1",
|
||||||
"smol-toml": "^1.4.1"
|
"smol-toml": "^1.6.0",
|
||||||
|
"undici": "^6.24.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.1.4",
|
"@biomejs/biome": "^2.4.7",
|
||||||
|
"@types/jest": "^29.5.14",
|
||||||
"@types/js-yaml": "^4.0.9",
|
"@types/js-yaml": "^4.0.9",
|
||||||
"@types/node": "^24.2.1",
|
"@types/node": "^25.5.0",
|
||||||
"@types/semver": "^7.7.0",
|
"@types/semver": "^7.7.1",
|
||||||
"@vercel/ncc": "^0.38.3",
|
"esbuild": "^0.27.4",
|
||||||
"js-yaml": "^4.1.0",
|
"jest": "^30.3.0",
|
||||||
"typescript": "^5.9.2"
|
"js-yaml": "^4.1.1",
|
||||||
|
"ts-jest": "^29.4.6",
|
||||||
|
"typescript": "^5.9.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { mkdir, rm } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { build } from "esbuild";
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
const repoRoot = path.resolve(__dirname, "..");
|
||||||
|
|
||||||
|
const builds = [
|
||||||
|
{
|
||||||
|
entryPoint: path.join(repoRoot, "src", "ruff-action.ts"),
|
||||||
|
outfile: path.join(repoRoot, "dist", "ruff-action", "index.cjs"),
|
||||||
|
staleOutfile: path.join(repoRoot, "dist", "ruff-action", "index.js"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
entryPoint: path.join(repoRoot, "src", "update-known-checksums.ts"),
|
||||||
|
outfile: path.join(repoRoot, "dist", "update-known-checksums", "index.cjs"),
|
||||||
|
staleOutfile: path.join(
|
||||||
|
repoRoot,
|
||||||
|
"dist",
|
||||||
|
"update-known-checksums",
|
||||||
|
"index.js",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
builds.map(async ({ entryPoint, outfile, staleOutfile }) => {
|
||||||
|
await rm(staleOutfile, { force: true });
|
||||||
|
await mkdir(path.dirname(outfile), { recursive: true });
|
||||||
|
await build({
|
||||||
|
bundle: true,
|
||||||
|
entryPoints: [entryPoint],
|
||||||
|
format: "cjs",
|
||||||
|
outfile,
|
||||||
|
platform: "node",
|
||||||
|
target: "node24",
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
File diff suppressed because it is too large
Load Diff
+148
-148
@@ -2,16 +2,16 @@ import { promises as fs } from "node:fs";
|
|||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import * as core from "@actions/core";
|
import * as core from "@actions/core";
|
||||||
import * as tc from "@actions/tool-cache";
|
import * as tc from "@actions/tool-cache";
|
||||||
import { Octokit } from "@octokit/core";
|
|
||||||
import { paginateRest } from "@octokit/plugin-paginate-rest";
|
|
||||||
import { restEndpointMethods } from "@octokit/plugin-rest-endpoint-methods";
|
|
||||||
import * as pep440 from "@renovatebot/pep440";
|
|
||||||
import * as semver from "semver";
|
import * as semver from "semver";
|
||||||
import { OWNER, REPO, TOOL_CACHE_NAME } from "../utils/constants";
|
import {
|
||||||
|
ASTRAL_MIRROR_PREFIX,
|
||||||
|
GITHUB_RELEASES_PREFIX,
|
||||||
|
TOOL_CACHE_NAME,
|
||||||
|
VERSIONS_MANIFEST_URL,
|
||||||
|
} from "../utils/constants";
|
||||||
import type { Architecture, Platform } from "../utils/platforms";
|
import type { Architecture, Platform } from "../utils/platforms";
|
||||||
import { validateChecksum } from "./checksum/checksum";
|
import { validateChecksum } from "./checksum/checksum";
|
||||||
|
import { getArtifact } from "./manifest";
|
||||||
const PaginatingOctokit = Octokit.plugin(paginateRest, restEndpointMethods);
|
|
||||||
|
|
||||||
export function tryGetFromToolCache(
|
export function tryGetFromToolCache(
|
||||||
arch: Architecture,
|
arch: Architecture,
|
||||||
@@ -32,31 +32,48 @@ export async function downloadVersion(
|
|||||||
platform: Platform,
|
platform: Platform,
|
||||||
arch: Architecture,
|
arch: Architecture,
|
||||||
version: string,
|
version: string,
|
||||||
checkSum: string | undefined,
|
checksum: string | undefined,
|
||||||
githubToken: string,
|
githubToken: string,
|
||||||
|
manifestUrl?: string,
|
||||||
|
downloadFromAstralMirror = true,
|
||||||
): Promise<{ version: string; cachedToolDir: string }> {
|
): Promise<{ version: string; cachedToolDir: string }> {
|
||||||
const artifact = `ruff-${arch}-${platform}`;
|
const artifact = await getArtifact(version, arch, platform, manifestUrl);
|
||||||
let extension = ".tar.gz";
|
|
||||||
if (platform === "pc-windows-msvc") {
|
|
||||||
extension = ".zip";
|
|
||||||
}
|
|
||||||
const downloadUrl = constructDownloadUrl(version, platform, arch);
|
|
||||||
core.debug(`Downloading ruff from "${downloadUrl}" ...`);
|
|
||||||
|
|
||||||
const downloadPath = await tc.downloadTool(
|
if (!artifact) {
|
||||||
downloadUrl,
|
throw new Error(
|
||||||
undefined,
|
getMissingArtifactMessage(version, arch, platform, manifestUrl),
|
||||||
githubToken,
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 downloadPath = await downloadArtifact(
|
||||||
|
artifact.downloadUrl,
|
||||||
|
platform,
|
||||||
|
arch,
|
||||||
|
version,
|
||||||
|
getDownloadToken(artifact.downloadUrl, githubToken),
|
||||||
|
downloadFromAstralMirror,
|
||||||
|
);
|
||||||
|
await validateChecksum(
|
||||||
|
resolvedChecksum,
|
||||||
|
downloadPath,
|
||||||
|
arch,
|
||||||
|
platform,
|
||||||
|
version,
|
||||||
);
|
);
|
||||||
core.debug(`Downloaded ruff to "${downloadPath}"`);
|
|
||||||
await validateChecksum(checkSum, downloadPath, arch, platform, version);
|
|
||||||
|
|
||||||
const extractedDir = await extractDownloadedArtifact(
|
const extractedDir = await extractDownloadedArtifact(
|
||||||
version,
|
version,
|
||||||
downloadPath,
|
downloadPath,
|
||||||
extension,
|
getExtension(platform),
|
||||||
platform,
|
platform,
|
||||||
artifact,
|
`ruff-${arch}-${platform}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const cachedToolDir = await tc.cacheDir(
|
const cachedToolDir = await tc.cacheDir(
|
||||||
@@ -65,25 +82,67 @@ export async function downloadVersion(
|
|||||||
version,
|
version,
|
||||||
arch,
|
arch,
|
||||||
);
|
);
|
||||||
return { cachedToolDir, version: version };
|
return { cachedToolDir, version };
|
||||||
}
|
}
|
||||||
|
|
||||||
function constructDownloadUrl(
|
/**
|
||||||
version: string,
|
* 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(
|
||||||
|
downloadUrl: string,
|
||||||
platform: Platform,
|
platform: Platform,
|
||||||
arch: Architecture,
|
arch: Architecture,
|
||||||
): string {
|
version: string,
|
||||||
const artifactVersionSuffix =
|
githubToken: string | undefined,
|
||||||
semver.lte(version, "v0.4.10") && semver.gte(version, "v0.1.8")
|
downloadFromAstralMirror: boolean,
|
||||||
? `-${version}`
|
): Promise<string> {
|
||||||
: "";
|
const mirrorUrl = downloadFromAstralMirror
|
||||||
const artifact = `ruff${artifactVersionSuffix}-${arch}-${platform}`;
|
? rewriteToMirror(downloadUrl)
|
||||||
let extension = ".tar.gz";
|
: undefined;
|
||||||
if (platform === "pc-windows-msvc") {
|
const resolvedDownloadUrl = mirrorUrl ?? downloadUrl;
|
||||||
extension = ".zip";
|
|
||||||
|
try {
|
||||||
|
return await downloadFile(
|
||||||
|
resolvedDownloadUrl,
|
||||||
|
mirrorUrl !== undefined ? undefined : githubToken,
|
||||||
|
);
|
||||||
|
} 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 downloadFile(
|
||||||
|
constructDownloadUrl(version, platform, arch),
|
||||||
|
githubToken,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const versionPrefix = semver.lte(version, "v0.4.10") ? "v" : "";
|
}
|
||||||
return `https://github.com/${OWNER}/${REPO}/releases/download/${versionPrefix}${version}/${artifact}${extension}`;
|
|
||||||
|
async function downloadFile(
|
||||||
|
downloadUrl: string,
|
||||||
|
githubToken: string | undefined,
|
||||||
|
): Promise<string> {
|
||||||
|
core.info(`Downloading ruff from "${downloadUrl}" ...`);
|
||||||
|
const downloadPath = await tc.downloadTool(
|
||||||
|
downloadUrl,
|
||||||
|
undefined,
|
||||||
|
githubToken,
|
||||||
|
);
|
||||||
|
core.debug(`Downloaded ruff to "${downloadPath}"`);
|
||||||
|
return downloadPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function extractDownloadedArtifact(
|
async function extractDownloadedArtifact(
|
||||||
@@ -98,7 +157,7 @@ async function extractDownloadedArtifact(
|
|||||||
const fullPathWithExtension = `${downloadPath}${extension}`;
|
const fullPathWithExtension = `${downloadPath}${extension}`;
|
||||||
await fs.copyFile(downloadPath, fullPathWithExtension);
|
await fs.copyFile(downloadPath, fullPathWithExtension);
|
||||||
ruffDir = await tc.extractZip(fullPathWithExtension);
|
ruffDir = await tc.extractZip(fullPathWithExtension);
|
||||||
// On windows extracting the zip does not create an intermediate directory
|
// On windows extracting the zip does not create an intermediate directory.
|
||||||
} else {
|
} else {
|
||||||
ruffDir = await tc.extractTar(downloadPath);
|
ruffDir = await tc.extractTar(downloadPath);
|
||||||
if (semver.gte(version, "v0.5.0")) {
|
if (semver.gte(version, "v0.5.0")) {
|
||||||
@@ -111,116 +170,57 @@ async function extractDownloadedArtifact(
|
|||||||
return ruffDir;
|
return ruffDir;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resolveVersion(
|
function getMissingArtifactMessage(
|
||||||
versionInput: string,
|
|
||||||
githubToken: string,
|
|
||||||
): Promise<string> {
|
|
||||||
core.debug(`Resolving ${versionInput}...`);
|
|
||||||
const 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);
|
|
||||||
const resolvedVersion = maxSatisfying(availableVersions, version);
|
|
||||||
if (resolvedVersion === undefined) {
|
|
||||||
throw new Error(`No version found for ${version}`);
|
|
||||||
}
|
|
||||||
core.debug(`Resolved version: ${resolvedVersion}`);
|
|
||||||
return resolvedVersion;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getAvailableVersions(githubToken: string): Promise<string[]> {
|
|
||||||
try {
|
|
||||||
const octokit = new PaginatingOctokit({
|
|
||||||
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 PaginatingOctokit();
|
|
||||||
return await getReleaseTagNames(octokit);
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getReleaseTagNames(
|
|
||||||
octokit: InstanceType<typeof PaginatingOctokit>,
|
|
||||||
): 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 response.map((release) => release.tag_name);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getLatestVersion(githubToken: string) {
|
|
||||||
const octokit = new PaginatingOctokit({
|
|
||||||
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 PaginatingOctokit();
|
|
||||||
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.");
|
|
||||||
}
|
|
||||||
return latestRelease.tag_name;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getLatestRelease(
|
|
||||||
octokit: InstanceType<typeof PaginatingOctokit>,
|
|
||||||
) {
|
|
||||||
const { data: latestRelease } = await octokit.rest.repos.getLatestRelease({
|
|
||||||
owner: OWNER,
|
|
||||||
repo: REPO,
|
|
||||||
});
|
|
||||||
return latestRelease;
|
|
||||||
}
|
|
||||||
|
|
||||||
function maxSatisfying(
|
|
||||||
versions: string[],
|
|
||||||
version: string,
|
version: string,
|
||||||
): string | undefined {
|
arch: Architecture,
|
||||||
const maxSemver = tc.evaluateVersions(versions, version);
|
platform: Platform,
|
||||||
if (maxSemver !== "") {
|
manifestUrl?: string,
|
||||||
core.debug(`Found a version that satisfies the semver range: ${maxSemver}`);
|
): string {
|
||||||
return maxSemver;
|
if (manifestUrl === undefined) {
|
||||||
|
return `Could not find artifact for version ${version}, arch ${arch}, platform ${platform} in ${VERSIONS_MANIFEST_URL} .`;
|
||||||
}
|
}
|
||||||
const maxPep440 = pep440.maxSatisfying(versions, version);
|
|
||||||
if (maxPep440 !== null) {
|
return `manifest-file does not contain version ${version}, arch ${arch}, platform ${platform}.`;
|
||||||
core.debug(
|
}
|
||||||
`Found a version that satisfies the pep440 specifier: ${maxPep440}`,
|
|
||||||
);
|
function resolveChecksum(
|
||||||
return maxPep440;
|
checksum: string | undefined,
|
||||||
}
|
manifestChecksum: string,
|
||||||
return undefined;
|
): string {
|
||||||
|
return checksum !== undefined && checksum !== ""
|
||||||
|
? checksum
|
||||||
|
: manifestChecksum;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDownloadToken(
|
||||||
|
downloadUrl: string,
|
||||||
|
githubToken: string,
|
||||||
|
): string | undefined {
|
||||||
|
return downloadUrl.startsWith(GITHUB_RELEASES_PREFIX)
|
||||||
|
? githubToken
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function constructDownloadUrl(
|
||||||
|
version: string,
|
||||||
|
platform: Platform,
|
||||||
|
arch: Architecture,
|
||||||
|
): string {
|
||||||
|
const normalizedVersion = stripVersionPrefix(version);
|
||||||
|
const artifactVersionSuffix =
|
||||||
|
semver.lte(version, "v0.4.10") && semver.gte(version, "v0.1.8")
|
||||||
|
? `-${normalizedVersion}`
|
||||||
|
: "";
|
||||||
|
const artifact = `ruff${artifactVersionSuffix}-${arch}-${platform}`;
|
||||||
|
const versionPrefix = semver.lte(version, "v0.4.10") ? "v" : "";
|
||||||
|
|
||||||
|
return `${GITHUB_RELEASES_PREFIX}${versionPrefix}${normalizedVersion}/${artifact}${getExtension(platform)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripVersionPrefix(version: string): string {
|
||||||
|
return version.startsWith("v") ? version.slice(1) : version;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getExtension(platform: Platform): string {
|
||||||
|
return platform === "pc-windows-msvc" ? ".zip" : ".tar.gz";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,243 @@
|
|||||||
|
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) =>
|
||||||
|
matchesManifestVersion(candidate.version, version),
|
||||||
|
);
|
||||||
|
if (!versionData) {
|
||||||
|
core.debug(`Version ${version} not found in manifest ${manifestUrl}`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetPlatforms = getTargetPlatforms(
|
||||||
|
versionData.version,
|
||||||
|
arch,
|
||||||
|
platform,
|
||||||
|
);
|
||||||
|
const matchingArtifacts = versionData.artifacts.filter((candidate) =>
|
||||||
|
targetPlatforms.includes(candidate.platform),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (matchingArtifacts.length === 0) {
|
||||||
|
core.debug(
|
||||||
|
`Artifact for ${targetPlatforms.join(" or ")} not found in version ${version}. Available platforms: ${versionData.artifacts
|
||||||
|
.map((candidate) => candidate.platform)
|
||||||
|
.join(", ")}`,
|
||||||
|
);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const artifact = selectDefaultVariant(
|
||||||
|
matchingArtifacts,
|
||||||
|
`Multiple artifacts found for ${targetPlatforms.join(" or ")} 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 matchesManifestVersion(
|
||||||
|
manifestVersion: string,
|
||||||
|
requestedVersion: string,
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
manifestVersion === requestedVersion ||
|
||||||
|
manifestVersion === withVersionPrefix(requestedVersion)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTargetPlatforms(
|
||||||
|
manifestVersion: string,
|
||||||
|
arch: string,
|
||||||
|
platform: string,
|
||||||
|
): string[] {
|
||||||
|
const targetPlatform = `${arch}-${platform}`;
|
||||||
|
const versionPrefixedTargetPlatform = `${stripVersionPrefix(manifestVersion)}-${targetPlatform}`;
|
||||||
|
|
||||||
|
return [targetPlatform, versionPrefixedTargetPlatform];
|
||||||
|
}
|
||||||
|
|
||||||
|
function withVersionPrefix(version: string): string {
|
||||||
|
return version.startsWith("v") ? version : `v${version}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripVersionPrefix(version: string): string {
|
||||||
|
return version.startsWith("v") ? version.slice(1) : version;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
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)}. ruff-action 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(", ");
|
||||||
|
}
|
||||||
+41
-47
@@ -1,17 +1,17 @@
|
|||||||
import * as fs from "node:fs";
|
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import * as core from "@actions/core";
|
import * as core from "@actions/core";
|
||||||
import * as exec from "@actions/exec";
|
import * as exec from "@actions/exec";
|
||||||
import * as semver from "semver";
|
import * as semver from "semver";
|
||||||
import {
|
import {
|
||||||
downloadVersion,
|
downloadVersion,
|
||||||
resolveVersion,
|
|
||||||
tryGetFromToolCache,
|
tryGetFromToolCache,
|
||||||
} from "./download/download-version";
|
} from "./download/download-version";
|
||||||
import {
|
import {
|
||||||
args,
|
args,
|
||||||
checkSum,
|
checkSum,
|
||||||
|
downloadFromAstralMirror,
|
||||||
githubToken,
|
githubToken,
|
||||||
|
manifestFile,
|
||||||
src,
|
src,
|
||||||
version,
|
version,
|
||||||
versionFile as versionFileInput,
|
versionFile as versionFileInput,
|
||||||
@@ -22,7 +22,12 @@ import {
|
|||||||
getPlatform,
|
getPlatform,
|
||||||
type Platform,
|
type Platform,
|
||||||
} from "./utils/platforms";
|
} from "./utils/platforms";
|
||||||
import { getRuffVersionFromRequirementsFile } from "./utils/pyproject";
|
import {
|
||||||
|
expandSourceInput,
|
||||||
|
getVersionSourceDirectory,
|
||||||
|
splitInput,
|
||||||
|
} from "./utils/source-input";
|
||||||
|
import { resolveRuffVersion } from "./version/resolve";
|
||||||
|
|
||||||
async function run(): Promise<void> {
|
async function run(): Promise<void> {
|
||||||
const platform = getPlatform();
|
const platform = getPlatform();
|
||||||
@@ -35,7 +40,13 @@ async function run(): Promise<void> {
|
|||||||
if (arch === undefined) {
|
if (arch === undefined) {
|
||||||
throw new Error(`Unsupported architecture: ${process.arch}`);
|
throw new Error(`Unsupported architecture: ${process.arch}`);
|
||||||
}
|
}
|
||||||
const setupResult = await setupRuff(platform, arch, checkSum, githubToken);
|
const setupResult = await setupRuff(
|
||||||
|
platform,
|
||||||
|
arch,
|
||||||
|
checkSum,
|
||||||
|
githubToken,
|
||||||
|
downloadFromAstralMirror,
|
||||||
|
);
|
||||||
|
|
||||||
addRuffToPath(setupResult.ruffDir);
|
addRuffToPath(setupResult.ruffDir);
|
||||||
setOutputFormat();
|
setOutputFormat();
|
||||||
@@ -43,11 +54,7 @@ async function run(): Promise<void> {
|
|||||||
core.setOutput("ruff-version", setupResult.version);
|
core.setOutput("ruff-version", setupResult.version);
|
||||||
core.info(`Successfully installed ruff version ${setupResult.version}`);
|
core.info(`Successfully installed ruff version ${setupResult.version}`);
|
||||||
|
|
||||||
await runRuff(
|
await runRuff(path.join(setupResult.ruffDir, "ruff"), args, src);
|
||||||
path.join(setupResult.ruffDir, "ruff"),
|
|
||||||
args.split(" "),
|
|
||||||
src.split(" "),
|
|
||||||
);
|
|
||||||
|
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -60,8 +67,10 @@ async function setupRuff(
|
|||||||
arch: Architecture,
|
arch: Architecture,
|
||||||
checkSum: string | undefined,
|
checkSum: string | undefined,
|
||||||
githubToken: string,
|
githubToken: string,
|
||||||
|
downloadFromAstralMirror: boolean,
|
||||||
): Promise<{ ruffDir: string; version: string }> {
|
): Promise<{ ruffDir: string; version: string }> {
|
||||||
const resolvedVersion = await determineVersion();
|
const resolvedVersion = await determineVersion();
|
||||||
|
const manifestUrl = manifestFile || undefined;
|
||||||
if (semver.lt(resolvedVersion, "v0.0.247")) {
|
if (semver.lt(resolvedVersion, "v0.0.247")) {
|
||||||
throw Error(
|
throw Error(
|
||||||
"This action does not support ruff versions older than 0.0.247",
|
"This action does not support ruff versions older than 0.0.247",
|
||||||
@@ -82,6 +91,8 @@ async function setupRuff(
|
|||||||
resolvedVersion,
|
resolvedVersion,
|
||||||
checkSum,
|
checkSum,
|
||||||
githubToken,
|
githubToken,
|
||||||
|
manifestUrl,
|
||||||
|
downloadFromAstralMirror,
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -91,35 +102,17 @@ async function setupRuff(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function determineVersion(): Promise<string> {
|
async function determineVersion(): Promise<string> {
|
||||||
if (versionFileInput !== "" && version !== "") {
|
return await resolveRuffVersion({
|
||||||
throw Error("It is not allowed to specify both version and version-file");
|
manifestFile: manifestFile || undefined,
|
||||||
}
|
sourceDirectory: await getVersionSourceDirectory(
|
||||||
if (version !== "") {
|
src,
|
||||||
return await resolveVersion(version, githubToken);
|
version,
|
||||||
}
|
versionFileInput,
|
||||||
if (versionFileInput !== "") {
|
),
|
||||||
const versionFromPyproject =
|
version,
|
||||||
getRuffVersionFromRequirementsFile(versionFileInput);
|
versionFile: versionFileInput,
|
||||||
if (versionFromPyproject === undefined) {
|
workspaceRoot: process.env.GITHUB_WORKSPACE || ".",
|
||||||
core.warning(
|
});
|
||||||
`Could not parse version from ${versionFileInput}. Using latest version.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return await resolveVersion(versionFromPyproject || "latest", githubToken);
|
|
||||||
}
|
|
||||||
const pyProjectPath = path.join(src, "pyproject.toml");
|
|
||||||
if (!fs.existsSync(pyProjectPath)) {
|
|
||||||
core.info(`Could not find ${pyProjectPath}. Using latest version.`);
|
|
||||||
return await resolveVersion("latest", githubToken);
|
|
||||||
}
|
|
||||||
const versionFromPyproject =
|
|
||||||
getRuffVersionFromRequirementsFile(pyProjectPath);
|
|
||||||
if (versionFromPyproject === undefined) {
|
|
||||||
core.info(
|
|
||||||
`Could not parse version from ${pyProjectPath}. Using latest version.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return await resolveVersion(versionFromPyproject || "latest", githubToken);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function addRuffToPath(cachedPath: string): void {
|
function addRuffToPath(cachedPath: string): void {
|
||||||
@@ -133,22 +126,23 @@ function setOutputFormat() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function addMatchers(): void {
|
function addMatchers(): void {
|
||||||
const matchersPath = path.join(
|
const actionRoot = getActionRoot();
|
||||||
__dirname,
|
const matchersPath = path.join(actionRoot, ".github", "matchers");
|
||||||
`..${path.sep}..`,
|
|
||||||
".github",
|
|
||||||
"matchers",
|
|
||||||
);
|
|
||||||
core.info(`##[add-matcher]${path.join(matchersPath, "check.json")}`);
|
core.info(`##[add-matcher]${path.join(matchersPath, "check.json")}`);
|
||||||
core.info(`##[add-matcher]${path.join(matchersPath, "format.json")}`);
|
core.info(`##[add-matcher]${path.join(matchersPath, "format.json")}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getActionRoot(): string {
|
||||||
|
const entrypoint = process.argv[1] ?? process.cwd();
|
||||||
|
return path.resolve(path.dirname(entrypoint), "..", "..");
|
||||||
|
}
|
||||||
|
|
||||||
async function runRuff(
|
async function runRuff(
|
||||||
ruffExecutablePath: string,
|
ruffExecutablePath: string,
|
||||||
args: string[],
|
args: string,
|
||||||
src: string[],
|
src: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const execArgs = [...args, ...src];
|
const execArgs = [...splitInput(args), ...(await expandSourceInput(src))];
|
||||||
await exec.exec(ruffExecutablePath, execArgs);
|
await exec.exec(ruffExecutablePath, execArgs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ const PaginatingOctokit = Octokit.plugin(paginateRest, restEndpointMethods);
|
|||||||
|
|
||||||
async function run(): Promise<void> {
|
async function run(): Promise<void> {
|
||||||
const checksumFilePath = process.argv.slice(2)[0];
|
const checksumFilePath = process.argv.slice(2)[0];
|
||||||
const github_token = process.argv.slice(2)[1];
|
const githubToken = process.argv.slice(2)[1];
|
||||||
|
|
||||||
const octokit = new PaginatingOctokit({ auth: github_token });
|
const octokit = new PaginatingOctokit({ auth: githubToken });
|
||||||
|
|
||||||
const response = await octokit.paginate(octokit.rest.repos.listReleases, {
|
const response = await octokit.paginate(octokit.rest.repos.listReleases, {
|
||||||
owner: OWNER,
|
owner: OWNER,
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
export const REPO = "ruff";
|
export const REPO = "ruff";
|
||||||
export const OWNER = "astral-sh";
|
export const OWNER = "astral-sh";
|
||||||
export const TOOL_CACHE_NAME = "ruff";
|
export const TOOL_CACHE_NAME = "ruff";
|
||||||
|
export const VERSIONS_MANIFEST_URL =
|
||||||
|
"https://raw.githubusercontent.com/astral-sh/versions/main/v1/ruff.ndjson";
|
||||||
|
export const GITHUB_RELEASES_PREFIX =
|
||||||
|
"https://github.com/astral-sh/ruff/releases/download/";
|
||||||
|
export const ASTRAL_MIRROR_PREFIX =
|
||||||
|
"https://releases.astral.sh/github/ruff/releases/download/";
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { ProxyAgent, type RequestInit, fetch as undiciFetch } from "undici";
|
||||||
|
|
||||||
|
export function getProxyAgent() {
|
||||||
|
const httpProxy = process.env.HTTP_PROXY || process.env.http_proxy;
|
||||||
|
if (httpProxy) {
|
||||||
|
return new ProxyAgent(httpProxy);
|
||||||
|
}
|
||||||
|
|
||||||
|
const httpsProxy = process.env.HTTPS_PROXY || process.env.https_proxy;
|
||||||
|
if (httpsProxy) {
|
||||||
|
return new ProxyAgent(httpsProxy);
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fetch = async (url: string, opts: RequestInit) =>
|
||||||
|
await undiciFetch(url, {
|
||||||
|
dispatcher: getProxyAgent(),
|
||||||
|
...opts,
|
||||||
|
});
|
||||||
@@ -6,3 +6,15 @@ export const githubToken = core.getInput("github-token");
|
|||||||
export const args = core.getInput("args");
|
export const args = core.getInput("args");
|
||||||
export const src = core.getInput("src");
|
export const src = core.getInput("src");
|
||||||
export const versionFile = core.getInput("version-file");
|
export const versionFile = core.getInput("version-file");
|
||||||
|
export const manifestFile = core.getInput("manifest-file");
|
||||||
|
export const downloadFromAstralMirror = getBooleanInput(
|
||||||
|
"download-from-astral-mirror",
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
function getBooleanInput(name: string, defaultValue: boolean): boolean {
|
||||||
|
if (core.getInput(name) === "") {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
return core.getBooleanInput(name);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import * as fs from "node:fs";
|
||||||
|
import * as path from "node:path";
|
||||||
|
import * as core from "@actions/core";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search for a pyproject.toml file starting from the given source path
|
||||||
|
* and traversing upwards through parent directories until reaching
|
||||||
|
* the GitHub workspace root.
|
||||||
|
*
|
||||||
|
* If the source path points to a file, the search begins in that file's
|
||||||
|
* parent directory.
|
||||||
|
*
|
||||||
|
* @param startPath The source path to start the search from (file or directory)
|
||||||
|
* @param workspaceRoot The GitHub workspace directory (GITHUB_WORKSPACE)
|
||||||
|
* @returns The path to the found pyproject.toml, or undefined if not found
|
||||||
|
*/
|
||||||
|
export function findPyprojectToml(
|
||||||
|
startPath: string,
|
||||||
|
workspaceRoot: string,
|
||||||
|
): string | undefined {
|
||||||
|
let currentDir = resolveStartDirectory(startPath);
|
||||||
|
const resolvedWorkspaceRoot = path.resolve(workspaceRoot);
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const pyprojectPath = path.join(currentDir, "pyproject.toml");
|
||||||
|
core.debug(`Checking for ${pyprojectPath}`);
|
||||||
|
|
||||||
|
if (fs.existsSync(pyprojectPath)) {
|
||||||
|
core.info(`Found pyproject.toml at ${pyprojectPath}`);
|
||||||
|
return pyprojectPath;
|
||||||
|
}
|
||||||
|
if (currentDir === resolvedWorkspaceRoot) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentDir = path.dirname(currentDir);
|
||||||
|
if (
|
||||||
|
parentDir === currentDir ||
|
||||||
|
!isPathWithinWorkspace(parentDir, resolvedWorkspaceRoot)
|
||||||
|
) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentDir = parentDir;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveStartDirectory(startPath: string): string {
|
||||||
|
const resolvedStartPath = path.resolve(startPath);
|
||||||
|
|
||||||
|
if (!fs.existsSync(resolvedStartPath)) {
|
||||||
|
return resolvedStartPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = fs.statSync(resolvedStartPath);
|
||||||
|
return stats.isDirectory()
|
||||||
|
? resolvedStartPath
|
||||||
|
: path.dirname(resolvedStartPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a given path is within or equal to the workspace root.
|
||||||
|
*
|
||||||
|
* @param checkPath The path to check
|
||||||
|
* @param workspaceRoot The workspace root directory
|
||||||
|
* @returns true if within or equal to workspace, false if outside, undefined if can't determine
|
||||||
|
*/
|
||||||
|
function isPathWithinWorkspace(
|
||||||
|
checkPath: string,
|
||||||
|
workspaceRoot: string,
|
||||||
|
): boolean {
|
||||||
|
const relativePath = path.relative(workspaceRoot, checkPath);
|
||||||
|
return !relativePath.startsWith("..") && !path.isAbsolute(relativePath);
|
||||||
|
}
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
import * as fs from "node:fs";
|
|
||||||
import * as core from "@actions/core";
|
|
||||||
import * as toml from "smol-toml";
|
|
||||||
|
|
||||||
function getRuffVersionFromAllDependencies(
|
|
||||||
allDependencies: string[],
|
|
||||||
): string | undefined {
|
|
||||||
const ruffVersionDefinition = allDependencies.find((dep: string) =>
|
|
||||||
dep.startsWith("ruff"),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (ruffVersionDefinition) {
|
|
||||||
const ruffVersion = ruffVersionDefinition
|
|
||||||
.match(/^ruff([^A-Z0-9._-]+.*)$/)?.[1]
|
|
||||||
.trim();
|
|
||||||
if (ruffVersion?.startsWith("==")) {
|
|
||||||
return ruffVersion.slice(2);
|
|
||||||
}
|
|
||||||
core.info(`Found ruff version in pyproject.toml: ${ruffVersion}`);
|
|
||||||
return ruffVersion;
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Pyproject {
|
|
||||||
project?: {
|
|
||||||
dependencies?: string[];
|
|
||||||
"optional-dependencies"?: Record<string, string[]>;
|
|
||||||
};
|
|
||||||
"dependency-groups"?: Record<string, Array<string | object>>;
|
|
||||||
tool?: {
|
|
||||||
poetry?: {
|
|
||||||
dependencies?: Record<string, string | object>;
|
|
||||||
group?: Record<string, { dependencies: Record<string, string | object> }>;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function parsePyproject(pyprojectContent: string): string | undefined {
|
|
||||||
const pyproject: Pyproject = toml.parse(pyprojectContent);
|
|
||||||
const dependencies: string[] = pyproject?.project?.dependencies || [];
|
|
||||||
const optionalDependencies: string[] = Object.values(
|
|
||||||
pyproject?.project?.["optional-dependencies"] || {},
|
|
||||||
).flat();
|
|
||||||
const devDependencies: string[] = Object.values(
|
|
||||||
pyproject?.["dependency-groups"] || {},
|
|
||||||
)
|
|
||||||
.flat()
|
|
||||||
.filter((item: string | object) => typeof item === "string");
|
|
||||||
return (
|
|
||||||
getRuffVersionFromAllDependencies(
|
|
||||||
dependencies.concat(optionalDependencies, devDependencies),
|
|
||||||
) || getRuffVersionFromPoetryGroups(pyproject)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getRuffVersionFromPoetryGroups(
|
|
||||||
pyproject: Pyproject,
|
|
||||||
): string | undefined {
|
|
||||||
// Special handling for Poetry until it supports PEP 735
|
|
||||||
// See: <https://github.com/python-poetry/poetry/issues/9751>
|
|
||||||
const poetry = pyproject?.tool?.poetry || {};
|
|
||||||
const poetryGroups = Object.values(poetry.group || {});
|
|
||||||
if (poetry.dependencies) {
|
|
||||||
poetryGroups.unshift({ dependencies: poetry.dependencies });
|
|
||||||
}
|
|
||||||
return poetryGroups
|
|
||||||
.flatMap((group) => Object.entries(group.dependencies))
|
|
||||||
.map(([name, spec]) => {
|
|
||||||
if (name === "ruff" && typeof spec === "string") return spec;
|
|
||||||
return undefined;
|
|
||||||
})
|
|
||||||
.find((version) => version !== undefined);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getRuffVersionFromRequirementsFile(
|
|
||||||
filePath: string,
|
|
||||||
): string | undefined {
|
|
||||||
if (!fs.existsSync(filePath)) {
|
|
||||||
core.warning(`Could not find file: ${filePath}`);
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
const pyprojectContent = fs.readFileSync(filePath, "utf-8");
|
|
||||||
if (filePath.endsWith(".txt")) {
|
|
||||||
return getRuffVersionFromAllDependencies(pyprojectContent.split("\n"));
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return parsePyproject(pyprojectContent);
|
|
||||||
} catch (err) {
|
|
||||||
const message = (err as Error).message;
|
|
||||||
core.warning(`Error while parsing ${filePath}: ${message}`);
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import * as fs from "node:fs/promises";
|
||||||
|
import * as path from "node:path";
|
||||||
|
import * as glob from "@actions/glob";
|
||||||
|
|
||||||
|
const GLOB_PATTERN = /[*?[]/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Split an action input into whitespace-delimited tokens.
|
||||||
|
*
|
||||||
|
* The action has historically documented both `args` and `src` as plain
|
||||||
|
* whitespace-delimited inputs. Keep that behavior instead of invoking a shell.
|
||||||
|
*/
|
||||||
|
export function splitInput(input: string): string[] {
|
||||||
|
return input.trim().split(/\s+/).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expand glob patterns from the `src` input in a cross-platform way.
|
||||||
|
*
|
||||||
|
* Glob syntax is defined by @actions/glob. This action does not emulate shell
|
||||||
|
* expansion. Unmatched patterns are preserved so Ruff can report the missing
|
||||||
|
* path.
|
||||||
|
*/
|
||||||
|
export async function expandSourceInput(srcInput: string): Promise<string[]> {
|
||||||
|
const sources = splitInput(srcInput);
|
||||||
|
|
||||||
|
if (sources.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const expandedSources: string[] = [];
|
||||||
|
for (const source of sources) {
|
||||||
|
if (!hasGlobPattern(source)) {
|
||||||
|
expandedSources.push(source);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const matches = await globMatches(source);
|
||||||
|
expandedSources.push(...(matches.length > 0 ? matches : [source]));
|
||||||
|
}
|
||||||
|
|
||||||
|
return expandedSources;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the starting path for version discovery from the `src` input.
|
||||||
|
*
|
||||||
|
* Takes the first whitespace-delimited token. Literal paths are preserved. Glob
|
||||||
|
* patterns use their first sorted match when available, so pyproject.toml
|
||||||
|
* discovery starts from a path Ruff will actually check. Unmatched globs fall
|
||||||
|
* back to @actions/glob's search path.
|
||||||
|
*/
|
||||||
|
export async function getVersionSourceDirectory(
|
||||||
|
srcInput: string,
|
||||||
|
versionInput: string | undefined,
|
||||||
|
versionFileInput: string | undefined,
|
||||||
|
): Promise<string> {
|
||||||
|
if (hasInput(versionInput) || hasInput(versionFileInput)) {
|
||||||
|
return ".";
|
||||||
|
}
|
||||||
|
|
||||||
|
return await getSourceBasePath(srcInput);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSourceBasePath(srcInput: string): Promise<string> {
|
||||||
|
const firstSource = splitInput(srcInput)[0] ?? ".";
|
||||||
|
|
||||||
|
if (!hasGlobPattern(firstSource)) {
|
||||||
|
return stripTrailingSeparators(firstSource);
|
||||||
|
}
|
||||||
|
|
||||||
|
const globber = await createGlobber(firstSource);
|
||||||
|
const matches = (await globber.glob()).sort();
|
||||||
|
if (matches.length > 0) {
|
||||||
|
return (
|
||||||
|
(await preserveSourcePathStyle(firstSource, [matches[0] ?? "."]))[0] ??
|
||||||
|
"."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchPaths = globber.getSearchPaths().sort();
|
||||||
|
if (searchPaths.length > 0) {
|
||||||
|
return (
|
||||||
|
(
|
||||||
|
await preserveSourcePathStyle(firstSource, [searchPaths[0] ?? "."])
|
||||||
|
)[0] ?? "."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ".";
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasInput(input: string | undefined): boolean {
|
||||||
|
return input !== undefined && input.trim() !== "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasGlobPattern(source: string): boolean {
|
||||||
|
return GLOB_PATTERN.test(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripTrailingSeparators(source: string): string {
|
||||||
|
const root = path.parse(source).root;
|
||||||
|
const strippedSource = source.replace(/[/\\]+$/, "");
|
||||||
|
|
||||||
|
if (root !== "" && strippedSource.length < root.length) {
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
return strippedSource || root || ".";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function globMatches(pattern: string): Promise<string[]> {
|
||||||
|
const globber = await createGlobber(pattern);
|
||||||
|
const matches = await globber.glob();
|
||||||
|
|
||||||
|
return await preserveSourcePathStyle(pattern, matches.sort());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createGlobber(pattern: string): Promise<glob.Globber> {
|
||||||
|
return await glob.create(normalizeGlobPattern(pattern), {
|
||||||
|
excludeHiddenFiles: !explicitlyTargetsHiddenPath(pattern),
|
||||||
|
followSymbolicLinks: false,
|
||||||
|
implicitDescendants: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function preserveSourcePathStyle(
|
||||||
|
pattern: string,
|
||||||
|
matches: string[],
|
||||||
|
): Promise<string[]> {
|
||||||
|
if (path.isAbsolute(pattern)) {
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cwd = await fs.realpath(process.cwd());
|
||||||
|
return matches.map((match) => path.relative(cwd, match) || ".");
|
||||||
|
}
|
||||||
|
|
||||||
|
function explicitlyTargetsHiddenPath(pattern: string): boolean {
|
||||||
|
return splitSourcePath(pattern).some(isHiddenPathSegment);
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitSourcePath(source: string): string[] {
|
||||||
|
const root = path.parse(source).root;
|
||||||
|
const relativePath = source.slice(root.length);
|
||||||
|
const separatorPattern = process.platform === "win32" ? /[/\\]+/ : /\/+/;
|
||||||
|
|
||||||
|
return relativePath.split(separatorPattern).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isHiddenPathSegment(segment: string): boolean {
|
||||||
|
return segment.startsWith(".") && segment !== "." && segment !== "..";
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeGlobPattern(pattern: string): string {
|
||||||
|
if (process.platform !== "win32") {
|
||||||
|
return pattern;
|
||||||
|
}
|
||||||
|
|
||||||
|
return pattern.replace(/\\/g, "/");
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import * as core from "@actions/core";
|
||||||
|
import * as toml from "smol-toml";
|
||||||
|
import { normalizeVersionSpecifier } from "./specifier";
|
||||||
|
import type { ParsedVersionFile, VersionFileFormat } from "./types";
|
||||||
|
|
||||||
|
interface VersionFileParser {
|
||||||
|
format: VersionFileFormat;
|
||||||
|
parse(filePath: string): string | undefined;
|
||||||
|
supports(filePath: string): boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Pyproject {
|
||||||
|
project?: {
|
||||||
|
dependencies?: string[];
|
||||||
|
"optional-dependencies"?: Record<string, string[]>;
|
||||||
|
};
|
||||||
|
"dependency-groups"?: Record<string, Array<string | object>>;
|
||||||
|
tool?: {
|
||||||
|
poetry?: {
|
||||||
|
dependencies?: Record<string, string | object>;
|
||||||
|
group?: Record<string, { dependencies: Record<string, string | object> }>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UvLock {
|
||||||
|
package?: Array<{ name?: string; version?: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VERSION_FILE_PARSERS: VersionFileParser[] = [
|
||||||
|
{
|
||||||
|
format: "pyproject.toml",
|
||||||
|
parse: (filePath) => {
|
||||||
|
const fileContent = fs.readFileSync(filePath, "utf-8");
|
||||||
|
return getRuffVersionFromPyprojectContent(fileContent);
|
||||||
|
},
|
||||||
|
supports: (filePath) => filePath.endsWith("pyproject.toml"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
format: "uv.lock",
|
||||||
|
parse: (filePath) => {
|
||||||
|
const fileContent = fs.readFileSync(filePath, "utf-8");
|
||||||
|
return getRuffVersionFromUvLockContent(fileContent);
|
||||||
|
},
|
||||||
|
supports: (filePath) => filePath.endsWith("uv.lock"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
format: "requirements",
|
||||||
|
parse: (filePath) => {
|
||||||
|
const fileContent = fs.readFileSync(filePath, "utf-8");
|
||||||
|
return getRuffVersionFromRequirementsText(fileContent);
|
||||||
|
},
|
||||||
|
supports: (filePath) => filePath.endsWith(".txt"),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function getParsedVersionFile(
|
||||||
|
filePath: string,
|
||||||
|
): ParsedVersionFile | undefined {
|
||||||
|
core.info(`Trying to find version for ruff in: ${filePath}`);
|
||||||
|
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
core.warning(`Could not find file: ${filePath}`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parser = getVersionFileParser(filePath);
|
||||||
|
if (parser === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const specifier = parser.parse(filePath);
|
||||||
|
if (specifier === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedSpecifier = normalizeVersionSpecifier(specifier);
|
||||||
|
core.info(`Found version for ruff in ${filePath}: ${normalizedSpecifier}`);
|
||||||
|
return {
|
||||||
|
format: parser.format,
|
||||||
|
specifier: normalizedSpecifier,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
core.warning(
|
||||||
|
`Error while parsing ${filePath}: ${(error as Error).message}`,
|
||||||
|
);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRuffVersionFromFile(filePath: string): string | undefined {
|
||||||
|
return getParsedVersionFile(filePath)?.specifier;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findRuffVersionInSpec(spec: string): string | undefined {
|
||||||
|
const trimmedSpec = spec.trim();
|
||||||
|
|
||||||
|
if (!trimmedSpec.startsWith("ruff")) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
let versionSpec = trimmedSpec.slice("ruff".length);
|
||||||
|
if (!versionSpec.match(/^(?:\s+|[=<>~!])/)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
versionSpec = versionSpec.replace(/\\$/, "").trim();
|
||||||
|
|
||||||
|
const match = versionSpec.match(/^([^;]+)(?:;.*)?$/);
|
||||||
|
|
||||||
|
if (match) {
|
||||||
|
let version = match[1].trim();
|
||||||
|
if (version) {
|
||||||
|
version = normalizeVersionSpecifier(version);
|
||||||
|
if (trimmedSpec.includes(";")) {
|
||||||
|
core.warning(
|
||||||
|
"Environment markers are ignored. ruff is a standalone tool that works independently of Python version.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
core.info(`Found ruff version in requirements file: ${version}`);
|
||||||
|
return version;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRuffVersionFromRequirementsText(
|
||||||
|
fileContent: string,
|
||||||
|
): string | undefined {
|
||||||
|
return getRuffVersionFromAllDependencies(fileContent.split("\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRuffVersionFromPyprojectContent(
|
||||||
|
pyprojectContent: string,
|
||||||
|
): string | undefined {
|
||||||
|
const pyproject = parsePyprojectContent(pyprojectContent);
|
||||||
|
return getRuffVersionFromParsedPyproject(pyproject);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parsePyprojectContent(pyprojectContent: string): Pyproject {
|
||||||
|
return toml.parse(pyprojectContent) as Pyproject;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRuffVersionFromUvLockContent(
|
||||||
|
uvLockContent: string,
|
||||||
|
): string | undefined {
|
||||||
|
const uvLock = toml.parse(uvLockContent) as UvLock;
|
||||||
|
const ruffPackage = (uvLock.package || []).find((pkg) => pkg.name === "ruff");
|
||||||
|
if (ruffPackage?.version === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
core.info(`Found ruff version in uv.lock: ${ruffPackage.version}`);
|
||||||
|
return ruffPackage.version;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getVersionFileParser(filePath: string): VersionFileParser | undefined {
|
||||||
|
return VERSION_FILE_PARSERS.find((parser) => parser.supports(filePath));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRuffVersionFromParsedPyproject(
|
||||||
|
pyproject: Pyproject,
|
||||||
|
): string | undefined {
|
||||||
|
const dependencies: string[] = pyproject.project?.dependencies || [];
|
||||||
|
const optionalDependencies: string[] = Object.values(
|
||||||
|
pyproject.project?.["optional-dependencies"] || {},
|
||||||
|
).flat();
|
||||||
|
const devDependencies: string[] = Object.values(
|
||||||
|
pyproject["dependency-groups"] || {},
|
||||||
|
)
|
||||||
|
.flat()
|
||||||
|
.filter((item: string | object) => typeof item === "string");
|
||||||
|
|
||||||
|
return (
|
||||||
|
getRuffVersionFromAllDependencies(
|
||||||
|
dependencies.concat(optionalDependencies, devDependencies),
|
||||||
|
) || getRuffVersionFromPoetryGroups(pyproject)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRuffVersionFromPoetryGroups(
|
||||||
|
pyproject: Pyproject,
|
||||||
|
): string | undefined {
|
||||||
|
const poetry = pyproject.tool?.poetry || {};
|
||||||
|
const poetryGroups = Object.values(poetry.group || {});
|
||||||
|
if (poetry.dependencies) {
|
||||||
|
poetryGroups.unshift({ dependencies: poetry.dependencies });
|
||||||
|
}
|
||||||
|
|
||||||
|
return poetryGroups
|
||||||
|
.flatMap((group) => Object.entries(group.dependencies))
|
||||||
|
.map(([name, spec]) => {
|
||||||
|
if (typeof spec === "string") {
|
||||||
|
return findRuffVersionInSpec(`${name} ${spec}`);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
})
|
||||||
|
.find((version) => version !== undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRuffVersionFromAllDependencies(
|
||||||
|
allDependencies: string[],
|
||||||
|
): string | undefined {
|
||||||
|
return allDependencies
|
||||||
|
.map((dependency) => findRuffVersionInSpec(dependency))
|
||||||
|
.find((version) => version !== undefined);
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import * as core from "@actions/core";
|
||||||
|
import * as tc from "@actions/tool-cache";
|
||||||
|
import * as pep440 from "@renovatebot/pep440";
|
||||||
|
import { getAllVersions, getLatestVersion } from "../download/manifest";
|
||||||
|
import {
|
||||||
|
type ParsedVersionSpecifier,
|
||||||
|
parseVersionSpecifier,
|
||||||
|
} from "./specifier";
|
||||||
|
import type { ResolveRuffVersionOptions } from "./types";
|
||||||
|
import { resolveVersionRequest } from "./version-request-resolver";
|
||||||
|
|
||||||
|
interface ConcreteVersionResolutionContext {
|
||||||
|
manifestUrl?: string;
|
||||||
|
parsedSpecifier: ParsedVersionSpecifier;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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> {
|
||||||
|
if (context.parsedSpecifier.kind !== "latest") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await getLatestVersion(context.manifestUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class RangeVersionResolver implements ConcreteVersionResolver {
|
||||||
|
async resolve(
|
||||||
|
context: ConcreteVersionResolutionContext,
|
||||||
|
): Promise<string | undefined> {
|
||||||
|
if (context.parsedSpecifier.kind !== "range") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const availableVersions = await getAllVersions(context.manifestUrl);
|
||||||
|
const resolvedVersion = maxSatisfying(
|
||||||
|
availableVersions,
|
||||||
|
context.parsedSpecifier.normalized,
|
||||||
|
);
|
||||||
|
if (resolvedVersion === undefined) {
|
||||||
|
throw new Error(`No version found for ${context.parsedSpecifier.raw}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
core.debug(`Resolved version: ${resolvedVersion}`);
|
||||||
|
return resolvedVersion;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const CONCRETE_VERSION_RESOLVERS: ConcreteVersionResolver[] = [
|
||||||
|
new ExactVersionResolver(),
|
||||||
|
new LatestVersionResolver(),
|
||||||
|
new RangeVersionResolver(),
|
||||||
|
];
|
||||||
|
|
||||||
|
export async function resolveRuffVersion(
|
||||||
|
options: ResolveRuffVersionOptions,
|
||||||
|
): Promise<string> {
|
||||||
|
const request = resolveVersionRequest(options);
|
||||||
|
return await resolveVersion(request.specifier, options.manifestFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveVersion(
|
||||||
|
versionInput: string,
|
||||||
|
manifestUrl?: string,
|
||||||
|
): Promise<string> {
|
||||||
|
core.debug(`Resolving ${versionInput}...`);
|
||||||
|
|
||||||
|
const context: ConcreteVersionResolutionContext = {
|
||||||
|
manifestUrl,
|
||||||
|
parsedSpecifier: parseVersionSpecifier(versionInput),
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import * as tc from "@actions/tool-cache";
|
||||||
|
|
||||||
|
export type ParsedVersionSpecifier =
|
||||||
|
| {
|
||||||
|
kind: "exact";
|
||||||
|
normalized: string;
|
||||||
|
raw: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: "latest";
|
||||||
|
normalized: "latest";
|
||||||
|
raw: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
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 {
|
||||||
|
kind: "range",
|
||||||
|
normalized,
|
||||||
|
raw,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
export type VersionSource =
|
||||||
|
| "input"
|
||||||
|
| "version-file"
|
||||||
|
| "pyproject.toml"
|
||||||
|
| "default";
|
||||||
|
|
||||||
|
export type VersionFileFormat = "pyproject.toml" | "requirements" | "uv.lock";
|
||||||
|
|
||||||
|
export interface ParsedVersionFile {
|
||||||
|
format: VersionFileFormat;
|
||||||
|
specifier: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolveRuffVersionOptions {
|
||||||
|
manifestFile?: string;
|
||||||
|
sourceDirectory: string;
|
||||||
|
version?: string;
|
||||||
|
versionFile?: string;
|
||||||
|
workspaceRoot: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VersionRequest {
|
||||||
|
format?: VersionFileFormat;
|
||||||
|
source: VersionSource;
|
||||||
|
sourcePath?: string;
|
||||||
|
specifier: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import * as core from "@actions/core";
|
||||||
|
import { findPyprojectToml } from "../utils/pyproject-finder";
|
||||||
|
import { getParsedVersionFile } from "./file-parser";
|
||||||
|
import { normalizeVersionSpecifier } from "./specifier";
|
||||||
|
import type {
|
||||||
|
ParsedVersionFile,
|
||||||
|
ResolveRuffVersionOptions,
|
||||||
|
VersionRequest,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
export interface VersionRequestResolver {
|
||||||
|
resolve(context: VersionRequestContext): VersionRequest | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class VersionRequestContext {
|
||||||
|
readonly sourceDirectory: string;
|
||||||
|
readonly version: string | undefined;
|
||||||
|
readonly versionFile: string | undefined;
|
||||||
|
readonly workspaceRoot: string;
|
||||||
|
|
||||||
|
private readonly parsedFiles = new Map<
|
||||||
|
string,
|
||||||
|
ParsedVersionFile | undefined
|
||||||
|
>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
version: string | undefined,
|
||||||
|
versionFile: string | undefined,
|
||||||
|
sourceDirectory: string,
|
||||||
|
workspaceRoot: string,
|
||||||
|
) {
|
||||||
|
this.version = version;
|
||||||
|
this.versionFile = versionFile;
|
||||||
|
this.sourceDirectory = sourceDirectory;
|
||||||
|
this.workspaceRoot = workspaceRoot;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
getWorkspacePyprojectPath(): string | undefined {
|
||||||
|
return findPyprojectToml(this.sourceDirectory, this.workspaceRoot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
core.warning(
|
||||||
|
`Could not parse version from ${context.versionFile}. Using latest version.`,
|
||||||
|
);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
format: versionFile.format,
|
||||||
|
source: "version-file",
|
||||||
|
sourcePath: context.versionFile,
|
||||||
|
specifier: versionFile.specifier,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WorkspaceVersionResolver implements VersionRequestResolver {
|
||||||
|
resolve(context: VersionRequestContext): VersionRequest | undefined {
|
||||||
|
const pyprojectPath = context.getWorkspacePyprojectPath();
|
||||||
|
if (!pyprojectPath) {
|
||||||
|
core.info("Could not find pyproject.toml. Using latest version.");
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const versionFile = context.getVersionFile(pyprojectPath);
|
||||||
|
if (versionFile === undefined) {
|
||||||
|
core.info(
|
||||||
|
`Could not parse version from ${pyprojectPath}. Using latest version.`,
|
||||||
|
);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
format: versionFile.format,
|
||||||
|
source: "pyproject.toml",
|
||||||
|
sourcePath: pyprojectPath,
|
||||||
|
specifier: versionFile.specifier,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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: ResolveRuffVersionOptions,
|
||||||
|
): VersionRequest {
|
||||||
|
const version = emptyToUndefined(options.version);
|
||||||
|
const versionFile = emptyToUndefined(options.versionFile);
|
||||||
|
|
||||||
|
if (version !== undefined && versionFile !== undefined) {
|
||||||
|
throw new Error(
|
||||||
|
"It is not allowed to specify both version and version-file",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const context = new VersionRequestContext(
|
||||||
|
version,
|
||||||
|
versionFile,
|
||||||
|
options.sourceDirectory,
|
||||||
|
options.workspaceRoot,
|
||||||
|
);
|
||||||
|
|
||||||
|
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 Ruff version.");
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyToUndefined(value: string | undefined): string | undefined {
|
||||||
|
return value === undefined || value === "" ? undefined : value;
|
||||||
|
}
|
||||||
+8
-8
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */,
|
"esModuleInterop": true,
|
||||||
"module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */,
|
"isolatedModules": true,
|
||||||
"noImplicitAny": true /* Raise error on expressions and declarations with an implied 'any' type. */,
|
"module": "esnext",
|
||||||
"outDir": "./lib" /* Redirect output structure to the directory. */,
|
"moduleResolution": "bundler",
|
||||||
"rootDir": "./src" /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */,
|
"noImplicitAny": true,
|
||||||
"strict": true /* Enable all strict type-checking options. */,
|
"strict": true,
|
||||||
"target": "ES2022" /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
|
"target": "ES2022"
|
||||||
},
|
},
|
||||||
"exclude": ["node_modules", "**/*.test.ts"]
|
"include": ["src/**/*.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user