Skip to content

Commit 3cc88f0

Browse files
authored
fix: resolve PROJECT_ROOT by walking up to pyproject.toml (#5)
## Summary - Fix PROJECT_ROOT resolution in `qa.py`, `setup.sh`, and `setup.ps1` — walk up to `pyproject.toml` instead of assuming `SCRIPT_DIR.parent` - Add `TestProjectRootWalkUp` test class with 5 tests covering scripts/, .github/scripts/, arbitrary depth, and missing pyproject.toml - Document as resolved decisions 22 (pull-based sync) and 23 (PROJECT_ROOT bug) in PLAN.md ## Problem Scripts assumed `PROJECT_ROOT = SCRIPT_DIR.parent` (one level up). When running from `.github/scripts/` (two levels deep, the synced location in downstream repos), the parent resolved to `.github/` instead of the repo root. This broke tool discovery and `cwd` for every subprocess call. ## Fix Walk up from the script's directory until `pyproject.toml` is found. Applied consistently to `qa.py`, `setup.sh`, and `setup.ps1`. The `PROJECT_ROOT` env var override is preserved as an escape hatch. ## Test plan - [x] `test_qa_from_scripts_dir` — walk-up from scripts/ (one level) - [x] `test_qa_from_github_scripts_dir` — walk-up from .github/scripts/ (two levels) - [x] `test_qa_discovers_checks_from_github_scripts` — full orchestrator with all checks skipped - [x] `test_no_pyproject_toml_fails` — clear error message when pyproject.toml is missing - [x] `test_deeply_nested_scripts` — walk-up from a/b/c/scripts/ (four levels)
1 parent b001ad1 commit 3cc88f0

File tree

6 files changed

+145
-5
lines changed

6 files changed

+145
-5
lines changed

PLAN.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -969,6 +969,20 @@ conflicting — the template dogfoods what the org requires.
969969

970970
21. **VSCode rulers at 120.** Matching ruff `line-length`. The previous 96/98
971971
rulers were resume-specific and are removed from the org standard.
972+
22. **Sync is pull-based, not push-based.** Each downstream repo owns a thin
973+
workflow that calls `self-update.yml` as a reusable workflow. The template
974+
publishes releases; consumers pull when ready. No cross-repo credentials,
975+
no PATs, no push permissions. The original `sync-downstream.yml` required a
976+
fine-grained PAT (`TEMPLATE_SYNC_PAT`) to clone and push to private
977+
downstream repos, which was never configured and is bad practice for a
978+
personal org.
979+
23. **PROJECT_ROOT is resolved by walking up to `pyproject.toml`, not by
980+
assuming `SCRIPT_DIR.parent`.** Scripts can live at `scripts/` (one level
981+
deep) or `.github/scripts/` (two levels deep). The old `SCRIPT_DIR.parent`
982+
assumption resolved to `.github/` when running from the synced location,
983+
breaking tool discovery and `cwd` for every subprocess call. The walk-up
984+
pattern traverses parent directories until it finds `pyproject.toml`,
985+
working from any depth. Applied to `qa.py`, `setup.sh`, and `setup.ps1`.
972986

973987
## Pilot Migration: `nwarila/resume`
974988

scripts/qa.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,20 @@
1818
from pathlib import Path
1919

2020
SCRIPT_DIR = Path(__file__).resolve().parent
21-
PROJECT_ROOT = SCRIPT_DIR.parent
21+
22+
23+
def _find_project_root() -> Path:
24+
"""Walk up from SCRIPT_DIR to find the directory containing pyproject.toml."""
25+
d = SCRIPT_DIR
26+
while d != d.parent:
27+
if (d / "pyproject.toml").exists():
28+
return d
29+
d = d.parent
30+
print(f"Error: could not find pyproject.toml above {SCRIPT_DIR}", file=sys.stderr)
31+
sys.exit(1)
32+
33+
34+
PROJECT_ROOT = _find_project_root()
2235

2336

2437
# ---------------------------------------------------------------------------

scripts/setup.ps1

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,26 @@
33
$ErrorActionPreference = 'Stop'
44

55
$ScriptDir = Split-Path -Parent $PSCommandPath
6-
$ProjectRoot = if ($env:PROJECT_ROOT) { $env:PROJECT_ROOT } else { Split-Path -Parent $ScriptDir }
6+
7+
# Walk up from ScriptDir to find the repo root (where pyproject.toml lives).
8+
# This works whether the script is at scripts/ or .github/scripts/.
9+
if ($env:PROJECT_ROOT) {
10+
$ProjectRoot = $env:PROJECT_ROOT
11+
} else {
12+
$SearchDir = $ScriptDir
13+
$ProjectRoot = $null
14+
while ($SearchDir -and $SearchDir -ne (Split-Path -Parent $SearchDir)) {
15+
if (Test-Path (Join-Path $SearchDir 'pyproject.toml')) {
16+
$ProjectRoot = $SearchDir
17+
break
18+
}
19+
$SearchDir = Split-Path -Parent $SearchDir
20+
}
21+
if (-not $ProjectRoot) {
22+
Write-Error "Could not find pyproject.toml above $ScriptDir"
23+
exit 1
24+
}
25+
}
726

827
Write-Host "Project root: $ProjectRoot"
928
Set-Location $ProjectRoot

scripts/setup.sh

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,25 @@
44
set -euo pipefail
55

66
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
7-
PROJECT_ROOT="${PROJECT_ROOT:-$(cd "$SCRIPT_DIR/.." && pwd)}"
7+
8+
# Walk up from SCRIPT_DIR to find the repo root (where pyproject.toml lives).
9+
# This works whether the script is at scripts/ or .github/scripts/.
10+
if [ -n "${PROJECT_ROOT:-}" ]; then
11+
: # Already set via env var
12+
else
13+
_dir="$SCRIPT_DIR"
14+
while [ "$_dir" != "/" ]; do
15+
if [ -f "$_dir/pyproject.toml" ]; then
16+
PROJECT_ROOT="$_dir"
17+
break
18+
fi
19+
_dir="$(dirname "$_dir")"
20+
done
21+
if [ -z "${PROJECT_ROOT:-}" ]; then
22+
echo "Error: could not find pyproject.toml above $SCRIPT_DIR" >&2
23+
exit 1
24+
fi
25+
fi
826

927
echo "Project root: ${PROJECT_ROOT}"
1028
cd "$PROJECT_ROOT"

tests/conftest.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,4 +113,8 @@ def test_main_runs() -> None:
113113
scripts_dst = tmp_path / "scripts"
114114
shutil.copytree(scripts_src, scripts_dst)
115115

116+
# Also copy to .github/scripts/ (simulating the synced path in downstream repos)
117+
gh_scripts_dst = tmp_path / ".github" / "scripts"
118+
shutil.copytree(scripts_src, gh_scripts_dst)
119+
116120
return tmp_path

tests/test_scripts.py

Lines changed: 74 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,11 @@
1212
from pathlib import Path
1313

1414

15-
def _run_script(project: Path, script_name: str, *extra_args: str) -> subprocess.CompletedProcess[str]:
15+
def _run_script(
16+
project: Path, script_name: str, *extra_args: str, scripts_dir: str = "scripts"
17+
) -> subprocess.CompletedProcess[str]:
1618
"""Run a check script inside the given project directory."""
17-
script = project / "scripts" / script_name
19+
script = project / scripts_dir / script_name
1820
return subprocess.run( # noqa: S603 - controlled test invocation of local scripts
1921
[sys.executable, str(script), *extra_args],
2022
cwd=project,
@@ -151,3 +153,73 @@ def test_skip_flag(self, tmp_project: Path) -> None:
151153
"package",
152154
)
153155
assert "SKIP" in result.stdout
156+
157+
158+
class TestProjectRootWalkUp:
159+
"""Tests that scripts find PROJECT_ROOT from both scripts/ and .github/scripts/.
160+
161+
Before the fix, scripts assumed PROJECT_ROOT = SCRIPT_DIR.parent (one level
162+
up). This broke when running from .github/scripts/ (two levels deep) because
163+
the parent resolved to .github/ instead of the repo root. The walk-up logic
164+
traverses upward until it finds pyproject.toml.
165+
"""
166+
167+
def test_qa_from_scripts_dir(self, tmp_project: Path) -> None:
168+
"""qa.py finds PROJECT_ROOT from the standard scripts/ location."""
169+
result = _run_script(tmp_project, "qa.py", "--help")
170+
assert result.returncode == 0
171+
172+
def test_qa_from_github_scripts_dir(self, tmp_project: Path) -> None:
173+
"""qa.py finds PROJECT_ROOT from the synced .github/scripts/ location."""
174+
result = _run_script(tmp_project, "qa.py", "--help", scripts_dir=".github/scripts")
175+
assert result.returncode == 0
176+
177+
def test_qa_discovers_checks_from_github_scripts(self, tmp_project: Path) -> None:
178+
"""qa.py discovers check_*.py siblings when running from .github/scripts/."""
179+
result = _run_script(
180+
tmp_project,
181+
"qa.py",
182+
"--skip",
183+
"lint",
184+
"--skip",
185+
"types",
186+
"--skip",
187+
"tests",
188+
"--skip",
189+
"security",
190+
"--skip",
191+
"spelling",
192+
"--skip",
193+
"package",
194+
scripts_dir=".github/scripts",
195+
)
196+
# All checks skipped = success; proves the script loaded and found pyproject.toml
197+
assert result.returncode == 0
198+
assert "SKIP" in result.stdout
199+
assert "could not find pyproject.toml" not in result.stderr
200+
201+
def test_no_pyproject_toml_fails(self, tmp_path: Path) -> None:
202+
"""qa.py fails with a clear error when pyproject.toml is missing."""
203+
scripts_dir = tmp_path / "deep" / "nested" / "scripts"
204+
scripts_dir.mkdir(parents=True)
205+
source_qa = Path(__file__).resolve().parent.parent / "scripts" / "qa.py"
206+
(scripts_dir / "qa.py").write_text(source_qa.read_text())
207+
208+
result = subprocess.run( # noqa: S603
209+
[sys.executable, str(scripts_dir / "qa.py"), "--help"],
210+
cwd=tmp_path,
211+
capture_output=True,
212+
text=True,
213+
)
214+
assert result.returncode != 0
215+
assert "could not find pyproject.toml" in result.stderr
216+
217+
def test_deeply_nested_scripts(self, tmp_project: Path) -> None:
218+
"""Walk-up works from an arbitrarily deep location."""
219+
import shutil
220+
221+
deep = tmp_project / "a" / "b" / "c" / "scripts"
222+
shutil.copytree(tmp_project / "scripts", deep)
223+
224+
result = _run_script(tmp_project, "qa.py", "--help", scripts_dir="a/b/c/scripts")
225+
assert result.returncode == 0

0 commit comments

Comments
 (0)