Skip to content

Commit 545c985

Browse files
JuliaEdomclaude
andcommitted
chore(deps): make the dependency ignores Dependabot actually applies
PR #254 re-proposed mcp <2 -> <3 on 2026-09-08 at 10:08, six hours after 5839a7f landed an ignore written to stop exactly that. The ignore was not missing and was not misspelled -- it reached the job intact, and did nothing. `update-types: ["version-update:semver-major"]` is derived from the CURRENT version. Neither pip ecosystem here has one: every requirement is a range (`mcp>=1.0,<2`) and Dependabot has no pip lockfile to resolve it against. Root pip run 34213488343 prints the whole failure in three lines -- "Checking if mcp needs updating" with an empty version, "Ignored versions:" with an empty list, "Updating mcp from to 2.1.1". All six conditions in that job behaved the same way: langchain-core, langchain-text-splitters, langsmith, dagster, mcp and pyiceberg-core each logged an empty ignore list. Only mcp produced a PR because it is the only one whose latest release currently sits above its ceiling; the other five were inert too, just not yet visibly so. Each condition now states `versions: [">=<ceiling>"]`, evaluated against the candidate release, so no current version is needed. Ceilings mirror the upper bound each manifest declares: langchain-core >=2, langchain-text-splitters >=2, langsmith >=1, dagster >=2, mcp >=2 (both halves of the mirrored pin), pyiceberg-core >=0.8. No policy changed -- these are the same gates 5839a7f documented, expressed in the mechanism that fires. docker and github-actions keep `update-types`: an image tag and an action ref are pinned current versions, so the update type is computable there. tests/unit/test_dependabot_ignore_contract.py holds the shape (no `update-types` in a pip ignore), ties each ceiling to the `<` bound parsed out of pyproject.toml / integrations/pyproject.toml so a widened pin cannot leave its ignore behind, and keeps the two halves of the mcp pin equal. 6 passed; test_security_tooling_policy.py and test_contract_dependencies.py still pass (27 passed). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015HsJh4f7Lqv3XmkCPSA1uF
1 parent e962be9 commit 545c985

2 files changed

Lines changed: 172 additions & 7 deletions

File tree

‎.github/dependabot.yml‎

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -56,22 +56,37 @@ updates:
5656
# manual major bump (the whole reason their majors are gated). Do NOT
5757
# "fix" by dropping the ignore — that re-introduces the unwanted
5858
# major-version PR churn these floors exist to prevent.
59+
#
60+
# NOTE (2026-09-08): every condition below states an explicit version
61+
# range. `update-types: ["version-update:semver-major"]` looks equivalent
62+
# and is not: the update type is derived from the CURRENT version, and in
63+
# these pip ecosystems there is no current version to derive it from —
64+
# every requirement is a range and Dependabot has no pip lockfile to
65+
# resolve it against. The updater says so in as many words (root pip run
66+
# 34213488343): "Checking if mcp needs updating", then "Ignored
67+
# versions:" with nothing after it, then "Updating mcp from to 2.1.1".
68+
# All six conditions reached that job and all six ignored-version lists
69+
# came back empty, which is how PR #254 re-proposed #244's exact widening
70+
# hours after the mcp ignore landed. A version range needs no current
71+
# version, so it matches. Each ceiling mirrors the upper bound declared
72+
# in pyproject.toml / integrations/pyproject.toml;
73+
# tests/unit/test_dependabot_ignore_contract.py keeps the two in step.
5974
- dependency-name: "langchain-core"
60-
update-types: ["version-update:semver-major"]
75+
versions: [">=2"]
6176
- dependency-name: "langchain-text-splitters"
62-
update-types: ["version-update:semver-major"]
77+
versions: [">=2"]
6378
- dependency-name: "langsmith"
64-
update-types: ["version-update:semver-major"]
79+
versions: [">=1"]
6580
- dependency-name: "dagster"
66-
update-types: ["version-update:semver-major"]
81+
versions: [">=2"]
6782
# mcp 2.x's lowlevel Server drops the list_tools/call_tool decorators
6883
# integrations/agentflow_integrations/mcp/server.py is built on (checked
6984
# against 2.2.0), so a major bump is a port, not an update. PR #244
7085
# proposed <3 and was closed on that basis; the ignore stops it coming
7186
# back weekly. Same NOTE above applies: the safety/pip-audit jobs still
7287
# scan mcp and fail on a CVE that only a major fixes.
7388
- dependency-name: "mcp"
74-
update-types: ["version-update:semver-major"]
89+
versions: [">=2"]
7590
# pyiceberg-core is pinned to the release PyIceberg pairs with, not to
7691
# the newest one. PyIceberg 0.11.1 declares pyiceberg-core>=0.5.1,<0.9.0;
7792
# the only step up inside that window is 0.8.0, published as
@@ -80,7 +95,7 @@ updates:
8095
# its own range, and that arrives as a pyiceberg PR. PR #245 proposed
8196
# <0.11 and was closed on that basis.
8297
- dependency-name: "pyiceberg-core"
83-
update-types: ["version-update:semver-minor"]
98+
versions: [">=0.8"]
8499

