diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..ac2ee34 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,25 @@ +version: 2 +updates: + # Keep GitHub Actions pinned to immutable commit SHAs up-to-date. + # Dependabot opens a PR whenever a newer SHA is available for a pinned action. + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + labels: + - "dependencies" + + # Keep Python runtime dependencies up-to-date within the bounded ranges in + # pyproject.toml [project.dependencies] (requirements.txt must stay in sync). + # Dependabot opens a PR when a newer version fits those bounds — it does NOT + # refresh requirements-lock.txt. CI installs from the lock, so after merging a + # Dependabot pip PR you must regenerate the lock: run the "Update dependency + # lock file" workflow (Actions tab) or pip-compile locally (see README). + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + labels: + - "dependencies" diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 428228d..4a2a1e2 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -16,6 +16,61 @@ concurrency: cancel-in-progress: true jobs: + # ── Lock file + requirements sync (closes #47) ─────────────────────────── + lockfile: + name: Lock file freshness + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.12" + + - name: Verify requirements.txt matches pyproject.toml + run: | + python <<'PY' + import sys + import tomllib + from pathlib import Path + + with open("pyproject.toml", "rb") as f: + py_deps = tomllib.load(f)["project"]["dependencies"] + req_deps = [ + line.strip() + for line in Path("requirements.txt").read_text().splitlines() + if line.strip() and not line.strip().startswith("#") + ] + if sorted(py_deps) != sorted(req_deps): + print( + "requirements.txt [project.dependencies] drift from pyproject.toml", + file=sys.stderr, + ) + print("pyproject.toml:", sorted(py_deps), file=sys.stderr) + print("requirements.txt:", sorted(req_deps), file=sys.stderr) + sys.exit(1) + PY + + - name: Install pip-tools + # Pin matches update-lock.yml so lock verification uses the same resolver. + run: python -m pip install 'pip-tools==7.5.3' + + - name: Verify requirements-lock.txt is up to date + # Same pip-compile flags as update-lock.yml, without --upgrade. + run: | + pip-compile requirements.txt \ + --output-file /tmp/requirements-lock.txt \ + --no-header \ + --annotation-style=line \ + --allow-unsafe \ + --quiet + diff -u \ + <(grep -E '^[A-Za-z0-9_.-]+==' requirements-lock.txt | sort) \ + <(grep -E '^[A-Za-z0-9_.-]+==' /tmp/requirements-lock.txt | sort) + # ── Unit tests: matrix across OS and Python version ─────────────────────── # Closes #13. The unittest suite is the merge gate. Multi-OS catches the # rare path / line-ending issue that a single-OS run hides; multi-Python @@ -41,12 +96,15 @@ jobs: python-version: ${{ matrix.python-version }} - name: Install runtime + test dependencies - # Only what the tests actually exercise. `pywebview` from - # requirements.txt is the desktop-launcher dep and pulls GTK / Qt - # system packages on Linux — out of scope for the unittest suite. + # Install from the pinned lock file for deterministic dependency + # resolution (closes #47). pytest is added on top — it is not in + # requirements-lock.txt because it is a dev-only dep. pywebview is + # the desktop-launcher dep and pulls GTK / Qt system libraries on + # Linux — intentionally excluded from the CI unittest matrix. run: | python -m pip install --upgrade pip - python -m pip install 'flask>=3.0' 'fpdf2>=2.7' 'pytest>=8' + python -m pip install -r requirements-lock.txt + python -m pip install 'pytest>=8,<9' - name: Run unittest suite run: python -m unittest discover tests -v @@ -78,9 +136,12 @@ jobs: python-version: "3.12" - name: Install runtime deps + mypy + # Install from the pinned lock file for deterministic resolution, + # then add mypy (dev-only; not in requirements-lock.txt). run: | python -m pip install --upgrade pip - python -m pip install 'flask>=3.0' 'fpdf2>=2.7' 'mypy>=1.10' + python -m pip install -r requirements-lock.txt + python -m pip install 'mypy>=1.10,<2' - name: Run mypy # No `continue-on-error` — mypy now exits zero on this repo (closes #29), diff --git a/.github/workflows/update-lock.yml b/.github/workflows/update-lock.yml new file mode 100644 index 0000000..074f9a9 --- /dev/null +++ b/.github/workflows/update-lock.yml @@ -0,0 +1,74 @@ +name: Update dependency lock file + +on: + # Run every Monday at 08:00 UTC — picks up upstream patch / security + # releases that land within the bounded ranges in requirements.txt. + schedule: + - cron: "0 8 * * 1" + # Allow manual trigger from the Actions tab for ad-hoc refreshes. + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +jobs: + update-lock: + name: Regenerate requirements-lock.txt + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.12" + + - name: Install pip-tools + # Pin matches tests.yml lockfile job so lock generation and verification agree. + run: python -m pip install 'pip-tools==7.5.3' + + - name: Regenerate lock file + run: | + pip-compile requirements.txt \ + --output-file requirements-lock.txt \ + --no-header \ + --annotation-style=line \ + --allow-unsafe \ + --upgrade + + - name: Prepend lock file header + # pip-compile --no-header strips our docs header every run; restore via + # heredoc (single-quoted HEADER=... would leave literal \n characters). + run: | + cat > /tmp/lock-header <<'EOF' + # Pinned lock file — generated by pip-compile (pip-tools). + # Install: pip install -r requirements-lock.txt + # Update: pip-compile requirements.txt --output-file requirements-lock.txt --no-header --annotation-style=line --allow-unsafe --upgrade + # Run periodically (e.g. via the "Update dependency lock file" CI workflow) to pick up + # upstream patch / security releases within the bounded ranges in requirements.txt. + EOF + cat /tmp/lock-header requirements-lock.txt > /tmp/lock.tmp + mv /tmp/lock.tmp requirements-lock.txt + + - name: Open PR if lock file changed + uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7 + with: + commit-message: "chore: update requirements-lock.txt" + branch: "chore/update-lock-file" + delete-branch: true + title: "chore: update dependency lock file" + body: | + Automated weekly refresh of `requirements-lock.txt`. + + Generated by `pip-compile --upgrade` from the bounded specifiers + in `requirements.txt` (must match `pyproject.toml` `[project.dependencies]`). + + **Dependabot pip PRs** may bump bounds in `requirements.txt` / `pyproject.toml` + but do not regenerate this lock file — merge those first, then merge this PR + (or run **Actions → Update dependency lock file → Run workflow**). + + Review the diff to confirm no unexpected major-version jumps before merging. + labels: dependencies diff --git a/README.md b/README.md index 9c63939..a6354a2 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,36 @@ source venv/bin/activate pip install -r requirements.txt ``` +For reproducible installs (same versions as CI), use the pinned lock file: + +```bash +pip install -r requirements-lock.txt +``` + +### Dependency bounds and lock file + +Runtime version **bounds** live in `pyproject.toml` under `[project.dependencies]` (`flask`, `fpdf2`, `pillow`, etc.). `requirements.txt` mirrors those specifiers for backward compatibility — keep them identical when you change deps. + +**CI** installs from `requirements-lock.txt`, which pins exact versions (including transitive packages). The lock is produced on **Linux** (same as CI and `update-lock.yml`); `pip-compile` on Windows may add platform-only pins such as `colorama` — do not commit those. + +Regenerate after editing bounds (prefer **Actions → Update dependency lock file → Run workflow**, or on Linux / WSL): + +```bash +pip install pip-tools +pip-compile requirements.txt \ + --output-file requirements-lock.txt \ + --no-header \ + --annotation-style=line \ + --allow-unsafe +``` + +Then restore the comment header at the top of `requirements-lock.txt` (see the existing file) and commit both `requirements.txt` / `pyproject.toml` and `requirements-lock.txt`. + +**Automated updates:** + +- **Dependabot** (`.github/dependabot.yml`) — weekly PRs for `pip` and `github-actions` when newer versions fit the declared bounds. Merging a Dependabot **pip** PR does **not** refresh the lock file; run the lock workflow or `pip-compile` locally afterward. +- **Update dependency lock file** (`.github/workflows/update-lock.yml`) — scheduled Mondays 08:00 UTC (and manual **Actions → Run workflow**) runs `pip-compile --upgrade` and opens a PR with an updated `requirements-lock.txt`. + ## Quick Start (Web UI) ```bash @@ -73,7 +103,7 @@ The Werkzeug debugger is **off by default** and must be opted in explicitly via ## Tests -Run the full suite from the repository root (install `requirements.txt` first): +Run the full suite from the repository root (install `requirements-lock.txt` or `requirements.txt` first): ```bash python -m unittest discover tests -v @@ -147,7 +177,9 @@ Cursor CLI agent sessions are read from `~/.cursor/chats/` (the default path use ``` cursor-chat-browser-python/ ├── app.py # Flask application entry point -├── requirements.txt # Python dependencies +├── requirements.txt # Runtime bounds (mirrors pyproject.toml) +├── requirements-lock.txt # Pinned lock file used by CI +├── pyproject.toml # Package metadata and canonical dependency bounds ├── api/ # API route blueprints │ ├── workspaces.py # /api/workspaces endpoints │ ├── composers.py # /api/composers endpoints diff --git a/pyproject.toml b/pyproject.toml index 5f03f9f..6e8aa62 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,9 +18,9 @@ requires-python = ">=3.10" dependencies = [ "flask>=3.0,<4", "fpdf2>=2.7,<3", - # Security floor: fpdf2 allows Pillow>=8.3.2, so 9.x can still be resolved. - # CVE-2024-28219 (buffer overflow) fixed in Pillow 10.3.0 — https://nvd.nist.gov/vuln/detail/CVE-2024-28219 - "pillow>=10.3.0", + # Security floor: fpdf2 allows Pillow>=8.3.2 (no upper cap); pin 12.x to avoid + # known high-severity CVEs in Pillow 10.x (e.g. CVE-2024-28219 and later advisories). + "pillow>=12.2.0,<13", ] [project.optional-dependencies] diff --git a/requirements-lock.txt b/requirements-lock.txt new file mode 100644 index 0000000..e1c0759 --- /dev/null +++ b/requirements-lock.txt @@ -0,0 +1,18 @@ +# Pinned lock file — generated by pip-compile (pip-tools). +# Install: pip install -r requirements-lock.txt +# Update: pip-compile requirements.txt --output-file requirements-lock.txt --no-header --annotation-style=line --allow-unsafe --upgrade +# Run periodically (e.g. via the "Update dependency lock file" CI workflow) to pick up +# upstream patch / security releases within the bounded ranges in requirements.txt. +# Lock is generated on Linux (CI / update-lock.yml). Windows-only transitives (e.g. +# colorama via click) are omitted — pip still installs them on Windows when needed. +blinker==1.9.0 # via flask +click==8.4.0 # via flask +defusedxml==0.7.1 # via fpdf2 +flask==3.1.3 # via -r requirements.txt +fonttools==4.63.0 # via fpdf2 +fpdf2==2.8.7 # via -r requirements.txt +itsdangerous==2.2.0 # via flask +jinja2==3.1.6 # via flask +markupsafe==3.0.3 # via flask, jinja2, werkzeug +pillow==12.2.0 # via -r requirements.txt, fpdf2 +werkzeug==3.1.8 # via flask diff --git a/requirements.txt b/requirements.txt index 17e3882..f08a70a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,5 +6,5 @@ # pip install -e ".[desktop]" (+ pywebview for the GUI launcher) flask>=3.0,<4 fpdf2>=2.7,<3 -pillow>=10.3.0 +pillow>=12.2.0,<13 # pywebview is desktop-only — install with: pip install -e ".[desktop]"