mirror of
https://github.com/astral-sh/ruff-action.git
synced 2026-05-17 23:00:13 +02:00
Fix wildcard src input
This commit is contained in:
@@ -395,6 +395,34 @@ jobs:
|
||||
fi
|
||||
env:
|
||||
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:
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
@@ -479,6 +507,8 @@ jobs:
|
||||
- test-with-explicit-token
|
||||
- test-args
|
||||
- test-failure
|
||||
- test-glob-src
|
||||
- test-glob-single-star-src
|
||||
- test-multiple-src
|
||||
- test-parent-directory-pyproject
|
||||
- test-custom-manifest-file
|
||||
|
||||
@@ -33,7 +33,7 @@ anything `ruff` can (ex, fix).
|
||||
| `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 |
|
||||
| `manifest-file` | URL to a custom Ruff manifest in the `astral-sh/versions` format. | None |
|
||||
| `args` | The arguments to pass to the `ruff` command. See [Configuring Ruff] | `check` |
|
||||
| `src` | The directory or single files to run `ruff` on. | [github.workspace] |
|
||||
| `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` |
|
||||
|
||||
@@ -56,6 +56,8 @@ By default, Ruff version metadata is resolved from the
|
||||
|
||||
### Specify multiple files
|
||||
|
||||
Separate multiple `src` values with whitespace.
|
||||
|
||||
```yaml
|
||||
- uses: astral-sh/ruff-action@v3
|
||||
with:
|
||||
@@ -64,6 +66,24 @@ By default, Ruff version metadata is resolved from the
|
||||
path/to/file2.py
|
||||
```
|
||||
|
||||
### Use glob patterns
|
||||
|
||||
Glob patterns (`*`, `?`, `[...]`, and `**`) are expanded by the action using
|
||||
`@actions/glob`, so they work consistently across Linux, macOS, and Windows
|
||||
runners. Hidden files and directories are skipped by default; target them
|
||||
explicitly, for example with `.venv/**/*.py`, to include them.
|
||||
|
||||
```yaml
|
||||
- uses: astral-sh/ruff-action@v3
|
||||
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.
|
||||
|
||||
### Use to install ruff
|
||||
|
||||
This action adds ruff to the PATH, so you can use it in subsequent steps.
|
||||
|
||||
@@ -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,396 @@
|
||||
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("preserves existing literal paths that contain glob metacharacters", async () => {
|
||||
const projectDir = await createTempProject();
|
||||
const source = path.join(projectDir, "src", "pkg[legacy]");
|
||||
|
||||
await expect(expandSourceInput(source)).resolves.toEqual([source]);
|
||||
});
|
||||
|
||||
it("escapes existing literal path segments before later glob segments", 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("escapes existing literal path segments that contain * or ?", async () => {
|
||||
if (process.platform === "win32") {
|
||||
return;
|
||||
}
|
||||
|
||||
const projectDir = await createTempProject();
|
||||
await fs.mkdir(path.join(projectDir, "src", "pkg*star"));
|
||||
await fs.writeFile(path.join(projectDir, "src", "pkg*star", "main.py"), "");
|
||||
await fs.mkdir(path.join(projectDir, "src", "pkg-other-star"));
|
||||
await fs.writeFile(
|
||||
path.join(projectDir, "src", "pkg-other-star", "main.py"),
|
||||
"",
|
||||
);
|
||||
await fs.mkdir(path.join(projectDir, "src", "pkg?question"));
|
||||
await fs.writeFile(
|
||||
path.join(projectDir, "src", "pkg?question", "main.py"),
|
||||
"",
|
||||
);
|
||||
await fs.mkdir(path.join(projectDir, "src", "pkgxquestion"));
|
||||
await fs.writeFile(
|
||||
path.join(projectDir, "src", "pkgxquestion", "main.py"),
|
||||
"",
|
||||
);
|
||||
|
||||
await expect(
|
||||
expandSourceInput(path.join(projectDir, "src", "pkg*star", "*.py")),
|
||||
).resolves.toEqual([path.join(projectDir, "src", "pkg*star", "main.py")]);
|
||||
await expect(
|
||||
expandSourceInput(path.join(projectDir, "src", "pkg?question", "*.py")),
|
||||
).resolves.toEqual([
|
||||
path.join(projectDir, "src", "pkg?question", "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("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("preserves existing literal base paths that contain glob metacharacters", async () => {
|
||||
const projectDir = await createTempProject();
|
||||
const originalCwd = process.cwd();
|
||||
|
||||
try {
|
||||
process.chdir(projectDir);
|
||||
await expect(getSourceBasePath("src/pkg[legacy]")).resolves.toBe(
|
||||
"src/pkg[legacy]",
|
||||
);
|
||||
await expect(getSourceBasePath("src/pkg[legacy]/*.py")).resolves.toBe(
|
||||
path.join("src", "pkg[legacy]", "main.py"),
|
||||
);
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps version discovery rooted in existing literal paths with 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}",
|
||||
);
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -7,7 +7,7 @@ inputs:
|
||||
required: false
|
||||
default: "check"
|
||||
src:
|
||||
description: "Source to run Ruff. Defaults to the current directory."
|
||||
description: "Source path(s) or glob pattern(s) to run Ruff on. Defaults to the current workspace."
|
||||
required: false
|
||||
default: ${{ github.workspace }}
|
||||
version:
|
||||
|
||||
+3164
-492
File diff suppressed because it is too large
Load Diff
Generated
+47
@@ -11,6 +11,7 @@
|
||||
"dependencies": {
|
||||
"@actions/core": "^3.0.0",
|
||||
"@actions/exec": "^3.0.0",
|
||||
"@actions/glob": "^0.7.0",
|
||||
"@actions/tool-cache": "^4.0.0",
|
||||
"@octokit/core": "^7.0.3",
|
||||
"@octokit/plugin-paginate-rest": "^13.1.1",
|
||||
@@ -51,6 +52,52 @@
|
||||
"@actions/io": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@actions/glob": {
|
||||
"version": "0.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@actions/glob/-/glob-0.7.0.tgz",
|
||||
"integrity": "sha512-+7s3wM+cXapDLmLL1NVWHawqcJOZzXZy2df/VhNn8DnZtS/x83iTCKaUn9F0llur4h3CII0AilvKKH4CMPL8Gw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@actions/core": "^3.0.0",
|
||||
"minimatch": "^10.2.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@actions/glob/node_modules/balanced-match": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@actions/glob/node_modules/brace-expansion": {
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@actions/glob/node_modules/minimatch": {
|
||||
"version": "10.2.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
|
||||
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^5.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/@actions/http-client": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-4.0.0.tgz",
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"dependencies": {
|
||||
"@actions/core": "^3.0.0",
|
||||
"@actions/exec": "^3.0.0",
|
||||
"@actions/glob": "^0.7.0",
|
||||
"@actions/tool-cache": "^4.0.0",
|
||||
"@octokit/core": "^7.0.3",
|
||||
"@octokit/plugin-paginate-rest": "^13.1.1",
|
||||
|
||||
+14
-9
@@ -21,6 +21,11 @@ import {
|
||||
getPlatform,
|
||||
type Platform,
|
||||
} from "./utils/platforms";
|
||||
import {
|
||||
expandSourceInput,
|
||||
getVersionSourceDirectory,
|
||||
splitInput,
|
||||
} from "./utils/source-input";
|
||||
import { resolveRuffVersion } from "./version/resolve";
|
||||
|
||||
async function run(): Promise<void> {
|
||||
@@ -42,11 +47,7 @@ async function run(): Promise<void> {
|
||||
core.setOutput("ruff-version", setupResult.version);
|
||||
core.info(`Successfully installed ruff version ${setupResult.version}`);
|
||||
|
||||
await runRuff(
|
||||
path.join(setupResult.ruffDir, "ruff"),
|
||||
args.split(" "),
|
||||
src.split(" "),
|
||||
);
|
||||
await runRuff(path.join(setupResult.ruffDir, "ruff"), args, src);
|
||||
|
||||
process.exit(0);
|
||||
} catch (err) {
|
||||
@@ -94,7 +95,11 @@ async function setupRuff(
|
||||
async function determineVersion(): Promise<string> {
|
||||
return await resolveRuffVersion({
|
||||
manifestFile: manifestFile || undefined,
|
||||
sourceDirectory: src,
|
||||
sourceDirectory: await getVersionSourceDirectory(
|
||||
src,
|
||||
version,
|
||||
versionFileInput,
|
||||
),
|
||||
version,
|
||||
versionFile: versionFileInput,
|
||||
workspaceRoot: process.env.GITHUB_WORKSPACE || ".",
|
||||
@@ -125,10 +130,10 @@ function getActionRoot(): string {
|
||||
|
||||
async function runRuff(
|
||||
ruffExecutablePath: string,
|
||||
args: string[],
|
||||
src: string[],
|
||||
args: string,
|
||||
src: string,
|
||||
): Promise<void> {
|
||||
const execArgs = [...args, ...src];
|
||||
const execArgs = [...splitInput(args), ...(await expandSourceInput(src))];
|
||||
await exec.exec(ruffExecutablePath, execArgs);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,19 +3,22 @@ import * as path from "node:path";
|
||||
import * as core from "@actions/core";
|
||||
|
||||
/**
|
||||
* Search for a pyproject.toml file starting from the given directory
|
||||
* Search for a pyproject.toml file starting from the given source path
|
||||
* and traversing upwards through parent directories until reaching
|
||||
* the GitHub workspace root.
|
||||
*
|
||||
* @param startDir The directory to start the search from (e.g., the src input)
|
||||
* 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(
|
||||
startDir: string,
|
||||
startPath: string,
|
||||
workspaceRoot: string,
|
||||
): string | undefined {
|
||||
let currentDir = path.resolve(startDir);
|
||||
let currentDir = resolveStartDirectory(startPath);
|
||||
const resolvedWorkspaceRoot = path.resolve(workspaceRoot);
|
||||
|
||||
while (true) {
|
||||
@@ -42,6 +45,19 @@ export function findPyprojectToml(
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
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.
|
||||
*
|
||||
* Shell expansion is not portable across GitHub runners (notably Windows), so
|
||||
* we expand globs with @actions/glob and pass the result to Ruff as argv entries.
|
||||
* Unmatched patterns are preserved, matching the default behavior of POSIX
|
||||
* shells and allowing Ruff to 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) || (await pathExists(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 match when available, so pyproject.toml discovery
|
||||
* starts from the project that Ruff will actually check. Unmatched globs fall
|
||||
* back to their literal prefix so Ruff can report the unmatched source later.
|
||||
*/
|
||||
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) || (await pathExists(firstSource))) {
|
||||
return stripTrailingSeparators(firstSource);
|
||||
}
|
||||
|
||||
const matches = await globMatches(firstSource);
|
||||
if (matches.length > 0) {
|
||||
return matches[0] ?? ".";
|
||||
}
|
||||
|
||||
return await getUnmatchedSourceBasePath(firstSource);
|
||||
}
|
||||
|
||||
async function getUnmatchedSourceBasePath(source: string): Promise<string> {
|
||||
const sourcePath = splitSourcePath(source);
|
||||
const existingSegments = await getExistingLiteralSegments(sourcePath);
|
||||
const nextSegment = sourcePath.segments[existingSegments.length];
|
||||
if (existingSegments.length > 0 && hasGlobPattern(nextSegment ?? "")) {
|
||||
return formatSourcePath(sourcePath.root, existingSegments);
|
||||
}
|
||||
|
||||
const globIndex = source.search(GLOB_PATTERN);
|
||||
const literalPrefix = source.substring(0, globIndex);
|
||||
if (literalPrefix === "") {
|
||||
return ".";
|
||||
}
|
||||
|
||||
if (hasTrailingSeparator(literalPrefix)) {
|
||||
return stripTrailingSeparators(literalPrefix);
|
||||
}
|
||||
|
||||
return path.dirname(literalPrefix);
|
||||
}
|
||||
|
||||
function hasInput(input: string | undefined): boolean {
|
||||
return input !== undefined && input.trim() !== "";
|
||||
}
|
||||
|
||||
function hasGlobPattern(source: string): boolean {
|
||||
return GLOB_PATTERN.test(source);
|
||||
}
|
||||
|
||||
function hasTrailingSeparator(source: string): boolean {
|
||||
return /[/\\]$/.test(source);
|
||||
}
|
||||
|
||||
function stripTrailingSeparators(source: string): string {
|
||||
return source.replace(/[/\\]+$/, "") || ".";
|
||||
}
|
||||
|
||||
async function pathExists(source: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.stat(source);
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (isPathNotFoundError(err)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function isPathNotFoundError(err: unknown): boolean {
|
||||
const code = (err as { code?: unknown }).code;
|
||||
return code === "ENOENT" || code === "ENOTDIR";
|
||||
}
|
||||
|
||||
async function getGlobPattern(pattern: string): Promise<string> {
|
||||
const sourcePath = splitSourcePath(pattern);
|
||||
const existingSegments = await getExistingLiteralSegments(sourcePath);
|
||||
|
||||
if (existingSegments.length === 0) {
|
||||
return normalizeGlobPattern(pattern);
|
||||
}
|
||||
|
||||
const escapedSegments = sourcePath.segments.map((segment, index) =>
|
||||
index < existingSegments.length ? globEscape(segment) : segment,
|
||||
);
|
||||
return normalizeGlobPattern(
|
||||
formatSourcePath(sourcePath.root, escapedSegments),
|
||||
);
|
||||
}
|
||||
|
||||
interface SourcePath {
|
||||
root: string;
|
||||
segments: string[];
|
||||
}
|
||||
|
||||
function splitSourcePath(source: string): SourcePath {
|
||||
const root = path.parse(source).root;
|
||||
const relativePath = source.slice(root.length);
|
||||
const separatorPattern = process.platform === "win32" ? /[/\\]+/ : /\/+/;
|
||||
|
||||
return {
|
||||
root,
|
||||
segments: relativePath.split(separatorPattern).filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
async function getExistingLiteralSegments(
|
||||
sourcePath: SourcePath,
|
||||
): Promise<string[]> {
|
||||
const existingSegments: string[] = [];
|
||||
|
||||
for (const segment of sourcePath.segments) {
|
||||
const candidateSegments = [...existingSegments, segment];
|
||||
if (
|
||||
!(await pathExists(toFileSystemPath(sourcePath.root, candidateSegments)))
|
||||
) {
|
||||
break;
|
||||
}
|
||||
|
||||
existingSegments.push(segment);
|
||||
}
|
||||
|
||||
return existingSegments;
|
||||
}
|
||||
|
||||
function toFileSystemPath(root: string, segments: string[]): string {
|
||||
if (segments.length === 0) {
|
||||
return root || ".";
|
||||
}
|
||||
|
||||
return root ? path.join(root, ...segments) : path.join(...segments);
|
||||
}
|
||||
|
||||
function formatSourcePath(root: string, segments: string[]): string {
|
||||
if (segments.length === 0) {
|
||||
return root || ".";
|
||||
}
|
||||
|
||||
if (!root) {
|
||||
return segments.join("/");
|
||||
}
|
||||
|
||||
const normalizedRoot = root.replace(/\\/g, "/").replace(/\/+$/, "");
|
||||
return `${normalizedRoot || "/"}/${segments.join("/")}`;
|
||||
}
|
||||
|
||||
function globEscape(source: string): string {
|
||||
return source
|
||||
.replace(/(\[)(?=[^/]+\])/g, "[[]")
|
||||
.replace(/\?/g, "[?]")
|
||||
.replace(/\*/g, "[*]");
|
||||
}
|
||||
|
||||
async function globMatches(pattern: string): Promise<string[]> {
|
||||
const globber = await glob.create(await getGlobPattern(pattern), {
|
||||
excludeHiddenFiles: !explicitlyTargetsHiddenPath(pattern),
|
||||
followSymbolicLinks: false,
|
||||
implicitDescendants: false,
|
||||
});
|
||||
const matches = await globber.glob();
|
||||
|
||||
return await preserveSourcePathStyle(pattern, matches.sort());
|
||||
}
|
||||
|
||||
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).segments.some(isHiddenPathSegment);
|
||||
}
|
||||
|
||||
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, "/");
|
||||
}
|
||||
Reference in New Issue
Block a user