diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml new file mode 100644 index 0000000..1175923 --- /dev/null +++ b/.github/workflows/checks.yml @@ -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 diff --git a/docs/standard.md b/docs/standard.md index 2b510d2..ad4b05e 100644 --- a/docs/standard.md +++ b/docs/standard.md @@ -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 diff --git a/scripts/python_matrix.py b/scripts/python_matrix.py new file mode 100644 index 0000000..b94a19b --- /dev/null +++ b/scripts/python_matrix.py @@ -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() diff --git a/tests/test_python_matrix.py b/tests/test_python_matrix.py new file mode 100644 index 0000000..3967a99 --- /dev/null +++ b/tests/test_python_matrix.py @@ -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"]