Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/how-tos/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ Customize builds with overrides, variants, and version handling.
pyproject-overrides
multiple-versions
pre-release-versions
release-age-cooldown

Analyzing Builds
----------------
Expand Down
132 changes: 132 additions & 0 deletions docs/how-tos/release-age-cooldown.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
Protect Against Supply-Chain Attacks with Release-Age Cooldown
==============================================================

Fromager's release-age cooldown policy rejects package versions that were
published fewer than a configured number of days ago. This protects automated
builds from supply-chain attacks where a malicious version is published and
immediately pulled in before it can be reviewed.

How It Works
------------

When a cooldown is active, any candidate whose ``upload-time`` is more recent
than the cutoff (current time minus the configured minimum age) is not
considered a valid option during constraint resolution. If no versions of a
package satisfy both the cooldown window and any other provided constraints,
resolution fails with an informative error.

The cutoff timestamp is fixed at the start of each run, so all package
resolutions within a single bootstrap share the same boundary.

Enabling the Cooldown
---------------------

Use the global ``--min-release-age`` flag, or set the equivalent environment
variable ``FROMAGER_MIN_RELEASE_AGE``:

.. code-block:: bash

# Reject versions published in the last 7 days
fromager --min-release-age 7 bootstrap -r requirements.txt

# Same, via environment variable (useful for CI and builder integrations)
FROMAGER_MIN_RELEASE_AGE=7 fromager bootstrap -r requirements.txt

# Disable the cooldown (default)
fromager --min-release-age 0 bootstrap -r requirements.txt

The ``--min-release-age`` flag accepts a non-negative integer number of days.
A value of ``0`` (the default) disables the check entirely.

Scope
-----

The cooldown applies to both **sdist resolution** and **pre-built wheel
resolution** — any candidate whose ``upload-time`` is more recent than the
cutoff is rejected, regardless of whether it is an sdist or a wheel.

The following are **not** subject to the cooldown:

* Fromager's internal build and cache wheel servers. These are not used for
version selection — they are checked only for already-resolved pinned
versions — so the cooldown has no insertion point.
* Packages resolved from Git URLs. Git timestamps are set by the client, not
the server, and cannot be trusted for cooldown enforcement.

Resolution from a private package index (sdist or wheel) depends on
``upload-time`` being present in the index's PEP 691 JSON responses. If the
index does not provide that metadata, candidates are rejected under the
fail-closed policy described below. Use ``resolver_dist.min_release_age: 0``
to bypass cooldown for packages from indexes that structurally cannot supply
timestamps.


Fail-Closed Behavior
--------------------

If a candidate has no ``upload-time`` metadata — whether it is an sdist or a
wheel — it is rejected when a cooldown is active. Fromager uses the
`PEP 691 JSON Simple API`_ when fetching package metadata, which reliably
includes upload timestamps for PyPI.org.

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

For indexes that only implement the `PEP 503`_ HTML API and cannot supply
timestamps, use the per-package ``resolver_dist.min_release_age: 0`` override
to bypass the cooldown for affected packages rather than disabling it globally.

.. _PEP 503: https://peps.python.org/pep-0503/

.. note::

If you are writing a ``get_resolver_provider`` plugin that uses
:class:`~fromager.resolver.PyPIProvider` with a private index that only
implements the PEP 503 HTML API, pass ``supports_upload_time=False`` to
``PyPIProvider``. This switches the provider from fail-closed to
warn-and-skip, so candidates without upload timestamps are skipped with a
warning rather than causing resolution to fail.

Example
-------

Given a package ``example-pkg`` with three available versions:

* ``2.0.0`` — published 3 days ago
* ``1.9.0`` — published 45 days ago
* ``1.8.0`` — published 120 days ago

With a 7-day cooldown, ``2.0.0`` is blocked and ``1.9.0`` is selected:

.. code-block:: bash

fromager --min-release-age 7 bootstrap example-pkg

With a 60-day cooldown, both ``2.0.0`` and ``1.9.0`` are blocked and ``1.8.0``
is selected:

.. code-block:: bash

fromager --min-release-age 60 bootstrap example-pkg

Per-Package Override
--------------------

The cooldown can be adjusted on a per-package basis using the
``resolver_dist.min_release_age`` setting in the package's settings file:

.. code-block:: yaml

# overrides/settings/my-package.yaml
resolver_dist:
min_release_age: 0 # disable cooldown for this package
# min_release_age: 30 # or use a different number of days

Valid values:

* Omit the key (default): inherit the global ``--min-release-age`` setting.
* ``0``: disable the cooldown for this package, regardless of the global flag.
* Positive integer: use this many days instead of the global setting.

This is useful when a specific package is trusted enough to allow recent
versions, or when a package's release cadence makes the global cooldown
impractical.
8 changes: 8 additions & 0 deletions e2e/ci_bootstrap_suite.sh
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,14 @@ run_test "bootstrap_cache"
run_test "bootstrap_sdist_only"
run_test "bootstrap_multiple_versions"

