Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
55 changes: 0 additions & 55 deletions .github/scripts/check_ruff_sync.py

This file was deleted.

90 changes: 83 additions & 7 deletions .github/scripts/check_runtime_pins.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -9,9 +13,12 @@

# 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,._-]+\])?")
# A final release (post-releases included): `~=X.Y.Z` excludes a locked pre-release.
RELEASE = re.compile(r"^(\d+)\.(\d+)(?:\.(\d+)(?:\.\d+)*)?(?:\.post\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]]:
Expand All @@ -20,16 +27,84 @@ 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:
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:
return re.sub(r"[-_.]+", "-", name).lower()


def pinned(spec: str, lock: dict[str, object]) -> str:
"""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)
# 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}")

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]]:
"""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}"')

def main() -> int:
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)

Expand All @@ -41,7 +116,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

Expand All @@ -50,4 +126,4 @@ def main() -> int:


if __name__ == "__main__":
sys.exit(main())
sys.exit(main(sys.argv[1:]))
70 changes: 70 additions & 0 deletions .github/scripts/new_vulns.py
Original file line number Diff line number Diff line change
@@ -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]))
2 changes: 0 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
90 changes: 90 additions & 0 deletions .github/workflows/dependabot-pins.yml
Original file line number Diff line number Diff line change
@@ -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}"
Loading
Loading