Skip to content
Closed
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
26 changes: 26 additions & 0 deletions .github/workflows/test_action.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,29 @@ jobs:
else
grep -q '^+.*numpy' changes.diff
fi

constraints:
runs-on: ubuntu-latest
name: "Run action in constraints mode"
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Write constraints using local action
id: spec0
uses: ./
with:
mode: constraints
packages: numpy scipy pandas python
schedule_path: tests/test_data/test_schedule.json
reference_date: "2025-04-01"
- name: Check constraints file and outputs
env:
CONSTRAINTS_FILE: ${{ steps.spec0.outputs.constraints_file }}
VERSIONS: ${{ steps.spec0.outputs.versions }}
PYTHON_VERSION: ${{ steps.spec0.outputs.python_version }}
run: |
printf 'numpy==1.26.0\nscipy==1.12.0\npandas==2.1.0\n' > expected.txt
diff expected.txt "$CONSTRAINTS_FILE"
test "$PYTHON_VERSION" = "3.11"
echo "$VERSIONS" | python -c 'import json, sys; assert json.load(sys.stdin)["numpy"] == "1.26.0"'
git diff --exit-code
53 changes: 52 additions & 1 deletion action.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,40 @@ inputs:
description: "If set, compute SPEC 0 core-package floors from PyPI using this many years of support (e.g., 3). Python still follows the supplied schedule; exclusions take precedence."
required: false
default: ""
mode:
description: "'update' (default) updates the lower bounds in the project file and can open a PR. 'constraints' only writes a constraints file with exact SPEC 0 minimum versions, to install and test the oldest supported versions in CI."
required: false
default: "update"
packages:
description: "constraints mode: comma- or whitespace-separated packages to pin, e.g. 'numpy scipy pandas'. Include python to get its minimum version in the outputs."
required: false
default: ""
constraints_file:
description: "constraints mode: path of the constraints file to write, relative to the workspace."
required: false
default: "constraints-spec0.txt"
reference_date:
description: "constraints mode: date (YYYY-MM-DD) to compute the floors for. Defaults to today."
required: false
default: ""
outputs:
constraints_file:
description: "constraints mode: path of the written constraints file."
value: ${{ steps.constraints.outputs.constraints_file }}
versions:
description: "constraints mode: JSON mapping of package to minimum version."
value: ${{ steps.constraints.outputs.versions }}
python_version:
description: "constraints mode: minimum Python version, if python was requested."
value: ${{ steps.constraints.outputs.python_version }}
runs:
using: "composite"
steps:
- name: Checkout code
if: ${{ inputs.mode != 'constraints' }}
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Git
if: ${{ inputs.mode != 'constraints' }}
shell: bash
run: |
git config user.name "Scientific Python [bot]"
Expand All @@ -69,6 +97,7 @@ runs:
latest: true
fileName: "schedule.json"
- name: Run update script
if: ${{ inputs.mode != 'constraints' }}
shell: bash
env:
PROJECT_FILE_NAME: ${{ inputs.project_file_name }}
Expand All @@ -92,7 +121,29 @@ runs:
UPDATE_ARGS+=(--spec0-support-years "$SPEC0_SUPPORT_YEARS")
fi
pixi run --manifest-path "${GITHUB_ACTION_PATH}/pyproject.toml" update-dependencies "${GITHUB_WORKSPACE}/${PROJECT_FILE_NAME}" "$SCHEDULE_PATH" --excluded-packages "$EXCLUDED_PACKAGES" "${UPDATE_ARGS[@]}"
- name: Write constraints file
if: ${{ inputs.mode == 'constraints' }}
id: constraints
shell: bash
env:
SCHEDULE_INPUT: ${{ inputs.schedule_path }}
PACKAGES: ${{ inputs.packages }}
CONSTRAINTS_FILE: ${{ inputs.constraints_file }}
REFERENCE_DATE: ${{ inputs.reference_date }}
run: |
set -e
if [ -n "$SCHEDULE_INPUT" ]; then
SCHEDULE_PATH="${GITHUB_WORKSPACE}/${SCHEDULE_INPUT}"
else
SCHEDULE_PATH="${GITHUB_WORKSPACE}/schedule.json"
fi
ARGS=(--packages "$PACKAGES" --output "${GITHUB_WORKSPACE}/${CONSTRAINTS_FILE}")
if [ -n "$REFERENCE_DATE" ]; then
ARGS+=(--reference-date "$REFERENCE_DATE")
fi
pixi run --manifest-path "${GITHUB_ACTION_PATH}/pyproject.toml" write-constraints "$SCHEDULE_PATH" "${ARGS[@]}"
- name: Changes
if: ${{ inputs.mode != 'constraints' }}
id: changes
shell: bash
env:
Expand All @@ -106,7 +157,7 @@ runs:
echo "changes_detected=true" >> "$GITHUB_OUTPUT"
fi
- name: Create Pull Request
if: ${{ fromJSON(inputs.create_pr) && fromJSON(steps.changes.outputs.changes_detected) }}
if: ${{ inputs.mode != 'constraints' && fromJSON(inputs.create_pr) && fromJSON(steps.changes.outputs.changes_detected) }}
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
with:
token: ${{ inputs.token || github.token }}
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ spec0-action= { path = ".", editable = true }

