Files
deepagents/.github/scripts/checks/check_lockfiles_pre_commit.py
Mason Daugherty ed0b6785f8 feat(ci): raise langchain minimums workflow (#5330)
Adds a workflow that raises LangChain-ecosystem dependency lower bounds
to the latest stable PyPI release and opens a `chore(deps):` PR with the
result. Runs weekly (Monday 09:00 UTC) across every release package, and
can be dispatched manually against a single package or `all`.

Keeping integration minimums current is a recurring manual chore. This
automates the "bump the floor" half: for each in-scope requirement
(`langchain*`, `langgraph*`, `langsmith*`, `deepagents*`) declaring a
`>=`/`~=` floor in `[project.dependencies]`,
`[project.optional-dependencies]`, or `[dependency-groups]`, the floor
is rewritten in place and one PR is opened.

- **Raises within the existing range, never past an upper bound.**
`deepagents>=0.7.0,<0.8.0` becomes `>=0.7.9,<0.8.0`, never the
unsatisfiable `>=0.8.0,<0.8.0`. Pre-releases are never selected, and a
floor already ahead of the latest stable release (intentional prerelease
coordination) is left alone. Each rewrite is checked against the
versions PyPI actually offers, so a bump can't quietly exclude a version
the old range allowed.
- **Preserves everything else in the specifier.** Upper bounds, extras,
and markers survive verbatim, and `~=` ceilings are held by keeping the
original component count — `~=1.2` becomes `~=1.9`, not `~=1.9.4`.
- **Skips what it shouldn't touch.** Exact `==` pins, specs with no
floor, URL requirements, workspace-local `[tool.uv.sources]` deps, and
the manifest's own package name.
- **Regenerates every `uv.lock` the edits invalidate** — not only the
edited packages: a lockfile embeds the specifiers of anything it
resolves from a local path source, so raising a floor in
`libs/deepagents` stales `libs/evals` and friends. Interpreter versions
come from `check_lockfiles_pre_commit` so they match what
`check_lockfiles.yml` verifies.
- **Reports failure instead of silence.** An unresolvable PyPI lookup or
an unrewritable manifest is listed in the PR body under "Not raised" and
fails the run, so an unattended cron never renders an outage as
"everything is already up to date".
- **Idempotent per package.** If a PR is already open for the selected
package, or nothing needs raising, the run exits without creating a
duplicate. PRs are created with the Org Membership App token so required
`pull_request` checks trigger, and titled `chore(deps):` so
release-please doesn't fan out a separate release PR.
2026-08-05 19:06:32 -04:00

103 lines
3.1 KiB
Python

"""Run lockfile checks only for packages touched by changed paths."""
from __future__ import annotations
import shlex
import subprocess
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[3]
LIBS_ROOT = REPO_ROOT / "libs"
EXAMPLES_ROOT = REPO_ROOT / "examples"
def package_dirs() -> list[Path]:
"""Return every package directory that owns a `uv.lock`, in repo-path order."""
libs = [path.parent for path in LIBS_ROOT.glob("*/Makefile")]
partners = [path.parent for path in (LIBS_ROOT / "partners").glob("*/Makefile")]
examples = [path.parent for path in EXAMPLES_ROOT.glob("*/pyproject.toml")]
return sorted([*libs, *partners, *examples], key=_repo_path)
def _repo_path(path: Path) -> str:
return path.relative_to(REPO_ROOT).as_posix()
def python_version(package: Path) -> str:
"""Return the interpreter version `package`'s lockfile must be resolved with."""
if package == LIBS_ROOT / "acp":
return "3.14"
return "3.12"
def _label(package: Path) -> str:
try:
return package.relative_to(LIBS_ROOT).as_posix()
except ValueError:
return f"../{package.relative_to(REPO_ROOT).as_posix()}"
def _touches_package(path: str, package: Path) -> bool:
package_path = _repo_path(package)
return path == package_path or path.startswith(f"{package_path}/")
def _packages_for_paths(paths: list[str]) -> list[Path]:
packages = package_dirs()
if not paths:
return packages
return [
package
for package in packages
if any(_touches_package(path, package) for path in paths)
]
def _lock_command(package: Path, *, check: bool) -> list[str]:
command = ["uv", "lock"]
if check:
command.append("--check")
return [
*command,
"--directory",
package.relative_to(REPO_ROOT).as_posix(),
"--python",
python_version(package),
]
def _lockfile_error(package: Path) -> str:
package_path = package.relative_to(REPO_ROOT).as_posix()
lockfile = f"{package_path}/uv.lock"
command = shlex.join(_lock_command(package, check=False))
return (
f"::error file={lockfile},title=Out-of-date uv.lock::"
f"{lockfile} is out of sync with {package_path}/pyproject.toml. "
f"From the repository root, run `{command}` and commit the updated lockfile."
)
def main(paths: list[str]) -> int:
"""Check lockfiles for packages touched by `paths`, or every package if empty."""
packages = _packages_for_paths(paths)
if not packages:
print("✅ No package lockfiles need checking.")
return 0
for package in packages:
print(f"🔍 Checking {_label(package)}")
result = subprocess.run(
_lock_command(package, check=True),
check=False,
cwd=REPO_ROOT,
)
if result.returncode != 0:
print(_lockfile_error(package), file=sys.stderr)
return result.returncode
print("✅ All applicable lockfiles are up-to-date!")
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))