test_section "bootstrap cooldown tests"
run_test "bootstrap_cooldown"
run_test "bootstrap_cooldown_transitive"
run_test "bootstrap_cooldown_gitlab"
run_test "bootstrap_cooldown_github"
run_test "bootstrap_cooldown_override"
run_test "bootstrap_cooldown_prebuilt"

test_section "bootstrap git URL tests"
run_test "bootstrap_git_url"
run_test "bootstrap_git_url_tag"
Expand Down
2 changes: 2 additions & 0 deletions e2e/cooldown_override_settings/stevedore.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
resolver_dist:
min_release_age: 0
3 changes: 3 additions & 0 deletions e2e/cooldown_prebuilt_settings/stevedore.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
variants:
cpu:
pre_built: true
13 changes: 13 additions & 0 deletions e2e/github_override_example/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
[build-system]
requires = ["setuptools"]
build-backend = "setuptools.build_meta"

[project]
name = "github-example-with-package-plugin"
version = "0.1.0"
description = "Example Fromager package plugin demonstrating GitHubTagProvider via get_resolver_provider"
requires-python = ">=3.12"
dependencies = []

[project.entry-points."fromager.project_overrides"]
stevedore = "package_plugins.stevedore_github"
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
from packaging.requirements import Requirement

from fromager import context, resolver


def get_resolver_provider(
ctx: context.WorkContext,
req: Requirement,
sdist_server_url: str,
include_sdists: bool,
include_wheels: bool,
req_type: resolver.RequirementType | None = None,
ignore_platform: bool = False,
) -> resolver.GitHubTagProvider:
"""Return a GitHubTagProvider for the stevedore test repo on github.com."""
return resolver.GitHubTagProvider(
organization="python-wheel-build",
repo="stevedore-test-repo",
constraints=ctx.constraints,
req_type=req_type,
override_download_url=(
"https://github.com/{organization}/{repo}"
"/archive/refs/tags/{tagname}.tar.gz"
),
)
13 changes: 13 additions & 0 deletions e2e/gitlab_override_example/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
[build-system]
requires = ["setuptools"]
build-backend = "setuptools.build_meta"

[project]
name = "gitlab-example-with-package-plugin"
version = "0.1.0"
description = "Example Fromager package plugin demonstrating GitLabTagProvider via get_resolver_provider"
requires-python = ">=3.12"
dependencies = []

[project.entry-points."fromager.project_overrides"]
python_gitlab = "package_plugins.python_gitlab"
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
from packaging.requirements import Requirement

from fromager import context, resolver


def get_resolver_provider(
ctx: context.WorkContext,
req: Requirement,
sdist_server_url: str,
include_sdists: bool,
include_wheels: bool,
req_type: resolver.RequirementType | None = None,
ignore_platform: bool = False,
) -> resolver.GitLabTagProvider:
"""Return a GitLabTagProvider for the python-gitlab project on gitlab.com."""
return resolver.GitLabTagProvider(
project_path="python-gitlab/python-gitlab",
constraints=ctx.constraints,
req_type=req_type,
)
85 changes: 85 additions & 0 deletions e2e/test_bootstrap_cooldown.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
#!/bin/bash
# -*- indent-tabs-mode: nil; tab-width: 2; sh-indentation: 2; -*-

# Tests that --min-release-age rejects versions published within the cooldown
# window and falls back to an older stevedore version. Verifies both the
# CLI flag (--min-release-age) and the equivalent environment variable
# (FROMAGER_MIN_RELEASE_AGE) produce identical behaviour.
#
# Release timeline (all times UTC):
#
# stevedore 5.1.0 2023-05-15
# stevedore 5.2.0 2024-02-22
# stevedore 5.3.0 2024-08-22 (the expected fallback)
# stevedore 5.4.0 2024-11-20 (blocked by cooldown)
# stevedore 5.5.0+ future (all blocked by cooldown)
#
# We compute --min-release-age dynamically as the age of stevedore 5.4.0 in
# days plus a 1-day buffer, ensuring stevedore 5.4.0 is always just inside the
# cooldown window while stevedore 5.3.0 (released ~90 days earlier) always
# clears it.
#
# Anchoring to 5.4.0 (released 2024-11-20) also ensures the build toolchain
# can use flit_core 3.10.0 (released 2024-10-31), which is required for
# Python 3.14 compatibility.

SCRIPTDIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
source "$SCRIPTDIR/common.sh"

