Release cooldown for version resolution

  • Author: Lalatendu Mohanty

  • Created: 2026-03-31

  • Status: Open

  • Issue: #877

What

A configurable minimum release age (“cooldown”) for version resolution. When enabled, fromager skips package versions published fewer than N days ago. One global setting controls all providers. Per-package overrides allow exceptions.

Why

Supply-chain attacks often publish a malicious package version and rely on automated builds picking it up immediately. A cooldown window lets the community detect and report compromised releases before fromager consumes them. It also means new versions get broader testing before entering the build.

References:

Goals

  • A single --min-release-age CLI option (days, default 0) that applies to every resolver provider

  • Per-package overrides via resolver_dist.min_release_age in package settings, taking priority over the CLI default

  • Provider-aware fail-closed: providers that support timestamps reject candidates with missing upload_time; providers that do not support timestamps skip cooldown with a warning. A future strictness option may be added to control enforcement for providers that gain timestamp support (e.g., Phase 3), allowing gradual rollout without breaking existing builds.

  • Pre-built wheels subject to cooldown when the index supports timestamps; bypass via per-package override otherwise

  • list-versions shows timestamps, ages, and cooldown status

  • list-overrides shows per-package cooldown values

  • Age calculated from bootstrap start time, not wall-clock time during resolution

Non-goals

  • Provider-specific flags (--pypi-min-age, --github-min-age). The provider a package uses (PyPI, GitHub, GitLab) reflects how it is obtained, not how trusted it is. Most GitHub/GitLab packages are there because of broken PyPI sdists or midstream forks. Separate flags per provider would create a confusing configuration matrix and cannot coexist cleanly with a global model. This proposal uses one global default plus per-package overrides.

  • SSH transport for git timestamp retrieval.

== pin exemptions (implemented)

Top-level single exact == pins (e.g. torch==2.5.1) bypass cooldown automatically. Wildcard (==1.*) and compound specifiers are not exempt. Transitive == pins remain subject to cooldown for security. See the how-to guide for details. Tracked in #1123.

How

Configuration

CLI and environment variable

A top-level --min-release-age option accepts a non-negative integer (days, default 0). Negative values are rejected. The corresponding environment variable FROMAGER_MIN_RELEASE_AGE is automatically available via Click’s auto_envvar_prefix.

The value is stored on WorkContext with a start_time captured once at construction (UTC). A fixed start time ensures consistent results when the same package is resolved multiple times during a build.

Per-package overrides

A new field in ResolverDist:

# Trusted internal package -- bypass cooldown
resolver_dist:
  min_release_age: 0

# Extra scrutiny -- 2-week cooldown
resolver_dist:
  min_release_age: 14

Semantics:

  • None (default) – use the global --min-release-age

  • 0 – no cooldown for this package

  • Positive integer – override the global value

The effective cooldown for a package is resolved by checking the per-package override first, falling back to the global default.

Enforcement

During candidate validation, BaseProvider rejects candidates whose age is less than the effective cooldown. The behavior depends on whether the provider can supply timestamps:

  • Supports timestamps (e.g. PyPI with PEP 691, GitLab): candidates with a known upload_time younger than the cutoff are rejected. A candidate with no upload_time is also rejected (fail-closed).

  • Does not support timestamps (e.g. GitHub, generic providers): cooldown is skipped with a one-time warning per package. Custom providers inherit this default.

Each provider declares its timestamp capability. PyPIProvider supports timestamps by default but allows callers to opt out for indexes that only implement PEP 503 (no upload-time field).

After provider creation, the resolver supplies:

  • The effective cooldown period (days, after resolving global vs. per-package override)

  • The reference timestamp (bootstrap start time)

The provider uses these during candidate validation. Setting them after construction ensures cooldown applies uniformly to all providers – including those returned by custom plugins – without requiring plugin changes.

Error messages

When cooldown blocks all candidates, error messages state the reason clearly so users are not confused by a generic “no match”:

  • “found N candidate(s) for X but all were published within the last M days (cooldown policy)”

  • “found N candidate(s) for X but none have upload timestamp metadata; cannot enforce the M-day cooldown”

Timestamp availability

Provider

supports_upload_time

Source

PyPIProvider

Yes (PEP 691 indexes); No (PEP 503-only indexes)

