Skip to content

Commit e59ae9e

Browse files
docs(proposal): add release cooldown design for version resolution
See: #877 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Lalatendu Mohanty <lmohanty@redhat.com>
1 parent aec9c9c commit e59ae9e

3 files changed

Lines changed: 277 additions & 0 deletions

File tree

docs/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ Contributing to fromager.
7676
:maxdepth: 2
7777

7878
develop.md
79+
proposals/index.rst
7980

8081
What's with the name?
8182
---------------------

docs/proposals/index.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
Fromager Enhancement Proposals
2+
==============================
3+
4+
.. toctree::
5+
:maxdepth: 1
6+
7+
release-cooldown

docs/proposals/release-cooldown.md

Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
# Release cooldown for version resolution
2+
3+
- Author: Lalatendu Mohanty
4+
- Created: 2026-03-31
5+
- Status: Open
6+
- Issue: [#877](https://github.com/python-wheel-build/fromager/issues/877)
7+
8+
## What
9+
10+
A configurable minimum release age ("cooldown") for version resolution.
11+
When enabled, fromager skips package versions published fewer than N
12+
days ago. One global setting controls all providers. Per-package
13+
overrides allow exceptions.
14+
15+
## Why
16+
17+
Supply-chain attacks often publish a malicious package version and rely
18+
on automated builds picking it up immediately. A cooldown window lets
19+
the community detect and report compromised releases before fromager
20+
consumes them. It also means new versions get broader testing before
21+
entering the build.
22+
23+
References:
24+
25+
- [We should all be using dependency cooldowns](https://blog.yossarian.net/2025/11/21/We-should-all-be-using-dependency-cooldowns)
26+
- [Malicious sha1hulud](https://helixguard.ai/blog/malicious-sha1hulud-2025-11-24)
27+
28+
## Goals
29+
30+
- A single `--min-release-age` CLI option (days, default 0) that
31+
applies to every resolver provider
32+
- Per-package overrides via `resolver_dist.min_release_age` in package
33+
settings, taking priority over the CLI default
34+
- Provider-aware fail-closed: providers that support timestamps
35+
reject candidates with missing `upload_time`; providers that do
36+
not support timestamps skip cooldown with a warning
37+
- Pre-built wheels exempt (different trust model)
38+
- `list-versions` shows timestamps, ages, and cooldown status
39+
- `list-overrides` shows per-package cooldown values
40+
- Age calculated from bootstrap start time, not wall-clock time during
41+
resolution
42+
43+
## Non-goals
44+
45+
- **Provider-specific flags** (`--pypi-min-age`, `--github-min-age`).
46+
The provider a package uses (PyPI, GitHub, GitLab) reflects *how* it
47+
is obtained, not how trusted it is. Most GitHub/GitLab packages are
48+
there because of broken PyPI sdists or midstream forks. Separate
49+
flags per provider would create a confusing configuration matrix and
50+
cannot coexist cleanly with a global model. This proposal uses one
51+
global default plus per-package overrides.
52+
- **Bypassing cooldown for `==` pins.** The cooldown applies uniformly.
53+
Use `min_release_age: 0` on a specific package if needed.
54+
- **SSH transport** for git timestamp retrieval.
55+
56+
## How
57+
58+
### Configuration
59+
60+
#### CLI and environment variable
61+
62+
```python
63+
@click.option(
64+
"--min-release-age",
65+
type=click.IntRange(min=0),
66+
default=0,
67+
envvar="FROMAGER_MIN_RELEASE_AGE",
68+
help="Minimum days a release must be public before use (0 = no cooldown)",
69+
)
70+
```
71+
72+
The value is stored on `WorkContext` with a `start_time` captured once
73+
at construction (UTC). A fixed start time ensures consistent results
74+
when the same package is resolved multiple times during a build.
75+
76+
#### Per-package overrides
77+
78+
A new field in `ResolverDist`:
79+
80+
```yaml
81+
# Trusted internal package -- bypass cooldown
82+
resolver_dist:
83+
min_release_age: 0
84+
85+
# Extra scrutiny -- 2-week cooldown
86+
resolver_dist:
87+
min_release_age: 14
88+
```
89+
90+
Semantics:
91+
92+
- `None` (default) -- use the global `--min-release-age`
93+
- `0` -- no cooldown for this package
94+
- Positive integer -- override the global value
95+
96+
`PackageBuildInfo.resolver_min_release_age(global_default)` resolves
97+
the effective value.
98+
99+
### Enforcement
100+
101+
The check lives in `BaseProvider.validate_candidate()`, inherited by
102+
every provider:
103+
104+
```text
105+
validate_candidate()
106+
1. [existing] Reject known bad versions (incompatibilities)
107+
2. [new] Cooldown check
108+
if provider supports timestamps:
109+
upload_time unknown → reject (fail-closed)
110+
age < min_release_age → reject
111+
if provider does not support timestamps:
112+
if per-package override set → reject (fail-closed)
113+
otherwise → skip with warning
114+
3. [existing] Accept if any requirement's specifier and
115+
constraints are satisfied (is_satisfied_by)
116+
```
117+
118+
Each provider declares whether it supports timestamps via a
119+
class-level `supports_upload_time` flag. Providers that can supply
120+
timestamps (`PyPIProvider`, `GitLabTagProvider`) fail-closed when a
121+
candidate is missing one. Providers that cannot
122+
(`GitHubTagProvider`, `GenericProvider`, `VersionMapProvider`) skip
123+
cooldown with a warning -- unless the operator explicitly sets a
124+
per-package `min_release_age`, in which case fail-closed applies.
125+
126+
`resolver.resolve()` sets `min_release_age_days` and `start_time`
127+
on the provider after creation, so cooldown applies to all
128+
providers including plugin-returned ones. No plugin changes needed.
129+
130+
#### Error messages
131+
132+
When cooldown blocks all candidates, error messages state the
133+
reason clearly so users are not confused by a generic "no match":
134+
135+
- "found N candidate(s) for X but all were published within the last
136+
M days (cooldown policy)"
137+
- "found N candidate(s) for X but none have upload timestamp metadata;
138+
cannot enforce the M-day cooldown"
139+
140+
### Timestamp availability
141+
142+
| Provider | `supports_upload_time` | Source |
143+
| -- | -- | -- |
144+
| PyPIProvider | Yes | `upload-time` (PEP 691 JSON API) |
145+
| GitLabTagProvider | Yes | `created_at` (tag or commit) |
146+
| GitHubTagProvider | No | Needs Phase 3 |
147+
| GenericProvider | No | Callback-dependent |
148+
| VersionMapProvider | No | N/A |
149+
150+
Custom providers default to `supports_upload_time = False`. Plugin
151+
authors that populate `upload_time` on candidates should set the
152+
flag to `True` on their provider subclass.
153+
154+
#### PyPI sdists (primary use case)
155+
156+
Most packages resolve through `PyPIProvider`, making PyPI sdists the
157+
largest attack surface and the easiest to protect.
158+
159+
PyPI's PEP 691 JSON API provides `upload-time` per distribution
160+
file, not per version. Each sdist and wheel has its own timestamp.
161+
Fromager already reads this field via the `pypi_simple` library and
162+
stores it on `Candidate.upload_time` -- no extra API calls needed.
163+
164+
When `sdist_server_url` points to a non-PyPI simple index (e.g., a
165+
corporate mirror), `upload-time` may be absent. Fail-closed applies;
166+
use `min_release_age: 0` for packages from indices without timestamps.
167+
168+
#### GitHub timestamps (Phase 3)
169+
170+
The GitHub tags list API does not return dates.
171+
`GitHubTagProvider` sets `supports_upload_time = False`, so it
172+
skips cooldown with a warning until Phase 3 adds timestamp
173+
support via the Releases API and commit date fallback.
174+
175+
### Exempt sources
176+
177+
#### Pre-built wheels
178+
179+
Pre-built wheels are served from curated indices and use a different
180+
trust model. `resolve_prebuilt_wheel()` passes
181+
`min_release_age_days=0` to the provider, bypassing the cooldown.
182+
183+
#### Direct git clone URLs
184+
185+
Requirements with explicit git URLs (`pkg @ git+https://...@tag`)
186+
bypass all resolver providers entirely. No `Candidate` object is
187+
created and `validate_candidate()` never runs, so there is no
188+
insertion point for a cooldown check.
189+
190+
These are also exempt by design:
191+
192+
- Only allowed for top-level requirements, not transitive deps
193+
- The user explicitly specifies the URL and ref -- this is a
194+
deliberate pin, not automatic version selection
195+
- Git timestamps (author date, committer date) are set by the
196+
client, not the server, so they cannot be trusted for cooldown
197+
enforcement the way PyPI's server-side `upload-time` can
198+
199+
### Command updates
200+
201+
**`list-versions`**:
202+
203+
- Shows `upload_time` and age (days) for each candidate
204+
- Marks candidates blocked by cooldown
205+
- `--ignore-per-package-overrides` shows what cooldown would hide
206+
207+
**`list-overrides`** (with `--details`):
208+
209+
- New column for per-package `min_release_age`
210+
211+
## Implementation phases
212+
213+
### Phase 1 -- Core (single PR)
214+
215+
- `WorkContext`: `min_release_age_days`, `start_time`
216+
- CLI: `--min-release-age` / `FROMAGER_MIN_RELEASE_AGE`
217+
- `ResolverDist.min_release_age` field
218+
- `PackageBuildInfo.resolver_min_release_age()` method
219+
- `BaseProvider.validate_candidate()` cooldown check
220+
- `BaseProvider.supports_upload_time` class flag
221+
- `resolver.resolve()`: set cooldown on provider after creation
222+
- `default_resolver_provider()`: per-package lookup
223+
- Pre-built wheel exemption
224+
- Unit tests
225+
226+
PyPI sdists and GitLab-sourced packages work immediately after this
227+
phase (timestamps already available). GitHub-sourced packages require
228+
Phase 3.
229+
230+
### Phase 2 -- Commands (follow-up PR)
231+
232+
- `list-versions` enhancements
233+
- `list-overrides` enhancements
234+
235+
### Phase 3 -- GitHub timestamps (follow-up PR)
236+
237+
- Releases API + commit fallback in `GitHubTagProvider`
238+
239+
**Migration note**: Until Phase 3 ships, GitHub-sourced packages
240+
skip cooldown with a warning (since `GitHubTagProvider` has
241+
`supports_upload_time = False`). No manual `min_release_age: 0`
242+
overrides are needed. Phase 3 enables cooldown enforcement for
243+
these packages by adding timestamp support.
244+
245+
## Examples
246+
247+
```bash
248+
# 7-day cooldown
249+
fromager --min-release-age 7 bootstrap -r requirements.txt
250+
251+
# Same, via environment variable
252+
FROMAGER_MIN_RELEASE_AGE=7 fromager bootstrap -r requirements.txt
253+
254+
# No cooldown (default)
255+
fromager bootstrap -r requirements.txt
256+
257+
# Inspect available versions under a 7-day cooldown
258+
fromager --min-release-age 7 package list-versions torch
259+
```
260+
261+
```yaml
262+
# overrides/settings/internal-package.yaml
263+
resolver_dist:
264+
min_release_age: 0 # trusted, no cooldown
265+
266+
# overrides/settings/risky-dep.yaml
267+
resolver_dist:
268+
min_release_age: 14 # 2-week cooldown
269+
```

0 commit comments

Comments
 (0)