Skip to content

Commit 0d0fa24

Browse files
feat(resolver): exempt top-level == pins from release cooldown
Closes: #1123 Co-Authored-By: Claude <claude@anthropic.com> Signed-off-by: Lalatendu Mohanty <lmohanty@redhat.com>
1 parent b5b31d9 commit 0d0fa24

6 files changed

Lines changed: 142 additions & 14 deletions

File tree

docs/how-tos/release-age-cooldown.rst

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,3 +130,13 @@ Valid values:
130130
This is useful when a specific package is trusted enough to allow recent
131131
versions, or when a package's release cadence makes the global cooldown
132132
impractical.
133+
134+
Top-Level ``==`` Pin Exemption
135+
------------------------------
136+
137+
Top-level requirements that use ``==`` (e.g. ``torch==2.5.1``) bypass the
138+
cooldown automatically — the operator has explicitly chosen that version.
139+
140+
``==`` specifiers in transitive dependencies are **not** exempt; without
141+
this distinction a malicious package could pin its own dependencies to
142+
bypass cooldown.

docs/proposals/release-cooldown.md

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -55,17 +55,12 @@ References:
5555
global default plus per-package overrides.
5656
- **SSH transport** for git timestamp retrieval.
5757

58-
### Future consideration: `==` pin exemptions
59-
60-
Whether `==` pins in top-level requirements or constraints files
61-
should automatically bypass cooldown is deferred. The per-package
62-
`resolver_dist.min_release_age: 0` override already provides an
63-
explicit, auditable escape hatch for packages that need to use
64-
recently-published versions. Adding automatic `==` exemptions
65-
would introduce a special case that weakens the security model
66-
and requires users to understand the distinction. This can be
67-
revisited if the per-package override proves too cumbersome in
68-
practice.
58+
### `==` pin exemptions (implemented)
59+
60+
Top-level `==` pins bypass cooldown automatically. Transitive `==` pins
61+
remain subject to cooldown for security. See
62+
[the how-to guide](../how-tos/release-age-cooldown.rst) for details.
63+
Tracked in [#1123](https://github.com/python-wheel-build/fromager/issues/1123).
6964

7065
## How
7166

src/fromager/resolver.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ def resolve(
103103
req_type=req_type,
104104
ignore_platform=ignore_platform,
105105
)
106-
provider.cooldown = resolve_package_cooldown(ctx, req)
106+
provider.cooldown = resolve_package_cooldown(ctx, req, req_type=req_type)
107107
max_age_cutoff = _compute_max_age_cutoff(ctx)
108108
results = find_all_matching_from_provider(
109109
provider, req, max_age_cutoff=max_age_cutoff
@@ -137,19 +137,36 @@ def default_resolver_provider(
137137
)
138138

139139

140+
def _has_equality_pin(req: Requirement) -> bool:
141+
"""Return True if the requirement has a single exact ``==`` pin.
142+
143+
Rejects wildcard pins (``==1.*``) and compound specifiers (``==1,>2``)
144+
which are not true exact version pins.
145+
"""
146+
specs = list(req.specifier)
147+
return len(specs) == 1 and specs[0].operator == "==" and "*" not in specs[0].version
148+
149+
140150
def resolve_package_cooldown(
141151
ctx: context.WorkContext,
142152
req: Requirement,
153+
req_type: RequirementType | None = None,
143154
) -> Cooldown | None:
144155
"""Compute the effective cooldown for a single package.
145156
146157
Args:
147158
ctx: The current work context (provides the global cooldown).
148159
req: The package requirement being resolved.
160+
req_type: The requirement type (top-level, install, etc.).
149161
150162
Returns:
151163
The cooldown to pass to the provider, or ``None`` if disabled.
152164
"""
165+
if req_type == RequirementType.TOP_LEVEL and _has_equality_pin(req):
166+
if ctx.cooldown is not None:
167+
logger.info("cooldown bypassed as the top-level requirement uses == pin")
168+
return None
169+
153170
per_package_days = ctx.package_build_info(req).resolver_min_release_age
154171
global_cooldown = ctx.cooldown
155172
if per_package_days is None:

src/fromager/sources.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ def get_source_provider(
153153
ignore_platform=pbi.resolver_ignore_platform,
154154
),
155155
)
156-
provider.cooldown = resolver.resolve_package_cooldown(ctx, req)
156+
provider.cooldown = resolver.resolve_package_cooldown(ctx, req, req_type=req_type)
157157
return provider
158158

159159

src/fromager/wheels.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -549,7 +549,9 @@ def resolve_all_prebuilt_wheels(
549549
provider = get_prebuilt_wheel_provider(
550550
ctx=ctx, req=req, wheel_server_url=url, req_type=req_type
551551
)
552-
provider.cooldown = resolver.resolve_package_cooldown(ctx, req)
552+
provider.cooldown = resolver.resolve_package_cooldown(
553+
ctx, req, req_type=req_type
554+
)
553555
# The local fromager wheel server is PEP 503-only and serves
554556
# packages that were already resolved and vetted earlier in the
555557
# same run. Don't fail-closed on missing upload_time there.

tests/test_cooldown.py

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from packaging.version import Version
2121

2222
from fromager import candidate, context, packagesettings, resolver, sources, wheels
23+
from fromager.requirements_file import RequirementType
2324

2425
_BOOTSTRAP_TIME = datetime.datetime(2026, 3, 26, 0, 0, 0, tzinfo=datetime.UTC)
2526
_COOLDOWN_7_DAYS = datetime.timedelta(days=7)
@@ -247,6 +248,43 @@ def test_cooldown_applied_automatically_via_ctx(tmp_path: pathlib.Path) -> None:
247248
assert str(version) == "1.3.2"
248249

249250

251+
def test_toplevel_equality_pin_bypasses_cooldown_via_resolve(
252+
tmp_path: pathlib.Path,
253+
) -> None:
254+
"""Top-level == pin threads through resolve() and bypasses cooldown end-to-end.
255+
256+
Verifies the req_type plumbing in resolver.resolve() actually causes
257+
resolve_package_cooldown() to disable cooldown, allowing a recent version
258+
that would normally be filtered.
259+
"""
260+
ctx = context.WorkContext(
261+
active_settings=None,
262+
constraints_file=None,
263+
patches_dir=tmp_path / "patches",
264+
sdists_repo=tmp_path / "sdists-repo",
265+
wheels_repo=tmp_path / "wheels-repo",
266+
work_dir=tmp_path / "work-dir",
267+
cooldown=_COOLDOWN,
268+
)
269+
270+
with requests_mock.Mocker() as r:
271+
r.get(
272+
"https://pypi.org/simple/test-pkg/",
273+
json=_cooldown_json_response,
274+
headers={"Content-Type": _PYPI_SIMPLE_JSON_CONTENT_TYPE},
275+
)
276+
277+
_, version = resolver.resolve(
278+
ctx=ctx,
279+
req=Requirement("test-pkg==2.0.0"),
280+
sdist_server_url="https://pypi.org/simple/",
281+
include_sdists=True,
282+
include_wheels=True,
283+
req_type=RequirementType.TOP_LEVEL,
284+
)
285+
assert str(version) == "2.0.0"
286+
287+
250288
def test_cooldown_applied_via_get_source_provider(tmp_path: pathlib.Path) -> None:
251289
"""ctx.cooldown propagates through sources.get_source_provider() to any provider.
252290
@@ -860,3 +898,69 @@ def test_compute_max_age_cutoff_disabled(
860898
"""_compute_max_age_cutoff returns None when max_release_age is not set."""
861899
cutoff = resolver._compute_max_age_cutoff(tmp_context)
862900
assert cutoff is None
901+
902+
903+
def test_resolve_package_cooldown_exempt_toplevel_equality_pin(
904+
tmp_path: pathlib.Path,
905+
) -> None:
906+
"""Top-level == pin bypasses cooldown."""
907+
ctx = _make_ctx(tmp_path, cooldown=_COOLDOWN)
908+
result = resolver.resolve_package_cooldown(
909+
ctx, Requirement("test-pkg==1.3.2"), req_type=RequirementType.TOP_LEVEL
910+
)
911+
assert result is None
912+
913+
914+
def test_resolve_package_cooldown_enforced_transitive_equality_pin(
915+
tmp_path: pathlib.Path,
916+
) -> None:
917+
"""Transitive == pin does NOT bypass cooldown."""
918+
ctx = _make_ctx(tmp_path, cooldown=_COOLDOWN)
919+
result = resolver.resolve_package_cooldown(
920+
ctx, Requirement("test-pkg==1.3.2"), req_type=RequirementType.INSTALL
921+
)
922+
assert result is _COOLDOWN
923+
924+
925+
def test_resolve_package_cooldown_enforced_toplevel_no_pin(
926+
tmp_path: pathlib.Path,
927+
) -> None:
928+
"""Top-level requirement without == still gets cooldown."""
929+
ctx = _make_ctx(tmp_path, cooldown=_COOLDOWN)
930+
result = resolver.resolve_package_cooldown(
931+
ctx, Requirement("test-pkg>=1.0"), req_type=RequirementType.TOP_LEVEL
932+
)
933+
assert result is _COOLDOWN
934+
935+
936+
def test_resolve_package_cooldown_none_req_type_not_exempt(
937+
tmp_path: pathlib.Path,
938+
) -> None:
939+
"""Unknown req_type (None) with == does NOT bypass cooldown."""
940+
ctx = _make_ctx(tmp_path, cooldown=_COOLDOWN)
941+
result = resolver.resolve_package_cooldown(
942+
ctx, Requirement("test-pkg==1.3.2"), req_type=None
943+
)
944+
assert result is _COOLDOWN
945+
946+
947+
def test_resolve_package_cooldown_toplevel_wildcard_equality_not_exempt(
948+
tmp_path: pathlib.Path,
949+
) -> None:
950+
"""Top-level wildcard equality (==1.*) is not an exact pin — cooldown applies."""
951+
ctx = _make_ctx(tmp_path, cooldown=_COOLDOWN)
952+
result = resolver.resolve_package_cooldown(
953+
ctx, Requirement("test-pkg==1.*"), req_type=RequirementType.TOP_LEVEL
954+
)
955+
assert result is _COOLDOWN
956+
957+
958+
def test_resolve_package_cooldown_toplevel_compound_specifier_not_exempt(
959+
tmp_path: pathlib.Path,
960+
) -> None:
961+
"""Top-level compound specifier (==1.0,>0.9) is not a single exact pin."""
962+
ctx = _make_ctx(tmp_path, cooldown=_COOLDOWN)
963+
result = resolver.resolve_package_cooldown(
964+
ctx, Requirement("test-pkg==1.0,>0.9"), req_type=RequirementType.TOP_LEVEL
965+
)
966+
assert result is _COOLDOWN

0 commit comments

Comments
 (0)