From 5eee2a4332c5430ddb4c30597986a81190636956 Mon Sep 17 00:00:00 2001 From: Kevin Stillhammer Date: Wed, 28 Jan 2026 11:38:26 +0100 Subject: [PATCH] search in parent dir (#306) Fixes: #164 --------- Co-authored-by: Clawdbot --- .github/workflows/test.yml | 20 ++ .../parent-config-project/pyproject.toml | 13 ++ .../parent-config-project/subproject/hello.py | 3 + dist/ruff-action/index.js | Bin 1573030 -> 1577134 bytes src/ruff-action.ts | 11 +- src/utils/pyproject-finder.test.ts | 183 ++++++++++++++++++ src/utils/pyproject-finder.ts | 79 ++++++++ 7 files changed, 305 insertions(+), 4 deletions(-) create mode 100644 __tests__/fixtures/parent-config-project/pyproject.toml create mode 100644 __tests__/fixtures/parent-config-project/subproject/hello.py create mode 100644 src/utils/pyproject-finder.test.ts create mode 100644 src/utils/pyproject-finder.ts 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 c460e43741a04e550bc167ef1c8c400b1ae6b3e4..2ae53e478aa3e253715d9c187c8b57498e098dea 100644 GIT binary patch delta 2381 zcmZ`*T}vES7-lzaZ2U~^sKE-p+BD9}bZlrzFTz4oV`ExM#VFngdYnD$PTifEo^xhJ zOx#EZLMgNXNe(nO=B9yO7$gO+`vY>({*cmt(7xx)&g@q*us3JkkLNt^^FFh`9c`UH z-+CBjqHJ^_>WOmE#i%!WC%P2%Mg7r0G#CvC{LMbV$1Q<59qcd1 zK$eLp=ixxV!I!eIVGPJ8#26k*vDyOr$(&(1d{3?htg&ns>Jt(>i8THw|szSUX2u5m@aTOW{dxRA^Ml(O$DMLUQ^+cNDQV#Xd z6K(|Ov(yL~h!r zYJtz{faetFLyJlq9yZpYSZuzp6nc(ueXo0drXB}g-fD8BP$y!lPytTJ6y1uU0#Yx^ zHly6H)M%QFnv{S4Ff?MVCp_z$cSN_*lE3-npGy<{0O?A}X=02|Hk(;6(|*kv`TS~j zTt579I5(*Ugc`egkjAJTc(A3QoVnSFrtsByUXr=&s5}|T=H%l;qxXX}D^?~E-Ib1C z{xUY0#;GdDN3X~03uWr;DvI~$Yz_6K1c#if*`ycLDod78uWIvJP$(2IOA}aZ<167@ zt+#sS=i#ny!zduYxVn{ySno*DEqb#!Y4jCvg@uCk1BkV%mO!3uWZpmi>Gfd!8y~TT zfl^V&^5DKTp2j5fZnX>>rRtF?HJevxSWpvVqok`g6gM2Bf$&5(s4TkFNtLn!zRv}% zc5@Y1(+H5NmbPec`bP5EX{Nv4uF8f&V{>dGi-)x1dlc`D*4;g(F47V@l*77Q&3)Y! z=qc%}VkRc_Mp$nirTnv&80L#o7D&wQZ4Lm3~9avB~sx! z%*XGRTclRI@i@d=LPZv-psb2+)b2lT;WN2FhY3k@(U`2K*IQ{UO{WW`0WG?WwVc9M zM7%A%EvTgguAdrHT`zmuBmO4DgK93kCfaleryscJd6yG#*5t8qbX+KZxnxH=TmI_(a-+_TnI5< delta 167 zcmZ3tFkx9^LPHB<3sVbo3rh=Y3tJ0&3r7oQ3s(zw3r`Dg3ttO=i$IHDi%^U377?$| z>DsOQCevqEh@@@b5Gta_xLqV%M22g6ZnX#(Pi9_0X-RRspSaj zl>tThS*gh-(+#GIh)w^NBqG3)mYJ6_T_Rb;VEP1S5$@>=Tt(QXZ%7bP->#n|A|lYP S-XsFVqChORUA;-Xy$=8)BR4Jp 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; + } +}