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 d6e81d2..36b583d 100644 Binary files a/dist/ruff-action/index.cjs and b/dist/ruff-action/index.cjs differ diff --git a/package-lock.json b/package-lock.json index 37e2ec1..7987f5d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", 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, "/"); +}