# Compute min-age: days since stevedore 5.4.0 was published, plus a buffer.
# stevedore 5.4.0 was released 2024-11-20; adding 1 day ensures it is
# always just inside the cooldown window regardless of when the test runs.
MIN_AGE=$(python3 -c "
from datetime import date
age = (date.today() - date(2024, 11, 20)).days
print(age + 1)
")

# --- Pass 1: enforce cooldown via CLI flag ---

fromager \
--log-file="$OUTDIR/bootstrap-flag.log" \
--error-log-file="$OUTDIR/fromager-errors-flag.log" \
--sdists-repo="$OUTDIR/sdists-repo" \
--wheels-repo="$OUTDIR/wheels-repo" \
--work-dir="$OUTDIR/work-dir" \
--min-release-age="$MIN_AGE" \
bootstrap 'stevedore'

pass=true

# stevedore 5.4.0 is blocked; the resolver must fall back to 5.3.0.
if ! grep -q "new toplevel dependency stevedore resolves to 5.3.0" "$OUTDIR/bootstrap-flag.log"; then
echo "FAIL (flag): expected stevedore to resolve to 5.3.0 but it did not" 1>&2
pass=false
fi

if ! find "$OUTDIR/wheels-repo/downloads/" -name 'stevedore-5.3.0*.whl' | grep -q .; then
echo "FAIL (flag): stevedore-5.3.0 wheel not found in wheels-repo" 1>&2
pass=false
fi

# --- Pass 2: enforce the same cooldown via environment variable (FROMAGER_MIN_RELEASE_AGE) ---

# Wipe output so the second run starts clean.
rm -rf "$OUTDIR/sdists-repo" "$OUTDIR/wheels-repo" "$OUTDIR/work-dir"

FROMAGER_MIN_RELEASE_AGE="$MIN_AGE" fromager \
--log-file="$OUTDIR/bootstrap-envvar.log" \
--error-log-file="$OUTDIR/fromager-errors-envvar.log" \
--sdists-repo="$OUTDIR/sdists-repo" \
--wheels-repo="$OUTDIR/wheels-repo" \
--work-dir="$OUTDIR/work-dir" \
bootstrap 'stevedore'

if ! grep -q "new toplevel dependency stevedore resolves to 5.3.0" "$OUTDIR/bootstrap-envvar.log"; then
echo "FAIL (envvar): expected stevedore to resolve to 5.3.0 but it did not" 1>&2
pass=false
fi

if ! find "$OUTDIR/wheels-repo/downloads/" -name 'stevedore-5.3.0*.whl' | grep -q .; then
echo "FAIL (envvar): stevedore-5.3.0 wheel not found in wheels-repo" 1>&2
pass=false
fi

$pass
59 changes: 59 additions & 0 deletions e2e/test_bootstrap_cooldown_github.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
#!/bin/bash
# -*- indent-tabs-mode: nil; tab-width: 2; sh-indentation: 2; -*-

# Tests that when --min-release-age is active and a package is resolved through
# the GitHubTagProvider, the cooldown is NOT enforced (because GitHub does not
# yet provide upload timestamps), but a warning is emitted for each candidate.
#
# The stevedore test repo (python-wheel-build/stevedore-test-repo) is used as
# a convenient GitHub-hosted package with known tags.
#
# MIN_AGE is anchored to stevedore 5.4.1 (2025-02-20), so it is large enough
# that enforcement WOULD block the resolved candidate — confirming that the
# GitHubTagProvider correctly skips enforcement rather than failing closed.
# Using a modest cooldown (rather than 9999 days) avoids inadvertently blocking
# PyPI build dependencies like setuptools, which are always recent.

SCRIPTDIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
source "$SCRIPTDIR/common.sh"

# Anchor: stevedore 5.4.1 was released 2025-02-20.
# MIN_AGE exceeds its age, so the cooldown would block it if enforced.
MIN_AGE=$(python3 -c "
from datetime import date
age = (date.today() - date(2025, 2, 20)).days
print(age + 1)
")

# Install the override plugin that routes stevedore through GitHubTagProvider.
# Uninstall on exit so its entry points don't leak into subsequent e2e tests.
trap 'python3 -m pip uninstall -y github_override_example >/dev/null 2>&1 || true' EXIT
pip install "$SCRIPTDIR/github_override_example"
Comment thread
coderabbitai[bot] marked this conversation as resolved.

fromager \
--log-file="$OUTDIR/bootstrap.log" \
--error-log-file="$OUTDIR/fromager-errors.log" \
--sdists-repo="$OUTDIR/sdists-repo" \
--wheels-repo="$OUTDIR/wheels-repo" \
--work-dir="$OUTDIR/work-dir" \
--min-release-age="$MIN_AGE" \
bootstrap 'stevedore'

find "$OUTDIR/wheels-repo/" -name '*.whl'

pass=true

# Resolution must succeed despite the 9999-day cooldown — GitHub timestamps
# are not yet supported, so the cooldown is skipped rather than enforced.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if ! find "$OUTDIR/wheels-repo/downloads/" -name 'stevedore-*.whl' | grep -q .; then
echo "FAIL: no stevedore wheel found — resolution should have succeeded despite cooldown" 1>&2
pass=false
fi

# A warning must be emitted explaining why the cooldown was skipped.
if ! grep -q "not yet implemented" "$OUTDIR/bootstrap.log"; then
echo "FAIL: expected cooldown-skipped warning not found in log" 1>&2
pass=false
fi

$pass
Loading
Loading