From dbb035151ec99a7ad9da4da0552c149e982413c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=E5=B0=8F=E4=BA=8C?= Date: Sun, 4 Oct 2026 16:44:29 +0800 Subject: [PATCH] ci: generate locks from pyproject, guard course pins, harden Validate - Lock files are now produced by `make lock` (uv pip compile --universal from the 3.11 floor) instead of by hand; every existing pin is kept, and the Windows-only colorama and <3.13 typing-extensions pins the manual lock missed are added. A new `lock` job runs `make lock-check`, which re-resolves copies of the committed locks and fails on any diff. - tests/test_dependency_pins.py requires course/path requirements.txt pins to equal the CI lock, so CI tests the stack learners install. - Validate adds Python 3.14, sets fail-fast: false, SHA-pins actions, moves setup-uv v7 -> v10.2.0, and pins uv 0.12.19. - Remove notify-site.yml: WEBSITE_SYNC_TOKEN was never set, so all 32 runs skipped the dispatch while reporting success, and the website builds from its own pin anyway. - Remove mypy, its type stubs, and [tool.mypy]: never locked or run. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .github/workflows/notify-site.yml | 23 ------- .github/workflows/validate.yml | 23 ++++++- CHANGELOG.md | 20 ++++++ CONTRIBUTING.md | 8 +++ Makefile | 29 ++++++++- pyproject.toml | 10 --- requirements-dev.lock.txt | 100 +++++++++++++++++++++++++----- requirements.lock.txt | 12 +++- tests/test_dependency_pins.py | 69 +++++++++++++++++++++ 9 files changed, 242 insertions(+), 52 deletions(-) delete mode 100644 .github/workflows/notify-site.yml create mode 100644 tests/test_dependency_pins.py diff --git a/.github/workflows/notify-site.yml b/.github/workflows/notify-site.yml deleted file mode 100644 index 404549d..0000000 --- a/.github/workflows/notify-site.yml +++ /dev/null @@ -1,23 +0,0 @@ -name: Dispatch Website Build - -on: - push: - branches: - - master - tags: - - "v*" - -jobs: - dispatch: - runs-on: ubuntu-latest - steps: - - name: Trigger Website Workflow - uses: peter-evans/repository-dispatch@v4 - if: env.SYNC_TOKEN != '' - with: - token: ${{ secrets.WEBSITE_SYNC_TOKEN }} - repository: flypythoncom/flypython.com - event-type: python-repo-updated - client-payload: '{"ref": "${{ github.ref }}", "sha": "${{ github.sha }}"}' - env: - SYNC_TOKEN: ${{ secrets.WEBSITE_SYNC_TOKEN }} diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 6a34ed5..e2cfe21 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -14,19 +14,36 @@ concurrency: cancel-in-progress: true jobs: + lock: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Install uv + uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + with: + version: "0.12.19" + + - name: Lock files match pyproject.toml + run: make lock-check + validate: runs-on: ubuntu-latest strategy: + fail-fast: false matrix: - python-version: ["3.11", "3.12", "3.13"] + python-version: ["3.11", "3.12", "3.13", "3.14"] timeout-minutes: 15 steps: - name: Check out repository - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install uv - uses: astral-sh/setup-uv@v7 + uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 with: + version: "0.12.19" enable-cache: true - name: Set up Python ${{ matrix.python-version }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 40021de..7ada1d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,26 @@ This file records notable catalog-contract and maintenance changes. ## [Unreleased] +### Changed — 2026-10-04 CI hardening + +- `requirements.lock.txt` and `requirements-dev.lock.txt` are now generated + by `make lock` (`uv pip compile --universal` from the Python 3.11 floor) + instead of hand-maintained. All existing pins were kept; the Windows-only + `colorama` and Python <3.13 `typing-extensions` transitive pins the manual + lock had missed are now included. A new `lock` CI job runs + `make lock-check` and fails when the locks drift from `pyproject.toml`. +- New `tests/test_dependency_pins.py`: every course/path `requirements.txt` + must pin exactly the versions CI tests with. +- Validate matrix adds Python 3.14 and no longer cancels sibling versions + on the first failure; actions are pinned to commit SHAs, `setup-uv` moves + from v7 to v10.2.0, and CI pins uv 0.12.19. +- Removed `notify-site.yml`: its `WEBSITE_SYNC_TOKEN` secret was never + configured, so all 32 runs skipped the dispatch step while reporting + success, and the website builds from its own content pin regardless. +- Removed the unused `mypy`, `types-PyYAML`, and `types-requests` dev + dependencies and the `[tool.mypy]` config; mypy was never installed by + the lock or run by CI. + ### Changed — 2026-10-04 README restructure - Root READMEs now carry a condensed catalog index (learning-path bullets diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e2855be..29bf90f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -76,6 +76,14 @@ python -m venv .venv python -m pip install -r requirements-dev.lock.txt ``` +The lock files are generated, not hand-edited. To change a dependency, edit +`pyproject.toml` and run `make lock` (needs [uv](https://docs.astral.sh/uv/)); +existing pins are kept wherever they still satisfy the new constraints. CI runs +`make lock-check` and fails if the locks drift from `pyproject.toml`. Course +and path folders that ship their own `requirements.txt` must pin exactly the +versions in `requirements-dev.lock.txt` (`tests/test_dependency_pins.py`), so +CI tests the stack learners install. + After changing catalog sources, regenerate the public export: ```bash diff --git a/Makefile b/Makefile index 4374736..19da60c 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,17 @@ -.PHONY: help check export render manifest test verify courses paths lint all +.PHONY: help check export render manifest test verify courses paths lint lock lock-check all PYTHON ?= python3 +UV ?= uv + +# Lock files are resolved once for every supported Python and platform +# (--universal from the 3.11 floor). The dev lock is constrained to the +# runtime lock so both always agree on shared packages. +UV_COMPILE = $(UV) pip compile pyproject.toml --universal --python-version 3.11 \ + --custom-compile-command "make lock" --quiet +define compile_locks + $(UV_COMPILE) -o requirements.lock.txt + $(UV_COMPILE) --extra dev -c requirements.lock.txt -o requirements-dev.lock.txt +endef help: @echo "FlyPython Development Workflow:" @@ -13,6 +24,8 @@ help: @echo " make verify - Verify all runnable examples" @echo " make courses - Verify all course folders" @echo " make paths - Verify all learning-path contracts" + @echo " make lock - Re-resolve requirements*.lock.txt from pyproject.toml (keeps existing pins where valid)" + @echo " make lock-check - Fail if the lock files no longer match pyproject.toml" @echo " make all - Regenerate all exports and run all checks and tests" check: lint test @@ -48,4 +61,18 @@ courses: paths: $(PYTHON) tools/verify_paths.py +lock: + $(compile_locks) + +# Re-resolve copies of the committed locks in a scratch directory: uv keeps +# every committed pin that still satisfies pyproject.toml, so any diff means +# the locks drifted from the declared dependencies. +lock-check: + @tmp=$$(mktemp -d) && \ + cp pyproject.toml requirements.lock.txt requirements-dev.lock.txt "$$tmp/" && \ + $(MAKE) --no-print-directory -C "$$tmp" -f "$(CURDIR)/Makefile" lock && \ + diff -u requirements.lock.txt "$$tmp/requirements.lock.txt" && \ + diff -u requirements-dev.lock.txt "$$tmp/requirements-dev.lock.txt" && \ + rm -rf "$$tmp" && echo "lock files match pyproject.toml" + all: export render manifest check diff --git a/pyproject.toml b/pyproject.toml index 4b5b364..13722bc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,9 +16,6 @@ dev = [ "pytest>=8.3.0", "jsonschema>=4.20.0", "ruff>=0.9.0", - "mypy>=2.3.1", - "types-PyYAML>=6.0.12.20260906", - "types-requests>=2.33.0.20260906", "pandas>=2.3,<2.4", "matplotlib>=3.10,<3.11", ] @@ -40,10 +37,3 @@ ignore = ["E501"] # Course starters are deliberately unfinished: pre-imported modules are # scaffolding the learner will use when implementing the contract. "courses/*/starter/*.py" = ["F401"] - -[tool.mypy] -python_version = "3.11" -strict = false -warn_return_any = true -warn_unused_configs = true -disallow_untyped_defs = false diff --git a/requirements-dev.lock.txt b/requirements-dev.lock.txt index 37bcd76..4ed7721 100644 --- a/requirements-dev.lock.txt +++ b/requirements-dev.lock.txt @@ -1,27 +1,99 @@ --r requirements.lock.txt +# This file was autogenerated by uv via the following command: +# make lock attrs==26.1.0 + # via + # jsonschema + # referencing +certifi==2026.7.22 + # via + # -c requirements.lock.txt + # requests +charset-normalizer==3.5.2 + # via + # -c requirements.lock.txt + # requests +colorama==0.4.6 ; sys_platform == 'win32' + # via pytest +contourpy==1.3.3 ; python_full_version < '3.12' + # via matplotlib +contourpy==1.4.0 ; python_full_version >= '3.12' + # via matplotlib +cycler==0.12.1 + # via matplotlib +fonttools==4.66.1 + # via matplotlib +idna==3.20 + # via + # -c requirements.lock.txt + # requests iniconfig==2.3.0 + # via pytest jsonschema==4.26.0 + # via flypython (pyproject.toml) jsonschema-specifications==2025.9.1 -packaging==26.3 -pluggy==1.6.0 -Pygments==2.21.0 -pytest==9.1.1 -referencing==0.37.0 -rpds-py==2026.6.3 -ruff==0.16.9 -contourpy==1.4.0; python_version >= "3.12" -contourpy==1.3.3; python_version < "3.12" -cycler==0.12.1 -fonttools==4.66.1 + # via jsonschema kiwisolver==1.5.1 + # via matplotlib matplotlib==3.10.9 -numpy==2.5.3; python_version >= "3.12" -numpy==2.4.6; python_version < "3.12" + # via flypython (pyproject.toml) +numpy==2.4.6 ; python_full_version < '3.12' + # via + # contourpy + # matplotlib + # pandas +numpy==2.5.3 ; python_full_version >= '3.12' + # via + # contourpy + # matplotlib + # pandas +packaging==26.3 + # via + # matplotlib + # pytest pandas==2.3.3 + # via flypython (pyproject.toml) pillow==12.3.0 + # via matplotlib +pluggy==1.6.0 + # via pytest +pygments==2.21.0 + # via pytest pyparsing==3.3.3 + # via matplotlib +pytest==9.1.1 + # via flypython (pyproject.toml) python-dateutil==2.9.0.post0 + # via + # matplotlib + # pandas pytz==2026.4 + # via pandas +pyyaml==6.0.3 + # via + # -c requirements.lock.txt + # flypython (pyproject.toml) +referencing==0.37.0 + # via + # jsonschema + # jsonschema-specifications +requests==2.34.2 + # via + # -c requirements.lock.txt + # flypython (pyproject.toml) +rpds-py==2026.6.3 + # via + # jsonschema + # referencing +ruff==0.16.9 + # via flypython (pyproject.toml) six==1.17.0 + # via python-dateutil +typing-extensions==4.16.0 ; python_full_version < '3.13' + # via referencing tzdata==2026.4 + # via pandas +urllib3==2.8.0 + # via + # -c requirements.lock.txt + # flypython (pyproject.toml) + # requests diff --git a/requirements.lock.txt b/requirements.lock.txt index b1c32ba..8ec274b 100644 --- a/requirements.lock.txt +++ b/requirements.lock.txt @@ -1,6 +1,16 @@ -PyYAML==6.0.3 +# This file was autogenerated by uv via the following command: +# make lock certifi==2026.7.22 + # via requests charset-normalizer==3.5.2 + # via requests idna==3.20 + # via requests +pyyaml==6.0.3 + # via flypython (pyproject.toml) requests==2.34.2 + # via flypython (pyproject.toml) urllib3==2.8.0 + # via + # flypython (pyproject.toml) + # requests diff --git a/tests/test_dependency_pins.py b/tests/test_dependency_pins.py new file mode 100644 index 0000000..1bd2254 --- /dev/null +++ b/tests/test_dependency_pins.py @@ -0,0 +1,69 @@ +"""Learners install a course's own requirements.txt; CI verifies the course +with requirements-dev.lock.txt. Both must resolve to the same versions, or CI +would pass on a stack learners never get (the pandas 3 risk in PR #94).""" + +from __future__ import annotations + +import re +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +PIN = re.compile(r"^([A-Za-z0-9][A-Za-z0-9._-]*)==([^\s;]+)\s*(;.*)?$") + + +def _normalize(name: str) -> str: + return re.sub(r"[-_.]+", "-", name).lower() + + +def _requirement_lines(path: Path) -> list[str]: + lines = [] + for raw in path.read_text(encoding="utf-8").splitlines(): + line = raw.split("#", 1)[0].strip() + if line: + lines.append(line) + return lines + + +def _learner_requirement_files() -> list[Path]: + tracked = subprocess.run( + ["git", "-C", str(ROOT), "ls-files", "courses/*/requirements.txt", "paths/**/requirements.txt"], + check=True, + capture_output=True, + text=True, + ).stdout.split() + return [ROOT / name for name in sorted(tracked)] + + +def _dev_lock_pins() -> dict[str, list[tuple[str, str | None]]]: + pins: dict[str, list[tuple[str, str | None]]] = {} + for line in _requirement_lines(ROOT / "requirements-dev.lock.txt"): + match = PIN.match(line) + if match: + name, version, marker = match.groups() + pins.setdefault(_normalize(name), []).append((version, marker)) + return pins + + +def test_learner_requirement_files_exist() -> None: + assert _learner_requirement_files(), "expected course/path requirements.txt files" + + +def test_learner_requirements_match_the_ci_lock() -> None: + lock = _dev_lock_pins() + problems = [] + for path in _learner_requirement_files(): + rel = path.relative_to(ROOT) + for line in _requirement_lines(path): + match = PIN.match(line) + if not match or match.group(3): + problems.append(f"{rel}: '{line}' must be an exact, unconditional == pin") + continue + name, version, _ = match.groups() + locked = lock.get(_normalize(name)) + if locked is None: + problems.append(f"{rel}: {name} is not in requirements-dev.lock.txt") + elif locked != [(version, None)]: + found = ", ".join(v + (f" ({m.lstrip('; ')})" if m else "") for v, m in locked) + problems.append(f"{rel}: {name}=={version}, but the CI lock has {found}") + assert problems == []