From 0f4335ad60d6736ba11ce253ef0405fd0155dbde Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 00:35:12 +0000 Subject: [PATCH 1/6] chore(deps): update actions/setup-node action to v7 --- .github/workflows/test.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7067c19..f697927 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -83,7 +83,7 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: "24" @@ -163,7 +163,7 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: "24" From 98f9d3cb86ebac83ae9417a7cdbe565bd4fcfccf Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Fri, 18 Sep 2026 10:23:04 -0700 Subject: [PATCH 2/6] fix!: require Node.js 22.22 and repair CI BREAKING CHANGE: The Python wrapper now requires Node.js 22.22.0 or newer. --- .github/workflows/release-please.yml | 30 ++++- .github/workflows/test.yml | 158 +++++++++++++-------------- AGENTS.md | 19 ++-- CONTRIBUTING.md | 4 +- README.md | 16 +-- release-please-config.json | 6 +- src/promptfoo/cli.py | 23 +++- src/promptfoo/instructions.py | 57 +++++----- src/promptfoo/node.py | 27 +++++ tests/smoke/README.md | 16 ++- tests/smoke/test_smoke.py | 80 +++++++------- tests/test_cli.py | 61 ++++++++++- tests/test_instructions.py | 30 +++++ tests/test_node.py | 53 +++++++++ uv.lock | 5 +- 15 files changed, 391 insertions(+), 194 deletions(-) create mode 100644 src/promptfoo/node.py create mode 100644 tests/test_node.py diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index fa55514..c88a456 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -20,6 +20,7 @@ jobs: release-please: runs-on: ubuntu-latest permissions: + actions: write contents: write pull-requests: write outputs: @@ -33,6 +34,22 @@ jobs: with: token: ${{ secrets.GITHUB_TOKEN }} + - name: Run CI for release PRs opened or updated with the workflow token + if: steps.release.outputs.prs_created == 'true' + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + RELEASE_PRS: ${{ steps.release.outputs.prs }} + run: | + branches=$(jq -er 'map(.headBranchName) | unique | .[]' <<< "$RELEASE_PRS") + while IFS= read -r branch; do + case "$branch" in + release-please--*) ;; + *) echo "Unexpected release PR branch: $branch" >&2; exit 1 ;; + esac + gh workflow run test.yml --ref "$branch" + done <<< "$branches" + build: if: | inputs.tag != '' || @@ -54,7 +71,15 @@ jobs: run: uv python pin 3.12 - name: Install dependencies - run: uv sync --extra dev + env: + REPUBLISH_TAG: ${{ inputs.tag }} + run: | + if [[ -n "$REPUBLISH_TAG" ]]; then + # Historical tags predate keeping the package version in uv.lock in sync. + uv sync --extra dev + else + uv sync --locked --extra dev + fi - name: Run unit tests run: uv run pytest -m 'not smoke' -q @@ -67,7 +92,7 @@ jobs: TAG: ${{ needs.release-please.outputs.tag_name }} run: | EXPECTED_VERSION="${TAG#promptfoo-v}" - if ls dist/*-${EXPECTED_VERSION}-*.whl 1> /dev/null 2>&1; then + if compgen -G "dist/*-${EXPECTED_VERSION}-*.whl" > /dev/null; then echo "✓ Package version ${EXPECTED_VERSION} matches release tag ${TAG}" else echo "ERROR: Package version mismatch!" @@ -82,6 +107,7 @@ jobs: with: name: dist path: dist/ + if-no-files-found: error publish-pypi: if: | diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7067c19..35bbf07 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2,9 +2,6 @@ name: Python CI on: pull_request: - paths-ignore: - - "**.md" - - "LICENSE" push: branches: - main @@ -13,11 +10,38 @@ on: permissions: contents: read +env: + PROMPTFOO_DISABLE_TELEMETRY: "1" + PROMPTFOO_DISABLE_UPDATE: "1" + concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: + resolve-promptfoo: + name: Resolve promptfoo version + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + version: ${{ steps.promptfoo.outputs.version }} + steps: + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + with: + node-version: "24" + package-manager-cache: false + + - name: Resolve the published promptfoo version once for every test job + id: promptfoo + shell: bash + run: | + version=$(npm view promptfoo@latest version) + if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Unexpected promptfoo version: $version" >&2 + exit 1 + fi + echo "version=$version" >> "$GITHUB_OUTPUT" + lint: name: Lint and Format runs-on: ubuntu-latest @@ -33,7 +57,7 @@ jobs: run: uv python pin 3.12 - name: Install dependencies - run: uv sync --extra dev + run: uv sync --locked --extra dev - name: Lint with Ruff run: uv run ruff check src/ @@ -41,6 +65,9 @@ jobs: - name: Check formatting run: uv run ruff format --check src/ + - name: Check GitHub workflows + run: go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.12 + type-check: name: Type Check (${{ matrix.type-checker }}) runs-on: ubuntu-latest @@ -59,7 +86,7 @@ jobs: run: uv python pin 3.12 - name: Install dependencies - run: uv sync --extra dev + run: uv sync --locked --extra dev - name: Type check with mypy if: matrix.type-checker == 'mypy' @@ -70,60 +97,47 @@ jobs: run: uv run pyright src/promptfoo/ test: - name: Test (py${{ matrix.python-version }}, ${{ matrix.os }}) + name: Test (py${{ matrix.python-version }}, node${{ matrix.node-version }}, ${{ matrix.os }}) + needs: resolve-promptfoo runs-on: ${{ matrix.os }} timeout-minutes: 30 + env: + PROMPTFOO_VERSION: ${{ needs.resolve-promptfoo.outputs.version }} strategy: + fail-fast: false matrix: # Temporarily excluding macos-latest due to GitHub Actions runner resource constraints # causing BlockingIOError [Errno 35] when spawning subprocess os: [ubuntu-latest, windows-latest] # Test only min and max supported Python versions for efficiency python-version: ["3.10", "3.14"] + include: + - python-version: "3.10" + node-version: "22.22.0" + - python-version: "3.14" + node-version: "24" steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: - node-version: "24" + node-version: ${{ matrix.node-version }} + package-manager-cache: false - name: Configure npm on Windows if: matrix.os == 'windows-latest' shell: pwsh run: | - # Configure cache location (applies immediately to this step) $cacheDir = Join-Path $env:RUNNER_TEMP "npm-cache" - New-Item -ItemType Directory -Force -Path $cacheDir | Out-Null - npm config set cache $cacheDir --location=user - - # Configure prefix location (applies immediately to this step) - $globalPrefix = npm config get prefix - if (-not $globalPrefix -or $globalPrefix -eq "undefined") { - $globalPrefix = Join-Path $env:APPDATA "npm" - } - $globalPrefix = $globalPrefix.Trim() - npm config set prefix $globalPrefix --location=user - - # NOW clean and verify cache (cleans the correctly-configured cache) - npm cache clean --force - npm cache verify - - # Export settings for future steps + $globalPrefix = Join-Path $env:RUNNER_TEMP "npm-global" + New-Item -ItemType Directory -Force -Path $cacheDir, $globalPrefix | Out-Null "NPM_CONFIG_CACHE=$cacheDir" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append "NPM_CONFIG_PREFIX=$globalPrefix" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - "npm_config_prefix=$globalPrefix" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - - # Add global bin directories to PATH - $binPaths = @($globalPrefix, (Join-Path $globalPrefix "bin")) | Where-Object { Test-Path $_ } - foreach ($binPath in $binPaths) { - $binPath | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append - } - - Write-Host "npm cache: $cacheDir" - Write-Host "npm prefix: $globalPrefix" + $globalPrefix | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append - name: Install promptfoo globally - run: npm install -g promptfoo@latest + shell: bash + run: npm install --global "promptfoo@$PROMPTFOO_VERSION" env: NODE_OPTIONS: --max-old-space-size=4096 @@ -135,7 +149,10 @@ jobs: run: uv python pin ${{ matrix.python-version }} - name: Install package with dev dependencies - run: uv sync --extra dev + run: uv sync --locked --extra dev + + - name: Verify the wrapper can find the global npm installation + run: uv run python -c "from promptfoo.cli import _find_external_promptfoo; path = _find_external_promptfoo(); print(path); assert path is not None" - name: Run unit tests run: uv run pytest tests/ -v -m 'not smoke' @@ -150,51 +167,38 @@ jobs: run: uv run python -c "from promptfoo.cli import check_node_installed, check_npx_installed; assert check_node_installed(); assert check_npx_installed()" test-npx-fallback: - name: Test npx fallback (py${{ matrix.python-version }}, ${{ matrix.os }}) + name: Test npx fallback (py${{ matrix.python-version }}, node${{ matrix.node-version }}, ${{ matrix.os }}) + needs: resolve-promptfoo runs-on: ${{ matrix.os }} timeout-minutes: 30 + env: + PROMPTFOO_VERSION: ${{ needs.resolve-promptfoo.outputs.version }} strategy: + fail-fast: false matrix: # Test npx fallback (without global install) # Temporarily excluding macos-latest due to GitHub Actions runner resource constraints os: [ubuntu-latest, windows-latest] # Use middle-version Python for this test python-version: ["3.12"] + node-version: ["22.22.0", "24"] steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: - node-version: "24" + node-version: ${{ matrix.node-version }} + package-manager-cache: false - name: Configure npm on Windows if: matrix.os == 'windows-latest' shell: pwsh run: | - # Configure cache location (applies immediately to this step) $cacheDir = Join-Path $env:RUNNER_TEMP "npm-cache" - New-Item -ItemType Directory -Force -Path $cacheDir | Out-Null - npm config set cache $cacheDir --location=user - - # Configure prefix location (applies immediately to this step) - $globalPrefix = npm config get prefix - if (-not $globalPrefix -or $globalPrefix -eq "undefined") { - $globalPrefix = Join-Path $env:APPDATA "npm" - } - $globalPrefix = $globalPrefix.Trim() - npm config set prefix $globalPrefix --location=user - - # NOW clean and verify cache (cleans the correctly-configured cache) - npm cache clean --force - npm cache verify - - # Export settings for future steps + $globalPrefix = Join-Path $env:RUNNER_TEMP "npm-global" + New-Item -ItemType Directory -Force -Path $cacheDir, $globalPrefix | Out-Null "NPM_CONFIG_CACHE=$cacheDir" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append "NPM_CONFIG_PREFIX=$globalPrefix" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - "npm_config_prefix=$globalPrefix" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - - Write-Host "npm cache: $cacheDir" - Write-Host "npm prefix: $globalPrefix" # Intentionally skip installing promptfoo globally # This tests the npx fallback path @@ -207,7 +211,10 @@ jobs: run: uv python pin ${{ matrix.python-version }} - name: Install package with dev dependencies - run: uv sync --extra dev + run: uv sync --locked --extra dev + + - name: Verify the wrapper must fall back to npx + run: uv run python -c "from promptfoo.cli import _find_external_promptfoo, check_npx_installed; path = _find_external_promptfoo(); print(path); assert path is None; assert check_npx_installed()" - name: Run unit tests run: uv run pytest tests/ -v -m 'not smoke' @@ -243,36 +250,17 @@ jobs: with: name: dist path: dist/ + if-no-files-found: error ci-success: name: CI Success - needs: [lint, type-check, test, test-npx-fallback, build] + needs: [resolve-promptfoo, lint, type-check, test, test-npx-fallback, build] if: always() runs-on: ubuntu-latest steps: - name: Check if all jobs succeeded + env: + JOB_RESULTS: ${{ join(needs.*.result, ',') }} run: | - LINT_RESULT="${{ needs.lint.result }}" - TYPE_CHECK_RESULT="${{ needs.type-check.result }}" - TEST_RESULT="${{ needs.test.result }}" - TEST_NPX_FALLBACK_RESULT="${{ needs.test-npx-fallback.result }}" - BUILD_RESULT="${{ needs.build.result }}" - - echo "Job results:" - echo " lint: $LINT_RESULT" - echo " type-check: $TYPE_CHECK_RESULT" - echo " test: $TEST_RESULT" - echo " test-npx-fallback: $TEST_NPX_FALLBACK_RESULT" - echo " build: $BUILD_RESULT" - - if [[ "$LINT_RESULT" == "failure" || "$LINT_RESULT" == "cancelled" || - "$TYPE_CHECK_RESULT" == "failure" || "$TYPE_CHECK_RESULT" == "cancelled" || - "$TEST_RESULT" == "failure" || "$TEST_RESULT" == "cancelled" || - "$TEST_NPX_FALLBACK_RESULT" == "failure" || "$TEST_NPX_FALLBACK_RESULT" == "cancelled" || - "$BUILD_RESULT" == "failure" || "$BUILD_RESULT" == "cancelled" ]]; then - echo "Some CI checks failed!" - exit 1 - else - echo "All CI checks passed!" - exit 0 - fi + echo "Job results: $JOB_RESULTS" + [[ "$JOB_RESULTS" =~ ^success(,success)*$ ]] diff --git a/AGENTS.md b/AGENTS.md index ca4299a..22f8f4d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,14 +8,14 @@ This document provides comprehensive guidance for AI agents and developers worki - **Primary Purpose**: Enable pip-based installation of promptfoo for Python-centric environments - **Implementation**: Thin wrapper that delegates to the official TypeScript promptfoo package -- **Requirements**: Python 3.10+ and Node.js 20+ +- **Requirements**: Python 3.10+ and Node.js 22.22.0 or newer (Node.js 24 LTS recommended) ### How It Works 1. User installs via `pip install promptfoo` 2. User runs `promptfoo eval` (or any promptfoo command) 3. The Python wrapper (`src/promptfoo/cli.py`): - - Checks if Node.js/npx is available + - Checks that a supported Node.js version is available and detects npx when needed - Detects if promptfoo is globally installed - Falls back to `npx promptfoo@latest` if needed - Prevents recursive wrapper calls @@ -90,7 +90,7 @@ This repository uses **release-please** for automated releases. 2. **Release-please analyzes commits** and creates/updates a release PR 3. **Review the release PR** - It will contain: - Updated `CHANGELOG.md` - - Version bump in `pyproject.toml` and `.release-please-manifest.json` + - Version bump in `pyproject.toml`, `src/promptfoo/__init__.py`, `uv.lock`, and `.release-please-manifest.json` - Generated release notes 4. **Merge the release PR** - This triggers: - GitHub release creation with tag @@ -111,7 +111,7 @@ This is configured via `bump-patch-for-minor-pre-major: true` in `release-please - **`release-please-config.json`**: Main configuration for release-please - Defines release type (python) - - Specifies extra files to update (pyproject.toml) + - Specifies `uv.lock` as an extra file to update; the Python strategy updates `pyproject.toml` and `src/promptfoo/__init__.py` - Sets versioning behavior - **`.release-please-manifest.json`**: Tracks the last released version - Format: `{ ".": "0.2.0" }` @@ -141,13 +141,13 @@ Runs on every PR and push to main: - **Smoke Tests**: Integration tests against real CLI (`uv run pytest tests/smoke/`) - **Build**: Package build validation -Tests run on multiple Python versions (3.10, 3.14) and OSes (Ubuntu, Windows). +Tests run on multiple Python versions (3.10, 3.14) and OSes (Ubuntu, Windows), with Node.js 22.22.0 and 24. ### Release Workflow (`.github/workflows/release-please.yml`) Triggered on push to main: -1. **release-please job**: Creates/updates release PR +1. **release-please job**: Creates/updates release PR and dispatches its required Python CI workflow 2. **build job**: (on release PR merge) - Builds Python package with `uv build` - Verifies package version matches release @@ -191,7 +191,7 @@ We use **OpenID Connect (OIDC)** for secure, credential-free PyPI publishing: ```bash # Install dependencies -uv sync --extra dev +uv sync --locked --extra dev # Run linter uv run ruff check src/ @@ -265,13 +265,14 @@ tests/ CI tests across: - **Operating Systems**: Ubuntu, Windows (macOS temporarily excluded due to runner constraints) - **Python Versions**: 3.10 (min), 3.14 (max) +- **Node.js Versions**: 22.22.0 (minimum) and 24 (LTS) - **Scenarios**: Global promptfoo install vs. npx fallback ### Running Tests ```bash # Install dependencies with dev extras -uv sync --extra dev +uv sync --locked --extra dev # Run all tests (unit + smoke) uv run pytest @@ -301,7 +302,7 @@ Smoke tests verify critical CLI functionality: - **Echo Provider**: Variable substitution, multiple variables - **Assertions**: `contains`, `icontains`, failing assertions -The smoke tests use a 120-second timeout to accommodate the first `npx` call which downloads promptfoo. +The smoke tests allow up to five minutes for the initial `npx` download and use a 120-second timeout for individual commands. ## Security Practices diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c5c9c4c..38fa713 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -16,12 +16,12 @@ This repository is only the thin Python shim that lets people install promptfoo ### Setup -Requires Python 3.10+, Node.js 20+, and [uv](https://github.com/astral-sh/uv). +Requires Python 3.10+, Node.js 22.22.0 or newer (Node.js 24 LTS recommended), and [uv](https://github.com/astral-sh/uv). ```bash git clone https://github.com/promptfoo/promptfoo-python.git cd promptfoo-python -uv sync --extra dev +uv sync --locked --extra dev uv run pytest -m 'not smoke' # fast unit tests uv run pytest # all tests (requires Node.js) ``` diff --git a/README.md b/README.md index b707a4c..a569827 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ > **📦 About this Python package** > -> This is a lightweight wrapper that installs promptfoo via `pip`. It requires **Node.js 20+** and executes `npx promptfoo@latest` under the hood. +> This is a lightweight wrapper that installs promptfoo via `pip`. It requires **Node.js 22.22.0 or newer** and executes `npx promptfoo@latest` under the hood. > > **💡 If you have Node.js installed**, we recommend using `npx promptfoo@latest` directly for better performance: > @@ -48,7 +48,7 @@ ### Requirements - **Python 3.10+** (for this wrapper) -- **Node.js 20+** (required to run promptfoo) +- **Node.js 22.22.0 or newer** (required to run promptfoo; Node.js 24 LTS is recommended) ### Install from PyPI @@ -116,7 +116,7 @@ It also can generate [security vulnerability reports](https://www.promptfoo.dev/ This Python package is a thin wrapper that: -1. Checks if Node.js is installed +1. Checks that a supported Node.js version is installed 2. Executes `npx promptfoo@latest ` (or uses globally installed promptfoo if available) 3. Passes through all arguments and environment variables 4. Returns the same exit code @@ -151,9 +151,9 @@ promptfoo eval ```yaml - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: - node-version: "20" + node-version: "24" - name: Install promptfoo run: pip install promptfoo @@ -176,12 +176,12 @@ promptfoo eval ### "ERROR: promptfoo requires Node.js" -The wrapper needs Node.js to run. Install it: +The wrapper needs Node.js 22.22.0 or newer to run. Install a supported version, then verify it with `node --version`: - **macOS**: `brew install node` -- **Ubuntu/Debian**: `sudo apt install nodejs npm` +- **Ubuntu/Debian**: Use [NodeSource](https://github.com/nodesource/distributions) or [nvm](https://github.com/nvm-sh/nvm); some distribution repositories still offer older versions - **Windows**: Download from [nodejs.org](https://nodejs.org/) -- **Any OS**: Use [nvm](https://github.com/nvm-sh/nvm) +- **macOS/Linux**: With [nvm](https://github.com/nvm-sh/nvm), run `nvm install 24` ### Slow First Run diff --git a/release-please-config.json b/release-please-config.json index b07f3b1..fd309e8 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -8,9 +8,9 @@ "bump-patch-for-minor-pre-major": true, "extra-files": [ { - "type": "generic", - "path": "pyproject.toml", - "glob": false + "type": "toml", + "path": "uv.lock", + "jsonpath": "$.package[?(@.name.value == 'promptfoo')].version" } ] } diff --git a/src/promptfoo/cli.py b/src/promptfoo/cli.py index 6bfb7b0..920bed7 100644 --- a/src/promptfoo/cli.py +++ b/src/promptfoo/cli.py @@ -11,6 +11,7 @@ import sys from typing import NoReturn +from .node import MIN_NODE_VERSION, MIN_NODE_VERSION_TEXT, get_node_version from .telemetry import record_wrapper_used _WRAPPER_ENV = "PROMPTFOO_PY_WRAPPER" @@ -193,11 +194,29 @@ def main() -> NoReturn: Executes promptfoo using subprocess.run() with minimal configuration. """ try: - # Check for Node.js installation - if not check_node_installed(): + node_path = shutil.which("node") + if not node_path: print_installation_help() sys.exit(1) + node_version = get_node_version(node_path) + if node_version is None: + print( + f"ERROR: Could not determine the Node.js version. promptfoo requires Node.js {MIN_NODE_VERSION_TEXT} " + "or newer. Check that node --version reports a stable version.", + file=sys.stderr, + ) + sys.exit(1) + + if node_version < MIN_NODE_VERSION: + found_version = ".".join(map(str, node_version)) + print( + f"ERROR: promptfoo requires Node.js {MIN_NODE_VERSION_TEXT} or newer (found v{found_version}). " + "Please upgrade Node.js: https://nodejs.org/", + file=sys.stderr, + ) + sys.exit(1) + # Build command: try external promptfoo first, fall back to npx promptfoo_path = None if os.environ.get(_WRAPPER_ENV) else _find_external_promptfoo() if promptfoo_path: diff --git a/src/promptfoo/instructions.py b/src/promptfoo/instructions.py index dfe1994..7e9df19 100644 --- a/src/promptfoo/instructions.py +++ b/src/promptfoo/instructions.py @@ -5,6 +5,7 @@ """ from .environment import Environment +from .node import MIN_NODE_VERSION_TEXT def get_installation_instructions(env: Environment) -> str: @@ -19,8 +20,9 @@ def get_installation_instructions(env: Environment) -> str: """ lines = [] lines.append("=" * 70) - lines.append("ERROR: promptfoo requires Node.js but it's not installed") + lines.append(f"ERROR: promptfoo requires Node.js {MIN_NODE_VERSION_TEXT} or newer but it's not installed") lines.append("=" * 70) + lines.append("Install a supported version and verify it with: node --version") lines.append("") # Special cases first (Lambda, Cloud Functions, etc.) @@ -116,27 +118,27 @@ def _get_ci_instructions(env: Environment) -> list[str]: lines.extend( [ "Add Node.js to your workflow:", - " - uses: actions/setup-node@v4", + " - uses: actions/setup-node@v7", " with:", - " node-version: '20'", + " node-version: '24'", ] ) elif env.ci_platform == "gitlab": lines.extend( [ "Use a Docker image with Node.js:", - " image: node:20", - "Or install Node.js in before_script:", - " before_script:", - " - apt-get update && apt-get install -y nodejs npm", + " image: node:24", ] ) elif env.ci_platform == "circleci": lines.extend( [ - "Use a CircleCI image with Node.js:", - " docker:", - " - image: cimg/python:3.11-node", + "Use the CircleCI Node orb with your Python image:", + " orbs:", + " node: circleci/node@5", + " # Add under your job's steps:", + " - node/install:", + " node-version: '24'", ] ) else: @@ -165,12 +167,8 @@ def _get_docker_instructions(env: Environment) -> list[str]: lines.extend( [ "Add to your Dockerfile (Debian/Ubuntu):", - " RUN apt-get update && \\", - " apt-get install -y nodejs npm && \\", - " rm -rf /var/lib/apt/lists/*", - "", - "Or use NodeSource for newer version:", - " RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \\", + " RUN apt-get update && apt-get install -y ca-certificates curl && \\", + " curl -fsSL https://deb.nodesource.com/setup_24.x | bash - && \\", " apt-get install -y nodejs && \\", " rm -rf /var/lib/apt/lists/*", ] @@ -178,9 +176,10 @@ def _get_docker_instructions(env: Environment) -> list[str]: else: lines.extend( [ - "Add Node.js to your Dockerfile:", - " FROM python:3.11", - " RUN apt-get update && apt-get install -y nodejs npm", + "Use matching Debian-based Node.js and Python stages in your Dockerfile:", + " FROM node:24-bookworm-slim AS node", + " FROM python:3.12-slim-bookworm", + " COPY --from=node /usr/local/ /usr/local/", ] ) @@ -200,7 +199,7 @@ def _get_wsl_instructions() -> list[str]: " 2. Or use nvm for version management:", " curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash", " source ~/.bashrc", - " nvm install 20", + " nvm install 24", "", "Tips for WSL:", " - Store project files in the WSL filesystem (~/), not /mnt/c/", @@ -239,10 +238,10 @@ def _get_debian_instructions(env: Environment) -> list[str]: lines.extend( [ "Option 1 - Install from NodeSource (recommended for production):", - " curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -", + " curl -fsSL https://deb.nodesource.com/setup_24.x | sudo -E bash -", " sudo apt install -y nodejs", "", - "Option 2 - Install from default repository (may be outdated):", + "Option 2 - Use the default repository only if it supplies a supported version:", " sudo apt update", " sudo apt install -y nodejs npm", "", @@ -257,7 +256,7 @@ def _get_debian_instructions(env: Environment) -> list[str]: "You don't have sudo access. Use nvm (Node Version Manager):", " curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash", " source ~/.bashrc", - " nvm install 20", + " nvm install 24", ] ) @@ -282,7 +281,7 @@ def _get_rhel_instructions(env: Environment) -> list[str]: " sudo dnf install -y nodejs", "", "Amazon Linux 2:", - " curl -fsSL https://rpm.nodesource.com/setup_20.x | sudo bash -", + " curl -fsSL https://rpm.nodesource.com/setup_24.x | sudo bash -", " sudo yum install -y nodejs", ] ) @@ -292,7 +291,7 @@ def _get_rhel_instructions(env: Environment) -> list[str]: "Use nvm (Node Version Manager) - no sudo needed:", " curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash", " source ~/.bashrc", - " nvm install 20", + " nvm install 24", ] ) else: @@ -307,7 +306,7 @@ def _get_rhel_instructions(env: Environment) -> list[str]: " sudo yum install -y nodejs npm", "", "Or use NodeSource for newer version:", - " curl -fsSL https://rpm.nodesource.com/setup_20.x | sudo bash -", + " curl -fsSL https://rpm.nodesource.com/setup_24.x | sudo bash -", " sudo yum install -y nodejs", ] ) @@ -317,7 +316,7 @@ def _get_rhel_instructions(env: Environment) -> list[str]: "Use nvm (Node Version Manager) - no sudo needed:", " curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash", " source ~/.bashrc", - " nvm install 20", + " nvm install 24", ] ) @@ -364,7 +363,7 @@ def _get_generic_linux_instructions() -> list[str]: "Option 1 - nvm (Node Version Manager, works on any Linux):", " curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash", " source ~/.bashrc", - " nvm install 20", + " nvm install 24", "", "Option 2 - Download binary from https://nodejs.org/", ] @@ -381,7 +380,7 @@ def _get_macos_instructions() -> list[str]: "Option 2 - nvm (Node Version Manager, for version management):", " curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash", " source ~/.zshrc # or ~/.bashrc", - " nvm install 20", + " nvm install 24", "", "Option 3 - Official installer:", " Download from https://nodejs.org/", diff --git a/src/promptfoo/node.py b/src/promptfoo/node.py new file mode 100644 index 0000000..cbf86f6 --- /dev/null +++ b/src/promptfoo/node.py @@ -0,0 +1,27 @@ +"""Node.js runtime requirements shared by the CLI and installation help.""" + +import re +import subprocess + +MIN_NODE_VERSION = (22, 22, 0) +MIN_NODE_VERSION_TEXT = ".".join(map(str, MIN_NODE_VERSION)) + + +def get_node_version(node_path: str) -> tuple[int, int, int] | None: + """Return the stable Node.js version, or None if it cannot be determined.""" + try: + result = subprocess.run( + [node_path, "--version"], + capture_output=True, + check=True, + encoding="ascii", + errors="replace", + timeout=5, + ) + except (OSError, subprocess.SubprocessError): + return None + + match = re.fullmatch(r"v([0-9]+)\.([0-9]+)\.([0-9]+)(?:\+[A-Za-z0-9.-]+)?", result.stdout.strip()) + if not match: + return None + return int(match[1]), int(match[2]), int(match[3]) diff --git a/tests/smoke/README.md b/tests/smoke/README.md index 43f964e..8725555 100644 --- a/tests/smoke/README.md +++ b/tests/smoke/README.md @@ -1,6 +1,6 @@ # Smoke Tests -These smoke tests verify that the core promptfoo CLI functionality works correctly through the Python wrapper. +These smoke tests verify that the core promptfoo CLI functionality works correctly through the installed Python console script, even when an npm installation is also on `PATH`. ## What are Smoke Tests? @@ -15,17 +15,20 @@ Smoke tests are high-level integration tests that verify the most critical funct ## Running Smoke Tests ```bash +# Install the wrapper and its test dependencies +uv sync --locked --extra dev + # Run all smoke tests -pytest tests/smoke/ +uv run pytest tests/smoke/ # Run with verbose output -pytest tests/smoke/ -v +uv run pytest tests/smoke/ -v # Run a specific test class -pytest tests/smoke/test_smoke.py::TestEvalCommand +uv run pytest tests/smoke/test_smoke.py::TestEvalCommand # Run a specific test -pytest tests/smoke/test_smoke.py::TestEvalCommand::test_basic_eval +uv run pytest tests/smoke/test_smoke.py::TestEvalCommand::test_basic_eval ``` ## Test Structure @@ -83,6 +86,7 @@ The `echo` provider is perfect for smoke tests because: ## Notes - Smoke tests run slower than unit tests (they spawn subprocesses) -- They require Node.js and promptfoo to be installed +- They require Node.js 22.22.0 or newer and either a global promptfoo installation or network access for the first npx run +- Settings and output use pytest temporary directories; telemetry and update checks are disabled - They test the integration between Python and Node.js - They should be kept focused on critical functionality diff --git a/tests/smoke/test_smoke.py b/tests/smoke/test_smoke.py index 7f0fe10..25eb63e 100644 --- a/tests/smoke/test_smoke.py +++ b/tests/smoke/test_smoke.py @@ -10,8 +10,8 @@ import json import os -import shutil import subprocess +import sysconfig from collections.abc import Generator from pathlib import Path from typing import Optional @@ -25,7 +25,7 @@ SMOKE_DIR = Path(__file__).parent FIXTURES_DIR = SMOKE_DIR / "fixtures" CONFIGS_DIR = FIXTURES_DIR / "configs" -OUTPUT_DIR = SMOKE_DIR / ".temp-output" +WRAPPER = Path(sysconfig.get_path("scripts")) / ("promptfoo.exe" if os.name == "nt" else "promptfoo") def run_promptfoo( @@ -48,7 +48,7 @@ def run_promptfoo( Returns: Tuple of (stdout, stderr, exit_code) """ - cmd = ["promptfoo"] + args + cmd = [str(WRAPPER)] + args full_env = os.environ.copy() full_env["NO_COLOR"] = "1" # Disable color output for easier parsing @@ -83,16 +83,17 @@ def run_promptfoo( @pytest.fixture(scope="module", autouse=True) -def setup_and_teardown() -> Generator[None, None, None]: - """Create and cleanup output directory for smoke tests.""" - OUTPUT_DIR.mkdir(exist_ok=True) - yield - if OUTPUT_DIR.exists(): - shutil.rmtree(OUTPUT_DIR) +def isolate_promptfoo(tmp_path_factory: pytest.TempPathFactory) -> Generator[None, None, None]: + """Keep settings, evaluations and telemetry away from the developer or runner's home.""" + with pytest.MonkeyPatch.context() as monkeypatch: + monkeypatch.setenv("PROMPTFOO_CONFIG_DIR", str(tmp_path_factory.mktemp("promptfoo-config"))) + monkeypatch.setenv("PROMPTFOO_DISABLE_TELEMETRY", "1") + monkeypatch.setenv("PROMPTFOO_DISABLE_UPDATE", "1") + yield @pytest.fixture(scope="module", autouse=True) -def warmup_npx() -> Generator[None, None, None]: +def warmup_npx(isolate_promptfoo: None) -> None: """ Warm up npx by running promptfoo --version before all tests. @@ -100,22 +101,17 @@ def warmup_npx() -> Generator[None, None, None]: downloads and caches promptfoo, which can take several minutes on Windows. Running this warmup prevents the first actual test from timing out. """ - # Run with a longer timeout (5 minutes) for the initial npx download + assert WRAPPER.is_file(), f"Python console script was not installed at {WRAPPER}" try: - subprocess.run( - ["promptfoo", "--version"], - capture_output=True, - timeout=300, # 5 minutes for initial npx download - encoding="utf-8", - errors="replace", + stdout, stderr, exit_code = run_promptfoo(["--version"], timeout=300) + except (OSError, subprocess.TimeoutExpired) as error: + pytest.fail(f"Python wrapper could not start promptfoo during warmup: {error}") + + if exit_code != 0 or not stdout.strip(): + pytest.fail( + f"Python wrapper could not start promptfoo during warmup (exit {exit_code}).\n" + f"STDOUT:\n{stdout}\nSTDERR:\n{stderr}" ) - except subprocess.TimeoutExpired: - # If warmup times out, tests will likely fail but let them run anyway - pass - except FileNotFoundError: - # promptfoo not installed, tests will fail but let them try - pass - yield class TestBasicCLI: @@ -190,10 +186,10 @@ def test_basic_eval(self) -> None: # Should show evaluation results assert "pass" in stdout.lower() or "✓" in stdout or "success" in stdout.lower() - def test_json_output(self) -> None: + def test_json_output(self, tmp_path: Path) -> None: """Test eval outputs valid JSON.""" config_path = CONFIGS_DIR / "basic.yaml" - output_path = OUTPUT_DIR / "output.json" + output_path = tmp_path / "output.json" stdout, stderr, exit_code = run_promptfoo( ["eval", "-c", str(config_path), "-o", str(output_path), "--no-cache"] @@ -203,7 +199,7 @@ def test_json_output(self) -> None: assert output_path.exists(), "Output file was not created" # Verify it's valid JSON with expected structure - with open(output_path) as f: + with open(output_path, encoding="utf-8") as f: data = json.load(f) assert "results" in data @@ -219,10 +215,10 @@ def test_json_output(self) -> None: assert "Hello" in output_text assert "World" in output_text - def test_yaml_output(self) -> None: + def test_yaml_output(self, tmp_path: Path) -> None: """Test eval outputs YAML format.""" config_path = CONFIGS_DIR / "basic.yaml" - output_path = OUTPUT_DIR / "output.yaml" + output_path = tmp_path / "output.yaml" stdout, stderr, exit_code = run_promptfoo( ["eval", "-c", str(config_path), "-o", str(output_path), "--no-cache"] @@ -232,15 +228,15 @@ def test_yaml_output(self) -> None: assert output_path.exists() # Verify it contains YAML-like content - with open(output_path) as f: + with open(output_path, encoding="utf-8") as f: content = f.read() assert "results:" in content - def test_csv_output(self) -> None: + def test_csv_output(self, tmp_path: Path) -> None: """Test eval outputs CSV format.""" config_path = CONFIGS_DIR / "basic.yaml" - output_path = OUTPUT_DIR / "output.csv" + output_path = tmp_path / "output.csv" stdout, stderr, exit_code = run_promptfoo( ["eval", "-c", str(config_path), "-o", str(output_path), "--no-cache"] @@ -250,7 +246,7 @@ def test_csv_output(self) -> None: assert output_path.exists() # Verify it's CSV format (has header row with columns) - with open(output_path) as f: + with open(output_path, encoding="utf-8") as f: content = f.read() lines = content.strip().split("\n") @@ -268,10 +264,10 @@ def test_max_concurrency_flag(self) -> None: assert exit_code == 0 - def test_repeat_flag(self) -> None: + def test_repeat_flag(self, tmp_path: Path) -> None: """Test --repeat flag runs tests multiple times.""" config_path = CONFIGS_DIR / "basic.yaml" - output_path = OUTPUT_DIR / "repeat-output.json" + output_path = tmp_path / "repeat-output.json" stdout, stderr, exit_code = run_promptfoo( [ @@ -289,7 +285,7 @@ def test_repeat_flag(self) -> None: assert exit_code == 0 # Verify we got repeated results - with open(output_path) as f: + with open(output_path, encoding="utf-8") as f: data = json.load(f) # With repeat=2 and 1 test case, we should have 2 results @@ -342,10 +338,10 @@ def test_config_error_exit_code(self) -> None: class TestEchoProvider: """Echo provider smoke tests.""" - def test_echo_provider_basic(self) -> None: + def test_echo_provider_basic(self, tmp_path: Path) -> None: """Test echo provider returns the prompt.""" config_path = CONFIGS_DIR / "basic.yaml" - output_path = OUTPUT_DIR / "echo-test.json" + output_path = tmp_path / "echo-test.json" stdout, stderr, exit_code = run_promptfoo( ["eval", "-c", str(config_path), "-o", str(output_path), "--no-cache"] @@ -354,7 +350,7 @@ def test_echo_provider_basic(self) -> None: assert exit_code == 0 # Verify echo provider returns the prompt - with open(output_path) as f: + with open(output_path, encoding="utf-8") as f: data = json.load(f) first_result = data["results"]["results"][0] @@ -364,10 +360,10 @@ def test_echo_provider_basic(self) -> None: assert "Hello" in output assert "World" in output - def test_echo_provider_with_multiple_vars(self) -> None: + def test_echo_provider_with_multiple_vars(self, tmp_path: Path) -> None: """Test echo provider with multiple variables.""" config_path = CONFIGS_DIR / "assertions.yaml" - output_path = OUTPUT_DIR / "echo-multi-var.json" + output_path = tmp_path / "echo-multi-var.json" stdout, stderr, exit_code = run_promptfoo( ["eval", "-c", str(config_path), "-o", str(output_path), "--no-cache"] @@ -375,7 +371,7 @@ def test_echo_provider_with_multiple_vars(self) -> None: assert exit_code == 0 - with open(output_path) as f: + with open(output_path, encoding="utf-8") as f: data = json.load(f) first_result = data["results"]["results"][0] diff --git a/tests/test_cli.py b/tests/test_cli.py index dded5bc..4dccac0 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -395,8 +395,15 @@ def test_normalize_exit_code_maps_unix_signal_status(self, monkeypatch: pytest.M class TestMainFunction: """Test the main CLI entry point with various scenarios.""" + @pytest.fixture(autouse=True) + def supported_node(self, monkeypatch: pytest.MonkeyPatch) -> MagicMock: + """Provide a supported Node version independently of downstream command mocks.""" + version = MagicMock(return_value=(22, 22, 0)) + monkeypatch.setattr("promptfoo.cli.get_node_version", version) + return version + def test_main_exits_when_node_not_installed( - self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture + self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture, supported_node: MagicMock ) -> None: """Exits with code 1 and prints help when Node.js not found.""" monkeypatch.setattr("shutil.which", lambda cmd: None) @@ -407,9 +414,59 @@ def test_main_exits_when_node_not_installed( assert exc_info.value.code == 1 captured = capsys.readouterr() assert "ERROR: promptfoo requires Node.js" in captured.err + assert "22.22.0" in captured.err + supported_node.assert_not_called() + + @pytest.mark.parametrize("version", [(20, 20, 2), (21, 7, 3), (22, 0, 0), (22, 21, 9)]) + def test_main_rejects_unsupported_node_before_running_promptfoo( + self, + version: tuple[int, int, int], + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture, + supported_node: MagicMock, + ) -> None: + """Reject old Node installations before looking up or executing either CLI path.""" + which = MagicMock(return_value="node") + downstream = MagicMock() + monkeypatch.setattr("shutil.which", which) + monkeypatch.setattr("promptfoo.cli._find_external_promptfoo", downstream) + monkeypatch.setattr("promptfoo.cli._run_command", downstream) + monkeypatch.setattr("promptfoo.cli.record_wrapper_used", downstream) + supported_node.return_value = version + + with pytest.raises(SystemExit) as exc_info: + main() - def test_main_uses_external_promptfoo_when_available(self, monkeypatch: pytest.MonkeyPatch) -> None: + assert exc_info.value.code == 1 + output = capsys.readouterr() + assert output.out == "" + assert "requires Node.js 22.22.0 or newer" in output.err + assert f"found v{'.'.join(map(str, version))}" in output.err + which.assert_called_once_with("node") + downstream.assert_not_called() + + def test_main_rejects_unreadable_node_version( + self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture, supported_node: MagicMock + ) -> None: + """Fail with an actionable error when the executable cannot report its version.""" + monkeypatch.setattr("shutil.which", MagicMock(return_value="node")) + downstream = MagicMock() + monkeypatch.setattr("promptfoo.cli._find_external_promptfoo", downstream) + supported_node.return_value = None + + with pytest.raises(SystemExit) as exc_info: + main() + + assert exc_info.value.code == 1 + assert "Could not determine the Node.js version" in capsys.readouterr().err + downstream.assert_not_called() + + @pytest.mark.parametrize("version", [(22, 22, 0), (22, 23, 0), (23, 0, 0), (24, 0, 0)]) + def test_main_uses_external_promptfoo_when_available( + self, monkeypatch: pytest.MonkeyPatch, supported_node: MagicMock, version: tuple[int, int, int] + ) -> None: """Uses external promptfoo when found and sets wrapper env var.""" + supported_node.return_value = version monkeypatch.setattr(sys, "argv", ["promptfoo", "eval"]) monkeypatch.setattr( "shutil.which", diff --git a/tests/test_instructions.py b/tests/test_instructions.py index 999f2ce..e02b5bd 100644 --- a/tests/test_instructions.py +++ b/tests/test_instructions.py @@ -5,10 +5,40 @@ different platforms and environments. """ +import pytest + from promptfoo.environment import Environment from promptfoo.instructions import get_installation_instructions +@pytest.mark.parametrize( + ("env", "recommended"), + [ + (Environment(os_type="linux", is_lambda=True), "node --version"), + (Environment(os_type="linux", is_cloud_function=True, cloud_provider="gcp"), "node --version"), + (Environment(os_type="linux", is_ci=True, ci_platform="github"), "node-version: '24'"), + (Environment(os_type="linux", is_ci=True, ci_platform="gitlab"), "image: node:24"), + (Environment(os_type="linux", is_ci=True, ci_platform="circleci"), "node-version: '24'"), + (Environment(os_type="linux", linux_distro="ubuntu", is_docker=True), "setup_24.x"), + (Environment(os_type="linux", is_docker=True), "FROM node:24-bookworm-slim"), + (Environment(os_type="linux", is_wsl=True), "nvm install 24"), + (Environment(os_type="linux", linux_distro="rhel", has_sudo=True), "setup_24.x"), + (Environment(os_type="darwin"), "nvm install 24"), + (Environment(os_type="windows"), "OpenJS.NodeJS.LTS"), + ], +) +def test_installation_help_requires_supported_node(env: Environment, recommended: str) -> None: + """Every platform states the exact minimum and versioned examples install a supported runtime.""" + instructions = get_installation_instructions(env) + + assert "requires Node.js 22.22.0 or newer" in instructions + assert recommended in instructions + assert "nvm install 20" not in instructions + assert "setup_20.x" not in instructions + assert "node-version: '20'" not in instructions + assert "image: node:20" not in instructions + + class TestLambdaInstructions: """Test instructions for AWS Lambda.""" diff --git a/tests/test_node.py b/tests/test_node.py new file mode 100644 index 0000000..0fb1632 --- /dev/null +++ b/tests/test_node.py @@ -0,0 +1,53 @@ +"""Tests for reading the Node.js version reported by the executable.""" + +import subprocess +from unittest.mock import MagicMock + +import pytest + +from promptfoo.node import get_node_version + + +@pytest.mark.parametrize( + ("output", "expected"), + [ + ("v20.20.2\n", (20, 20, 2)), + ("v22.21.9\n", (22, 21, 9)), + ("v22.22.0\r\n", (22, 22, 0)), + ("v22.22.1\n", (22, 22, 1)), + ("v24.0.0\n", (24, 0, 0)), + ("v26.1.0+custom.1\n", (26, 1, 0)), + ("v22.22.0-rc.1\n", None), + ("v26.0.0-nightly20260507\n", None), + ("22.22.0", None), + ("v22.22", None), + ("v22.22.0\nunexpected", None), + ("unexpected", None), + ("", None), + ], +) +def test_get_node_version(monkeypatch: pytest.MonkeyPatch, output: str, expected: tuple[int, int, int] | None) -> None: + """Parse stable versions and leave malformed or prerelease output unsupported.""" + run = MagicMock(return_value=subprocess.CompletedProcess([], 0, stdout=output)) + monkeypatch.setattr(subprocess, "run", run) + + assert get_node_version("/path with spaces/node") == expected + run.assert_called_once_with( + ["/path with spaces/node", "--version"], + capture_output=True, + check=True, + encoding="ascii", + errors="replace", + timeout=5, + ) + + +@pytest.mark.parametrize( + "error", + [OSError("not executable"), subprocess.CalledProcessError(1, ["node"]), subprocess.TimeoutExpired(["node"], 5)], +) +def test_get_node_version_failure(monkeypatch: pytest.MonkeyPatch, error: Exception) -> None: + """A broken or unresponsive executable does not escape into the wrapper.""" + monkeypatch.setattr(subprocess, "run", MagicMock(side_effect=error)) + + assert get_node_version("node") is None diff --git a/uv.lock b/uv.lock index d9cfff7..289c1b9 100644 --- a/uv.lock +++ b/uv.lock @@ -6,9 +6,6 @@ resolution-markers = [ "python_full_version < '3.15'", ] -[options] -exclude-newer = "2026-05-14T17:19:39Z" - [[package]] name = "ast-serialize" version = "0.3.0" @@ -192,7 +189,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ From 0e7d7495257d96ff74a4913828a7a9f4f28fa1bd Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Fri, 18 Sep 2026 10:25:39 -0700 Subject: [PATCH 3/6] test: isolate npm version in wrapper unit tests --- tests/test_cli.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/test_cli.py b/tests/test_cli.py index 4dccac0..fbe4e50 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -18,6 +18,7 @@ import pytest from promptfoo.cli import ( + _VERSION_ENV, _WINDOWS_SHELL_EXTENSIONS, _WRAPPER_ENV, _find_external_promptfoo, @@ -400,6 +401,7 @@ def supported_node(self, monkeypatch: pytest.MonkeyPatch) -> MagicMock: """Provide a supported Node version independently of downstream command mocks.""" version = MagicMock(return_value=(22, 22, 0)) monkeypatch.setattr("promptfoo.cli.get_node_version", version) + monkeypatch.delenv(_VERSION_ENV, raising=False) return version def test_main_exits_when_node_not_installed( @@ -556,6 +558,22 @@ def test_main_falls_back_to_npx(self, monkeypatch: pytest.MonkeyPatch) -> None: assert "promptfoo@latest" in cmd assert "eval" in cmd + def test_main_passes_requested_version_to_npx(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Use the requested npm package version when there is no external CLI.""" + monkeypatch.setenv(_VERSION_ENV, "0.123.0") + monkeypatch.setattr(sys, "argv", ["promptfoo", "eval"]) + monkeypatch.setattr("shutil.which", lambda cmd: {"node": "node", "npx": "npx"}.get(cmd)) + monkeypatch.setattr("promptfoo.cli._find_external_promptfoo", lambda: None) + monkeypatch.setattr("promptfoo.cli.record_wrapper_used", lambda mode: None) + run = MagicMock(return_value=subprocess.CompletedProcess([], 0)) + monkeypatch.setattr("promptfoo.cli._run_command", run) + + with pytest.raises(SystemExit) as exc_info: + main() + + assert exc_info.value.code == 0 + run.assert_called_once_with(["npx", "-y", "promptfoo@0.123.0", "eval"]) + def test_main_exits_when_neither_external_nor_npx_available( self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture ) -> None: From 1ad16d23dd4670d39ddb0cab6c27f3f8445f2ce9 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Fri, 18 Sep 2026 10:27:57 -0700 Subject: [PATCH 4/6] ci: use eligible pull request checks for release branches --- .github/workflows/release-please.yml | 20 ++------------------ AGENTS.md | 7 ++++++- 2 files changed, 8 insertions(+), 19 deletions(-) diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index c88a456..0ea944f 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -20,7 +20,6 @@ jobs: release-please: runs-on: ubuntu-latest permissions: - actions: write contents: write pull-requests: write outputs: @@ -32,23 +31,8 @@ jobs: id: release if: github.event_name == 'push' with: - token: ${{ secrets.GITHUB_TOKEN }} - - - name: Run CI for release PRs opened or updated with the workflow token - if: steps.release.outputs.prs_created == 'true' - env: - GH_TOKEN: ${{ github.token }} - GH_REPO: ${{ github.repository }} - RELEASE_PRS: ${{ steps.release.outputs.prs }} - run: | - branches=$(jq -er 'map(.headBranchName) | unique | .[]' <<< "$RELEASE_PRS") - while IFS= read -r branch; do - case "$branch" in - release-please--*) ;; - *) echo "Unexpected release PR branch: $branch" >&2; exit 1 ;; - esac - gh workflow run test.yml --ref "$branch" - done <<< "$branches" + # A dedicated token lets the resulting pull_request run start without manual workflow approval. + token: ${{ secrets.RELEASE_PLEASE_TOKEN || github.token }} build: if: | diff --git a/AGENTS.md b/AGENTS.md index 22f8f4d..892cc90 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -147,7 +147,7 @@ Tests run on multiple Python versions (3.10, 3.14) and OSes (Ubuntu, Windows), w Triggered on push to main: -1. **release-please job**: Creates/updates release PR and dispatches its required Python CI workflow +1. **release-please job**: Creates/updates release PR 2. **build job**: (on release PR merge) - Builds Python package with `uv build` - Verifies package version matches release @@ -156,6 +156,11 @@ Triggered on push to main: - Downloads build artifacts - Publishes to PyPI using OIDC (no tokens!) +The required Python CI check must come from a `pull_request` run. If `RELEASE_PLEASE_TOKEN` +is configured with access to create and update release PRs, GitHub starts those runs automatically. +With the default workflow token, a maintainer must use **Approve workflows to run** on the release PR. +Manually dispatching the Python CI workflow can help diagnose problems, but its checks do not satisfy branch protection. + ### OIDC Publishing to PyPI We use **OpenID Connect (OIDC)** for secure, credential-free PyPI publishing: From 61c50b81abb7f2224ac7f0c70ee137da2ee1a879 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Fri, 18 Sep 2026 10:37:57 -0700 Subject: [PATCH 5/6] ci(deps): isolate Windows npm installations --- .github/workflows/test.yml | 53 ++++---------------------------------- 1 file changed, 5 insertions(+), 48 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f697927..64b733e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -91,36 +91,12 @@ jobs: if: matrix.os == 'windows-latest' shell: pwsh run: | - # Configure cache location (applies immediately to this step) $cacheDir = Join-Path $env:RUNNER_TEMP "npm-cache" - New-Item -ItemType Directory -Force -Path $cacheDir | Out-Null - npm config set cache $cacheDir --location=user - - # Configure prefix location (applies immediately to this step) - $globalPrefix = npm config get prefix - if (-not $globalPrefix -or $globalPrefix -eq "undefined") { - $globalPrefix = Join-Path $env:APPDATA "npm" - } - $globalPrefix = $globalPrefix.Trim() - npm config set prefix $globalPrefix --location=user - - # NOW clean and verify cache (cleans the correctly-configured cache) - npm cache clean --force - npm cache verify - - # Export settings for future steps + $globalPrefix = Join-Path $env:RUNNER_TEMP "npm-global" + New-Item -ItemType Directory -Force -Path $cacheDir, $globalPrefix | Out-Null "NPM_CONFIG_CACHE=$cacheDir" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append "NPM_CONFIG_PREFIX=$globalPrefix" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - "npm_config_prefix=$globalPrefix" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - - # Add global bin directories to PATH - $binPaths = @($globalPrefix, (Join-Path $globalPrefix "bin")) | Where-Object { Test-Path $_ } - foreach ($binPath in $binPaths) { - $binPath | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append - } - - Write-Host "npm cache: $cacheDir" - Write-Host "npm prefix: $globalPrefix" + $globalPrefix | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append - name: Install promptfoo globally run: npm install -g promptfoo@latest @@ -171,30 +147,11 @@ jobs: if: matrix.os == 'windows-latest' shell: pwsh run: | - # Configure cache location (applies immediately to this step) $cacheDir = Join-Path $env:RUNNER_TEMP "npm-cache" - New-Item -ItemType Directory -Force -Path $cacheDir | Out-Null - npm config set cache $cacheDir --location=user - - # Configure prefix location (applies immediately to this step) - $globalPrefix = npm config get prefix - if (-not $globalPrefix -or $globalPrefix -eq "undefined") { - $globalPrefix = Join-Path $env:APPDATA "npm" - } - $globalPrefix = $globalPrefix.Trim() - npm config set prefix $globalPrefix --location=user - - # NOW clean and verify cache (cleans the correctly-configured cache) - npm cache clean --force - npm cache verify - - # Export settings for future steps + $globalPrefix = Join-Path $env:RUNNER_TEMP "npm-global" + New-Item -ItemType Directory -Force -Path $cacheDir, $globalPrefix | Out-Null "NPM_CONFIG_CACHE=$cacheDir" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append "NPM_CONFIG_PREFIX=$globalPrefix" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - "npm_config_prefix=$globalPrefix" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - - Write-Host "npm cache: $cacheDir" - Write-Host "npm prefix: $globalPrefix" # Intentionally skip installing promptfoo globally # This tests the npx fallback path From a7a2e7f25a81d381e4d56b47ff771434deafbe8f Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Fri, 18 Sep 2026 10:55:03 -0700 Subject: [PATCH 6/6] fix(deps): disable unused setup-node caching --- .github/workflows/test.yml | 2 ++ README.md | 4 ++-- src/promptfoo/instructions.py | 4 ++-- tests/test_instructions.py | 3 ++- 4 files changed, 8 insertions(+), 5 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 64b733e..20980cc 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -86,6 +86,7 @@ jobs: - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: "24" + package-manager-cache: false - name: Configure npm on Windows if: matrix.os == 'windows-latest' @@ -142,6 +143,7 @@ jobs: - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: "24" + package-manager-cache: false - name: Configure npm on Windows if: matrix.os == 'windows-latest' diff --git a/README.md b/README.md index b707a4c..092592f 100644 --- a/README.md +++ b/README.md @@ -151,9 +151,9 @@ promptfoo eval ```yaml - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: - node-version: "20" + node-version: "24" - name: Install promptfoo run: pip install promptfoo diff --git a/src/promptfoo/instructions.py b/src/promptfoo/instructions.py index dfe1994..09bf99e 100644 --- a/src/promptfoo/instructions.py +++ b/src/promptfoo/instructions.py @@ -116,9 +116,9 @@ def _get_ci_instructions(env: Environment) -> list[str]: lines.extend( [ "Add Node.js to your workflow:", - " - uses: actions/setup-node@v4", + " - uses: actions/setup-node@v7", " with:", - " node-version: '20'", + " node-version: '24'", ] ) elif env.ci_platform == "gitlab": diff --git a/tests/test_instructions.py b/tests/test_instructions.py index 999f2ce..6b64f2d 100644 --- a/tests/test_instructions.py +++ b/tests/test_instructions.py @@ -70,7 +70,8 @@ def test_github_actions_instructions(self) -> None: instructions = get_installation_instructions(env) - assert "actions/setup-node" in instructions + assert "actions/setup-node@v7" in instructions + assert "node-version: '24'" in instructions assert "GITHUB" in instructions.upper() def test_gitlab_ci_instructions(self) -> None: