diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c3657dc --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,157 @@ +name: CI + +# Execution-based test gate for the sample apps. +# +# Every app is built (compile + typecheck) and then driven with a smoke check +# that boots the built app and asserts it responds. Pinecone is MOCKED or GATED +# with a dummy key — CI needs no live Pinecone connection and no real API key +# (per AGENTS.md). See TESTING.md for the pattern and the manual +# "run against real Pinecone" path. +# +# The LIVE validation lane (real Pinecone in CI) is intentionally NOT here yet: +# it is blocked on PIN-50 (PINECONE_API_KEY secret, owner jhamon@pinecone.io). +# Add it as a separate, secret-gated job when PIN-50 lands. + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + # Dummy, non-secret placeholders. The Pinecone clients only require the key to + # be a non-empty string at construction; no live call is made in these checks. + PINECONE_API_KEY: pclocal-ci-dummy-key + NODE_VERSION: "20" + +jobs: + legal-semantic-search: + name: legal-semantic-search (Next.js) + runs-on: ubuntu-latest + defaults: + run: + working-directory: legal-semantic-search + env: + PINECONE_INDEX: ci-dummy-index + VOYAGE_API_KEY: ci-dummy-voyage + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + # Committed lockfile predates a langchain peer-dep bump; --legacy-peer-deps + # installs it the way the app is documented to install. + - run: npm install --legacy-peer-deps + - name: Build (next build — compiles + typechecks) + run: npm run build + - name: Smoke — boot app, expect HTTP 200 on / + run: bash ../scripts/ci/smoke_http.sh "http://127.0.0.1:3000/" 200 -- npx next start -p 3000 + + namespace-notes-client: + name: namespace-notes / client (Next.js) + runs-on: ubuntu-latest + defaults: + run: + working-directory: namespace-notes/client + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + - run: npm install --legacy-peer-deps + - name: Build (next build — compiles + typechecks) + run: npm run build + - name: Smoke — boot app, expect HTTP 200 on / + run: bash ../../scripts/ci/smoke_http.sh "http://127.0.0.1:3000/" 200 -- npx next start -p 3000 + + namespace-notes-server: + name: namespace-notes / server (Express + TS) + runs-on: ubuntu-latest + defaults: + run: + working-directory: namespace-notes/server + env: + PORT: "4001" + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + # `npm run build` runs `npm install --include=dev && tsc` per the repo. + - name: Build (install + tsc) + run: npm run build + # The server has no Pinecone-free route, so the smoke asserts the process + # boots, binds the port, and routes a request (any HTTP response = alive; + # the handler itself 500s because Pinecone is gated with a dummy key). + - name: Smoke — boot server, expect any HTTP response + run: bash ../../scripts/ci/smoke_http.sh "http://127.0.0.1:4001/api/documents/files/ci-smoke" any -- node dist/index.js + + pinecone-assistant: + name: pinecone-assistant (Next.js + pnpm) + runs-on: ubuntu-latest + defaults: + run: + working-directory: pinecone-assistant + env: + # Skip pnpm's pre-run dependency verification, which otherwise fails the + # run on optional native build scripts (sharp) being blocked in CI. + NPM_CONFIG_VERIFY_DEPS_BEFORE_RUN: "false" + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 9 + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: pnpm + cache-dependency-path: pinecone-assistant/pnpm-lock.yaml + - run: pnpm install --frozen-lockfile + - name: Build (next build — compiles + typechecks) + run: pnpm run build + - name: Smoke — boot app, expect HTTP 200 on / + run: bash ../scripts/ci/smoke_http.sh "http://127.0.0.1:3000/" 200 -- pnpm exec next start -p 3000 + + shop-the-look-web: + name: shop-the-look / web (Next.js) + runs-on: ubuntu-latest + defaults: + run: + working-directory: shop-the-look + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + - run: npm ci + - name: Build (next build — compiles + typechecks) + run: npm run build + - name: Smoke — boot app, expect HTTP 200 on / + run: bash ../scripts/ci/smoke_http.sh "http://127.0.0.1:3000/" 200 -- npx next start -p 3000 + + shop-the-look-api: + name: shop-the-look / api (FastAPI + Python) + runs-on: ubuntu-latest + defaults: + run: + working-directory: shop-the-look + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Install (runtime + test deps) + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt -r requirements-dev.txt + # Execution-based smoke: boots the FastAPI app via TestClient with Pinecone + # mocked, and asserts the root route serves. No live Pinecone needed. + - name: Smoke — pytest (Pinecone mocked) + run: python -m pytest tests/ -q diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 0000000..14f4e55 --- /dev/null +++ b/TESTING.md @@ -0,0 +1,102 @@ +# Testing & CI — the sample-apps test gate + +This repo ships **no versioned artifact**. Its value is that every sample app +still **compiles, boots, and behaves**. CI enforces that with an +**execution-based test gate**: each app is built and then *driven* while +running. This document is the reusable pattern — other Pinecone example repos +should adopt the same shape. + +## What CI does + +Workflow: [`.github/workflows/ci.yml`](.github/workflows/ci.yml). One job per +app (they run in parallel): + +| App | Build | Smoke / behavior check | +|-----|-------|------------------------| +| `legal-semantic-search` | `next build` | boot `next start`, expect **HTTP 200** on `/` | +| `namespace-notes/client` | `next build` | boot `next start`, expect **HTTP 200** on `/` | +| `namespace-notes/server` | `npm install --include=dev && tsc` | boot `node dist/index.js`, expect **any HTTP response** (process alive + routing) | +| `pinecone-assistant` | `next build` (pnpm) | boot `next start`, expect **HTTP 200** on `/` | +| `shop-the-look/web` | `next build` | boot `next start`, expect **HTTP 200** on `/` | +| `shop-the-look/api` | pip install | `pytest` boots the FastAPI app (Pinecone mocked) and asserts the root route serves | + +**Build** catches the failure that matters most for an example: source that no +longer compiles or typechecks against its pinned Pinecone client. **Smoke** then +proves the built app actually starts and serves — not just that `tsc`/`next +build` was happy. + +## Pinecone is mocked or gated — never live in CI + +Per the repo `AGENTS.md` rule, CI does **not** connect to Pinecone: + +- **Node apps** get a **dummy** `PINECONE_API_KEY` (`pclocal-ci-dummy-key`). The + Pinecone TS client only requires the key to be a non-empty string at + construction, so the app boots; the homepage smoke never triggers a live call. + The `namespace-notes` server *does* reach Pinecone on its document routes, so + its smoke accepts **any** HTTP response — a `500` from the gated client still + proves the process booted and routes requests. +- **Python (`shop-the-look/api`)** mocks `pinecone.Pinecone` in + [`shop-the-look/tests/test_smoke.py`](shop-the-look/tests/test_smoke.py) and + sets dummy `PINECONE_*` env, then drives the app with FastAPI's `TestClient`. + +No real key is ever committed or required for the gate. + +## The reusable smoke helper + +[`scripts/ci/smoke_http.sh`](scripts/ci/smoke_http.sh) boots a server command, +polls an HTTP endpoint until it responds (or times out), then shuts it down: + +```bash +scripts/ci/smoke_http.sh <200|any> -- +``` + +- `200` — require HTTP 200 (static homepage that doesn't need Pinecone). +- `any` — accept any HTTP response (proves the process booted and is routing, + even if the handler errors because Pinecone is gated). + +Reuse this in any Node/Python example repo that boots an HTTP server. + +## Run the gate locally + +```bash +# Node app (example: legal-semantic-search) +cd legal-semantic-search +npm install --legacy-peer-deps +PINECONE_API_KEY=pclocal-ci-dummy-key PINECONE_INDEX=ci-dummy-index VOYAGE_API_KEY=ci-dummy \ + npm run build +PINECONE_API_KEY=pclocal-ci-dummy-key \ + bash ../scripts/ci/smoke_http.sh "http://127.0.0.1:3000/" 200 -- npx next start -p 3000 + +# Python api (shop-the-look) +cd shop-the-look +pip install -r requirements.txt -r requirements-dev.txt +python -m pytest tests/ -q +``` + +## Run against real Pinecone (manual) + +The gate above is intentionally offline. To exercise an app end-to-end against a +real Pinecone project, follow the app's own README, then export real credentials +in your shell instead of the dummy values: + +```bash +export PINECONE_API_KEY="" # from https://app.pinecone.io +export PINECONE_INDEX="" # legal-semantic-search +export PINECONE_INDEX_NAME="" # shop-the-look/api +export PINECONE_TOP_K=5 # shop-the-look/api +# ...plus any app-specific keys (VOYAGE_API_KEY, OpenAI, Google, etc.) +npm run dev # or: uvicorn api.index:app --reload (shop-the-look/api) +``` + +For the Python smoke, running against real Pinecone means dropping the +`mock.patch("pinecone.Pinecone")` in `tests/test_smoke.py` and exporting the real +`PINECONE_*` env before `pytest`. + +## Live CI lane — blocked on PIN-50 + +A **live** validation lane (real Pinecone in CI, gated on a `PINECONE_API_KEY` +repository secret) is deliberately **not** wired up yet. It is blocked on +**PIN-50** (provision `PINECONE_API_KEY`, owner: jhamon@pinecone.io). When PIN-50 +lands, add a separate job that runs only when the secret is present (self-skips +otherwise), following the `langchain-retrieval-agent-example` / `examples` +pattern from the DevRel OSS inventory. diff --git a/namespace-notes/package.json b/namespace-notes/package.json index 948cb1b..327dd1b 100644 --- a/namespace-notes/package.json +++ b/namespace-notes/package.json @@ -4,10 +4,10 @@ "description": "Sample app for namespace notes", "scripts": { "install:client": "cd client && npm install", - "install:server": "cd server/node && npm install", + "install:server": "cd server && npm install", "install:all": "npm run install:client && npm run install:server", "start:client": "cd client && npm start", - "start:server": "cd server/node && npm start" + "start:server": "cd server && npm start" }, "devDependencies": { "concurrently": "^8.2.2" diff --git a/scripts/ci/smoke_http.sh b/scripts/ci/smoke_http.sh new file mode 100755 index 0000000..ce0e13a --- /dev/null +++ b/scripts/ci/smoke_http.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# +# Boot a server, wait until an HTTP endpoint responds, then shut it down. +# This is the reusable "drive the running app" step of the sample-apps test gate. +# Pinecone is expected to be MOCKED or GATED (dummy key) by the caller — this +# script never needs a live Pinecone connection. +# +# Usage: +# smoke_http.sh -- +# URL to poll (e.g. http://127.0.0.1:3000/) +# "200" to require HTTP 200, or "any" to accept any HTTP response +# ("any" proves the process booted and is routing, even if the +# handler errors because Pinecone is gated). +# +set -uo pipefail + +URL="${1:?url required}" +EXPECT="${2:?expect required}" +shift 2 +[ "${1:-}" = "--" ] && shift + +echo "::group::smoke: booting server for $URL (expect=$EXPECT)" +"$@" & +PID=$! +cleanup() { kill "$PID" 2>/dev/null; wait "$PID" 2>/dev/null; } +trap cleanup EXIT + +ATTEMPTS=60 +for i in $(seq 1 "$ATTEMPTS"); do + if ! kill -0 "$PID" 2>/dev/null; then + echo "::error::server process exited before responding" + echo "::endgroup::" + exit 1 + fi + # curl prints '000' via -w on connection failure; do not append another. + CODE=$(curl -s -o /dev/null -w '%{http_code}' "$URL" 2>/dev/null) + CODE=${CODE:-000} + if [ "$CODE" != "000" ]; then + if [ "$EXPECT" = "any" ] || [ "$CODE" = "$EXPECT" ]; then + echo "smoke OK: $URL -> HTTP $CODE (attempt $i)" + echo "::endgroup::" + exit 0 + fi + echo " attempt $i: got HTTP $CODE, want $EXPECT — retrying" + else + echo " attempt $i: no response yet — retrying" + fi + sleep 2 +done + +echo "::error::smoke FAILED: $URL never returned expected status ($EXPECT) after $ATTEMPTS attempts" +echo "::endgroup::" +exit 1 diff --git a/shop-the-look/requirements-dev.txt b/shop-the-look/requirements-dev.txt new file mode 100644 index 0000000..d0276b7 --- /dev/null +++ b/shop-the-look/requirements-dev.txt @@ -0,0 +1,3 @@ +# Test-only dependencies for the execution-based smoke gate (see TESTING.md). +pytest==8.3.4 +httpx==0.28.1 diff --git a/shop-the-look/tests/test_smoke.py b/shop-the-look/tests/test_smoke.py new file mode 100644 index 0000000..7e1b873 --- /dev/null +++ b/shop-the-look/tests/test_smoke.py @@ -0,0 +1,35 @@ +""" +Execution-based smoke test for the Shop The Look FastAPI backend. + +Proves the app boots and serves requests with Pinecone **mocked** — no live +Pinecone connection and no API key required (per the repo AGENTS.md rule that +Pinecone calls are mocked or gated when no key is present in CI). + +`api/deps.py` constructs `Pinecone(...)` and `pc.Index(...)` at import time, and +`api/config.py` reads several env vars at import time, so we set dummy env and +patch `pinecone.Pinecone` before importing the app. + +To run against REAL Pinecone instead, see TESTING.md ("run against real +Pinecone"): export real PINECONE_* env vars and delete the patch. +""" +import os +from unittest import mock + +# Dummy env consumed at import time by api/config.py. Never a real key. +os.environ.setdefault("PINECONE_API_KEY", "pclocal-ci-dummy-key") +os.environ.setdefault("PINECONE_INDEX_NAME", "ci-dummy-index") +os.environ.setdefault("PINECONE_TOP_K", "5") + + +def test_app_boots_and_serves_root_with_mocked_pinecone(): + with mock.patch("pinecone.Pinecone") as MockPinecone: + MockPinecone.return_value.Index.return_value = mock.MagicMock() + + from api.index import app + from fastapi.testclient import TestClient + + client = TestClient(app) + resp = client.get("/api") + + assert resp.status_code == 200, resp.text + assert "Shop The Look" in resp.json()["message"]