From d816e5044de36f9d055a29742508b9efec50ca08 Mon Sep 17 00:00:00 2001 From: Daniel Mohedano Date: Tue, 7 Apr 2026 11:01:37 +0200 Subject: [PATCH] Pin dependencies and add supply chain hardening Pin all build, dev, test, QA, and CI dependencies to exact versions to prevent automatic resolution to potentially compromised releases. Runtime deps (bytecode, msgpack) use bounded ranges since this is a library. Add CI check script to enforce pinning policy. Co-Authored-By: Claude Opus 4.6 --- .github/scripts/check-pinned-deps.py | 99 ++++++++++++++++++++++++++++ .github/workflows/ci.yml | 18 +++-- .github/workflows/pypi-publish.yml | 2 +- pyproject.toml | 40 +++++------ 4 files changed, 133 insertions(+), 26 deletions(-) create mode 100644 .github/scripts/check-pinned-deps.py diff --git a/.github/scripts/check-pinned-deps.py b/.github/scripts/check-pinned-deps.py new file mode 100644 index 0000000..6d71f99 --- /dev/null +++ b/.github/scripts/check-pinned-deps.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +"""Check that all dependencies in pyproject.toml are pinned to exact versions. + +Runtime dependencies (under [project].dependencies) are allowed to use bounded +ranges (>=X,=, <) are acceptable (library runtime deps) +RUNTIME_ALLOWLIST_SECTIONS = frozenset({"project.dependencies"}) + +# Regex that matches a dependency pinned to an exact version with == +EXACT_PIN_RE = re.compile(r"==\S+") + +# Regex that detects unpinned or loosely-pinned version specifiers +LOOSE_SPECIFIERS_RE = re.compile(r"(>=|<=|~=|!=|>\s|<\s|\.\*)") + + +def _parse_deps(data: dict, path: str = "") -> list[tuple[str, str]]: + """Walk the TOML structure and collect (section_path, dep_string) pairs.""" + results: list[tuple[str, str]] = [] + + # [build-system].requires + if "build-system" in data: + for dep in data["build-system"].get("requires", []): + results.append(("build-system.requires", dep)) + + # [project].dependencies + if "project" in data: + for dep in data["project"].get("dependencies", []): + results.append(("project.dependencies", dep)) + + # [tool.hatch.envs.*].dependencies and extra-dependencies + hatch_envs = data.get("tool", {}).get("hatch", {}).get("envs", {}) + for env_name, env_cfg in hatch_envs.items(): + if isinstance(env_cfg, dict): + for dep in env_cfg.get("dependencies", []): + results.append((f"tool.hatch.envs.{env_name}.dependencies", dep)) + for dep in env_cfg.get("extra-dependencies", []): + results.append((f"tool.hatch.envs.{env_name}.extra-dependencies", dep)) + + return results + + +def _check_dep(section: str, dep: str) -> str | None: + """Return an error message if the dependency is not properly pinned.""" + # Runtime deps are allowed to use bounded ranges + if section in RUNTIME_ALLOWLIST_SECTIONS: + # They must still have *some* version constraint + if not re.search(r"[><=~!]", dep): + return f" {section}: {dep!r} has no version constraint" + return None + + # All other deps must use exact pins + if EXACT_PIN_RE.search(dep): + return None + + return f" {section}: {dep!r} is not pinned to an exact version (use ==X.Y.Z)" + + +def main() -> int: + pyproject_path = Path(__file__).resolve().parents[2] / "pyproject.toml" + if not pyproject_path.exists(): + print(f"ERROR: {pyproject_path} not found") + return 1 + + with open(pyproject_path, "rb") as f: + data = tomllib.load(f) + + deps = _parse_deps(data) + errors: list[str] = [] + + for section, dep in deps: + err = _check_dep(section, dep) + if err: + errors.append(err) + + if errors: + print("Unpinned dependencies found:") + for err in errors: + print(err) + return 1 + + print(f"All {len(deps)} dependencies are properly pinned.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 47911a2..d73322d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,8 +19,11 @@ jobs: python-version: "3.13" - name: Install Hatch - run: pip install "click<=8.2.1" "hatch==1.14.1" + run: pip install "hatch==1.16.5" + - name: Check pinned dependencies + run: python .github/scripts/check-pinned-deps.py + - name: Run QA checks run: hatch run qa:qa @@ -37,9 +40,9 @@ jobs: fail-fast: false matrix: python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] - pytest-version: ["7.2.0", "8.1.2", "8.4.*"] + pytest-version: ["7.2.0", "8.1.2", "8.4.1"] exclude: - # Python 3.12 and 3.13 only support pytest 8.1.2 and 8.4.* + # Python 3.12 and 3.13 only support pytest 8.1.2 and 8.4.1 - python-version: "3.12" pytest-version: "7.2.0" - python-version: "3.13" @@ -54,7 +57,12 @@ jobs: python-version: ${{ matrix.python-version }} - name: Install Hatch - run: pip install "click<=8.2.1" "hatch==1.14.1" + run: | + if [ "${{ matrix.python-version }}" = "3.9" ]; then + pip install "hatch==1.15.1" "virtualenv==20.29.3" + else + pip install "hatch==1.16.5" + fi - name: Run tests env: @@ -78,7 +86,7 @@ jobs: python-version: "3.13" - name: Install Hatch - run: pip install "click<=8.2.1" "hatch==1.14.1" + run: pip install "hatch==1.16.5" - name: Run coverage tests id: coverage diff --git a/.github/workflows/pypi-publish.yml b/.github/workflows/pypi-publish.yml index f5fb770..7176a2a 100644 --- a/.github/workflows/pypi-publish.yml +++ b/.github/workflows/pypi-publish.yml @@ -21,7 +21,7 @@ jobs: - name: Install build dependencies run: | python -m pip install --upgrade pip - python -m pip install build wheel setuptools + python -m pip install "build==1.4.2" "wheel==0.46.3" "setuptools==82.0.1" - name: Build release distributions run: | diff --git a/pyproject.toml b/pyproject.toml index 37d7788..d985bee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,13 +1,13 @@ [build-system] -requires = ["hatchling", "versioningit"] +requires = ["hatchling==1.27.0", "versioningit==3.3.0"] build-backend = "hatchling.build" [project] name = "ddtestpy" dynamic = ["version"] dependencies = [ - "bytecode>=0.15.0", - "msgpack>=1.0.0", + "bytecode>=0.15.0,<1.0", + "msgpack>=1.0.0,<2.0", ] license = "Apache-2.0" @@ -26,15 +26,15 @@ default-tag = "0.0.0" [tool.hatch.envs.hatch-test] dependencies = [ - "slipcover", - "pytest-socket", - "pytest-randomly~=3.15", - "pytest-rerunfailures~=14.0", - "pytest-xdist[psutil]~=3.5", + "slipcover==1.0.18", + "pytest-socket==0.7.0", + "pytest-randomly==3.15.0", + "pytest-rerunfailures==14.0", + "pytest-xdist[psutil]==3.5.0", ] extra-dependencies = [ - "pytest-memray", - "ddtrace", + "pytest-memray==1.8.0", + "ddtrace==4.6.5", ] randomize = true parallel = true @@ -43,11 +43,11 @@ retry-delay = 1 [[tool.hatch.envs.hatch-test.matrix]] python = ["3.13", "3.12"] -pytest = ["8.1.2", "8.4.*"] +pytest = ["8.1.2", "8.4.1"] [[tool.hatch.envs.hatch-test.matrix]] python = ["3.9", "3.10", "3.11"] -pytest = ["7.2.0", "8.1.2", "8.4.*"] +pytest = ["7.2.0", "8.1.2", "8.4.1"] [tool.hatch.envs.hatch-test.overrides] matrix.pytest.dependencies = [ @@ -63,9 +63,9 @@ cov-report = "true" [tool.hatch.envs.int_test] dependencies = [ - "pytest", - "pytest-socket", - "flask" + "pytest==8.4.1", + "pytest-socket==0.7.0", + "flask==3.1.3", ] [[tool.hatch.envs.int_test.matrix]] @@ -82,11 +82,11 @@ test = "pytest {args:test_fixtures}" [tool.hatch.envs.qa] python = "3.13" dependencies = [ - "black", - "ruff", - "mypy", - "pytest-mypy-plugins", - "ddtrace", + "black==25.12.0", + "ruff==0.15.9", + "mypy==1.20.0", + "pytest-mypy-plugins==4.0.0", + "ddtrace==4.6.5", ] [tool.hatch.envs.qa.scripts]