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
113 changes: 113 additions & 0 deletions docs/how-tos/release-age-cooldown.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
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 **sdist resolution** — selecting which version of a
package to build from source, including transitive dependencies. It does not
apply to:

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

Note that sdist resolution from a private package index depends on
``upload-time`` being present in the index's PEP 691 JSON responses. If the
index does not provide that metadata, candidates will be rejected under the
fail-closed policy described below.


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

If a candidate has no ``upload-time`` metadata — which can occur with older
PyPI Simple HTML responses — 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.

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

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.
7 changes: 7 additions & 0 deletions e2e/ci_bootstrap_suite.sh
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,13 @@ run_test "bootstrap_prerelease"
run_test "bootstrap_cache"
run_test "bootstrap_sdist_only"

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"

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
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,26 @@
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,
cooldown=ctx.cooldown,
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,21 @@
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,
cooldown=ctx.cooldown,
)
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"

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.
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