From 270e80dfe771096cac6ac76ff7e22cc948d9fc3f Mon Sep 17 00:00:00 2001 From: Kevin Stillhammer Date: Sun, 5 Jul 2026 11:55:02 +0200 Subject: [PATCH] Fix wildcard src input (#368) Fixes: #355 --- .github/workflows/test.yml | 30 ++ README.md | 29 +- .../fixtures/glob-project/pyproject.toml | 13 + __tests__/fixtures/glob-project/sub/main.py | 1 + .../glob-project/sub/nested/deeper.py | 1 + .../foo/bar/file_to_check.py | 1 + .../foo/file_to_ignore.py | 2 + .../glob-single-star-project/pyproject.toml | 13 + __tests__/utils/source-input.test.ts | 374 ++++++++++++++++++ action.yml | 2 +- dist/ruff-action/index.cjs | Bin 1369413 -> 1455118 bytes package-lock.json | 47 +++ package.json | 1 + src/ruff-action.ts | 23 +- src/utils/pyproject-finder.ts | 24 +- src/utils/source-input.ts | 161 ++++++++ 16 files changed, 707 insertions(+), 15 deletions(-) create mode 100644 __tests__/fixtures/glob-project/pyproject.toml create mode 100644 __tests__/fixtures/glob-project/sub/main.py create mode 100644 __tests__/fixtures/glob-project/sub/nested/deeper.py create mode 100644 __tests__/fixtures/glob-single-star-project/foo/bar/file_to_check.py create mode 100644 __tests__/fixtures/glob-single-star-project/foo/file_to_ignore.py create mode 100644 __tests__/fixtures/glob-single-star-project/pyproject.toml create mode 100644 __tests__/utils/source-input.test.ts create mode 100644 src/utils/source-input.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c1ab75f..3f745a6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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 diff --git a/README.md b/README.md index 5aa2d4c..09abd80 100644 --- a/README.md +++ b/README.md @@ -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@v4.0.0 with: @@ -64,6 +66,31 @@ 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`](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@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. Large glob expansions may hit command-line length limits, +> especially on Windows. + ### Use to install ruff This action adds ruff to the PATH, so you can use it in subsequent steps. diff --git a/__tests__/fixtures/glob-project/pyproject.toml b/__tests__/fixtures/glob-project/pyproject.toml new file mode 100644 index 0000000..f8999d3 --- /dev/null +++ b/__tests__/fixtures/glob-project/pyproject.toml @@ -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" diff --git a/__tests__/fixtures/glob-project/sub/main.py b/__tests__/fixtures/glob-project/sub/main.py new file mode 100644 index 0000000..11b15b1 --- /dev/null +++ b/__tests__/fixtures/glob-project/sub/main.py @@ -0,0 +1 @@ +print("hello") diff --git a/__tests__/fixtures/glob-project/sub/nested/deeper.py b/__tests__/fixtures/glob-project/sub/nested/deeper.py new file mode 100644 index 0000000..3a8df16 --- /dev/null +++ b/__tests__/fixtures/glob-project/sub/nested/deeper.py @@ -0,0 +1 @@ +print("deeper") diff --git a/__tests__/fixtures/glob-single-star-project/foo/bar/file_to_check.py b/__tests__/fixtures/glob-single-star-project/foo/bar/file_to_check.py new file mode 100644 index 0000000..372d2d1 --- /dev/null +++ b/__tests__/fixtures/glob-single-star-project/foo/bar/file_to_check.py @@ -0,0 +1 @@ +print("check me") diff --git a/__tests__/fixtures/glob-single-star-project/foo/file_to_ignore.py b/__tests__/fixtures/glob-single-star-project/foo/file_to_ignore.py new file mode 100644 index 0000000..ba51a4d --- /dev/null +++ b/__tests__/fixtures/glob-single-star-project/foo/file_to_ignore.py @@ -0,0 +1,2 @@ +# This file should be ignored when using foo/bar/*.py glob +print("ignore me") diff --git a/__tests__/fixtures/glob-single-star-project/pyproject.toml b/__tests__/fixtures/glob-single-star-project/pyproject.toml new file mode 100644 index 0000000..8a7200e --- /dev/null +++ b/__tests__/fixtures/glob-single-star-project/pyproject.toml @@ -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" diff --git a/__tests__/utils/source-input.test.ts b/__tests__/utils/source-input.test.ts new file mode 100644 index 0000000..89acf54 --- /dev/null +++ b/__tests__/utils/source-input.test.ts @@ -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 { + 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}", + ); + }); +}); diff --git a/action.yml b/action.yml index c63a362..e61cfa9 100644 --- a/action.yml +++ b/action.yml @@ -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 @actions/glob pattern(s) to run Ruff on. Defaults to the current workspace." required: false default: ${{ github.workspace }} version: diff --git a/dist/ruff-action/index.cjs b/dist/ruff-action/index.cjs index d6e81d2d88f95b4d4e654b708eb53d178ba91f4a..36b583d14ddbf5683394ec3ebd28cdc16a5782e3 100644 GIT binary patch delta 79105 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 delta 6035 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" + } + }, + "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", diff --git a/package.json b/package.json index 3425847..82cae0d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/ruff-action.ts b/src/ruff-action.ts index 38f610e..5c62d3d 100644 --- a/src/ruff-action.ts +++ b/src/ruff-action.ts @@ -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 { @@ -42,11 +47,7 @@ async function run(): Promise { 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 { 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 { - const execArgs = [...args, ...src]; + const execArgs = [...splitInput(args), ...(await expandSourceInput(src))]; await exec.exec(ruffExecutablePath, execArgs); } diff --git a/src/utils/pyproject-finder.ts b/src/utils/pyproject-finder.ts index d1c679a..df3590d 100644 --- a/src/utils/pyproject-finder.ts +++ b/src/utils/pyproject-finder.ts @@ -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. * diff --git a/src/utils/source-input.ts b/src/utils/source-input.ts new file mode 100644 index 0000000..14b8fc9 --- /dev/null +++ b/src/utils/source-input.ts @@ -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 { + 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 { + if (hasInput(versionInput) || hasInput(versionFileInput)) { + return "."; + } + + return await getSourceBasePath(srcInput); +} + +export async function getSourceBasePath(srcInput: string): Promise { + 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 { + const globber = await createGlobber(pattern); + const matches = await globber.glob(); + + return await preserveSourcePathStyle(pattern, matches.sort()); +} + +async function createGlobber(pattern: string): Promise { + return await glob.create(normalizeGlobPattern(pattern), { + excludeHiddenFiles: !explicitlyTargetsHiddenPath(pattern), + followSymbolicLinks: false, + implicitDescendants: false, + }); +} + +async function preserveSourcePathStyle( + pattern: string, + matches: string[], +): Promise { + 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, "/"); +}