How the auto dependency update broke our production

June 9, 2026

Sunday afternoon. I was just about to go out to grab some gelato when a Slack message came. "Your API is constantly returning status 422. Can you please check?" A couple of days later, another one: "Google Login is broken! Fix it!" We had so many tests - how did this slip through?

Complete Python Testing Guide

You'll learn proven techniques that make tests resilient to refactoring, easy to write and maintain, and valuable in catching real bugs - all while working seamlessly with AI coding tools.

Take Course

Breaking changes everywhere

Both stories above have the same root cause: a dependency upgrade. In both cases, a minor version upgrade introduced a breaking change.

For example, FastAPI introduced a breaking change in version 0.132.0. By default, it started rejecting requests without a Content-Type header. Guess what - the client, using some old Java HTTP library, wasn't explicitly setting the Content-Type. When the cron job started on Sunday, all requests failed. A similar thing happened with google-auth-oauthlib. The question is - how to prevent that?

Lock to specific dependency version

What used to feel like a safe auto-update — a minor version bump — isn't safe anymore. We'd better find a way to live with it. Therefore, the best thing you can do is to pin the dependencies to a specific version - at least the main ones. This way, no auto-upgrades can happen. Once you decide to upgrade the dependency, make sure that there are no breaking changes. If there are any, update the code to avoid breaking production.

AI to the rescue

I can hear you saying: "Who's going to read so many release notes? That's so boring and time-consuming!" That was my thinking as well. Fortunately, we can ask an AI agent to do that.

First, we need to provide a script to gather all the release notes of available new versions. Then the agent can review them, bump the versions, update the code as per breaking changes, and document what has been done and why.

Let's start with the script:

# scripts/check_dependency_updates.py
import concurrent.futures
import datetime
import json
import re
import sys
import tomllib
import urllib.error
import urllib.request
from dataclasses import dataclass
from pathlib import Path

PYPROJECT_PATH = Path(__file__).resolve().parent.parent / "pyproject.toml"

PRE_RELEASE_PATTERN = re.compile(r"(a|b|rc|dev|alpha|beta)\d*", re.IGNORECASE)

EXACT_PIN_PATTERN = re.compile(r"^([a-zA-Z0-9_-]+)(?:\[[^\]]+\])?==(.+)$")
RANGE_PIN_PATTERN = re.compile(r"^([a-zA-Z0-9_-]+)(?:\[[^\]]+\])?>=([^,]+),<(.+)$")

GITHUB_REPO_PATTERN = re.compile(r"github\.com/([^/]+/[^/]+?)(?:\.git|/|$)")

MAX_RELEASES_PER_PACKAGE = 10

SEMVER_PATTERN = re.compile(r"^(\d+)\.(\d+)\.(\d+)")


@dataclass
class Dependency:
    name: str
    current_version: str


@dataclass
class UpdateInfo:
    dep: Dependency
    latest_version: str | None
    is_outdated: bool
    error: str | None = None
    github_repo: str | None = None


@dataclass
class ReleaseNote:
    title: str
    body: str
    bump_type: str  # "major", "minor", "patch"


def parse_dependency(raw: str) -> Dependency | None:
    raw = raw.strip()
    if not raw:
        return None

    cleaned = raw.split("#")[0].strip()

    match = EXACT_PIN_PATTERN.match(cleaned)
    if match:
        return Dependency(name=match.group(1), current_version=match.group(2))

    match = RANGE_PIN_PATTERN.match(cleaned)
    if match:
        return Dependency(name=match.group(1), current_version=match.group(2))

    return None


def parse_all_dependencies(pyproject: dict) -> dict[str, list[Dependency]]:
    groups: dict[str, list[Dependency]] = {}

    main_deps = pyproject.get("project", {}).get("dependencies", [])
    groups["dependencies"] = [
        d for raw in main_deps if (d := parse_dependency(raw)) is not None
    ]

    for group_name, group_deps in pyproject.get("dependency-groups", {}).items():
        parsed = [
            d
            for raw in group_deps
            if isinstance(raw, str) and (d := parse_dependency(raw)) is not None
        ]
        if parsed:
            groups[group_name] = parsed

    return groups


def is_pre_release(version: str) -> bool:
    return bool(PRE_RELEASE_PATTERN.search(version))


def _extract_github_repo(pypi_data: dict) -> str | None:
    info = pypi_data.get("info", {})
    project_urls = info.get("project_urls") or {}

    for key in ("Source", "Source Code", "Repository", "GitHub", "Homepage", "Home", "Code"):
        url = project_urls.get(key, "")
        match = GITHUB_REPO_PATTERN.search(url)
        if match:
            return match.group(1)

    home_page = info.get("home_page", "") or ""
    match = GITHUB_REPO_PATTERN.search(home_page)
    if match:
        return match.group(1)

    return None


