Skip to content

Commit 1926e29

Browse files
authored
Merge pull request #1000 from LalatenduMohanty/proposal/release-cooldown
docs(proposal): add release cooldown design for version resolution
2 parents 8556bea + 69de515 commit 1926e29

2 files changed

Lines changed: 287 additions & 0 deletions

File tree

docs/proposals/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,4 @@ Fromager Enhancement Proposals
66

77
new-patcher-config
88
new-resolver-config
9+
release-cooldown

docs/proposals/release-cooldown.md

Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,286 @@
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. A future
37+
strictness option may be added to control enforcement for
38+
providers that gain timestamp support (e.g., Phase 3), allowing
39+
gradual rollout without breaking existing builds.
40+
- Pre-built wheels subject to cooldown when the index supports
41+
timestamps; bypass via per-package override otherwise
42+
- `list-versions` shows timestamps, ages, and cooldown status
43+
- `list-overrides` shows per-package cooldown values
44+
- Age calculated from bootstrap start time, not wall-clock time during
45+
resolution
46+
47+
## Non-goals
48+
49+
- **Provider-specific flags** (`--pypi-min-age`, `--github-min-age`).
50+
The provider a package uses (PyPI, GitHub, GitLab) reflects *how* it
51+
is obtained, not how trusted it is. Most GitHub/GitLab packages are
52+
there because of broken PyPI sdists or midstream forks. Separate
53+
flags per provider would create a confusing configuration matrix and
54+
cannot coexist cleanly with a global model. This proposal uses one
55+
global default plus per-package overrides.
56+
- **SSH transport** for git timestamp retrieval.
57+
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, reviewable 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.
69+
70+
## How
71+
72+
### Configuration
73+
74+
#### CLI and environment variable
75+
76+
A top-level `--min-release-age` option accepts a non-negative integer
77+
(days, default 0). Negative values are rejected. The corresponding
78+
environment variable `FROMAGER_MIN_RELEASE_AGE` is automatically
79+
available via Click's `auto_envvar_prefix`.
80+
81+
The value is stored on `WorkContext` with a `start_time` captured once
82+
at construction (UTC). A fixed start time ensures consistent results
83+
when the same package is resolved multiple times during a build.
84+
85+
#### Per-package overrides
86+
87+
A new field in `ResolverDist`:
88+
89+
```yaml
90+
# Trusted internal package -- bypass cooldown
91+
resolver_dist:
92+
min_release_age: 0
93+
94+
# Extra scrutiny -- 2-week cooldown
95+
resolver_dist:
96+
min_release_age: 14
97+
```
98+
99+
Semantics:
100+
101+
- `None` (default) -- use the global `--min-release-age`
102+
- `0` -- no cooldown for this package
103+
- Positive integer -- override the global value
104+
105+
The effective cooldown for a package is resolved by checking the
106+
per-package override first, falling back to the global default.
107+
108+
### Enforcement
109+
110+
During candidate validation, `BaseProvider` rejects candidates
111+
whose age is less than the effective cooldown. The behaviour
112+
depends on whether the provider can supply timestamps:
113+
114+
- **Supports timestamps** (e.g. PyPI with PEP 691, GitLab):
115+
candidates with a known `upload_time` younger than the cutoff
116+
are rejected. A candidate with no `upload_time` is also rejected
117+
(fail-closed).
118+
- **Does not support timestamps** (e.g. GitHub, generic
119+
providers): cooldown is skipped with a one-time warning per
120+
package. Custom providers inherit this default.
121+
122+
Each provider declares its timestamp capability. `PyPIProvider`
123+
supports timestamps by default but allows callers to opt out for
124+
indexes that only implement PEP 503 (no `upload-time` field).
125+
126+
After provider creation, the resolver supplies:
127+
128+
- The effective cooldown period (days, after resolving global vs.
129+
per-package override)
130+
- The reference timestamp (bootstrap start time)
131+
132+
The provider uses these during candidate validation. Setting them
133+
after construction ensures cooldown applies uniformly to all
134+
providers -- including those returned by custom plugins -- without
135+
requiring plugin changes.
136+
137+
#### Error messages
138+
139+
When cooldown blocks all candidates, error messages state the
140+
reason clearly so users are not confused by a generic "no match":
141+
142+
- "found N candidate(s) for X but all were published within the last
143+
M days (cooldown policy)"
144+
- "found N candidate(s) for X but none have upload timestamp metadata;
145+
cannot enforce the M-day cooldown"
146+
147+
### Timestamp availability
148+
149+
| Provider | `supports_upload_time` | Source |
150+
| -- | -- | -- |
151+
| PyPIProvider | Yes (PEP 691 indexes); No (PEP 503-only indexes) | `upload-time` field |
152+
| GitLabTagProvider | Yes | `created_at` (tag or commit) |
153+
| GitHubTagProvider | No | Needs Phase 3 |
154+
| GenericProvider | No | Callback-dependent |
155+
| VersionMapProvider | No | N/A |
156+
157+
Custom providers inherit `supports_upload_time = False` from
158+
`BaseProvider`. Plugin authors that populate `upload_time` on
159+
candidates should set the attribute to `True` in their provider's
160+
constructor.
161+
162+
#### PyPI sdists (primary use case)
163+
164+
Most packages resolve through `PyPIProvider`, making PyPI sdists the
165+
largest attack surface and the easiest to protect.
166+
167+
PyPI's PEP 691 JSON API provides `upload-time` per distribution
168+
file, not per version. Each sdist and wheel has its own timestamp.
169+
Fromager already reads this field via the `pypi_simple` library and
170+
stores it on `Candidate.upload_time` -- no extra API calls needed.
171+
172+
When `sdist_server_url` points to a non-PyPI simple index (e.g., a
173+
corporate mirror), `upload-time` may be absent. Fail-closed applies;
174+
use `min_release_age: 0` for packages from indices without timestamps.
175+
176+
#### GitHub timestamps (Phase 3)
177+
178+
The GitHub tags list API does not return dates.
179+
`GitHubTagProvider` sets `supports_upload_time = False`, so it
180+
skips cooldown with a warning until Phase 3 adds timestamp
181+
support via the Releases API and commit date fallback.
182+
183+
### Exempt sources
184+
185+
#### Pre-built wheels
186+
187+
Cooldown applies to pre-built wheels the same way it applies to
188+
sdists: if the index supports timestamps (e.g. PyPI.org with
189+
PEP 691), candidates younger than the cutoff are rejected. If the
190+
index does not support timestamps, fail-closed applies. Use
191+
`resolver_dist.min_release_age: 0` to bypass cooldown for
192+
packages resolved from indices without timestamp support.
193+
194+
Fromager's internal build and cache wheel servers are not used for
195+
version resolution, so cooldown does not apply to them.
196+
197+
#### Direct git clone URLs
198+
199+
Requirements with explicit git URLs (`pkg @ git+https://...@tag`)
200+
bypass all resolver providers entirely. No candidate is created
201+
and validation never runs, so there is no insertion point for a
202+
cooldown check.
203+
204+
These are also exempt by design:
205+
206+
- Only allowed for top-level requirements, not transitive deps
207+
- The user explicitly specifies the URL and ref -- this is a
208+
deliberate pin, not automatic version selection
209+
- Git timestamps (author date, committer date) are set by the
210+
client, not the server, so they cannot be trusted for cooldown
211+
enforcement the way PyPI's server-side `upload-time` can
212+
213+
### Command updates
214+
215+
**`list-versions`**:
216+
217+
- Shows `upload_time` and age (days) for each candidate
218+
- Marks candidates blocked by cooldown
219+
- `--ignore-per-package-overrides` shows what cooldown would hide
220+
221+
**`list-overrides`** (with `--details`):
222+
223+
- New column for per-package `min_release_age`
224+
225+
## Implementation phases
226+
227+
### Phase 1 -- Core (single PR)
228+
229+
- `--min-release-age` CLI option and `WorkContext` support
230+
- Per-package `resolver_dist.min_release_age` override in package
231+
settings
232+
- Cooldown check in provider candidate validation
233+
- `supports_upload_time` attribute on providers
234+
- Cooldown set on the provider after creation so custom plugins
235+
work without changes
236+
- Pre-built wheel exemption
237+
- Unit tests
238+
239+
PyPI sdists and GitLab-sourced packages work immediately after this
240+
phase (timestamps already available). GitHub-sourced packages require
241+
Phase 3.
242+
243+
### Phase 2 -- Commands (follow-up PR)
244+
245+
- `list-versions` enhancements
246+
- `list-overrides` enhancements
247+
248+
### Phase 3 -- GitHub timestamps (after Phase 1 is merged)
249+
250+
- A new `GitHubReleaseProvider` using the Releases API
251+
(`created_at` / `published_at`) with commit date fallback.
252+
GitHub's GraphQL API may be used for efficient bulk queries.
253+
- GraphQL requires authenticated requests (bearer token). If no
254+
token is available and cooldown is active, fail-closed applies.
255+
256+
**Migration note**: Until Phase 3 ships, GitHub-sourced packages
257+
skip cooldown with a warning (since `GitHubTagProvider` has
258+
`supports_upload_time = False`). No manual `min_release_age: 0`
259+
overrides are needed. Phase 3 enables cooldown enforcement for
260+
these packages by adding timestamp support.
261+
262+
## Examples
263+
264+
```bash
265+
# 7-day cooldown
266+
fromager --min-release-age 7 bootstrap -r requirements.txt
267+
268+
# Same, via environment variable
269+
FROMAGER_MIN_RELEASE_AGE=7 fromager bootstrap -r requirements.txt
270+
271+
# No cooldown (default)
272+
fromager bootstrap -r requirements.txt
273+
274+
# Inspect available versions under a 7-day cooldown
275+
fromager --min-release-age 7 package list-versions torch
276+
```
277+
278+
```yaml
279+
# overrides/settings/internal-package.yaml
280+
resolver_dist:
281+
min_release_age: 0 # trusted, no cooldown
282+
283+
# overrides/settings/risky-dep.yaml
284+
resolver_dist:
285+
min_release_age: 14 # 2-week cooldown
286+
```

0 commit comments

Comments
 (0)