From aec44c1382da63d841d56184a7602925328fbfd4 Mon Sep 17 00:00:00 2001 From: Ben Davidson Date: Mon, 26 Oct 2026 16:26:01 +1030 Subject: [PATCH 1/4] Stop Dependabot PRs failing CI for structural reasons Three causes failed most Dependabot PRs regardless of their content. - Runtime pins: Dependabot's uv ecosystem widens `~=X.Y.Z` to a range whatever `versioning-strategy` says. `check_runtime_pins.py --fix` rewrites offenders to `~=` the locked version, and the new dependabot-pins workflow applies it to Dependabot PRs and re-locks. The job that resolves packages holds no secret and runs `uv lock --no-build`; the push job runs only git with hooks off, refuses a patch changing anything but pyproject.toml and uv.lock, and pushes with a deploy key the main ruleset does not exempt. - The pin check now covers every extra and the `semantic` group, which users install but nothing checked. - ruff: the pre-commit hooks run the ruff uv.lock resolves, so a lock bump can no longer drift from a separate `rev:`. check_ruff_sync.py is gone. - pip-audit: on a PR, advisories the base branch already carries are reported as warnings and skipped, so a new CVE in an untouched transitive dependency no longer reds every open PR. Push and cron audits on main stay strict. --- .github/dependabot.yml | 5 +- .github/scripts/base_vulns.py | 44 +++++++ .github/scripts/check_ruff_sync.py | 55 --------- .github/scripts/check_runtime_pins.py | 77 +++++++++++- .github/workflows/ci.yml | 2 - .github/workflows/dependabot-pins.yml | 90 ++++++++++++++ .github/workflows/security.yml | 24 +++- .pre-commit-config.yaml | 18 ++- SECURITY.md | 11 +- ...r_audit_skips_what_main_already_carries.py | 46 +++++++ .../test_a_widened_runtime_pin_is_restored.py | 113 ++++++++++++++++++ 11 files changed, 409 insertions(+), 76 deletions(-) create mode 100644 .github/scripts/base_vulns.py delete mode 100644 .github/scripts/check_ruff_sync.py create mode 100644 .github/workflows/dependabot-pins.yml create mode 100644 tests/test_a_pr_audit_skips_what_main_already_carries.py create mode 100644 tests/test_a_widened_runtime_pin_is_restored.py diff --git a/.github/dependabot.yml b/.github/dependabot.yml index fc7039dd..d066a73d 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -23,9 +23,8 @@ updates: day: "monday" commit-message: prefix: "chore(deps)" - # Ineffective for the uv ecosystem as of 2026-08-31: Dependabot widens - # `~=X.Y.Z` to a range regardless (#134 twice, #138). The pin is enforced - # by .github/scripts/check_runtime_pins.py, not by this setting. + # Ineffective for uv: Dependabot widens `~=X.Y.Z` to a range regardless. + # check_runtime_pins.py enforces the pin; dependabot-pins.yml restores it. versioning-strategy: "increase" groups: dev-tooling: diff --git a/.github/scripts/base_vulns.py b/.github/scripts/base_vulns.py new file mode 100644 index 00000000..3f495ca8 --- /dev/null +++ b/.github/scripts/base_vulns.py @@ -0,0 +1,44 @@ +"""Print `--ignore-vuln ID` for every vulnerability a base-branch pip-audit JSON report found. + +A PR audit then fails only on what the PR introduces; the push and cron audits on +main stay strict, so an advisory against an untouched dependency still surfaces there. +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + + +def base_vulns(report: dict[str, object]) -> list[tuple[str, str, str]]: + """Return (package, version, id) for every vulnerability in a pip-audit JSON report.""" + found: list[tuple[str, str, str]] = [] + deps = report.get("dependencies", []) + assert isinstance(deps, list) + for dep in deps: + assert isinstance(dep, dict) + for vuln in dep.get("vulns", []): + found.append((dep["name"], dep["version"], vuln["id"])) + return found + + +def main(path: str) -> int: + report = Path(path) + # No report means the base audit errored; ignoring nothing keeps the PR audit strict. + if not report.is_file(): + print(f"::warning::no base audit at {path}; auditing the PR strictly", file=sys.stderr) + return 0 + + for package, version, vuln_id in base_vulns(json.loads(report.read_text(encoding="utf-8"))): + print( + f"::warning::{package} {version} {vuln_id} is already on the base branch", + file=sys.stderr, + ) + print("--ignore-vuln") + print(vuln_id) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1])) diff --git a/.github/scripts/check_ruff_sync.py b/.github/scripts/check_ruff_sync.py deleted file mode 100644 index 6118b4c6..00000000 --- a/.github/scripts/check_ruff_sync.py +++ /dev/null @@ -1,55 +0,0 @@ -"""The ruff-pre-commit rev must match the ruff version uv.lock resolves.""" - -from __future__ import annotations - -import re -import sys -import tomllib -from pathlib import Path - -REV = re.compile( - r"-\s+repo:\s*https://github\.com/astral-sh/ruff-pre-commit.*?^\s*rev:\s*v?([\d.]+)", - re.S | re.M, -) - - -def versions(root: Path) -> tuple[str | None, str | None]: - """Return (pre-commit rev, uv.lock version) for ruff.""" - config = (root / ".pre-commit-config.yaml").read_text(encoding="utf-8") - match = REV.search(config) - - lock = tomllib.loads((root / "uv.lock").read_text(encoding="utf-8")) - packages = lock.get("package", []) - assert isinstance(packages, list) - locked = next( - (p["version"] for p in packages if isinstance(p, dict) and p.get("name") == "ruff"), - None, - ) - return (match.group(1) if match else None, locked) - - -def main() -> int: - hook, locked = versions(Path(__file__).resolve().parents[2]) - - if hook is None or locked is None: - print("::error::could not read the ruff version from both files") - return 1 - - if hook != locked: - print( - f"::error::ruff drift, .pre-commit-config.yaml pins v{hook}, uv.lock resolves {locked}" - ) - print( - "\nThe hook and CI must run the same ruff. Drift means the hook " - "reformats\nfiles that `uv run ruff format --check .` then rejects, or " - "crashes on code\nCI accepts. Bump `rev:` to match in the same PR as the " - "lock change." - ) - return 1 - - print(f"ruff in sync: v{locked}") - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/.github/scripts/check_runtime_pins.py b/.github/scripts/check_runtime_pins.py index 541ba472..c03236cb 100644 --- a/.github/scripts/check_runtime_pins.py +++ b/.github/scripts/check_runtime_pins.py @@ -1,4 +1,8 @@ -"""Every user-facing dependency pins the minor: `~=X.Y.Z`, never `~=X.Y` or a range.""" +"""Every user-facing dependency pins the minor: `~=X.Y.Z`, never `~=X.Y` or a range. + +`--fix` rewrites each offender to `~=` the version uv.lock resolves, which is how a +Dependabot PR (its uv ecosystem always widens to a range) gets its pin back. +""" from __future__ import annotations @@ -9,9 +13,11 @@ # Three-component compatible-release, extras allowed: `termaid[rich]~=0.6.1`. PIN = re.compile(r"^[A-Za-z0-9._-]+(\[[A-Za-z0-9,._-]+\])?~=\d+\.\d+\.\d+$") +NAME = re.compile(r"^([A-Za-z0-9._-]+)(\[[A-Za-z0-9,._-]+\])?") +RELEASE = re.compile(r"^(\d+)\.(\d+)(?:\.(\d+))?") -# `dev` never reaches a user install; every other group here does. -USER_FACING_GROUPS = ("pdf-structure",) +# `dev` never reaches a user install; these groups and every extra do. +USER_FACING_GROUPS = ("pdf-structure", "semantic") def offenders(pyproject: dict[str, object]) -> list[tuple[str, str]]: @@ -20,6 +26,11 @@ def offenders(pyproject: dict[str, object]) -> list[tuple[str, str]]: assert isinstance(project, dict) checked = [("project.dependencies", d) for d in project["dependencies"]] + extras = project.get("optional-dependencies", {}) + assert isinstance(extras, dict) + for name, deps in extras.items(): + checked += [(f"project.optional-dependencies.{name}", d) for d in deps] + groups = pyproject.get("dependency-groups", {}) assert isinstance(groups, dict) for name in USER_FACING_GROUPS: @@ -28,8 +39,61 @@ def offenders(pyproject: dict[str, object]) -> list[tuple[str, str]]: return [(where, spec) for where, spec in checked if not PIN.match(spec)] -def main() -> int: +def _normalise(name: str) -> str: + return re.sub(r"[-_.]+", "-", name).lower() + + +def pinned(spec: str, lock: dict[str, object]) -> str: + """Return `spec` rewritten as `name[extras]~=X.Y.Z` at the version uv.lock resolves.""" + match = NAME.match(spec) + if match is None: + raise SystemExit(f"::error::cannot read a package name from {spec!r}") + name, extras = match.group(1), match.group(2) or "" + + packages = lock.get("package", []) + assert isinstance(packages, list) + locked = next( + ( + p["version"] + for p in packages + if isinstance(p, dict) and _normalise(str(p.get("name"))) == _normalise(name) + ), + None, + ) + release = RELEASE.match(str(locked)) if locked is not None else None + if release is None: + raise SystemExit(f"::error::uv.lock resolves no release version for {name}") + + major, minor, micro = release.group(1), release.group(2), release.group(3) or "0" + return f"{name}{extras}~={major}.{minor}.{micro}" + + +def fix(root: Path) -> list[tuple[str, str]]: + """Rewrite every offender in pyproject.toml in place; return (old, new) per change.""" + path = root / "pyproject.toml" + text = path.read_text(encoding="utf-8") + lock = tomllib.loads((root / "uv.lock").read_text(encoding="utf-8")) + + # An extra mirrored as a group holds the same spec twice; both copies move together. + specs = dict.fromkeys(spec for _, spec in offenders(tomllib.loads(text))) + changes = [(spec, pinned(spec, lock)) for spec in specs] + for old, new in changes: + quoted = f'"{old}"' + if quoted not in text: + raise SystemExit(f"::error::{quoted} is not written that way in pyproject.toml") + text = text.replace(quoted, f'"{new}"') + + path.write_text(text, encoding="utf-8") + return changes + + +def main(argv: list[str]) -> int: root = Path(__file__).resolve().parents[2] + + if argv == ["--fix"]: + for old, new in fix(root): + print(f"{old} -> {new}") + pyproject = tomllib.loads((root / "pyproject.toml").read_text(encoding="utf-8")) bad = offenders(pyproject) @@ -41,7 +105,8 @@ def main() -> int: f"\n{len(bad)} user-facing dependency specifier(s) are not pinned.\n" "uv.lock reaches no installer, so these specifiers are what every user\n" "resolves. A widened range ships versions CI never ran.\n" - "Dependabot widens by default: correct the specifier before merging." + "Dependabot widens by default: `dependabot-pins.yml` restores the pin,\n" + "or run this script with `--fix` and then `uv lock`." ) return 1 @@ -50,4 +115,4 @@ def main() -> int: if __name__ == "__main__": - sys.exit(main()) + sys.exit(main(sys.argv[1:])) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bda33abd..f0248c2f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -74,8 +74,6 @@ jobs: - run: uv sync --frozen --group dev - name: Ruff format (check only) run: uv run ruff format --check . - - name: pre-commit and CI run the same ruff - run: python3 .github/scripts/check_ruff_sync.py typecheck: name: pyright (strict) diff --git a/.github/workflows/dependabot-pins.yml b/.github/workflows/dependabot-pins.yml new file mode 100644 index 00000000..a3433de1 --- /dev/null +++ b/.github/workflows/dependabot-pins.yml @@ -0,0 +1,90 @@ +name: dependabot-pins + +# Dependabot's uv ecosystem widens `~=X.Y.Z` to a range whatever +# `versioning-strategy` says; this re-pins its PR and re-locks. + +on: + pull_request: + branches: [main] + paths: [pyproject.toml] + +permissions: + contents: read + +concurrency: + group: dependabot-pins-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + repin: + name: restore runtime pins + # Holds no secret: the only job that resolves packages. + if: >- + github.actor == 'dependabot[bot]' && + github.event.pull_request.user.login == 'dependabot[bot]' && + github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + outputs: + changed: ${{ steps.diff.outputs.changed }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ github.head_ref }} + persist-credentials: false + - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 + - run: uv python install 3.13 + - run: python3 .github/scripts/check_runtime_pins.py --fix + - run: uv lock --no-build + - id: diff + run: | + git diff --binary -- pyproject.toml uv.lock > repin.patch + # A lock-only diff is a uv format difference, not a restored pin. + if ! git diff --quiet -- pyproject.toml; then echo "changed=true" >> "$GITHUB_OUTPUT"; fi + - if: steps.diff.outputs.changed == 'true' + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 + with: + name: repin-patch + path: repin.patch + retention-days: 1 + + push: + name: push restored pins + # Runs only git, hooks off; the deploy key cannot bypass the main ruleset. + needs: repin + if: needs.repin.outputs.changed == 'true' + runs-on: ubuntu-latest + steps: + - name: Require the deploy key + # Without it checkout falls back to the read-only GITHUB_TOKEN. + env: + KEY: ${{ secrets.PIN_FIX_DEPLOY_KEY }} + run: | + if [ -z "$KEY" ]; then + echo "::error::PIN_FIX_DEPLOY_KEY is not set as a Dependabot secret" + exit 1 + fi + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ github.head_ref }} + ssh-key: ${{ secrets.PIN_FIX_DEPLOY_KEY }} + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 + with: + name: repin-patch + path: ${{ runner.temp }} + - name: Apply and push + env: + PATCH: ${{ runner.temp }}/repin.patch + HEAD_REF: ${{ github.head_ref }} + run: | + git apply "$PATCH" + # Porcelain -z lists every path whole (rename sources too); --summary adds mode changes. + extra="$(git status --porcelain -z | tr '\0' '\n' | grep -vxE ' M (pyproject\.toml|uv\.lock)' || true)$(git diff --summary)" + if [ -n "$extra" ]; then + echo "::error::the re-pin patch changes more than pyproject.toml and uv.lock:" + echo "$extra" + exit 1 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git -c core.hooksPath=/dev/null commit --no-verify -m "chore(deps): restore the ~=X.Y.Z runtime pin" -- pyproject.toml uv.lock + git push origin "HEAD:${HEAD_REF}" diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index a181ca61..b666d702 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -44,8 +44,30 @@ jobs: --no-hashes \ --no-emit-project \ -o requirements.frozen.txt + - name: Check out the base branch + if: github.event_name == 'pull_request' + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ github.event.pull_request.base.sha }} + path: base + persist-credentials: false + - name: Audit the base branch + # Its findings are what main already carries; the PR audit skips them. + if: github.event_name == 'pull_request' + run: | + uv export --project base \ + --format requirements-txt \ + --no-hashes \ + --no-emit-project \ + -o base.frozen.txt + uv tool run pip-audit -r base.frozen.txt --strict --format json -o base-audit.json || true - name: Audit - run: uv tool run pip-audit -r requirements.frozen.txt --strict + run: | + ignores=() + if [ -d base ]; then + mapfile -t ignores < <(python3 .github/scripts/base_vulns.py base-audit.json) + fi + uv tool run pip-audit -r requirements.frozen.txt --strict "${ignores[@]}" ruff-security: name: ruff (bandit ruleset) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c9a0103d..e1c98b0a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -13,15 +13,21 @@ repos: - id: check-added-large-files args: ["--maxkb=2000"] - - repo: https://github.com/astral-sh/ruff-pre-commit - # Keep in sync with the ruff pin in pyproject.toml dev deps; CI runs - # `uv run ruff format --check .` so drift here will reformat files - # locally that CI then rejects. - rev: v0.17.0 + - repo: local + # The ruff uv.lock resolves, so the hook and CI cannot run different versions. hooks: - id: ruff-format + name: ruff format + entry: uv run --frozen ruff format --force-exclude + language: system + types_or: [python, pyi, jupyter] + require_serial: true - id: ruff - args: ["--fix"] + name: ruff check + entry: uv run --frozen ruff check --force-exclude --fix + language: system + types_or: [python, pyi, jupyter] + require_serial: true - repo: local # Each pre-push check skips a tree `make batch-close` already passed diff --git a/SECURITY.md b/SECURITY.md index a6fec041..169ed7cb 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -81,8 +81,10 @@ Out of scope (today): `deps-latest.yml` is the deliberate exception: it runs `uv lock --upgrade` first, precisely to test outside the lock. - CI runs `pip-audit --strict` against the exported lockfile on every - push and weekly on a cron (`.github/workflows/security.yml`). The - build fails on any HIGH/CRITICAL CVE in a direct dep. + push to main and weekly on a cron (`.github/workflows/security.yml`). + On a PR it fails only on advisories the PR introduces: findings the + base branch already carries are listed as warnings and left to the + main audit and Dependabot security updates. - `.github/workflows/ci.yml` runs `check_runtime_pins.py` on every PR and it is a required check: a user-facing dependency that is not pinned `~=X.Y.Z` fails the build. Dependabot rewrites these pins into @@ -90,7 +92,10 @@ Out of scope (today): stop it, so this guard, not the Dependabot config, is what holds the convention. A patch bump is lock-only and passes untouched; a minor or major bump falls outside the pin, so Dependabot rewrites the range - and the specifier needs correcting by hand in that PR. + and `.github/workflows/dependabot-pins.yml` pushes the `~=` pin back + onto that PR (it needs the `PIN_FIX_DEPLOY_KEY` Dependabot secret). + Dependabot then treats the PR as edited and stops rebasing it; use + `@dependabot recreate`, after which the pin is restored again. - `.github/workflows/deps-latest.yml` runs weekly, resolves every dependency at its newest allowed version ignoring `uv.lock`, and runs the suite against it. With `~=X.Y.Z` pins that is the patch float: diff --git a/tests/test_a_pr_audit_skips_what_main_already_carries.py b/tests/test_a_pr_audit_skips_what_main_already_carries.py new file mode 100644 index 00000000..2131d0ca --- /dev/null +++ b/tests/test_a_pr_audit_skips_what_main_already_carries.py @@ -0,0 +1,46 @@ +"""`base_vulns.py` ignores exactly the base branch's findings, and nothing when the base audit failed.""" + +from __future__ import annotations + +import importlib.util +import json +from pathlib import Path + +import pytest + +_ROOT = Path(__file__).resolve().parents[1] +_SPEC = importlib.util.spec_from_file_location( + "base_vulns", _ROOT / ".github" / "scripts" / "base_vulns.py" +) +assert _SPEC is not None +assert _SPEC.loader is not None +base_vulns = importlib.util.module_from_spec(_SPEC) +_SPEC.loader.exec_module(base_vulns) + +_REPORT = { + "dependencies": [ + {"name": "filelock", "version": "3.29.0", "vulns": [{"id": "CVE-1"}, {"id": "CVE-2"}]}, + {"name": "rich", "version": "15.0.4", "vulns": []}, + {"name": "fndr", "skip_reason": "not on PyPI"}, + ], + "fixes": [], +} + + +def test_every_base_finding_becomes_an_ignore( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + report = tmp_path / "base-audit.json" + report.write_text(json.dumps(_REPORT), encoding="utf-8") + + assert base_vulns.main(str(report)) == 0 + + assert capsys.readouterr().out.split() == ["--ignore-vuln", "CVE-1", "--ignore-vuln", "CVE-2"] + + +def test_a_missing_base_report_ignores_nothing( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + assert base_vulns.main(str(tmp_path / "absent.json")) == 0 + + assert capsys.readouterr().out == "" diff --git a/tests/test_a_widened_runtime_pin_is_restored.py b/tests/test_a_widened_runtime_pin_is_restored.py new file mode 100644 index 00000000..e732f7e0 --- /dev/null +++ b/tests/test_a_widened_runtime_pin_is_restored.py @@ -0,0 +1,113 @@ +"""`check_runtime_pins.py --fix` turns a Dependabot range back into `~=` the locked version.""" + +from __future__ import annotations + +import importlib.util +from pathlib import Path + +import pytest + +_ROOT = Path(__file__).resolve().parents[1] +_SPEC = importlib.util.spec_from_file_location( + "check_runtime_pins", _ROOT / ".github" / "scripts" / "check_runtime_pins.py" +) +assert _SPEC is not None +assert _SPEC.loader is not None +check_runtime_pins = importlib.util.module_from_spec(_SPEC) +_SPEC.loader.exec_module(check_runtime_pins) + +_PYPROJECT = """\ +[project] +name = "demo" +dependencies = [ + "tantivy>=0.26.2,<0.28.0", + "Tomli_W>=1.2,<1.4", + "termaid[rich]>=0.9,<0.11", + "rich~=15.0.0", +] + +[project.optional-dependencies] +semantic = ["onnxruntime>=1.31.0,<1.33"] +cli = ["typer>=0.27,<0.29"] + +[dependency-groups] +pdf-structure = ["pymupdf4llm>=1.28.2,<1.30"] +semantic = ["onnxruntime>=1.31.0,<1.33"] +dev = ["ruff>=0.17.0"] +""" + +_LOCK = """\ +version = 1 +revision = 5 + +[[package]] +name = "tantivy" +version = "0.27.0" + +[[package]] +name = "tomli-w" +version = "1.3" + +[[package]] +name = "termaid" +version = "0.10.1" + +[[package]] +name = "rich" +version = "15.0.4" + +[[package]] +name = "pymupdf4llm" +version = "1.29.0.post1" + +[[package]] +name = "onnxruntime" +version = "1.32.1" + +[[package]] +name = "typer" +version = "0.28.2" +""" + + +def _project(tmp_path: Path, pyproject: str) -> Path: + (tmp_path / "pyproject.toml").write_text(pyproject, encoding="utf-8") + (tmp_path / "uv.lock").write_text(_LOCK, encoding="utf-8") + return tmp_path + + +def test_every_widened_pin_returns_to_the_locked_minor(tmp_path: Path) -> None: + root = _project(tmp_path, _PYPROJECT) + + changes = check_runtime_pins.fix(root) + + text = (root / "pyproject.toml").read_text(encoding="utf-8") + assert '"tantivy~=0.27.0"' in text + assert '"Tomli_W~=1.3.0"' in text + assert '"termaid[rich]~=0.10.1"' in text + assert '"pymupdf4llm~=1.29.0"' in text + assert text.count('"onnxruntime~=1.32.1"') == 2 + assert '"typer~=0.28.2"' in text + assert '"rich~=15.0.0"' in text + assert '"ruff>=0.17.0"' in text + assert len(changes) == 6 + + +def test_a_pinned_project_is_left_byte_identical(tmp_path: Path) -> None: + pinned = _PYPROJECT.replace("tantivy>=0.26.2,<0.28.0", "tantivy~=0.27.0") + pinned = pinned.replace("Tomli_W>=1.2,<1.4", "Tomli_W~=1.3.0") + pinned = pinned.replace("termaid[rich]>=0.9,<0.11", "termaid[rich]~=0.10.1") + pinned = pinned.replace('["pymupdf4llm>=1.28.2,<1.30"]', '["pymupdf4llm~=1.29.0"]') + pinned = pinned.replace("onnxruntime>=1.31.0,<1.33", "onnxruntime~=1.32.1") + pinned = pinned.replace("typer>=0.27,<0.29", "typer~=0.28.2") + root = _project(tmp_path, pinned) + + assert check_runtime_pins.fix(root) == [] + assert (root / "pyproject.toml").read_text(encoding="utf-8") == pinned + + +def test_an_unlocked_dependency_refuses_rather_than_guessing(tmp_path: Path) -> None: + root = _project(tmp_path, _PYPROJECT.replace("tantivy>=", "notlocked>=")) + + with pytest.raises(SystemExit, match="notlocked"): + check_runtime_pins.fix(root) From 70ef00081f40e10a7a2217f6ad521774924d18ad Mon Sep 17 00:00:00 2001 From: Ben Davidson Date: Mon, 26 Oct 2026 17:27:03 +1030 Subject: [PATCH 2/4] Scope inherited audit findings to their package and keep pin markers - The PR audit compares package and advisory pairs (IDs and aliases) against the base report instead of passing global `--ignore-vuln` flags, so an advisory shared with a different package still fails. A missing PR report fails; a missing base report audits strictly. - Both `uv export` calls are `--frozen`, so each audit reads the checked-in lock rather than a re-lock. - The pin check reads the requirement before any `;` marker, so a correctly pinned marked requirement passes, and `--fix` keeps the marker. - `--fix` reads every locked entry for a package: a forked resolution pins its lowest patch when the entries share one minor, and refuses when they span several. --- .github/scripts/base_vulns.py | 44 ------------ .github/scripts/check_runtime_pins.py | 40 +++++++---- .github/scripts/new_vulns.py | 70 +++++++++++++++++++ .github/workflows/security.yml | 15 ++-- ...r_audit_skips_what_main_already_carries.py | 67 +++++++++++------- .../test_a_widened_runtime_pin_is_restored.py | 27 +++++++ 6 files changed, 171 insertions(+), 92 deletions(-) delete mode 100644 .github/scripts/base_vulns.py create mode 100644 .github/scripts/new_vulns.py diff --git a/.github/scripts/base_vulns.py b/.github/scripts/base_vulns.py deleted file mode 100644 index 3f495ca8..00000000 --- a/.github/scripts/base_vulns.py +++ /dev/null @@ -1,44 +0,0 @@ -"""Print `--ignore-vuln ID` for every vulnerability a base-branch pip-audit JSON report found. - -A PR audit then fails only on what the PR introduces; the push and cron audits on -main stay strict, so an advisory against an untouched dependency still surfaces there. -""" - -from __future__ import annotations - -import json -import sys -from pathlib import Path - - -def base_vulns(report: dict[str, object]) -> list[tuple[str, str, str]]: - """Return (package, version, id) for every vulnerability in a pip-audit JSON report.""" - found: list[tuple[str, str, str]] = [] - deps = report.get("dependencies", []) - assert isinstance(deps, list) - for dep in deps: - assert isinstance(dep, dict) - for vuln in dep.get("vulns", []): - found.append((dep["name"], dep["version"], vuln["id"])) - return found - - -def main(path: str) -> int: - report = Path(path) - # No report means the base audit errored; ignoring nothing keeps the PR audit strict. - if not report.is_file(): - print(f"::warning::no base audit at {path}; auditing the PR strictly", file=sys.stderr) - return 0 - - for package, version, vuln_id in base_vulns(json.loads(report.read_text(encoding="utf-8"))): - print( - f"::warning::{package} {version} {vuln_id} is already on the base branch", - file=sys.stderr, - ) - print("--ignore-vuln") - print(vuln_id) - return 0 - - -if __name__ == "__main__": - sys.exit(main(sys.argv[1])) diff --git a/.github/scripts/check_runtime_pins.py b/.github/scripts/check_runtime_pins.py index c03236cb..ed43577c 100644 --- a/.github/scripts/check_runtime_pins.py +++ b/.github/scripts/check_runtime_pins.py @@ -36,7 +36,11 @@ def offenders(pyproject: dict[str, object]) -> list[tuple[str, str]]: for name in USER_FACING_GROUPS: checked += [(f"dependency-groups.{name}", d) for d in groups.get(name, [])] - return [(where, spec) for where, spec in checked if not PIN.match(spec)] + return [(where, spec) for where, spec in checked if not PIN.match(_requirement(spec))] + + +def _requirement(spec: str) -> str: + return spec.partition(";")[0].strip() def _normalise(name: str) -> str: @@ -44,28 +48,34 @@ def _normalise(name: str) -> str: def pinned(spec: str, lock: dict[str, object]) -> str: - """Return `spec` rewritten as `name[extras]~=X.Y.Z` at the version uv.lock resolves.""" - match = NAME.match(spec) + """Return `spec` rewritten as `name[extras]~=X.Y.Z; marker` at the version uv.lock resolves.""" + match = NAME.match(_requirement(spec)) if match is None: raise SystemExit(f"::error::cannot read a package name from {spec!r}") name, extras = match.group(1), match.group(2) or "" + marker = spec.partition(";")[2].strip() packages = lock.get("package", []) assert isinstance(packages, list) - locked = next( - ( - p["version"] - for p in packages - if isinstance(p, dict) and _normalise(str(p.get("name"))) == _normalise(name) - ), - None, - ) - release = RELEASE.match(str(locked)) if locked is not None else None - if release is None: + # A forked resolution locks one entry per platform; the pin must cover them all. + releases = [ + RELEASE.match(str(p["version"])) + for p in packages + if isinstance(p, dict) and _normalise(str(p.get("name"))) == _normalise(name) + ] + if not releases or None in releases: raise SystemExit(f"::error::uv.lock resolves no release version for {name}") - major, minor, micro = release.group(1), release.group(2), release.group(3) or "0" - return f"{name}{extras}~={major}.{minor}.{micro}" + versions = sorted( + (int(r.group(1)), int(r.group(2)), int(r.group(3) or 0)) for r in releases if r is not None + ) + if len({v[:2] for v in versions}) > 1: + raise SystemExit( + f"::error::uv.lock resolves {name} to several minors; no `~=` pin covers them" + ) + + major, minor, micro = versions[0] + return f"{name}{extras}~={major}.{minor}.{micro}" + (f"; {marker}" if marker else "") def fix(root: Path) -> list[tuple[str, str]]: diff --git a/.github/scripts/new_vulns.py b/.github/scripts/new_vulns.py new file mode 100644 index 00000000..d08055d4 --- /dev/null +++ b/.github/scripts/new_vulns.py @@ -0,0 +1,70 @@ +"""Fail on the vulnerabilities a PR's pip-audit JSON report has that the base branch's lacks. + +A finding is inherited only when the base reports the same package with an advisory +sharing an ID or alias. Push and cron audits on main stay strict, so inherited +findings still surface there. +""" + +from __future__ import annotations + +import json +import re +import sys +from pathlib import Path + +Finding = tuple[str, str, frozenset[str]] + + +def findings(report: dict[str, object]) -> list[Finding]: + """Return (package, version, advisory IDs and aliases) for every vulnerability in a report.""" + found: list[Finding] = [] + deps = report.get("dependencies", []) + assert isinstance(deps, list) + for dep in deps: + assert isinstance(dep, dict) + name = re.sub(r"[-_.]+", "-", str(dep["name"])).lower() + for vuln in dep.get("vulns", []): + found.append( + (name, str(dep["version"]), frozenset([vuln["id"], *vuln.get("aliases", [])])) + ) + return found + + +def introduced(base: list[Finding], pr: list[Finding]) -> list[Finding]: + """Return the PR findings no base finding matches by package and a shared advisory ID.""" + return [ + (package, version, ids) + for package, version, ids in pr + if not any(package == b_package and ids & b_ids for b_package, _, b_ids in base) + ] + + +def _load(path: Path) -> list[Finding] | None: + if not path.is_file(): + return None + return findings(json.loads(path.read_text(encoding="utf-8"))) + + +def main(base_path: str, pr_path: str) -> int: + pr = _load(Path(pr_path)) + if pr is None: + print(f"::error::the PR audit wrote no report at {pr_path}; see the step log") + return 1 + # No base report means the base audit errored; treating it as clean keeps the PR strict. + base = _load(Path(base_path)) + if base is None: + print(f"::warning::no base audit at {base_path}; auditing the PR strictly") + base = [] + + new = introduced(base, pr) + for package, version, ids in pr: + level = "error" if (package, version, ids) in new else "warning" + note = "introduced by this PR" if level == "error" else "already on the base branch" + print(f"::{level}::{package} {version} {', '.join(sorted(ids))}: {note}") + + print(f"{len(new)} new, {len(pr) - len(new)} inherited") + return 1 if new else 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1], sys.argv[2])) diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index b666d702..20534b9d 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -39,7 +39,7 @@ jobs: # 0 of the publish runbook lands `fnd` on PyPI, the flag stays: # we don't need to audit ourselves, only our deps. run: | - uv export \ + uv export --frozen \ --format requirements-txt \ --no-hashes \ --no-emit-project \ @@ -55,19 +55,20 @@ jobs: # Its findings are what main already carries; the PR audit skips them. if: github.event_name == 'pull_request' run: | - uv export --project base \ + uv export --frozen --project base \ --format requirements-txt \ --no-hashes \ --no-emit-project \ -o base.frozen.txt uv tool run pip-audit -r base.frozen.txt --strict --format json -o base-audit.json || true - name: Audit + if: github.event_name != 'pull_request' + run: uv tool run pip-audit -r requirements.frozen.txt --strict + - name: Audit what the PR introduces + if: github.event_name == 'pull_request' run: | - ignores=() - if [ -d base ]; then - mapfile -t ignores < <(python3 .github/scripts/base_vulns.py base-audit.json) - fi - uv tool run pip-audit -r requirements.frozen.txt --strict "${ignores[@]}" + uv tool run pip-audit -r requirements.frozen.txt --strict --format json -o pr-audit.json || true + python3 .github/scripts/new_vulns.py base-audit.json pr-audit.json ruff-security: name: ruff (bandit ruleset) diff --git a/tests/test_a_pr_audit_skips_what_main_already_carries.py b/tests/test_a_pr_audit_skips_what_main_already_carries.py index 2131d0ca..bff7f307 100644 --- a/tests/test_a_pr_audit_skips_what_main_already_carries.py +++ b/tests/test_a_pr_audit_skips_what_main_already_carries.py @@ -1,46 +1,61 @@ -"""`base_vulns.py` ignores exactly the base branch's findings, and nothing when the base audit failed.""" +"""`new_vulns.py` fails a PR only on a package and advisory pair the base branch lacks.""" from __future__ import annotations import importlib.util import json +from collections.abc import Mapping, Sequence from pathlib import Path -import pytest - _ROOT = Path(__file__).resolve().parents[1] _SPEC = importlib.util.spec_from_file_location( - "base_vulns", _ROOT / ".github" / "scripts" / "base_vulns.py" + "new_vulns", _ROOT / ".github" / "scripts" / "new_vulns.py" ) assert _SPEC is not None assert _SPEC.loader is not None -base_vulns = importlib.util.module_from_spec(_SPEC) -_SPEC.loader.exec_module(base_vulns) +new_vulns = importlib.util.module_from_spec(_SPEC) +_SPEC.loader.exec_module(new_vulns) + + +def _report(path: Path, *deps: tuple[str, str, Sequence[Mapping[str, object]]]) -> str: + body = [{"name": n, "version": v, "vulns": vulns} for n, v, vulns in deps] + body.append({"name": "fndr", "skip_reason": "not on PyPI"}) + path.write_text(json.dumps({"dependencies": body, "fixes": []}), encoding="utf-8") + return str(path) + + +_FILELOCK = ("filelock", "3.29.0", [{"id": "CVE-1", "aliases": ["GHSA-a"]}]) + + +def test_a_finding_the_base_carries_passes(tmp_path: Path) -> None: + base = _report(tmp_path / "base.json", _FILELOCK) + renamed = ("FileLock", "3.29.1", [{"id": "GHSA-a", "aliases": []}]) + pr = _report(tmp_path / "pr.json", renamed) + + assert new_vulns.main(base, pr) == 0 + + +def test_a_new_finding_fails(tmp_path: Path) -> None: + base = _report(tmp_path / "base.json", _FILELOCK) + pr = _report(tmp_path / "pr.json", _FILELOCK, ("virtualenv", "21.7.13", [{"id": "CVE-2"}])) + + assert new_vulns.main(base, pr) == 1 + -_REPORT = { - "dependencies": [ - {"name": "filelock", "version": "3.29.0", "vulns": [{"id": "CVE-1"}, {"id": "CVE-2"}]}, - {"name": "rich", "version": "15.0.4", "vulns": []}, - {"name": "fndr", "skip_reason": "not on PyPI"}, - ], - "fixes": [], -} +def test_a_shared_advisory_on_another_package_fails(tmp_path: Path) -> None: + base = _report(tmp_path / "base.json", _FILELOCK) + pr = _report(tmp_path / "pr.json", _FILELOCK, ("py-filelock", "1.0", [{"id": "CVE-1"}])) + assert new_vulns.main(base, pr) == 1 -def test_every_base_finding_becomes_an_ignore( - tmp_path: Path, capsys: pytest.CaptureFixture[str] -) -> None: - report = tmp_path / "base-audit.json" - report.write_text(json.dumps(_REPORT), encoding="utf-8") - assert base_vulns.main(str(report)) == 0 +def test_a_missing_base_report_audits_strictly(tmp_path: Path) -> None: + pr = _report(tmp_path / "pr.json", _FILELOCK) - assert capsys.readouterr().out.split() == ["--ignore-vuln", "CVE-1", "--ignore-vuln", "CVE-2"] + assert new_vulns.main(str(tmp_path / "absent.json"), pr) == 1 -def test_a_missing_base_report_ignores_nothing( - tmp_path: Path, capsys: pytest.CaptureFixture[str] -) -> None: - assert base_vulns.main(str(tmp_path / "absent.json")) == 0 +def test_a_missing_pr_report_fails(tmp_path: Path) -> None: + base = _report(tmp_path / "base.json") - assert capsys.readouterr().out == "" + assert new_vulns.main(base, str(tmp_path / "absent.json")) == 1 diff --git a/tests/test_a_widened_runtime_pin_is_restored.py b/tests/test_a_widened_runtime_pin_is_restored.py index e732f7e0..cd44d1ba 100644 --- a/tests/test_a_widened_runtime_pin_is_restored.py +++ b/tests/test_a_widened_runtime_pin_is_restored.py @@ -3,6 +3,7 @@ from __future__ import annotations import importlib.util +import tomllib from pathlib import Path import pytest @@ -111,3 +112,29 @@ def test_an_unlocked_dependency_refuses_rather_than_guessing(tmp_path: Path) -> with pytest.raises(SystemExit, match="notlocked"): check_runtime_pins.fix(root) + + +def test_a_marked_requirement_keeps_its_marker(tmp_path: Path) -> None: + marked = _PYPROJECT.replace('"rich~=15.0.0",', "\"rich>=15.0,<17 ; sys_platform == 'darwin'\",") + root = _project(tmp_path, marked) + + check_runtime_pins.fix(root) + + text = (root / "pyproject.toml").read_text(encoding="utf-8") + assert "\"rich~=15.0.4; sys_platform == 'darwin'\"" in text + assert check_runtime_pins.offenders(tomllib.loads(text)) == [] + + +def _forked(*versions: str) -> dict[str, object]: + return {"package": [{"name": "onnxruntime", "version": v} for v in versions]} + + +def test_a_forked_lock_pins_the_lowest_patch_of_its_one_minor() -> None: + lock = _forked("1.32.4", "1.32.1") + + assert check_runtime_pins.pinned("onnxruntime>=1.31,<1.33", lock) == "onnxruntime~=1.32.1" + + +def test_a_forked_lock_across_minors_refuses() -> None: + with pytest.raises(SystemExit, match="several minors"): + check_runtime_pins.pinned("onnxruntime>=1.31,<1.34", _forked("1.32.1", "1.33.0")) From fb9ae82ac1ba216a557e4ff61c14003460766c32 Mon Sep 17 00:00:00 2001 From: Ben Davidson Date: Mon, 26 Oct 2026 17:42:25 +1030 Subject: [PATCH 3/4] Refuse a locked pre-release and keep audit reports out of the checkout - `RELEASE` is anchored and admits only final and post-releases: a locked rc, dev or local version is outside the `~=X.Y.Z` pin it would produce, so `--fix` refuses instead. - The base and PR audit reports go to `$RUNNER_TEMP`, so a report committed in a PR can never stand in for one pip-audit failed to write. --- .github/scripts/check_runtime_pins.py | 3 ++- .github/workflows/security.yml | 6 +++--- tests/test_a_widened_runtime_pin_is_restored.py | 6 ++++++ 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/.github/scripts/check_runtime_pins.py b/.github/scripts/check_runtime_pins.py index ed43577c..6dee8cc0 100644 --- a/.github/scripts/check_runtime_pins.py +++ b/.github/scripts/check_runtime_pins.py @@ -14,7 +14,8 @@ # Three-component compatible-release, extras allowed: `termaid[rich]~=0.6.1`. PIN = re.compile(r"^[A-Za-z0-9._-]+(\[[A-Za-z0-9,._-]+\])?~=\d+\.\d+\.\d+$") NAME = re.compile(r"^([A-Za-z0-9._-]+)(\[[A-Za-z0-9,._-]+\])?") -RELEASE = re.compile(r"^(\d+)\.(\d+)(?:\.(\d+))?") +# A final release (post-releases included): `~=X.Y.Z` excludes a locked pre-release. +RELEASE = re.compile(r"^(\d+)\.(\d+)(?:\.(\d+))?(?:\.post\d+)?$") # `dev` never reaches a user install; these groups and every extra do. USER_FACING_GROUPS = ("pdf-structure", "semantic") diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 20534b9d..529554eb 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -60,15 +60,15 @@ jobs: --no-hashes \ --no-emit-project \ -o base.frozen.txt - uv tool run pip-audit -r base.frozen.txt --strict --format json -o base-audit.json || true + uv tool run pip-audit -r base.frozen.txt --strict --format json -o "$RUNNER_TEMP/base-audit.json" || true - name: Audit if: github.event_name != 'pull_request' run: uv tool run pip-audit -r requirements.frozen.txt --strict - name: Audit what the PR introduces if: github.event_name == 'pull_request' run: | - uv tool run pip-audit -r requirements.frozen.txt --strict --format json -o pr-audit.json || true - python3 .github/scripts/new_vulns.py base-audit.json pr-audit.json + uv tool run pip-audit -r requirements.frozen.txt --strict --format json -o "$RUNNER_TEMP/pr-audit.json" || true + python3 .github/scripts/new_vulns.py "$RUNNER_TEMP/base-audit.json" "$RUNNER_TEMP/pr-audit.json" ruff-security: name: ruff (bandit ruleset) diff --git a/tests/test_a_widened_runtime_pin_is_restored.py b/tests/test_a_widened_runtime_pin_is_restored.py index cd44d1ba..f9ce3b3f 100644 --- a/tests/test_a_widened_runtime_pin_is_restored.py +++ b/tests/test_a_widened_runtime_pin_is_restored.py @@ -138,3 +138,9 @@ def test_a_forked_lock_pins_the_lowest_patch_of_its_one_minor() -> None: def test_a_forked_lock_across_minors_refuses() -> None: with pytest.raises(SystemExit, match="several minors"): check_runtime_pins.pinned("onnxruntime>=1.31,<1.34", _forked("1.32.1", "1.33.0")) + + +@pytest.mark.parametrize("version", ["2.0.0rc1", "2.0.0.dev3", "1.2.3+cpu", "1!2.0.0"]) +def test_a_locked_non_final_release_refuses(version: str) -> None: + with pytest.raises(SystemExit, match="no release version"): + check_runtime_pins.pinned("onnxruntime>=1.31", _forked(version)) From a32875d2740da8a68c6392564a9990fe7d63508a Mon Sep 17 00:00:00 2001 From: Ben Davidson Date: Mon, 26 Oct 2026 17:57:22 +1030 Subject: [PATCH 4/4] Pin a locked four-part release to its first three components `~=1.28.2` covers `1.28.2.1`, so `RELEASE` admits trailing numeric components again; rc, dev, local and epoch versions are still refused. --- .github/scripts/check_runtime_pins.py | 2 +- tests/test_a_widened_runtime_pin_is_restored.py | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/scripts/check_runtime_pins.py b/.github/scripts/check_runtime_pins.py index 6dee8cc0..c5780de8 100644 --- a/.github/scripts/check_runtime_pins.py +++ b/.github/scripts/check_runtime_pins.py @@ -15,7 +15,7 @@ PIN = re.compile(r"^[A-Za-z0-9._-]+(\[[A-Za-z0-9,._-]+\])?~=\d+\.\d+\.\d+$") NAME = re.compile(r"^([A-Za-z0-9._-]+)(\[[A-Za-z0-9,._-]+\])?") # A final release (post-releases included): `~=X.Y.Z` excludes a locked pre-release. -RELEASE = re.compile(r"^(\d+)\.(\d+)(?:\.(\d+))?(?:\.post\d+)?$") +RELEASE = re.compile(r"^(\d+)\.(\d+)(?:\.(\d+)(?:\.\d+)*)?(?:\.post\d+)?$") # `dev` never reaches a user install; these groups and every extra do. USER_FACING_GROUPS = ("pdf-structure", "semantic") diff --git a/tests/test_a_widened_runtime_pin_is_restored.py b/tests/test_a_widened_runtime_pin_is_restored.py index f9ce3b3f..9fbd8a5c 100644 --- a/tests/test_a_widened_runtime_pin_is_restored.py +++ b/tests/test_a_widened_runtime_pin_is_restored.py @@ -135,6 +135,12 @@ def test_a_forked_lock_pins_the_lowest_patch_of_its_one_minor() -> None: assert check_runtime_pins.pinned("onnxruntime>=1.31,<1.33", lock) == "onnxruntime~=1.32.1" +def test_a_four_part_release_pins_its_first_three() -> None: + assert ( + check_runtime_pins.pinned("onnxruntime>=1.31", _forked("1.32.1.4")) == "onnxruntime~=1.32.1" + ) + + def test_a_forked_lock_across_minors_refuses() -> None: with pytest.raises(SystemExit, match="several minors"): check_runtime_pins.pinned("onnxruntime>=1.31,<1.34", _forked("1.32.1", "1.33.0"))