def _classify_semver_bump(*, prev_version: str, curr_version: str) -> str:
    prev = SEMVER_PATTERN.match(prev_version)
    curr = SEMVER_PATTERN.match(curr_version)
    if not prev or not curr:
        return "patch"

    if int(curr.group(1)) != int(prev.group(1)):
        return "major"
    if int(curr.group(2)) != int(prev.group(2)):
        return "minor"
    return "patch"


def _fetch_github_release_notes(
    *,
    github_repo: str,
    current_version: str,
    latest_version: str,
) -> str | None:
    url = f"https://api.github.com/repos/{github_repo}/releases?per_page=50"
    try:
        req = urllib.request.Request(url, headers={"Accept": "application/vnd.github+json"})
        with urllib.request.urlopen(req, timeout=15) as resp:
            releases = json.loads(resp.read().decode())
    except Exception:
        return None

    all_notes: list[ReleaseNote] = []
    prev_version = current_version
    relevant_releases: list[dict] = []
    for release in releases:
        tag = release.get("tag_name", "")
        tag_version = tag.lstrip("v")
        if tag_version == current_version:
            break
        relevant_releases.append(release)

    for release in reversed(relevant_releases):
        tag = release.get("tag_name", "")
        tag_version = tag.lstrip("v")
        body = (release.get("body") or "").strip()
        if not body:
            prev_version = tag_version
            continue
        bump = _classify_semver_bump(prev_version=prev_version, curr_version=tag_version)
        all_notes.append(ReleaseNote(
            title=release.get("name") or tag,
            body=body,
            bump_type=bump,
        ))
        prev_version = tag_version

    if not all_notes:
        return None

    if len(all_notes) <= MAX_RELEASES_PER_PACKAGE:
        selected = all_notes
    else:
        bump_priority = {"major": 0, "minor": 1, "patch": 2}
        selected = sorted(all_notes, key=lambda n: bump_priority[n.bump_type])[:MAX_RELEASES_PER_PACKAGE]
        selected = [n for n in all_notes if n in selected]

    lines: list[str] = []
    for note in selected:
        lines.append(f"### {note.title} [{note.bump_type}]\n{note.body}")

    result = "\n\n".join(lines)
    skipped = len(all_notes) - len(selected)
    if skipped > 0:
        result += f"\n\n... and {skipped} more patch release(s) — check GitHub for full history."

    return result


def fetch_changes_for_outdated(
    grouped_results: dict[str, list[UpdateInfo]],
) -> dict[str, str]:
    outdated: dict[str, UpdateInfo] = {}
    for infos in grouped_results.values():
        for info in infos:
            if info.is_outdated and info.github_repo and info.latest_version:
                outdated.setdefault(info.dep.name.lower(), info)

    if not outdated:
        return {}

    changes: dict[str, str] = {}
    with concurrent.futures.ThreadPoolExecutor(max_workers=10) as executor:
        future_to_name = {
            executor.submit(
                _fetch_github_release_notes,
                github_repo=info.github_repo,
                current_version=info.dep.current_version,
                latest_version=info.latest_version,
            ): info.dep.name
            for info in outdated.values()
        }
        for future in concurrent.futures.as_completed(future_to_name):
            name = future_to_name[future]
            result = future.result()
            if result:
                changes[name] = result

    return changes


def fetch_pypi_info(dep: Dependency) -> UpdateInfo:
    normalized_name = dep.name.lower().replace("_", "-")
    url = f"https://pypi.org/pypi/{normalized_name}/json"

    try:
        req = urllib.request.Request(url, headers={"Accept": "application/json"})
        with urllib.request.urlopen(req, timeout=15) as resp:
            data = json.loads(resp.read().decode())
    except urllib.error.HTTPError as e:
        return UpdateInfo(dep=dep, latest_version=None, is_outdated=False, error=f"HTTP {e.code}")
    except Exception as e:
        return UpdateInfo(dep=dep, latest_version=None, is_outdated=False, error=str(e))

    best_version: str | None = None
    best_upload_time: datetime.datetime | None = None

    for version, files in data.get("releases", {}).items():
        if is_pre_release(version) or not files:
            continue

        upload_times = [
            datetime.datetime.strptime(f["upload_time"], "%Y-%m-%dT%H:%M:%S")
            for f in files
            if f.get("upload_time")
        ]
        if not upload_times:
            continue

        earliest_upload = min(upload_times)

        if best_upload_time is None or earliest_upload > best_upload_time:
            best_version = version
            best_upload_time = earliest_upload

    if best_version is None:
        return UpdateInfo(dep=dep, latest_version=None, is_outdated=False, error="no suitable version found")

    github_repo = _extract_github_repo(data)

    return UpdateInfo(
        dep=dep,
        latest_version=best_version,
        is_outdated=best_version != dep.current_version,
        github_repo=github_repo,
    )


