Fix wildcard src input

This commit is contained in:
Kevin Stillhammer
2026-04-16 20:28:26 +02:00
parent a9cfed68e4
commit 7ea7640091
16 changed files with 3977 additions and 507 deletions
+30
View File
@@ -395,6 +395,34 @@ 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:
@@ -479,6 +507,8 @@ jobs:
- 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-parent-directory-pyproject
- test-custom-manifest-file - test-custom-manifest-file
+21 -1
View 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 | | `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 | | `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` | | `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 | | `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` | | `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 ### Specify multiple files
Separate multiple `src` values with whitespace.
```yaml ```yaml
- uses: astral-sh/ruff-action@v3 - uses: astral-sh/ruff-action@v3
with: with:
@@ -64,6 +66,24 @@ By default, Ruff version metadata is resolved from the
path/to/file2.py 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 ### 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.
@@ -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"
+396
View File
@@ -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
View File
@@ -7,7 +7,7 @@ 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 glob pattern(s) to run Ruff on. Defaults to the current workspace."
required: false required: false
default: ${{ github.workspace }} default: ${{ github.workspace }}
version: version:
Generated Vendored
+3164 -492
View File
File diff suppressed because it is too large Load Diff
+47
View File
@@ -11,6 +11,7 @@
"dependencies": { "dependencies": {
"@actions/core": "^3.0.0", "@actions/core": "^3.0.0",
"@actions/exec": "^3.0.0", "@actions/exec": "^3.0.0",
"@actions/glob": "^0.7.0",
"@actions/tool-cache": "^4.0.0", "@actions/tool-cache": "^4.0.0",
"@octokit/core": "^7.0.3", "@octokit/core": "^7.0.3",
"@octokit/plugin-paginate-rest": "^13.1.1", "@octokit/plugin-paginate-rest": "^13.1.1",
@@ -51,6 +52,52 @@
"@actions/io": "^3.0.2" "@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": { "node_modules/@actions/http-client": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-4.0.0.tgz", "resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-4.0.0.tgz",
+1
View File
@@ -30,6 +30,7 @@
"dependencies": { "dependencies": {
"@actions/core": "^3.0.0", "@actions/core": "^3.0.0",
"@actions/exec": "^3.0.0", "@actions/exec": "^3.0.0",
"@actions/glob": "^0.7.0",
"@actions/tool-cache": "^4.0.0", "@actions/tool-cache": "^4.0.0",
"@octokit/core": "^7.0.3", "@octokit/core": "^7.0.3",
"@octokit/plugin-paginate-rest": "^13.1.1", "@octokit/plugin-paginate-rest": "^13.1.1",
+14 -9
View File
@@ -21,6 +21,11 @@ import {
getPlatform, getPlatform,
type Platform, type Platform,
} from "./utils/platforms"; } from "./utils/platforms";
import {
expandSourceInput,
getVersionSourceDirectory,
splitInput,
} from "./utils/source-input";
import { resolveRuffVersion } from "./version/resolve"; import { resolveRuffVersion } from "./version/resolve";
async function run(): Promise<void> { async function run(): Promise<void> {
@@ -42,11 +47,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) {
@@ -94,7 +95,11 @@ async function setupRuff(
async function determineVersion(): Promise<string> { async function determineVersion(): Promise<string> {
return await resolveRuffVersion({ return await resolveRuffVersion({
manifestFile: manifestFile || undefined, manifestFile: manifestFile || undefined,
sourceDirectory: src, sourceDirectory: await getVersionSourceDirectory(
src,
version,
versionFileInput,
),
version, version,
versionFile: versionFileInput, versionFile: versionFileInput,
workspaceRoot: process.env.GITHUB_WORKSPACE || ".", workspaceRoot: process.env.GITHUB_WORKSPACE || ".",
@@ -125,10 +130,10 @@ function getActionRoot(): string {
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);
} }
+20 -4
View File
@@ -3,19 +3,22 @@ import * as path from "node:path";
import * as core from "@actions/core"; 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 * and traversing upwards through parent directories until reaching
* the GitHub workspace root. * 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) * @param workspaceRoot The GitHub workspace directory (GITHUB_WORKSPACE)
* @returns The path to the found pyproject.toml, or undefined if not found * @returns The path to the found pyproject.toml, or undefined if not found
*/ */
export function findPyprojectToml( export function findPyprojectToml(
startDir: string, startPath: string,
workspaceRoot: string, workspaceRoot: string,
): string | undefined { ): string | undefined {
let currentDir = path.resolve(startDir); let currentDir = resolveStartDirectory(startPath);
const resolvedWorkspaceRoot = path.resolve(workspaceRoot); const resolvedWorkspaceRoot = path.resolve(workspaceRoot);
while (true) { 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. * Check if a given path is within or equal to the workspace root.
* *
+252
View File
@@ -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, "/");
}