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
134 changes: 134 additions & 0 deletions .github/workflows/checks.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
name: checks
on:
workflow_call:
inputs:
docs:
description: Run `just docs-build`. Only for a repo with a docs site.
type: boolean
default: false
free-threaded:
description: Test the newest cycle's free-threaded build. Off only for a repo with a section 6 exemption.
type: boolean
default: true
service-image:
description: Image of the one service container the tests need, e.g. `postgres:17`. Empty means none.
type: string
default: ""
service-port:
description: The service's `host:container` port mapping, e.g. `5432:5432`.
type: string
default: ""
service-options:
description: "`docker create` options for the service: its health command and any `-e` it needs, e.g. `--health-cmd pg_isready -e POSTGRES_PASSWORD=password`."
type: string
default: ""
test-env:
description: "`KEY=VALUE` lines exported before `just test-ci`."
type: string
default: ""
free-threaded-env:
description: "`KEY=VALUE` lines exported before `just test-ci` on the free-threaded entry only."
type: string
default: ""

jobs:
matrix:
runs-on: ubuntu-latest
outputs:
floor: ${{ steps.derive.outputs.floor }}
versions: ${{ steps.derive.outputs.versions }}
package: ${{ steps.derive.outputs.package }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v6
with:
repository: ${{ job.workflow_repository }}
ref: ${{ job.workflow_sha }}
path: .modern-python
- run: curl --fail --silent --show-error --retry 3 --output cycles.json https://endoflife.date/api/python.json
- id: derive
shell: bash
run: python3 .modern-python/scripts/python_matrix.py --pyproject pyproject.toml --cycles cycles.json --free-threaded "$FREE_THREADED" | tee -a "$GITHUB_OUTPUT"
env:
FREE_THREADED: ${{ inputs.free-threaded }}

lint:
needs: matrix
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: extractions/setup-just@v4
- uses: astral-sh/setup-uv@v8.2.0
with:
enable-cache: true
cache-dependency-glob: "**/pyproject.toml"
- run: uv python install ${{ needs.matrix.outputs.floor }}
- run: uv python pin ${{ needs.matrix.outputs.floor }}
- run: just install lint-ci

pytest:
needs: matrix
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ${{ fromJSON(needs.matrix.outputs.versions) }}
services:
service:
image: ${{ inputs.service-image }}
ports:
- ${{ inputs.service-port }}
options: >-
${{ inputs.service-options }}
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v6
- uses: extractions/setup-just@v4
- uses: astral-sh/setup-uv@v8.2.0
with:
enable-cache: true
cache-dependency-glob: "**/pyproject.toml"
- run: uv python install ${{ matrix.python-version }}
- run: uv python pin ${{ matrix.python-version }}
- run: just install
- if: inputs.test-env != ''
run: printf '%s\n' "$TEST_ENV" >> "$GITHUB_ENV"
env:
TEST_ENV: ${{ inputs.test-env }}
- if: inputs.free-threaded-env != '' && endsWith(matrix.python-version, 't')
run: printf '%s\n' "$FREE_THREADED_ENV" >> "$GITHUB_ENV"
env:
FREE_THREADED_ENV: ${{ inputs.free-threaded-env }}
- name: Confirm the interpreter is free-threaded
if: endsWith(matrix.python-version, 't')
run: uv run --no-sync python -c "import sys, ${{ needs.matrix.outputs.package }}; assert not sys._is_gil_enabled(), 'GIL is enabled on a t-build'"
- run: just test-ci

links:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
# --offline blocks network requests and excludes every external URL, so this gate
# is deterministic: it fails only on a relative link or file path a diff broke.
# Tag-pinned and cross-repo URLs do not match the --remap pattern and stay
# excluded: they point at trees this checkout does not have.
- name: Check local links
uses: lycheeverse/lychee-action@v2
with:
args: >-
--offline
--no-progress
--remap
'https://github\.com/${{ github.repository }}/(?:blob|tree)/main/(.*) file://${{ github.workspace }}/$1'
'**/*.md'

docs:
if: inputs.docs
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: extractions/setup-just@v4
- uses: astral-sh/setup-uv@v8.2.0
- run: just docs-build
28 changes: 25 additions & 3 deletions docs/standard.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,9 +130,31 @@ Both call one reusable `checks.yml` with these jobs:
| `links` | [lychee](https://github.com/lycheeverse/lychee-action) with `--offline`, remapping this repo's `blob/main` URLs to the checkout, so it fails only on a relative link or file path the diff broke |
| `docs` | `just docs-build` (`mkdocs build --strict`), only for repos with a docs site |

The shared `checks.yml` will live in `modern-python/.github` and be referenced at `main`, so a
change to it reaches every repo on merge; such a change is first exercised from a branch ref in
one repo. Until it exists, each repo carries the same jobs in a local `_checks.yml`.
The shared `checks.yml` lives in
[`modern-python/.github`](https://github.com/modern-python/.github/blob/main/.github/workflows/checks.yml)
and is referenced at `main`, so a change to it reaches every repo on merge; such a change is first
exercised from a branch ref in one repo. A repo not yet calling it carries the same jobs in a local
`_checks.yml`. A caller is one job:

```yaml
jobs:
checks:
uses: modern-python/.github/.github/workflows/checks.yml@main
secrets: inherit
```

The matrix is derived at run time (section 6): the floor is the `>=` bound of `requires-python`,
the ceiling is the newest cycle [endoflife.date](https://endoflife.date/api/python.json) lists as
released, and the free-threaded entry is the ceiling's `t` build. Everything a repo may vary is an
input, all optional:

| Input | Meaning |
|---|---|
| `docs` | Run the `docs` job. Only for a repo with a docs site. |
| `free-threaded` | Test the free-threaded build. `false` only for a repo with a section 6 exemption. |
| `service-image`, `service-port`, `service-options` | The one service container the tests need: its image, `host:container` port mapping, and its `docker create` options, which is where the health command and any `-e` the image needs go. No image, no service. |
| `test-env` | `KEY=VALUE` lines exported before `just test-ci`, e.g. the DSN pointing at the service. |
| `free-threaded-env` | The same, applied on the free-threaded entry only, e.g. a switch that keeps a dependency's C extension from re-enabling the GIL. |

## 8. Release

Expand Down
71 changes: 71 additions & 0 deletions scripts/python_matrix.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
"""Derive a repo's CI Python matrix for the shared ``checks.yml``.

Every minor from the ``>=`` bound of ``requires-python`` to the newest released CPython cycle, plus
that cycle's free-threaded build. Prints ``$GITHUB_OUTPUT`` lines.
"""

import argparse
import dataclasses
import datetime
import json
import re
import sys
import tomllib
from pathlib import Path

_FLOOR = re.compile(r">=\s*(\d+)\.(\d+)")


@dataclasses.dataclass(frozen=True)
class Matrix:
floor: str
versions: list[str]
package: str


def derive(pyproject: str, cycles: list[dict[str, str]], today: datetime.date, *, free_threaded: bool) -> Matrix:
project = tomllib.loads(pyproject)["project"]
requires_python = project.get("requires-python", "")
floor_match = _FLOOR.search(requires_python)
if floor_match is None:
msg = f"requires-python {requires_python!r} has no >=X.Y bound to read the floor from"
raise ValueError(msg)
floor = (int(floor_match[1]), int(floor_match[2]))

released = [
tuple(int(part) for part in cycle["cycle"].split("."))
for cycle in cycles
if datetime.date.fromisoformat(cycle["releaseDate"]) <= today
]
ceiling = max(minor for minor in released if minor[0] == floor[0])
if floor > ceiling:
msg = f"floor {floor[0]}.{floor[1]} is above the newest released cycle {ceiling[0]}.{ceiling[1]}"
raise ValueError(msg)

versions = [f"{floor[0]}.{minor}" for minor in range(floor[1], ceiling[1] + 1)]
if free_threaded:
versions.append(f"{versions[-1]}t")
return Matrix(floor=versions[0], versions=versions, package=project["name"].replace("-", "_"))


def main(argv: list[str] | None = None) -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--pyproject", type=Path, required=True)
parser.add_argument("--cycles", type=Path, required=True, help="endoflife.date/api/python.json saved to a file")
parser.add_argument("--free-threaded", choices=["true", "false"], required=True)
parser.add_argument(
"--today", type=datetime.date.fromisoformat, default=datetime.datetime.now(tz=datetime.UTC).date()
)
args = parser.parse_args(argv)

matrix = derive(
args.pyproject.read_text(encoding="utf-8"),
json.loads(args.cycles.read_text(encoding="utf-8")),
args.today,
free_threaded=args.free_threaded == "true",
)
sys.stdout.write(f"floor={matrix.floor}\nversions={json.dumps(matrix.versions)}\npackage={matrix.package}\n")


if __name__ == "__main__":
main()
68 changes: 68 additions & 0 deletions tests/test_python_matrix.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import datetime
import json
from pathlib import Path

import pytest

from scripts.python_matrix import derive, main

_TODAY = datetime.date(2026, 9, 15)
_CYCLES = [
{"cycle": "3.15", "releaseDate": "2026-10-01"},
{"cycle": "3.14", "releaseDate": "2025-10-07"},
{"cycle": "3.13", "releaseDate": "2024-10-07"},
{"cycle": "3.12", "releaseDate": "2023-10-02"},
{"cycle": "3.11", "releaseDate": "2022-10-24"},
{"cycle": "3.10", "releaseDate": "2021-10-04"},
{"cycle": "3.9", "releaseDate": "2020-10-05"},
{"cycle": "2.7", "releaseDate": "2010-07-03"},
]


def _pyproject(requires_python: str, name: str = "modern-di-fastapi") -> str:
return f'[project]\nname = "{name}"\nrequires-python = "{requires_python}"\n'


def test_matrix_runs_from_the_floor_to_the_newest_released_cycle_plus_its_t_build() -> None:
result = derive(_pyproject(">=3.10,<4"), _CYCLES, _TODAY, free_threaded=True)
assert result.floor == "3.10"
assert result.versions == ["3.10", "3.11", "3.12", "3.13", "3.14", "3.14t"]


def test_a_cycle_whose_release_date_is_in_the_future_is_not_the_ceiling() -> None:
"""INVARIANT: endoflife.date lists a cycle before it ships; the matrix waits for the release."""
result = derive(_pyproject(">=3.13"), _CYCLES, datetime.date(2026, 10, 1), free_threaded=True)
assert result.versions == ["3.13", "3.14", "3.15", "3.15t"]


def test_exempt_repo_gets_no_free_threaded_entry() -> None:
result = derive(_pyproject(">=3.13"), _CYCLES, _TODAY, free_threaded=False)
assert result.versions == ["3.13", "3.14"]


def test_package_is_the_distribution_name_with_underscores() -> None:
result = derive(_pyproject(">=3.11", name="faststream-concurrent-aiokafka"), _CYCLES, _TODAY, free_threaded=True)
assert result.package == "faststream_concurrent_aiokafka"


@pytest.mark.parametrize("requires_python", ["", ">3.10", "==3.12.*", "<4"])
def test_a_floor_without_a_ge_bound_is_rejected(requires_python: str) -> None:
with pytest.raises(ValueError, match="requires-python"):
derive(_pyproject(requires_python), _CYCLES, _TODAY, free_threaded=True)


def test_a_floor_above_every_released_cycle_is_rejected() -> None:
with pytest.raises(ValueError, match=r"3\.15"):
derive(_pyproject(">=3.15"), _CYCLES, _TODAY, free_threaded=True)


def test_cli_prints_github_output_lines(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
pyproject = tmp_path / "pyproject.toml"
pyproject.write_text(_pyproject(">=3.12", name="db-retry"), encoding="utf-8")
cycles = tmp_path / "cycles.json"
cycles.write_text(json.dumps(_CYCLES), encoding="utf-8")

main(["--pyproject", str(pyproject), "--cycles", str(cycles), "--free-threaded", "false", "--today", "2026-09-15"])

lines = capsys.readouterr().out.splitlines()
assert lines == ["floor=3.12", 'versions=["3.12", "3.13", "3.14"]', "package=db_retry"]
Loading