[tool.pixi.tasks]
update-dependencies = { cmd = ["python", "run_spec0_update.py"] }
write-constraints = { cmd = ["python", "run_spec0_constraints.py"] }
generate-schedule = { cmd = ["python", "spec0_versions.py"] }

[tool.pixi.feature.test.tasks]
Expand Down
37 changes: 37 additions & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,43 @@ If PyPI cannot be reached, the affected dependency stays unchanged with a warnin
The CLI takes `--spec0-support-years 3` and the Python API `spec0_support_years=3`.
Extras such as `xarray[io]>=2026.5.1` and environment markers are preserved when bounds change.

### Testing the oldest supported versions in CI

With `mode: constraints` the action does not touch your project file or open a PR.
It writes a constraints file with exact pins of the SPEC 0 minimum versions, so a CI job can install and test the oldest supported combination:

```yaml
- uses: actions/checkout@v7
- uses: scientific-python/spec0-action@<commit>
id: spec0
with:
mode: constraints
packages: numpy scipy pandas scikit-learn matplotlib python
- uses: actions/setup-python@v6
with:
python-version: ${{ steps.spec0.outputs.python_version }}
- run: |
pip install -c constraints-spec0.txt .
pytest
```

The pins are exact (`numpy==2.3.0`), because with `numpy>=2.3.0` the installer would pick the newest release and the minimum would never be tested.
Constraints only restrict versions; they do not install the listed packages by themselves.

| Input | Default | Description |
| ------------------ | ----------------------- | ---------------------------------------------------------------------------------------- |
| `mode` | `update` | Set to `constraints` to only write the constraints file |
| `packages` | — | Comma- or whitespace-separated packages to pin; add `python` to get its minimum |
| `constraints_file` | `constraints-spec0.txt` | Path of the file to write, relative to the workspace |
| `reference_date` | today | Date (`YYYY-MM-DD`) to compute the floors for, for reproducible runs |
| `schedule_path` | — | Local `schedule.json`, relative to the repository root; uses the latest release if unset |

Outputs: `constraints_file`, `versions` (JSON mapping of package to minimum version) and `python_version` (empty unless `python` is requested).
The run fails with a clear message if the schedule has no floor for a requested package on the reference date, or if the schedule data is invalid.
A local `schedule_path` needs the repository to be checked out first.

The CLI is `python run_spec0_constraints.py schedule.json --packages "numpy scipy" --reference-date 2026-10-01`.

## Limitations

1. The action only tightens lower bounds and leaves upper bounds untouched.
Expand Down
80 changes: 80 additions & 0 deletions run_spec0_constraints.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import datetime
import json
import os
import sys
from argparse import ArgumentParser
from pathlib import Path

from spec0_action import read_schedule
from spec0_action.constraints import ScheduleError, format_constraints, minimum_versions


def parse_date(value: str) -> datetime.datetime:
try:
date = datetime.date.fromisoformat(value)
except ValueError as exc:
raise ValueError(
f"reference date must look like YYYY-MM-DD, got {value!r}"
) from exc
return datetime.datetime(date.year, date.month, date.day, tzinfo=datetime.UTC)


def main(argv: list[str] | None = None) -> int:
parser = ArgumentParser(
description="Write exact SPEC 0 minimum version pins to a constraints file, to test the oldest supported versions in CI.",
)
parser.add_argument("schedule_path", help="Path to the schedule json payload.")
parser.add_argument(
"--packages",
required=True,
metavar="NAMES",
help="Comma- or whitespace-separated packages to pin, e.g. 'numpy scipy pandas'. Include python to also report its minimum version.",
)
parser.add_argument(
"--output",
default="constraints-spec0.txt",
help="Where to write the constraints file. Defaults to 'constraints-spec0.txt'.",
)
parser.add_argument(
"--reference-date",
default=None,
metavar="YYYY-MM-DD",
help="Date the floors are computed for. Defaults to today (UTC).",
)
args = parser.parse_args(argv)

