diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 60a3063..c332e63 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -406,6 +406,25 @@ jobs: src: >- __tests__/fixtures/python-project/src/python_project/__init__.py __tests__/fixtures/python-project/src/python_project/hello_world.py + test-parent-directory-pyproject: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + persist-credentials: false + - name: Use version from parent directory pyproject.toml + id: ruff-action + uses: ./ + with: + src: __tests__/fixtures/parent-config-project/subproject + - name: Correct version gets installed + run: | + if [ "$RUFF_VERSION" != "0.10.0" ]; then + echo "Expected version 0.10.0 but got $RUFF_VERSION" + exit 1 + fi + env: + RUFF_VERSION: ${{ steps.ruff-action.outputs.ruff-version }} all-tests-passed: runs-on: ubuntu-latest @@ -431,6 +450,7 @@ jobs: - test-args - test-failure - test-multiple-src + - test-parent-directory-pyproject if: always() steps: - name: All tests passed diff --git a/__tests__/fixtures/parent-config-project/pyproject.toml b/__tests__/fixtures/parent-config-project/pyproject.toml new file mode 100644 index 0000000..b88937e --- /dev/null +++ b/__tests__/fixtures/parent-config-project/pyproject.toml @@ -0,0 +1,13 @@ +[project] +name = "parent-config-project" +version = "0.1.0" +description = "Test fixture for parent directory pyproject.toml search" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "ruff==0.10.0", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" diff --git a/__tests__/fixtures/parent-config-project/subproject/hello.py b/__tests__/fixtures/parent-config-project/subproject/hello.py new file mode 100644 index 0000000..9bbdc0c --- /dev/null +++ b/__tests__/fixtures/parent-config-project/subproject/hello.py @@ -0,0 +1,3 @@ +"""Hello world example.""" + +print("Hello, world!") diff --git a/dist/ruff-action/index.js b/dist/ruff-action/index.js index c460e43..2ae53e4 100644 Binary files a/dist/ruff-action/index.js and b/dist/ruff-action/index.js differ diff --git a/src/ruff-action.ts b/src/ruff-action.ts index e3a7ce2..0d4de6c 100644 --- a/src/ruff-action.ts +++ b/src/ruff-action.ts @@ -1,4 +1,3 @@ -import * as fs from "node:fs"; import * as path from "node:path"; import * as core from "@actions/core"; import * as exec from "@actions/exec"; @@ -23,6 +22,7 @@ import { type Platform, } from "./utils/platforms"; import { getRuffVersionFromRequirementsFile } from "./utils/pyproject"; +import { findPyprojectToml } from "./utils/pyproject-finder"; async function run(): Promise { const platform = getPlatform(); @@ -107,9 +107,12 @@ async function determineVersion(): Promise { } return await resolveVersion(versionFromPyproject || "latest", githubToken); } - const pyProjectPath = path.join(src, "pyproject.toml"); - if (!fs.existsSync(pyProjectPath)) { - core.info(`Could not find ${pyProjectPath}. Using latest version.`); + const pyProjectPath = findPyprojectToml( + src, + process.env.GITHUB_WORKSPACE || ".", + ); + if (!pyProjectPath) { + core.info(`Could not find pyproject.toml. Using latest version.`); return await resolveVersion("latest", githubToken); } const versionFromPyproject = diff --git a/src/utils/pyproject-finder.test.ts b/src/utils/pyproject-finder.test.ts new file mode 100644 index 0000000..f0b4872 --- /dev/null +++ b/src/utils/pyproject-finder.test.ts @@ -0,0 +1,183 @@ +import * as path from "node:path"; +import * as core from "@actions/core"; +import { findPyprojectToml } from "./pyproject-finder"; + +jest.mock("@actions/core", () => ({ + debug: jest.fn(), + info: jest.fn(), +})); + +describe("findPyprojectToml", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("when pyproject.toml exists in src directory", () => { + it("should return the exact path", () => { + const fixturesDir = path.join( + __dirname, + "..", + "..", + "__tests__", + "fixtures", + ); + const workspaceRoot = path.join(__dirname, "..", ".."); + + const result = findPyprojectToml(fixturesDir, workspaceRoot); + + expect(result).toContain("pyproject.toml"); + expect(result).toContain("fixtures"); + expect(core.info).toHaveBeenCalled(); + }); + }); + + describe("when pyproject.toml exists only in parent directory", () => { + it("should search upwards and find the parent's pyproject.toml", () => { + // subproject doesn't have a pyproject.toml, but its parent (parent-config-project) does + const subprojectDir = path.join( + __dirname, + "..", + "..", + "__tests__", + "fixtures", + "parent-config-project", + "subproject", + ); + const workspaceRoot = path.join(__dirname, "..", ".."); + + const result = findPyprojectToml(subprojectDir, workspaceRoot); + + expect(result).toBeTruthy(); + expect(result).toContain("pyproject.toml"); + expect(result).toContain("parent-config-project"); + expect(core.info).toHaveBeenCalled(); + }); + }); + + describe("boundary conditions", () => { + it("should stop searching at workspace root and return undefined when not found", () => { + // Create a path that won't have pyproject.toml above it + const nodeModulesDir = path.join( + __dirname, + "..", + "..", + "node_modules", + "@actions", + ); + const workspaceRoot = path.join(__dirname, "..", ".."); + + const result = findPyprojectToml(nodeModulesDir, workspaceRoot); + + // Should return undefined since there's no pyproject.toml in the search path + expect(result).toBeUndefined(); + expect(core.info).not.toHaveBeenCalledWith( + expect.stringContaining("Found pyproject.toml"), + ); + }); + + it("should find pyproject.toml when it exists at workspace root", () => { + // Use parent-config-project as the "workspace root" for this test + // Start from subproject (which has no pyproject.toml) to search up to workspace root + const subprojectDir = path.join( + __dirname, + "..", + "..", + "__tests__", + "fixtures", + "parent-config-project", + "subproject", + ); + const workspaceRoot = path.join( + __dirname, + "..", + "..", + "__tests__", + "fixtures", + "parent-config-project", + ); + + const result = findPyprojectToml(subprojectDir, workspaceRoot); + + expect(result).toBeTruthy(); + expect(result).toContain("pyproject.toml"); + expect(result).toContain("parent-config-project"); + }); + + it("should stop at workspace root even if searching from it", () => { + const workspaceRoot = path.join( + __dirname, + "..", + "..", + "__tests__", + "fixtures", + ); + + const result = findPyprojectToml(workspaceRoot, workspaceRoot); + + // Should find pyproject.toml at workspace root + expect(result).toBeTruthy(); + expect(result).toContain("pyproject.toml"); + expect(result).toContain("fixtures"); + }); + }); + + describe("edge cases", () => { + it("should handle relative paths", () => { + const srcDir = "./__tests__/fixtures"; + const workspaceRoot = "."; + + const result = findPyprojectToml(srcDir, workspaceRoot); + + // Should work with relative paths + expect(result).toBeTruthy(); + expect(result).toContain("pyproject.toml"); + }); + + it("should handle when src equals workspace root", () => { + const workspaceRoot = path.join( + __dirname, + "..", + "..", + "__tests__", + "fixtures", + ); + const result = findPyprojectToml(workspaceRoot, workspaceRoot); + + expect(result).toBeTruthy(); + expect(result).toContain("pyproject.toml"); + expect(result).toContain("fixtures"); + }); + + it("should log debug messages for each checked path", () => { + const pythonProjectDir = path.join( + __dirname, + "..", + "..", + "__tests__", + "fixtures", + "python-project", + ); + const workspaceRoot = path.join(__dirname, "..", ".."); + + findPyprojectToml(pythonProjectDir, workspaceRoot); + + expect(core.debug).toHaveBeenCalled(); + const debugCalls = (core.debug as jest.Mock).mock.calls; + expect(debugCalls.length).toBeGreaterThan(0); + + // First debug call should be for the starting directory + expect(debugCalls[0][0]).toContain("Checking for"); + expect(debugCalls[0][0]).toContain("python-project"); + }); + + it("should handle paths with trailing slashes", () => { + const fixturesDir = `${path.join(__dirname, "..", "..", "__tests__", "fixtures")}/`; + const workspaceRoot = path.join(__dirname, "..", ".."); + + const result = findPyprojectToml(fixturesDir, workspaceRoot); + + expect(result).toBeTruthy(); + expect(result).toContain("pyproject.toml"); + }); + }); +}); diff --git a/src/utils/pyproject-finder.ts b/src/utils/pyproject-finder.ts new file mode 100644 index 0000000..cde0812 --- /dev/null +++ b/src/utils/pyproject-finder.ts @@ -0,0 +1,79 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as core from "@actions/core"; + +/** + * Search for a pyproject.toml file starting from the given directory + * 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) + * @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, + workspaceRoot: string, +): string | undefined { + let currentDir = path.resolve(startDir); + const resolvedWorkspaceRoot = path.resolve(workspaceRoot); + + while (true) { + const pyprojectPath = path.join(currentDir, "pyproject.toml"); + core.debug(`Checking for ${pyprojectPath}`); + + if (fs.existsSync(pyprojectPath)) { + core.info(`Found pyproject.toml at ${pyprojectPath}`); + return pyprojectPath; + } + + // Check if we've reached the workspace root + if (currentDir === resolvedWorkspaceRoot) { + // If we're at workspace root and didn't find it, stop searching + break; + } + + // Move up to parent directory + const parentDir = path.dirname(currentDir); + + // If parent is the same as current, we've reached the filesystem root + if (parentDir === currentDir) { + break; + } + + currentDir = parentDir; + + // If we've gone past the workspace root, stop searching + if (isPathWithinWorkspace(currentDir, resolvedWorkspaceRoot) === false) { + break; + } + } + + return undefined; +} + +/** + * Check if a given path is within or equal to the workspace root. + * + * @param checkPath The path to check + * @param workspaceRoot The workspace root directory + * @returns true if within or equal to workspace, false if outside, undefined if can't determine + */ +function isPathWithinWorkspace( + checkPath: string, + workspaceRoot: string, +): boolean | undefined { + try { + const checkPathResolved = path.resolve(checkPath); + const workspaceRootResolved = path.resolve(workspaceRoot); + + // Check if checkPath starts with workspaceRoot (case-insensitive on Windows) + const relativePath = path.relative( + workspaceRootResolved, + checkPathResolved, + ); + return !relativePath.startsWith("..") && !path.isAbsolute(relativePath); + } catch { + return undefined; + } +}