85100
# ── Python: Flink runtime image manifest ───────────────────────────
86101
# Not an extra of the root project (its beam chain can never co-install
@@ -141,8 +156,11 @@ updates:
141156
# The other half of the mirrored mcp pin (see the root pip entry): the
142157
# ceiling is what the integration's decorators need, so leaving this
143158
# ecosystem unignored just reopens the same PR from the other directory.
159+
# `versions:`, not `update-types:`, for the reason recorded in the root
160+
# pip entry: without a resolved current version an update-type condition
161+
# never matches.
144162
- dependency-name: "mcp"
145-
update-types: ["version-update:semver-major"]
163+
versions: [">=2"]
146164

147165
# ── npm: TypeScript SDK ────────────────────────────────────────────
148166
- package-ecosystem: npm
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
"""Dependabot `ignore` conditions in the pip ecosystems must state version ranges.
2+
3+
`update-types: ["version-update:semver-major"]` reads like a major-version gate
4+
and is not one here. Dependabot derives the update type from the *current*
5+
version, and neither pip ecosystem in this repository has one: every
6+
requirement is a range (`mcp>=1.0,<2`) and there is no pip lockfile for the
7+
updater to resolve it against. With nothing to compare against, the condition
8+
matches nothing.
9+
10+
The root pip run 34213488343 (2026-09-08) shows it end to end: the job
11+
definition carried all six ignore conditions, the log printed ``Checking if mcp
12+
needs updating`` with an empty current version, then ``Ignored versions:`` with
13+
an empty list, then ``Updating mcp from to 2.1.1`` -- and PR #254 re-proposed
14+
the widening that #244 had already been closed for, hours after the mcp ignore
15+
landed.
16+
17+
An explicit ``versions`` range is evaluated against the candidate version, so it
18+
needs no current version. These tests keep every pip ignore on that mechanism
19+
and keep each ceiling equal to the upper bound the corresponding pyproject
20+
actually declares -- widening a pin without moving its ignore (or the reverse)
21+
fails here rather than by re-opening the same Dependabot PR every week.
22+
23+
The docker and github-actions ecosystems deliberately keep `update-types`: an
24+
image tag and an action ref *are* pinned current versions, so the mechanism
25+
works there.
26+
"""
27+
28+
import tomllib
29+
from pathlib import Path
30+
31+
import pytest
32+
import yaml
33+
from packaging.requirements import Requirement
34+
35+
PROJECT_ROOT = Path(__file__).resolve().parents[2]
36+
DEPENDABOT_PATH = PROJECT_ROOT / ".github" / "dependabot.yml"
37+
38+
# Dependabot `directory` -> the manifest whose upper bounds the ignores mirror.
39+
PIP_MANIFESTS = {
40+
"/": PROJECT_ROOT / "pyproject.toml",
41+
"/integrations": PROJECT_ROOT / "integrations" / "pyproject.toml",
42+
}
43+
44+
45+
def _load_dependabot() -> dict:
46+
return yaml.safe_load(DEPENDABOT_PATH.read_text(encoding="utf-8"))
47+
48+
49+
def _pip_updates_with_ignores() -> list[dict]:
50+
return [
51+
update
52+
for update in _load_dependabot()["updates"]
53+
if update["package-ecosystem"] == "pip" and update.get("ignore")
54+
]
55+
56+
57+
def _declared_requirements(manifest_path: Path) -> dict[str, Requirement]:
58+
manifest = tomllib.loads(manifest_path.read_text(encoding="utf-8"))
59+
project = manifest["project"]
60+
61+
raw: list[str] = list(project.get("dependencies", []))
62+
for extra in project.get("optional-dependencies", {}).values():
63+
raw.extend(extra)
64+
65+
requirements: dict[str, Requirement] = {}
66+
for entry in raw:
67+
requirement = Requirement(entry)
68+
# A name declared in several extras must declare the same bound in each,
69+
# otherwise "the" ceiling this ignore mirrors is ambiguous.
70+
previous = requirements.get(requirement.name)
71+
assert previous is None or str(previous.specifier) == str(requirement.specifier), (
72+
f"{requirement.name} is declared with conflicting specifiers in "
73+
f"{manifest_path.name}: {previous} vs {requirement}"
74+
)
75+
requirements[requirement.name] = requirement
76+
77+
return requirements
78+
79+
80+
def _exclusive_upper_bound(requirement: Requirement) -> str:
81+
uppers = [spec.version for spec in requirement.specifier if spec.operator == "<"]
82+
assert len(uppers) == 1, (
83+
f"{requirement.name} must declare exactly one `<` upper bound for its "
84+
f"Dependabot ignore to mirror; found {uppers or 'none'}"
85+
)
86+
return uppers[0]
87+
88+
89+
def test_pip_ecosystems_have_ignore_conditions() -> None:
90+
"""A vacuous suite would pass if the ignore blocks were dropped wholesale."""
91+
directories = {update["directory"] for update in _pip_updates_with_ignores()}
92+
assert directories == set(PIP_MANIFESTS), (
93+
"expected pip ignore conditions in exactly the root and integrations "
94+
f"ecosystems, found {sorted(directories)}"
95+
)
96+
97+
98+
@pytest.mark.parametrize("update", _pip_updates_with_ignores(), ids=lambda u: u["directory"])
99+
def test_pip_ignore_conditions_state_a_version_range(update: dict) -> None:
100+
for condition in update["ignore"]:
101+
name = condition["dependency-name"]
102+
assert "update-types" not in condition, (
103+
f"{name} ({update['directory']}) gates on update-types; without a "
104+
"resolved current version Dependabot cannot classify the update and "
105+
"the condition is inert (root pip run 34213488343). Use `versions`."
106+
)
107+
versions = condition.get("versions")
108+
assert versions, f"{name} ({update['directory']}) has no `versions` range"
109+
assert all(str(entry).startswith(">=") for entry in versions), (
110+
f"{name} ({update['directory']}) must ignore everything at or above "
111+
f"its declared ceiling, got {versions}"
112+
)
113+
114+
115+
@pytest.mark.parametrize("update", _pip_updates_with_ignores(), ids=lambda u: u["directory"])
116+
def test_pip_ignore_ceilings_mirror_the_declared_upper_bound(update: dict) -> None:
117+
requirements = _declared_requirements(PIP_MANIFESTS[update["directory"]])
118+
119+
for condition in update["ignore"]:
120+
name = condition["dependency-name"]
121+
requirement = requirements.get(name)
122+
assert requirement is not None, (
123+
f"{name} is ignored for {update['directory']} but that manifest no "
124+
"longer declares it; drop the ignore or restore the dependency"
125+
)
126+
ceiling = _exclusive_upper_bound(requirement)
127+
assert condition["versions"] == [f">={ceiling}"], (
128+
f"{name} ({update['directory']}) declares `<{ceiling}` but ignores "
129+
f"{condition['versions']}. Moving the pin without moving the ignore "
130+
"leaves Dependabot proposing the widening the pin exists to refuse."
131+
)
132+
133+
134+
def test_mcp_ignore_is_mirrored_across_both_pip_ecosystems() -> None:
135+
"""The pin has two halves; ignoring one directory just reopens the PR from the other."""
136+
ceilings = {
137+
update["directory"]: condition["versions"]
138+
for update in _pip_updates_with_ignores()
139+
for condition in update["ignore"]
140+
if condition["dependency-name"] == "mcp"
141+
}
142+
assert set(ceilings) == set(PIP_MANIFESTS), (
143+
f"mcp must be ignored in both pip ecosystems, found {sorted(ceilings)}"
144+
)
145+
assert len(set(map(tuple, ceilings.values()))) == 1, (
146+
f"the two halves of the mcp pin disagree: {ceilings}"
147+
)

0 commit comments

Comments
 (0)