schedule_path = Path(args.schedule_path)
if not schedule_path.exists():
raise ValueError(
f"{schedule_path} was supplied as path to schedule file but it did not exist"
)
if args.reference_date:
reference_date = parse_date(args.reference_date)
else:
reference_date = datetime.datetime.now(datetime.UTC)

try:
versions = minimum_versions(
read_schedule(schedule_path), args.packages, reference_date
)
except ScheduleError as exc:
print(f"error: {exc}", file=sys.stderr)
return 1

output = Path(args.output)
output.write_text(format_constraints(versions))
mapping = {package: str(version) for package, version in versions.items()}
print(f"Wrote {output}:")
print(output.read_text(), end="")

github_output = os.environ.get("GITHUB_OUTPUT")
if github_output:
with open(github_output, "a") as file:
file.write(f"constraints_file={output}\n")
file.write(f"versions={json.dumps(mapping)}\n")
file.write(f"python_version={mapping.get('python', '')}\n")
return 0


if __name__ == "__main__":
sys.exit(main())
104 changes: 104 additions & 0 deletions spec0_action/constraints.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
"""Exact SPEC 0 minimum version pins for testing the oldest supported versions in CI."""

import datetime
from collections.abc import Iterable, Sequence

from packaging.utils import canonicalize_name
from packaging.version import InvalidVersion, Version

from spec0_action.parsing import SupportSchedule


class ScheduleError(ValueError):
"""The schedule is malformed or cannot provide a requested floor."""


def _parse_start_date(entry: SupportSchedule, index: int) -> datetime.datetime:
try:
start = datetime.datetime.fromisoformat(entry["start_date"])
except (KeyError, TypeError, ValueError) as exc:
raise ScheduleError(
f"schedule entry {index} has a missing or invalid start_date"
) from exc
if start.tzinfo is None:
start = start.replace(tzinfo=datetime.UTC)
return start


def schedule_floors(
schedule_data: Sequence[SupportSchedule], reference_date: datetime.datetime
) -> dict[str, Version]:
"""Return the minimum version of every package in the schedule on ``reference_date``.

Each schedule entry only lists the packages whose floor changes at its start date,
so all entries up to the reference date are applied in order and later entries
overwrite earlier ones.
"""
if reference_date.tzinfo is None:
reference_date = reference_date.replace(tzinfo=datetime.UTC)
if not isinstance(schedule_data, Sequence) or isinstance(schedule_data, str):
raise ScheduleError("the schedule must be a list of entries")

dated = []
for index, entry in enumerate(schedule_data):
if not isinstance(entry, dict) or not isinstance(entry.get("packages"), dict):
raise ScheduleError(f"schedule entry {index} has no packages mapping")
dated.append((_parse_start_date(entry, index), index, entry))

floors: dict[str, Version] = {}
for start, index, entry in sorted(dated, key=lambda item: item[0]):
if start > reference_date:
break
for package, version in entry["packages"].items():
try:
floors[canonicalize_name(package)] = Version(version)
except (InvalidVersion, TypeError) as exc:
raise ScheduleError(
f"schedule entry {index} has an invalid version {version!r} for {package}"
) from exc
return floors


def parse_package_list(packages: str | Iterable[str]) -> list[str]:
"""Split a comma- or whitespace-separated list and normalize the names, keeping order."""
if isinstance(packages, str):
packages = packages.replace(",", " ").split()
names: list[str] = []
for package in packages:
name = canonicalize_name(package, validate=True)
if name not in names:
names.append(name)
return names


def minimum_versions(
schedule_data: Sequence[SupportSchedule],
packages: str | Iterable[str],
reference_date: datetime.datetime,
) -> dict[str, Version]:
"""Return the SPEC 0 floor of each requested package, failing if any is unknown."""
requested = parse_package_list(packages)
if not requested:
raise ScheduleError("no packages were requested")
floors = schedule_floors(schedule_data, reference_date)
missing = [package for package in requested if package not in floors]
if missing:
raise ScheduleError(
f"the schedule has no floor on {reference_date.date()} for: {', '.join(missing)}"
)
return {package: floors[package] for package in requested}


def format_constraints(versions: dict[str, Version]) -> str:
"""Format exact pins for a pip/uv constraints file.

Exact pins are needed: with ``>=`` the installer would pick the newest release and
the oldest supported version would never be tested. Python is not a package, so it
is left out here and reported separately.
"""
lines = [
f"{package}=={version}"
for package, version in versions.items()
if package != "python"
]
return "".join(f"{line}\n" for line in lines)
Loading