upload-time field

GitLabTagProvider

Yes

created_at (tag or commit)

GitHubTagProvider

No

Needs Phase 3

GenericProvider

No

Callback-dependent

VersionMapProvider

No

N/A

Custom providers inherit supports_upload_time = False from BaseProvider. Plugin authors that populate upload_time on candidates should set the attribute to True in their provider’s constructor.

PyPI sdists (primary use case)

Most packages resolve through PyPIProvider, making PyPI sdists the largest attack surface and the easiest to protect.

PyPI’s PEP 691 JSON API provides upload-time per distribution file, not per version. Each sdist and wheel has its own timestamp. Fromager already reads this field via the pypi_simple library and stores it on Candidate.upload_time – no extra API calls needed.

When sdist_server_url points to a non-PyPI simple index (e.g., a corporate mirror), upload-time may be absent. Fail-closed applies; use min_release_age: 0 for packages from indices without timestamps.

GitHub timestamps (Phase 3)

The GitHub tags list API does not return dates. GitHubTagProvider sets supports_upload_time = False, so it skips cooldown with a warning until Phase 3 adds timestamp support via the Releases API and commit date fallback.

Exempt sources

Pre-built wheels

Cooldown applies to pre-built wheels the same way it applies to sdists: if the index supports timestamps (e.g. PyPI.org with PEP 691), candidates younger than the cutoff are rejected. If the index does not support timestamps, fail-closed applies. Use resolver_dist.min_release_age: 0 to bypass cooldown for packages resolved from indices without timestamp support.

Fromager’s internal build and cache wheel servers are not used for version resolution, so cooldown does not apply to them.

Direct git clone URLs

Requirements with explicit git URLs (pkg @ git+https://...@tag) bypass all resolver providers entirely. No candidate is created and validation never runs, so there is no insertion point for a cooldown check.

These are also exempt by design:

  • Only allowed for top-level requirements, not transitive dependencies

  • The user explicitly specifies the URL and ref – this is a deliberate pin, not automatic version selection

  • Git timestamps (author date, committer date) are set by the client, not the server, so they cannot be trusted for cooldown enforcement the way PyPI’s server-side upload-time can

Command updates

list-versions:

  • Shows upload_time and age (days) for each candidate

  • Marks candidates blocked by cooldown

  • --ignore-per-package-overrides shows what cooldown would hide

list-overrides (with --details):

  • New column for per-package min_release_age

Implementation phases

Phase 1 – Core (single PR)

  • --min-release-age CLI option and WorkContext support

  • Per-package resolver_dist.min_release_age override in package settings

  • Cooldown check in provider candidate validation

  • supports_upload_time attribute on providers

  • Cooldown set on the provider after creation so custom plugins work without changes

  • Pre-built wheel exemption

  • Unit tests

PyPI sdists and GitLab-sourced packages work immediately after this phase (timestamps already available). GitHub-sourced packages require Phase 3.

Phase 2 – Commands (follow-up PR)

  • list-versions enhancements

  • list-overrides enhancements

Phase 3 – GitHub timestamps (after Phase 1 is merged)

  • A new GitHubReleaseProvider using the Releases API (created_at / published_at) with commit date fallback. GitHub’s GraphQL API may be used for efficient bulk queries.

  • GraphQL requires authenticated requests (bearer token). If no token is available and cooldown is active, fail-closed applies.

Migration note: Until Phase 3 ships, GitHub-sourced packages skip cooldown with a warning (since GitHubTagProvider has supports_upload_time = False). No manual min_release_age: 0 overrides are needed. Phase 3 enables cooldown enforcement for these packages by adding timestamp support.

Examples

# 7-day cooldown
fromager --min-release-age 7 bootstrap -r requirements.txt

# Same, via environment variable
FROMAGER_MIN_RELEASE_AGE=7 fromager bootstrap -r requirements.txt

# No cooldown (default)
fromager bootstrap -r requirements.txt

# Inspect available versions under a 7-day cooldown
fromager --min-release-age 7 package list-versions torch
# overrides/settings/internal-package.yaml
resolver_dist:
  min_release_age: 0    # trusted, no cooldown

# overrides/settings/risky-dep.yaml
resolver_dist:
  min_release_age: 14   # 2-week cooldown