def check_updates(
    groups: dict[str, list[Dependency]],
) -> dict[str, list[UpdateInfo]]:
    all_deps: dict[str, Dependency] = {}
    for group_deps in groups.values():
        for dep in group_deps:
            all_deps.setdefault(dep.name.lower(), dep)

    results: dict[str, UpdateInfo] = {}
    with concurrent.futures.ThreadPoolExecutor(max_workers=10) as executor:
        future_to_name = {
            executor.submit(fetch_pypi_info, dep): dep.name.lower()
            for dep in all_deps.values()
        }
        for future in concurrent.futures.as_completed(future_to_name):
            results[future_to_name[future]] = future.result()

    return {
        group_name: [results[dep.name.lower()] for dep in group_deps]
        for group_name, group_deps in groups.items()
    }


def format_table(grouped_results: dict[str, list[UpdateInfo]]) -> str:
    lines: list[str] = []

    for group_name, infos in grouped_results.items():
        if not infos:
            continue

        lines.append(f"\n{'=' * 70}")
        lines.append(f"  {group_name}")
        lines.append(f"{'=' * 70}")

        name_w = max(len(i.dep.name) for i in infos)
        curr_w = max(len(i.dep.current_version) for i in infos)

        lines.append(f"  {'Package':<{name_w}}  {'Current':<{curr_w}}  {'Latest':<15}  Status")
        lines.append(f"  {'-' * 66}")

        for info in sorted(infos, key=lambda i: i.dep.name.lower()):
            latest = info.latest_version or "?"
            if info.error:
                status = f"error: {info.error}"
            elif info.is_outdated:
                status = "UPDATE AVAILABLE"
            else:
                status = "up to date"
            lines.append(f"  {info.dep.name:<{name_w}}  {info.dep.current_version:<{curr_w}}  {latest:<15}  {status}")

    return "\n".join(lines)


def main() -> int:
    if not PYPROJECT_PATH.exists():
        print(f"Error: {PYPROJECT_PATH} not found", file=sys.stderr)
        return 1

    with open(PYPROJECT_PATH, "rb") as f:
        pyproject = tomllib.load(f)

    groups = parse_all_dependencies(pyproject)
    total = sum(len(deps) for deps in groups.values())
    print(f"Checking {total} dependencies across {len(groups)} group(s)...")

    grouped_results = check_updates(groups)
    print(format_table(grouped_results))

    outdated_count = sum(
        1 for infos in grouped_results.values() for info in infos if info.is_outdated
    )
    if outdated_count:
        print(f"\n{outdated_count} package(s) have updates available.")
        print("\nFetching release notes from GitHub...")
        changes = fetch_changes_for_outdated(grouped_results)
        if changes:
            print(f"\n{'=' * 80}")
            print("  RELEASE NOTES")
            print(f"{'=' * 80}")
            for pkg_name, notes in sorted(changes.items()):
                print(f"\n{'─' * 80}")
                print(f"  {pkg_name}")
                print(f"{'─' * 80}")
                print(notes)
        else:
            print("\nNo release notes found on GitHub for outdated packages.")

    return 0


if __name__ == "__main__":
    sys.exit(main())

Then you can add a command:

How to Upgrade Dependencies

Check ALL dependencies in pyproject.toml for available updates, and upgrade each one with a newer version.

Follow these steps:
1. Run `python scripts/check_dependency_updates.py` to get a full picture of what's outdated and what's current.
2. For each outdated package, check its changelog on PyPI/GitHub for breaking changes, deprecations, or behavior changes.
3. Update the version pin in pyproject.toml to the exact new version (e.g., fastapi==0.133.0). Check for duplicates across dependency groups and update all occurrences consistently.
4. Run `uv lock` to update uv.lock.
5. If there are breaking changes, apply the necessary code updates.
6. Run tests to verify nothing is broken.
7. Summarize what changed, which packages were skipped and why, and any breaking changes you handled.

Once you have both, you can run the command to update your dependencies more safely without spending time reading release notes.

Pro tip: Make sure to update regularly to get all the security patches and keep the diffs minimal.

Become a better engineer, one article at a time.

Practices, mindsets, and habits that actually move the needle. Delivered weekly to your inbox.

Conclusion

Pinning dependencies to exact versions gives you control over when and how upgrades happen. No more surprise breaking changes on a Sunday afternoon. When you're ready to upgrade, let your AI agent do the heavy lifting - reading release notes, flagging breaking changes, bumping the versions, and applying fixes. Just make sure you do it regularly so you don't fall too far behind.

Share