Skip to content

Commit e4d9e57

Browse files
ryanpetrelloclaude
andcommitted
fix(resolver): apply release-age cooldown to pre-built wheel resolution
Remove the sdist-only guard from resolver.py and sources.py so that cooldown applies regardless of whether wheels or sdists are being resolved. Also set provider.cooldown in resolve_all_prebuilt_wheels() since that path bypasses resolver.resolve() entirely. Adds an E2E test (test_bootstrap_cooldown_prebuilt.sh) that configures stevedore as pre_built and verifies cooldown blocks the 5.4.0 wheel in favour of 5.3.0. Co-Authored-By: Claude <claude@anthropic.com>
1 parent 22dbed9 commit e4d9e57

10 files changed

Lines changed: 306 additions & 134 deletions

File tree

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

Lines changed: 29 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -41,43 +41,50 @@ A value of ``0`` (the default) disables the check entirely.
4141
Scope
4242
-----
4343

44-
The cooldown applies to **sdist resolution** — selecting which version of a
45-
package to build from source, including transitive dependencies. It does not
46-
apply to:
44+
The cooldown applies to both **sdist resolution** and **pre-built wheel
45+
resolution** — any candidate whose ``upload-time`` is more recent than the
46+
cutoff is rejected, regardless of whether it is an sdist or a prebuilt wheel.
4747

48-
* Wheel-only lookups, including cache servers (``--cache-wheel-server-url``) and
49-
packages configured as ``pre_built: true`` in variant settings. These use a
50-
different trust model and are not subject to the cooldown regardless of which
51-
server they are fetched from.
52-
* Packages resolved from Git URLs that do not provide timestamp metadata.
48+
The following are **not** subject to the cooldown:
5349

54-
Note that sdist resolution from a private package index depends on
50+
* Fromager's internal build and cache wheel servers. These are not used for
51+
version selection — they are checked only for already-resolved pinned
52+
versions — so the cooldown has no insertion point.
53+
* Packages resolved from Git URLs. Git timestamps are set by the client, not
54+
the server, and cannot be trusted for cooldown enforcement.
55+
56+
Resolution from a private package index (sdist or wheel) depends on
5557
``upload-time`` being present in the index's PEP 691 JSON responses. If the
56-
index does not provide that metadata, candidates will be rejected under the
57-
fail-closed policy described below.
58+
index does not provide that metadata, candidates are rejected under the
59+
fail-closed policy described below. Use ``resolver_dist.min_release_age: 0``
60+
to bypass cooldown for packages from indexes that structurally cannot supply
61+
timestamps.
5862

5963

6064
Fail-Closed Behavior
6165
--------------------
6266

63-
If a candidate has no ``upload-time`` metadata — which can occur with older
64-
PyPI Simple HTML responses — it is rejected when a cooldown is active. Fromager
65-
uses the `PEP 691 JSON Simple API`_ when fetching package metadata, which
66-
reliably includes upload timestamps.
67+
If a candidate has no ``upload-time`` metadata — whether it is an sdist or a
68+
wheel — it is rejected when a cooldown is active. Fromager uses the
69+
`PEP 691 JSON Simple API`_ when fetching package metadata, which reliably
70+
includes upload timestamps for PyPI.org.
6771

6872
.. _PEP 691 JSON Simple API: https://peps.python.org/pep-0691/
6973

74+
For indexes that only implement the `PEP 503`_ HTML API and cannot supply
75+
timestamps, use the per-package ``resolver_dist.min_release_age: 0`` override
76+
to bypass the cooldown for affected packages rather than disabling it globally.
77+
78+
.. _PEP 503: https://peps.python.org/pep-0503/
79+
7080
.. note::
7181

7282
If you are writing a ``get_resolver_provider`` plugin that uses
7383
:class:`~fromager.resolver.PyPIProvider` with a private index that only
74-
implements the `PEP 503`_ HTML API (and therefore cannot supply upload
75-
timestamps), pass ``supports_upload_time=False`` to ``PyPIProvider``. This
76-
switches the provider from fail-closed to warn-and-skip, so candidates
77-
without upload timestamps are skipped with a warning rather than causing
78-
resolution to fail.
79-
80-
.. _PEP 503: https://peps.python.org/pep-0503/
84+
implements the PEP 503 HTML API, pass ``supports_upload_time=False`` to
85+
``PyPIProvider``. This switches the provider from fail-closed to
86+
warn-and-skip, so candidates without upload timestamps are skipped with a
87+
warning rather than causing resolution to fail.
8188

8289
Example
8390
-------

e2e/ci_bootstrap_suite.sh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ run_test "bootstrap_cooldown_transitive"
3939
run_test "bootstrap_cooldown_gitlab"
4040
run_test "bootstrap_cooldown_github"
4141
run_test "bootstrap_cooldown_override"
42+
run_test "bootstrap_cooldown_prebuilt"
4243

4344
test_section "bootstrap git URL tests"
4445
run_test "bootstrap_git_url"
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
variants:
2+
cpu:
3+
pre_built: true

e2e/test_bootstrap_cooldown_github.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ find "$OUTDIR/wheels-repo/" -name '*.whl'
4343

4444
pass=true
4545

46-
# Resolution must succeed despite the 9999-day cooldown — GitHub timestamps
46+
# Resolution must succeed despite the large cooldown — GitHub timestamps
4747
# are not yet supported, so the cooldown is skipped rather than enforced.
4848
if ! find "$OUTDIR/wheels-repo/downloads/" -name 'stevedore-*.whl' | grep -q .; then
4949
echo "FAIL: no stevedore wheel found — resolution should have succeeded despite cooldown" 1>&2
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
#!/bin/bash
2+
# -*- indent-tabs-mode: nil; tab-width: 2; sh-indentation: 2; -*-
3+
4+
# Tests that --min-release-age rejects pre-built wheel candidates published
5+
# within the cooldown window and falls back to an older stevedore version.
6+
# This exercises the resolve_all_prebuilt_wheels() code path in wheels.py,
7+
# which is taken when a package is configured with pre_built: true.
8+
#
9+
# Release timeline (wheel upload times, UTC):
10+
#
11+
# stevedore 5.3.0 2024-08-22 (the expected fallback)
12+
# stevedore 5.4.0 2024-11-20 (blocked by cooldown)
13+
# stevedore 5.5.0+ future (all blocked by cooldown)
14+
#
15+
# We use the same MIN_AGE anchor as test_bootstrap_cooldown.sh: the age of
16+
# stevedore 5.4.0 (released 2024-11-20) plus a 1-day buffer, ensuring 5.4.0
17+
# is always just inside the cooldown window while 5.3.0 (released ~90 days
18+
# earlier) always clears it.
19+
20+
SCRIPTDIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
21+
source "$SCRIPTDIR/common.sh"
22+
23+
MIN_AGE=$(python3 -c "
24+
from datetime import date
25+
age = (date.today() - date(2024, 11, 20)).days
26+
print(age + 1)
27+
")
28+
29+
fromager \
30+
--log-file="$OUTDIR/bootstrap.log" \
31+
--error-log-file="$OUTDIR/fromager-errors.log" \
32+
--sdists-repo="$OUTDIR/sdists-repo" \
33+
--wheels-repo="$OUTDIR/wheels-repo" \
34+
--work-dir="$OUTDIR/work-dir" \
35+
--settings-dir="$SCRIPTDIR/cooldown_prebuilt_settings" \
36+
--min-release-age="$MIN_AGE" \
37+
bootstrap 'stevedore'
38+
39+
pass=true
40+
41+
# stevedore 5.4.0's wheel is blocked; the resolver must fall back to 5.3.0.
42+
if ! grep -q "new toplevel dependency stevedore resolves to 5.3.0" "$OUTDIR/bootstrap.log"; then
43+
echo "FAIL: expected stevedore to resolve to 5.3.0 but it did not" 1>&2
44+
pass=false
45+
fi
46+
47+
# The wheel must have been downloaded as a pre-built (not built from source).
48+
if ! grep -q "uses a pre-built wheel" "$OUTDIR/bootstrap.log"; then
49+
echo "FAIL: stevedore was not downloaded as a pre-built wheel" 1>&2
50+
pass=false
51+
fi
52+
53+
# Wheel file must exist in the prebuilt directory.
54+
if ! find "$OUTDIR/wheels-repo/prebuilt/" -name 'stevedore-5.3.0*.whl' | grep -q .; then
55+
echo "FAIL: stevedore-5.3.0 wheel not found in wheels-repo/prebuilt" 1>&2
56+
pass=false
57+
fi
58+
59+
# No stevedore sdist should have been downloaded — it is pre_built only.
60+
if find "$OUTDIR/sdists-repo/" \( -name 'stevedore*.tar.gz' -o -name 'stevedore*.zip' \) 2>/dev/null | grep -q .; then
61+
echo "FAIL: stevedore sdist found in sdists-repo — should be pre-built only" 1>&2
62+
pass=false
63+
fi
64+
65+
$pass

src/fromager/packagesettings/_models.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,7 @@ class ResolverDist(pydantic.BaseModel):
178178
0: disable the release-age cooldown for this package.
179179
Positive integer: override the cooldown with this many days.
180180
181-
.. versionadded:: 0.81
181+
.. versionadded:: 0.82
182182
"""
183183

184184
@pydantic.model_validator(mode="after")

src/fromager/resolver.py

Lines changed: 71 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -104,14 +104,7 @@ def resolve(
104104
req_type=req_type,
105105
ignore_platform=ignore_platform,
106106
)
107-
# Apply cooldown settings to sdist resolution only. Wheel-only lookups
108-
# (cache servers, pre_built packages) use a different trust model.
109-
pbi = ctx.package_build_info(req)
110-
provider.cooldown = (
111-
resolve_package_cooldown(ctx.cooldown, pbi.resolver_min_release_age)
112-
if include_sdists
113-
else None
114-
)
107+
provider.cooldown = resolve_package_cooldown(ctx, req)
115108
results = find_all_matching_from_provider(provider, req)
116109
return results[0]
117110

@@ -143,19 +136,20 @@ def default_resolver_provider(
143136

144137

145138
def resolve_package_cooldown(
146-
global_cooldown: Cooldown | None,
147-
per_package_days: int | None,
139+
ctx: context.WorkContext,
140+
req: Requirement,
148141
) -> Cooldown | None:
149142
"""Compute the effective cooldown for a single package.
150143
151144
Args:
152-
global_cooldown: The run-wide cooldown from ``--min-release-age``.
153-
per_package_days: The ``resolver_dist.min_release_age`` setting for
154-
this package (None = inherit, 0 = disabled, positive = override).
145+
ctx: The current work context (provides the global cooldown).
146+
req: The package requirement being resolved.
155147
156148
Returns:
157149
The cooldown to pass to the provider, or ``None`` if disabled.
158150
"""
151+
per_package_days = ctx.package_build_info(req).resolver_min_release_age
152+
global_cooldown = ctx.cooldown
159153
if per_package_days is None:
160154
return global_cooldown
161155
if per_package_days == 0:
@@ -470,6 +464,7 @@ def get_project_from_pypi(
470464
class BaseProvider(ExtrasProvider):
471465
resolver_cache: typing.ClassVar[ResolverCache] = {}
472466
provider_description: typing.ClassVar[str]
467+
_cooldown_unsupported_warned: typing.ClassVar[set[str]] = set()
473468

474469
def __init__(
475470
self,
@@ -486,16 +481,11 @@ def __init__(
486481

487482
# cooldown specific settings
488483
self.cooldown = cooldown
489-
self.cooldown_unsupported_previously_warned = False
490-
"""
491-
Does this provider supply upload timestamps for candidates?
492-
493-
Defaults to False (safe/unknown). Subclasses that reliably populate
494-
upload_time on every candidate should set this to True in their __init__.
495-
496-
When a cooldown is active and this is False, the cooldown check is
497-
skipped with a warning rather than failing closed.
498-
"""
484+
# Does this provider supply upload timestamps for candidates?
485+
# Defaults to False (safe/unknown). Subclasses that reliably populate
486+
# upload_time on every candidate should set this to True in their __init__.
487+
# When a cooldown is active and this is False, the cooldown check is
488+
# skipped with a warning rather than failing closed.
499489
self.supports_upload_time: bool = False
500490

501491
@property
@@ -607,47 +597,62 @@ def is_satisfied_by(self, requirement: Requirement, candidate: Candidate) -> boo
607597
)
608598
return False
609599

610-
# Release-age cooldown: reject candidates published too recently.
611-
if self.cooldown is not None:
612-
if candidate.upload_time is None:
613-
if not self.supports_upload_time:
614-
# Provider does not yet support timestamp retrieval (e.g. GitHub).
615-
# Warn once per provider instance (i.e. per package) rather than
616-
# once per candidate.
617-
if not self.cooldown_unsupported_previously_warned:
618-
self.cooldown_unsupported_previously_warned = True
619-
logger.warning(
620-
"%s: release-age cooldown cannot be enforced — upload "
621-
"timestamp support is not yet implemented for %s; "
622-
"cooldown check skipped",
623-
requirement.name,
624-
self.get_provider_description(),
625-
)
626-
return True
627-
# Provider should supply timestamps but this candidate is missing one.
628-
# Fail closed: we cannot verify the age, so reject it.
629-
if DEBUG_RESOLVER:
630-
logger.debug(
631-
"%s: skipping %s — upload_time unknown, required for cooldown",
632-
requirement.name,
633-
candidate.version,
634-
)
635-
return False
636-
cutoff = self.cooldown.bootstrap_time - self.cooldown.min_age
637-
if candidate.upload_time > cutoff:
638-
if DEBUG_RESOLVER:
639-
age = self.cooldown.bootstrap_time - candidate.upload_time
640-
logger.debug(
641-
"%s: skipping %s uploaded %s ago (cooldown: %s)",
642-
requirement.name,
643-
candidate.version,
644-
age,
645-
self.cooldown.min_age,
646-
)
647-
return False
600+
if self.is_blocked_by_cooldown(candidate):
601+
return False
648602

649603
return True
650604

605+
def is_blocked_by_cooldown(self, candidate: Candidate) -> bool:
606+
"""Return True if the candidate is rejected by the release-age cooldown."""
607+
608+
# a cooldown is not specified...
609+
if self.cooldown is None:
610+
return False
611+
612+
# the target candidate doesn't provide a valid upload timestamp
613+
if candidate.upload_time is None:
614+
if not self.supports_upload_time:
615+
# this provider does not yet support timestamp retrieval (e.g. GitHub).
616+
# Warn once per package name across all provider instances.
617+
if candidate.name not in BaseProvider._cooldown_unsupported_warned:
618+
BaseProvider._cooldown_unsupported_warned.add(candidate.name)
619+
logger.warning(
620+
"%s: release-age cooldown cannot be enforced — upload "
621+
"timestamp support is not yet implemented for %s; "
622+
"cooldown check skipped",
623+
candidate.name,
624+
self.get_provider_description(),
625+
)
626+
return False
627+
# this provider is expected to supply timestamps,
628+
# but this candidate is missing one.
629+
# Fail closed: we cannot verify the age of this candidate, so reject it.
630+
if DEBUG_RESOLVER:
631+
logger.debug(
632+
"%s: skipping %s — upload_time unknown, required for cooldown",
633+
candidate.name,
634+
candidate.version,
635+
)
636+
return True
637+
638+
# cooldowns are enabled, and this candidate has a valid upload timestamp
639+
# so we can do the math to determine whether or not the candidate should
640+
# be blocked/skipped
641+
cutoff = self.cooldown.bootstrap_time - self.cooldown.min_age
642+
if candidate.upload_time > cutoff:
643+
# if this candidate is "too new", block/skip it
644+
if DEBUG_RESOLVER:
645+
age = self.cooldown.bootstrap_time - candidate.upload_time
646+
logger.debug(
647+
"%s: skipping %s uploaded %s ago (cooldown: %s)",
648+
candidate.name,
649+
candidate.version,
650+
age,
651+
self.cooldown.min_age,
652+
)
653+
return True
654+
return False
655+
651656
def get_dependencies(self, candidate: Candidate) -> list[Requirement]:
652657
# return candidate.dependencies
653658
return []
@@ -751,7 +756,7 @@ def __init__(
751756
use_resolver_cache: bool = True,
752757
override_download_url: str | None = None,
753758
cooldown: Cooldown | None = None,
754-
supports_upload_time: bool = True,
759+
supports_upload_time: bool | None = None,
755760
):
756761
super().__init__(
757762
constraints=constraints,
@@ -760,13 +765,10 @@ def __init__(
760765
cooldown=cooldown,
761766
)
762767

763-
# not all PyPI indexes reliably support
764-
# https://peps.python.org/pep-0691/, which is necessary
765-
# to reliably supply upload times for packages
766-
#
767-
# consumers of this provider which specify a custom sdist_server_url
768-
# that only provides support for https://peps.python.org/pep-0503/
769-
# Simple Repository API should specify support_upload_time=False
768+
# Only pypi.org reliably supports PEP 691 upload timestamps.
769+
# Default to True for pypi.org, False for all other indexes.
770+
if supports_upload_time is None:
771+
supports_upload_time = sdist_server_url.startswith(PYPI_SERVER_URL)
770772
self.supports_upload_time = supports_upload_time
771773
self.include_sdists = include_sdists
772774
self.include_wheels = include_wheels

src/fromager/sources.py

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -153,11 +153,7 @@ def get_source_provider(
153153
ignore_platform=pbi.resolver_ignore_platform,
154154
),
155155
)
156-
provider.cooldown = (
157-
resolver.resolve_package_cooldown(ctx.cooldown, pbi.resolver_min_release_age)
158-
if pbi.resolver_include_sdists
159-
else None
160-
)
156+
provider.cooldown = resolver.resolve_package_cooldown(ctx, req)
161157
return provider
162158

163159

src/fromager/wheels.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -549,6 +549,12 @@ 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)
553+
# The local fromager wheel server is PEP 503-only and serves
554+
# packages that were already resolved and vetted earlier in the
555+
# same run. Don't fail-closed on missing upload_time there.
556+
if ctx.wheel_server_url and url == ctx.wheel_server_url:
557+
provider.supports_upload_time = False
552558

553559
# Get all matching candidates from provider
554560
results = resolver.find_all_matching_from_provider(provider, req)

0 commit comments

Comments
 (0)