From d2b0904df535a41d75ab27b6bc8b2d55bfaf041e Mon Sep 17 00:00:00 2001 From: Jarry Shaw Date: Tue, 13 Oct 2026 22:24:46 -0400 Subject: [PATCH] ci(python): a weekly job keeps the Python version tiers current and opens a PR (#1660) - util/update_python_versions.py recomputes .github/python-versions.toml from python.org's release cycle, PyPy's versions.json, setup-python's versions-manifest.json and the runner-images README, for the labels the workflows run on. It rewrites the tier file line by line (a no-op run is byte-identical), regenerates the prose the audit pins, lists the renamed check runs and the ruleset edits, and holds a pre-release back. - cron-python-versions.yml: weekly and on dispatch, permissions {} by default. detect runs it and the audit with no secret; while the branch's pull request is open it skips tiers a commit ahead of main proposes, merges of main left out. pr, behind github-release, mints a release App token with contents and pull requests write, force-pushes bot/python-versions only, or recreates it once its pull request is merged or closed, and opens or updates that PR. - Workflow comments no longer restate the tiers, since that token cannot push a workflow file; coverage-comment.yml reads the leg's version from the artifact. - create-release.yml: once a version is tagged, version_check counts the wheels and conda builds in the tier file at the tag's commit, not the run's. - Tests against committed source snapshots and scratch repositories. The seven affected tests/project modules pass; the new module's 53 fail on main. --- .github/python-versions.toml | 4 +- .github/workflows/coverage-comment.yml | 9 +- .github/workflows/create-release.yml | 78 +- .github/workflows/cron-python-versions.yml | 177 ++ .github/workflows/lint.yml | 2 +- .github/workflows/unit-tests.yml | 6 +- docs/source/contributing/releasing.rst | 59 +- docs/source/contributing/workflows.rst | 70 +- .../pypy-versions.json | 447 +++++ .../python-versions.toml | 59 + .../release-cycle.json | 98 ++ .../runner-images-readme.md | 21 + .../versions-manifest.json | 844 +++++++++ .../project/test_python_matrix_tiers_1633.py | 7 +- tests/project/test_python_versions_1660.py | 51 +- .../test_python_versions_update_1660.py | 1258 ++++++++++++++ .../test_release_tags_the_run_sha_1634.py | 173 +- tests/project/test_unit_tests_queue.py | 4 +- util/update_python_versions.py | 1511 +++++++++++++++++ 19 files changed, 4787 insertions(+), 91 deletions(-) create mode 100644 .github/workflows/cron-python-versions.yml create mode 100644 tests/project/python_versions_fixtures/pypy-versions.json create mode 100644 tests/project/python_versions_fixtures/python-versions.toml create mode 100644 tests/project/python_versions_fixtures/release-cycle.json create mode 100644 tests/project/python_versions_fixtures/runner-images-readme.md create mode 100644 tests/project/python_versions_fixtures/versions-manifest.json create mode 100644 tests/project/test_python_versions_update_1660.py create mode 100644 util/update_python_versions.py diff --git a/.github/python-versions.toml b/.github/python-versions.toml index 018983db13..e87cdff8db 100644 --- a/.github/python-versions.toml +++ b/.github/python-versions.toml @@ -1,8 +1,8 @@ # The Python version tiers (#1660). Every workflow matrix and every # single-interpreter job's version is read from here through # util/python_versions.py, and tests/project/test_python_versions_1660.py pins -# the classifiers and docs to it. A tier change is a one-line edit below plus -# the matching edit to ruleset 23497679's Compat contexts. +# the classifiers and docs to it. cron-python-versions.yml recomputes it weekly +# and proposes each change, and the ruleset 23497679 edit it needs, as a PR. # # Tiers: supported (blocking), ktlo (end-of-life, non-blocking) and pre-release # (non-blocking; none listed today). `runner` is the image a version needs when diff --git a/.github/workflows/coverage-comment.yml b/.github/workflows/coverage-comment.yml index 8640310d92..deaf07daa5 100644 --- a/.github/workflows/coverage-comment.yml +++ b/.github/workflows/coverage-comment.yml @@ -1,6 +1,6 @@ name: Coverage Comment -# Posts the coverage the `test` job's Python 3.15 leg measured in +# Posts the coverage the `test` job's `latest` leg measured in # unit-tests.yml as a pull-request comment. It runs no tests. The comment cannot # be posted from unit-tests.yml itself: that file is also a reusable workflow, # and a called workflow can only narrow the token its caller passes, never widen @@ -81,8 +81,13 @@ jobs: if tests not in ('success', 'failure', 'cancelled', 'skipped'): sys.exit(f'invalid test outcome in artifact: {tests!r}') total = number(data['total']) + # The leg's version, from the artifact; one written before #1660 has none. + python = data.get('python') + if python is not None and not (isinstance(python, str) and re.fullmatch(r'3\.\d+', python)): + sys.exit(f'invalid Python version in artifact: {python!r}') + leg = f'Python {python}' if python else 'the coverage leg' status = '' if tests == 'success' else f' -- **tests {tests}**, so this is partial' - print(f"**Coverage: {total:.2f}%**{status} (unit tier, Python 3.15, " + print(f"**Coverage: {total:.2f}%**{status} (unit tier, {leg}, " f"`{os.environ['HEAD_SHA'][:9]}`, Unit Tests run {os.environ['RUN_CONCLUSION']})") print() print('| Package | Statements | Missed | Branches | Partial | Cover |') diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index a8ec0a1313..29909ecdcf 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -102,7 +102,7 @@ concurrency: # artefact -- the tag it checks is the one it is about to create -- but ``tag``, # ``pypi`` and ``conda`` now each read their own evidence instead: ``tag`` checks # for the ``conda-+0`` tag it creates, ``pypi`` checks PyPI's file count -# for the version against the 7 expected, and ``conda`` both checks Anaconda's +# for the version against the count expected, and ``conda`` both checks Anaconda's # aggregate count as a job-level cost saver and, per leg, whether that leg's own # platform/Python distribution is already there -- the correctness fix, since # retrying an upload Anaconda already has fails outright rather than skipping @@ -220,8 +220,9 @@ jobs: fetch-depth: 0 # #1660: .github/python-versions.toml, read with the runner's own python3: - # `latest` for this job, the ``pypi`` and ``conda`` matrices, and the tags - # the two completeness checks below count, all from this run's commit. + # `latest` for this job and the ``pypi`` and ``conda`` matrices, from this + # run's commit, and the tags the two completeness checks below count until + # the version is tagged. - name: Read the Python versions id: python-versions run: python3 util/python_versions.py --github-output @@ -262,11 +263,58 @@ jobs: with: tag: "conda-${{ steps.get_version.outputs.PCAPKIT_VERSION }}+0" - # PyPI evidence: compare the file count for this version against the 7 - # expected -- the 6 supported legs (``pypi``'s ``python-version`` axis, - # whose wheel tags ``PCAPKIT_PYPI_WHEELS`` lists) each producing a wheel, - # plus the sdist that the "No Source Distribution" step keeps only on the - # 3.15 leg, ``latest``. A release-level 200 only proves *some* + # #1660: the two checks below count what the version was released with. + # Once its ``v*`` tag exists, that is the tier file at the tag's commit, + # read by that commit's own util/python_versions.py, not this run's file: + # a version added to the supported tier since was never built for it, and + # counting it would read a finished release as incomplete, run the gate and + # the publishers, and fail #1634's tag check on every run until the next + # release. A tag older than the file counts the tiers the file was added + # with, which #1677 copied unchanged from the workflows of the time. + - name: Read the released Python versions + id: released + env: + PCAPKIT_TAG: v${{ steps.get_version.outputs.PCAPKIT_VERSION }} + PCAPKIT_TAG_EXISTS: ${{ steps.check_tag.outputs.exists }} + PCAPKIT_PYPI_WHEELS: ${{ steps.python-versions.outputs.pypi-wheels }} + PCAPKIT_CONDA_BUILDS: ${{ steps.python-versions.outputs.conda-builds }} + run: | + set -euo pipefail + + wheels="$PCAPKIT_PYPI_WHEELS" + builds="$PCAPKIT_CONDA_BUILDS" + tiers=.github/python-versions.toml + if [ "$PCAPKIT_TAG_EXISTS" = true ]; then + # The checkout fetched every tag, but not one pushed since. + git rev-parse -q --verify "refs/tags/${PCAPKIT_TAG}^{commit}" > /dev/null \ + || git fetch -q --no-tags origin "+refs/tags/${PCAPKIT_TAG}:refs/tags/${PCAPKIT_TAG}" + from="$(git rev-parse "refs/tags/${PCAPKIT_TAG}^{commit}")" + if ! git cat-file -e "${from}:${tiers}" 2> /dev/null; then + from="$(git log --diff-filter=A --format=%H -- "$tiers" | tail -n 1)" + if [ -z "$from" ]; then + echo "::error title=No tier file in the history::${tiers} was never added to this checkout's history." + exit 1 + fi + echo "::notice title=Released before the tier file::${PCAPKIT_TAG} predates ${tiers}; counting the tiers ${from} added it with." + fi + if [ "$from" != "$(git rev-parse HEAD)" ]; then + released="$(mktemp -d)" + git show "${from}:${tiers}" > "${released}/python-versions.toml" + git show "${from}:util/python_versions.py" > "${released}/python_versions.py" + wheels="$(python3 "${released}/python_versions.py" --file "${released}/python-versions.toml" pypi-wheels)" + builds="$(python3 "${released}/python_versions.py" --file "${released}/python-versions.toml" conda-builds)" + fi + fi + + echo "${PCAPKIT_TAG}: counting the wheels ${wheels} and the conda builds ${builds}" + echo "pypi-wheels=${wheels}" >> "$GITHUB_OUTPUT" + echo "conda-builds=${builds}" >> "$GITHUB_OUTPUT" + + # PyPI evidence: compare the file count for this version against the one + # expected -- a wheel from each supported leg (``pypi``'s ``python-version`` + # axis, whose wheel tags ``PCAPKIT_PYPI_WHEELS`` lists from the tiers read + # above), plus the sdist that the "No Source Distribution" step keeps only + # on the ``latest`` leg. A release-level 200 only proves *some* # file landed, not all of them, so presence alone -- ``PCAPKIT_TAG_EXISTS``'s # mistake, repeated -- is not enough. Only the supported tier's wheels are # counted (#1633): a KTLO leg's wheel neither completes the release nor, @@ -292,7 +340,7 @@ jobs: id: check_pypi env: PCAPKIT_VERSION: ${{ steps.get_version.outputs.PCAPKIT_VERSION }} - PCAPKIT_PYPI_WHEELS: ${{ steps.python-versions.outputs.pypi-wheels }} + PCAPKIT_PYPI_WHEELS: ${{ steps.released.outputs.pypi-wheels }} run: | set -euo pipefail @@ -342,19 +390,19 @@ jobs: echo "http $status, found $count of $expected expected files for ${PCAPKIT_VERSION} on PyPI" echo "PCAPKIT_PYPI_COMPLETE=$complete" >> "$GITHUB_OUTPUT" - # Anaconda evidence, same shape as PyPI's above but for the 10-leg - # ``conda`` matrix (2 OS x 5 Python versions). Unlike PyPI's, this endpoint - # reported ``cf-cache-status: DYNAMIC`` on every request checked while - # writing this, i.e. not cached -- so there is no staleness direction to + # Anaconda evidence, same shape as PyPI's above but for the ``conda`` + # matrix (2 OS x the supported tier read above). Unlike PyPI's, this + # endpoint reported ``cf-cache-status: DYNAMIC`` on every request checked + # while writing this, i.e. not cached -- so there is no staleness direction to # reason about here. This is the job-level cost saver mirroring ``pypi``'s; # ``conda``'s own per-leg checks (see that job) are the actual correctness - # fix and still run underneath it, since a release with only 9 of 10 legs + # fix and still run underneath it, since a release one leg short of # complete must not have the missing leg mistaken for "nothing to do". - name: Check Anaconda publish completeness id: check_conda env: PCAPKIT_VERSION: ${{ steps.get_version.outputs.PCAPKIT_VERSION }} - PCAPKIT_CONDA_BUILDS: ${{ steps.python-versions.outputs.conda-builds }} + PCAPKIT_CONDA_BUILDS: ${{ steps.released.outputs.conda-builds }} run: | set -euo pipefail diff --git a/.github/workflows/cron-python-versions.yml b/.github/workflows/cron-python-versions.yml new file mode 100644 index 0000000000..df1a68f4ea --- /dev/null +++ b/.github/workflows/cron-python-versions.yml @@ -0,0 +1,177 @@ +name: Python Versions + +# #1660: keeps .github/python-versions.toml current. `detect` recomputes the +# tiers from python.org's release cycle, PyPy's releases, setup-python's builds +# and the hosted runner images (util/update_python_versions.py, whose docstring +# gives the rules), rewrites the file and the prose the audit tests pin to it, +# and runs that audit. When something changed, `pr` pushes the change to +# `bot/python-versions` and opens that branch's pull request, or updates the +# open one, for the owner to merge. Nothing here pushes `main` or edits ruleset +# 23497679: the pull request body lists the required contexts to change by hand. +# +# Only these two triggers. A push to `bot/python-versions` starts the pull +# request's own checks, which is why `pr` spends the release App's token rather +# than GITHUB_TOKEN, whose pushes start nothing; nothing it starts runs this +# workflow again. +on: + schedule: + - cron: '41 5 * * 1' # Mondays at 05:41 UTC + workflow_dispatch: + +# Nothing by default; each job asks for what it needs. +permissions: {} + +# One run at a time, so two runs cannot race for the branch. A queued run is +# kept, not cancelled: it recomputes from the sources and skips what the branch +# already proposes. +concurrency: + group: python-versions + cancel-in-progress: false + +jobs: + # No secret and a read-only token. The computation reads the network and + # writes only this checkout, and the audit is the repository's own tests. + detect: + name: Recompute the Python version tiers + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + pull-requests: read + outputs: + changed: ${{ steps.update.outputs.changed }} + publish: ${{ steps.update.outputs.publish }} + + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + + # `latest` for this job, like every single-interpreter job (#1661). + - name: Read the Python versions + id: python-versions + run: python3 util/python_versions.py --github-output + + - uses: actions/setup-python@v7 + with: + python-version: ${{ steps.python-versions.outputs.latest }} + + # PyYAML, from the `test` extra, for the workflow readers and the audit; + # neither imports pcapkit. + - name: Install PyYAML + run: python -m pip install PyYAML + + # Fetches the four sources, rewrites the files, and writes the proposal to + # $RUNNER_TEMP/proposal. `publish` is false when nothing changed, or when + # `bot/python-versions` has an open pull request and a commit it holds + # ahead of main proposes these tiers, hand edits or merges of main on top + # or not (an anonymous, commits-only fetch), so an unmerged proposal is not + # pushed, nor its approval asked for, every week. When that branch holds + # commits it did not make and proposes other tiers, this fails as + # `publish` would refuse, before any approval. A branch with no open pull + # request was merged or closed, and is proposed afresh. The token reads + # that pull request's state. + - name: Recompute the tiers + id: update + env: + GH_TOKEN: ${{ github.token }} + run: >- + python util/update_python_versions.py update --write --out "$RUNNER_TEMP/proposal" + --proposal-branch bot/python-versions --github-output + + # The three modules that pin the workflows and the prose to the file, on + # the rewritten tree. A failure here does not stop the proposal: a tier + # move can need hand edits the script names, and the body lists what failed. + - name: Run the audit tests + id: audit + continue-on-error: true + shell: bash + run: >- + python -m unittest -v tests.project.test_python_versions_1660 + tests.project.test_python_matrix_tiers_1633 tests.project.test_ktlo_legs_stay_green_1672 + 2>&1 | tee "$RUNNER_TEMP/audit.log" + + - name: Write the pull request body + if: ${{ steps.update.outputs.changed == 'true' }} + env: + AUDIT_OUTCOME: ${{ steps.audit.outcome }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: >- + python util/update_python_versions.py body "$RUNNER_TEMP/proposal" + --audit-outcome "$AUDIT_OUTCOME" --audit-log "$RUNNER_TEMP/audit.log" --run-url "$RUN_URL" + + # Kept for as long as an environment approval can wait. The proposal holds + # files/.github/..., which upload-artifact leaves out unless told otherwise. + - name: Upload the proposal + if: ${{ steps.update.outputs.publish == 'true' }} + uses: actions/upload-artifact@v7 + with: + name: python-versions-proposal + path: ${{ runner.temp }}/proposal + include-hidden-files: true + retention-days: 30 + + - name: Say why no pull request opens + if: ${{ steps.update.outputs.publish == 'true' && vars.RELEASE_APP_CLIENT_ID == '' }} + run: >- + echo "::warning title=No pull request without the release App::vars.RELEASE_APP_CLIENT_ID is unset, + so the pr job skips. Set up the App as docs/source/contributing/releasing.rst describes." + + # With nothing changed the tree is main's, so a failing audit is main's own + # failure: the run says so rather than passing green over it. + - name: Fail on a failing audit of an unchanged tree + if: ${{ steps.update.outputs.changed != 'true' && steps.audit.outcome == 'failure' }} + run: | + echo "::error title=The audit fails on main::the tiers need no change, but the audit fails; see above." + exit 1 + + # The release App's private key is a secret of the `github-release` + # environment, which needs the owner's approval, so he is asked only on a run + # that has something to propose. The token is contents and pull requests + # write and nothing else: no `workflows`, so it cannot push a workflow file, + # and `update` writes none. `publish` force-pushes `bot/python-versions` alone, + # and while its pull request is open refuses it if it holds commits it did not + # make; with none open it recreates the branch from main. + pr: + name: Open or update the pull request + needs: detect + if: >- + ${{ needs.detect.outputs.publish == 'true' && vars.RELEASE_APP_CLIENT_ID != '' && + github.ref == 'refs/heads/main' }} + runs-on: ubuntu-latest + timeout-minutes: 10 + environment: github-release + # GITHUB_TOKEN only downloads `detect`'s artifact; the App's token does the rest. + permissions: + actions: read + + steps: + - name: Mint the release App's token + id: app + uses: actions/create-github-app-token@v3 + with: + client-id: ${{ vars.RELEASE_APP_CLIENT_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + permission-contents: write + permission-pull-requests: write + + # The commit `detect` read, with full history for the branch's merge base. + - uses: actions/checkout@v7 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + token: ${{ steps.app.outputs.token }} + + - name: Download the proposal + env: + GH_TOKEN: ${{ github.token }} + run: >- + gh run download "$GITHUB_RUN_ID" --repo "$GITHUB_REPOSITORY" --name python-versions-proposal + --dir "$RUNNER_TEMP/proposal" + + - name: Push the proposal and open or update its pull request + env: + GH_TOKEN: ${{ steps.app.outputs.token }} + APP_SLUG: ${{ steps.app.outputs.app-slug }} + APP_BOT_ID: ${{ vars.RELEASE_APP_BOT_ID }} + run: python3 util/update_python_versions.py publish "$RUNNER_TEMP/proposal" diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 02fc7360c3..6a6c05624d 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -160,7 +160,7 @@ jobs: - uses: actions/checkout@v7 # One interpreter, not a matrix. Unit Tests and Python Compatibility sweep - # 3.11-3.15, plus the non-blocking KTLO legs 3.7-3.10 (#1633), because + # the supported tier, plus the non-blocking KTLO legs (#1633), because # they check that the package *runs* everywhere; these tools read the # source, and running them several times over would produce several # copies of the same findings for several times the runner cost. diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 6eed5bbd3c..510168bee0 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -316,7 +316,7 @@ jobs: c['num_partial_branches'], round(pct(c), 2)] for name, c in sorted(packages.items())] total = round(data['totals']['percent_covered'], 2) tests = os.environ['TESTS_OUTCOME'] - json.dump({'total': total, 'tests': tests, 'packages': rows}, + json.dump({'total': total, 'tests': tests, 'packages': rows, 'python': os.environ['PYTHON']}, open('coverage-comment/coverage-summary.json', 'w')) status = '' if tests == 'success' else f' -- **tests {tests}**, so this is partial' print(f"**Coverage: {total:.2f}%**{status} (unit tier, Python {os.environ['PYTHON']}, `{os.environ['SHA'][:9]}`)") @@ -1748,8 +1748,8 @@ jobs: - name: Run full test suite run: python -m pytest -q -n auto --dist load - # Ruleset 23497679's required_status_checks requires six contexts: this - # job's `Required checks passed`, and five for `Compat Python 3.11`-`3.15` + # Ruleset 23497679's required_status_checks requires these contexts: this + # job's `Required checks passed`, and a `Compat Python` one per supported version, # from the `compatibility` job in `.github/workflows/python-compatibility.yml`. # This job aggregates the gating jobs of *this* file, the ones its `needs:` # lists, because rulesets match check names literally and support no diff --git a/docs/source/contributing/releasing.rst b/docs/source/contributing/releasing.rst index db0cd6c583..205e8a92d8 100644 --- a/docs/source/contributing/releasing.rst +++ b/docs/source/contributing/releasing.rst @@ -50,12 +50,12 @@ Editing ``__version__`` by hand without also moving :file:`CITATION.cff`'s ``tests/project/test_bump_version.py``'s ``RepositoryCitationTests.test_the_citation_file_names_the_packaged_version`` (``:494-514``) asserts the two agree, and ``create-release.yml``'s -``unit-tests`` job (``:419-433``) calls ``unit-tests.yml`` with +``unit-tests`` job (``:467-481``) calls ``unit-tests.yml`` with ``gate-only: true``, which runs the **full** suite rather than the tiered subset an ordinary push runs (its ``gate`` job, ``unit-tests.yml:1679-1749``). That gate runs whenever a release will -- a moved ``__version__`` is exactly what makes ``version_check``'s evidence read "not yet published" -- and every -publishing job requires it to have succeeded (``:452,637,802,1037``), so a +publishing job requires it to have succeeded (``:500,685,850,1085``), so a citation left behind fails the gate well before any approval is requested. Either run the script, or move both fields by hand in the same commit. @@ -66,7 +66,7 @@ process: the ``changelog`` job (``unit-tests.yml:1527-1546``) fails outright if ``CHANGELOG.md`` has drifted from its source entry under :file:`docs/source/changelog/`, and the release-body step warns, without failing, if that entry's heading still reads "unreleased" -(``create-release.yml:506-507``). Both enforce what the changelog convention +(``create-release.yml:554-555``). Both enforce what the changelog convention already asks for. Two Ways to Start a Release @@ -81,10 +81,10 @@ neither is a workaround for the other: release, not which version: ``version_check``'s checkout (``:216-220``) pins ``ref: ${{ github.sha }}``, which on this trigger is the commit the pushed tag points at, and reads ``pcapkit.__version__`` out - of *that* tree (``:244``). Every downstream tag and release name is built + of *that* tree (``:245``). Every downstream tag and release name is built from that output -- ``v${{ needs.version_check.outputs.PCAPKIT_VERSION }}`` - is ``github``'s ``tag_name`` at ``:548`` and the release ``pypi`` and - ``conda`` upload to at ``:944`` and ``:1290``, always from + is ``github``'s ``tag_name`` at ``:596`` and the release ``pypi`` and + ``conda`` upload to at ``:992`` and ``:1338``, always from ``PCAPKIT_VERSION``, never from ``github.ref_name`` -- so the version actually released is whatever ``__version__`` says on the tagged commit, not the name of the tag that was pushed. The two agree only if the tag @@ -128,19 +128,19 @@ days later, and tagged ``main``'s tip ``2c0008c3c`` as both ``v1.5.0b9`` and the default branch, and ``tag`` pulled ``main`` before tagging. * On ``workflow_run``, where ``github`` creates the ``v*`` tag, it passes - ``target_commitish`` ``github.sha`` (``:561``); on a ``v*`` push the tag + ``target_commitish`` ``github.sha`` (``:609``); on a ``v*`` push the tag already exists and it passes none. Either way it then checks that the tag names that commit. -* ``tag`` cuts ``conda-+0`` at that commit (``:674-701``) before it +* ``tag`` cuts ``conda-+0`` at that commit (``:722-749``) before it reads ``main`` at all; the :file:`conda/build` reset is a separate commit on - top of ``main`` (``:723-776``), described under `Precautions`_. + top of ``main`` (``:771-824``), described under `Precautions`_. * ``pypi`` and ``conda`` check out that commit and, before building, check that every tag they publish under names it, as ``tag`` does for the ``v*`` tag. A run created for a *later* commit of the same version therefore fails with an ``::error`` instead of publishing a second tree under that version; a partial release is finished by re-running the run that tagged it. * ``pypi`` and ``conda`` attach their files with ``gh release upload`` - (``:937-974``, ``:1285-1320``), not ``action-gh-release``, which calls Update + (``:985-1022``, ``:1333-1368``), not ``action-gh-release``, which calls Update a release before every upload (3.0.2's ``src/github.ts:631-683``). A file the release already has is left alone (`#1640 `__): the action @@ -234,11 +234,11 @@ The owner's choice on `#1634 the release with a GitHub App installation token, which Create a release accepts for a target that changes a workflow file when it has the "Workflows" repository permission (write). "Mint the release App's token" -(``create-release.yml:521-529``) runs +(``create-release.yml:569-577``) runs ``actions/create-github-app-token@v3`` with ``permission-contents: write`` and ``permission-workflows: write`` and no ``owner`` or ``repositories``, so the token is "scoped to only the current repository" and revoked in the action's -``post`` step (v3.3.0's README). Only Create Release spends it (``:565``). +``post`` step (v3.3.0's README). Only Create Release spends it (``:613``). While ``vars.RELEASE_APP_CLIENT_ID`` is unset, which reads as "an empty string", the step skips and Create Release spends ``GITHUB_TOKEN``, so the workflow keeps working, refusals included, until the App exists. A PAT would @@ -248,28 +248,33 @@ it from a tag pushed by hand. One-time setup: 1. Register a GitHub App with the **Contents** and **Workflows** repository - permissions at read and write, and install it on this repository only. + permissions at read and write, and install it on this repository only. Add + **Pull requests** at read and write too, and accept it on the installation: + the Python Versions workflow's ``pr`` job opens its pull request with a token + of this App, with contents and pull requests write only (`#1660 + `__, :doc:`workflows`). 2. Add repository variables ``RELEASE_APP_CLIENT_ID``, the App's Client ID, and ``RELEASE_APP_BOT_ID``, its bot's user id (``gh api "/users/[bot]" --jq .id``, as the action's README reads it). 3. Store the App's private key as ``RELEASE_APP_PRIVATE_KEY``, a secret of the ``github-release`` environment rather than of the repository: "If the environment requires approval, a job cannot access environment secrets - until one of the required reviewers approves it", and ``github`` is the only - job that names the environment or reads the secret. + until one of the required reviewers approves it". ``github`` and Python + Versions' ``pr`` are the only jobs that name the environment or read the + secret. Unlike ``GITHUB_TOKEN``'s, the App's tag starts a tag-push run. It would rerun the release test gate, ask for a second approval and race the first run's uploads from another concurrency group, so ``version_check`` skips a ``push`` whose ``github.actor_id`` is ``vars.RELEASE_APP_BOT_ID`` (``:210-212``), and ``release_status`` reports it with ``::notice title=Nothing to release`` -(``:1392-1394``). A tag pushed by a person runs as before. Whether a tag made +(``:1440-1442``). A tag pushed by a person runs as before. Whether a tag made through the Releases API fires ``push``, and with the bot as actor, is unmeasured; if not, the guard never matches and the cost is one approval to reject. A refused Create Release (404, or 403 "Resource not accessible by -integration") is explained by "Explain a refused release tag" (``:598-610``) +integration") is explained by "Explain a refused release tag" (``:646-658``) according to the token spent: with ``GITHUB_TOKEN`` it lists the setup above; with the App's, it points at the App's permissions and installation. For a final release, first delete any draft ``v`` the refused run left: @@ -320,6 +325,18 @@ All three evidence checks run inside ``version_check``, curling PyPI's and Anaconda's public, unauthenticated JSON APIs; the per-leg check runs inside ``conda`` itself, once per matrix leg, against the same Anaconda endpoint. +The PyPI and Anaconda counts are of the tiers the version was released with. +Until its ``v*`` tag exists, that is this run's +:file:`.github/python-versions.toml`; once it does, ``version_check`` reads the +file at the tag's commit with that commit's ``util/python_versions.py``. A +supported version added since (`#1660 +`__) was never built for the +release, and counting it would read a finished release as incomplete, run the +gate and ``pypi``, and fail `One Commit per Release`_'s tag check on every run +until the next release. A tag older than the file, as ``v1.5.0b12`` is, counts +the tiers the file was added with, which #1677 copied unchanged from the +workflows of the time. + On the tag-push path ``startsWith(github.ref_name, 'v')`` bypasses the evidence check unconditionally; on the ``workflow_run`` path it falls through to whichever evidence that job owns. @@ -406,8 +423,8 @@ Only ``github-release`` carries a required reviewer; ``conda-tag``, removed on `#887 `__. What still makes one approval mean *the whole release* is the ``needs:`` graph: ``tag`` and ``pypi`` both depend on ``github`` -(``.github/workflows/create-release.yml:637,802``), and ``conda`` depends on -``tag`` and ``github`` (``:1037``) -- so nothing downstream of ``github`` can +(``.github/workflows/create-release.yml:685,850``), and ``conda`` depends on +``tag`` and ``github`` (``:1085``) -- so nothing downstream of ``github`` can start before it is approved, and rejecting it leaves nothing tagged and nothing published. Before this change ``tag`` depended on ``version_check`` alone, so removing its reviewer without moving this dependency would have let @@ -454,7 +471,7 @@ back, and neither is needed, since a plain re-run self-heals; see **``environment: pypi`` stays even though its reviewer is gone.** ``pypi`` publishes through PyPI's OIDC trusted publishing (``environment: pypi`` at -``:797``, ``id-token: write`` at ``:801``), and PyPI's trusted-publisher +``:845``, ``id-token: write`` at ``:849``), and PyPI's trusted-publisher configuration can be scoped to a GitHub Actions environment name. If this project's publisher on PyPI is scoped that way, removing the ``pypi`` name -- not just its reviewer -- would break the upload with a claim mismatch; @@ -465,7 +482,7 @@ the name regardless, since there is no upside to removing it. **``conda-tag`` writes to ``main``, so a release is not read-only on the branch.** After cutting ``conda-+0`` at the run's commit, the ``tag`` job pushes a commit setting :file:`conda/build` to ``0`` on top of -``main`` (``.github/workflows/create-release.yml:723-776``) -- a plain +``main`` (``.github/workflows/create-release.yml:771-824``) -- a plain fast-forward push, never a rewrite -- so the next Conda Update bump of that version is ``+1``. There is none when the run's commit is not on ``main``, nor when ``main``'s tip already says ``0`` (the usual case, since diff --git a/docs/source/contributing/workflows.rst b/docs/source/contributing/workflows.rst index 764696557e..d193074cd8 100644 --- a/docs/source/contributing/workflows.rst +++ b/docs/source/contributing/workflows.rst @@ -3,7 +3,7 @@ GitHub Actions Workflows .. important:: - The eleven workflows under :file:`.github/workflows/` trigger each other, and + The twelve workflows under :file:`.github/workflows/` trigger each other, and the chain that results is not visible from any single file -- reading one ``on:`` block never shows what a *different* workflow's completion goes on to start. This page is the repository-wide graph. The release *pipeline* @@ -100,6 +100,13 @@ At a Glance - Mon 07:23 - -- - ``workflow_dispatch`` + * - Python Versions + - :file:`cron-python-versions.yml` + - -- + - -- + - Mon 05:41 + - -- + - ``workflow_dispatch`` .. note:: @@ -142,6 +149,7 @@ than starting a new one. PS["Project Status
project-status.yml"] CC["Coverage Comment
coverage-comment.yml"] CT["CI Timing
ci-timing.yml"] + PV["Python Versions
cron-python-versions.yml"] PUSH --> CQ PR --> CQ @@ -174,6 +182,9 @@ than starting a new one. SCHED -->|Mon 07:23| CT DISPATCH --> CT + SCHED -->|Mon 05:41| PV + DISPATCH --> PV + UT ==>|workflow_run: completed| VU UT ==>|workflow_run: completed| CU UT ==>|workflow_run: completed| GP @@ -261,7 +272,7 @@ the caller's own workflow run, as one of the caller's own jobs, rather than starting an independent run after the caller finishes. All four calls in this repository target the same reusable workflow, with the same input: -* ``.github/workflows/create-release.yml:431`` -- job ``unit-tests`` calls +* ``.github/workflows/create-release.yml:479`` -- job ``unit-tests`` calls ``./.github/workflows/unit-tests.yml`` with ``gate-only: true``. * ``.github/workflows/cron-conda.yml:50`` -- job ``unit-tests`` calls ``./.github/workflows/unit-tests.yml`` with ``gate-only: true``. @@ -318,13 +329,13 @@ anything other than ``success`` -- including ``skipped``, which is exactly what Vendor Update's *own* jobs report when *their* ``workflow_run``-path guards decline (see ``cron-vendor.yml``'s ``vendor-update`` job). ``unit-tests`` then depends on that job with -``needs: [ version_check ]`` (``create-release.yml:421``) and an ``if:`` that +``needs: [ version_check ]`` (``create-release.yml:469``) and an ``if:`` that calls no status-check function, so a skipped dependency skips it too -- and every job past it is gated the same way: ``github`` needs ``[ unit-tests, -version_check ]`` (``:452``), ``tag`` needs ``[ github, unit-tests, -version_check ]`` (``:637``), ``pypi`` needs ``[ github, unit-tests, -version_check ]`` (``:802``), and ``conda`` needs ``[ tag, github, -unit-tests, version_check ]`` (``:1037``). +version_check ]`` (``:500``), ``tag`` needs ``[ github, unit-tests, +version_check ]`` (``:685``), ``pypi`` needs ``[ github, unit-tests, +version_check ]`` (``:850``), and ``conda`` needs ``[ tag, github, +unit-tests, version_check ]`` (``:1085``). .. mermaid:: @@ -350,7 +361,7 @@ unit-tests, version_check ]`` (``:1037``). This is a transitive reduction, as in :doc:`releasing`: ``version_check`` and ``unit-tests`` are also direct ``needs:`` of ``tag`` and ``pypi`` -(``create-release.yml:637,802``) and of ``conda`` (``:1037``), but ``github`` +(``create-release.yml:685,850``) and of ``conda`` (``:1085``), but ``github`` already implies those edges, since it depends on both itself. Drawing them would add lines without changing which job can start before which, or which job a skip in ``version_check`` reaches. @@ -580,8 +591,9 @@ between the tiers below means editing the ruleset in the same change. (``unit-tests.yml:1841``). ``Compat Python`` is defined by one, the ``compatibility`` job in ``python-compatibility.yml`` (``:60``), whose matrix (``:74``) comes from :file:`.github/python-versions.toml`: the five required legs -are its supported tier (``:20-24``), and its four KTLO legs (``:16-19``) have -names ending in ``(KTLO)``, so no required context matches one. A comment at +are its supported tier (``python-versions.toml:20-24``), and its four KTLO legs +(``python-versions.toml:16-19``) have names ending in ``(KTLO)``, so no required +context matches one. A comment at ``unit-tests.yml:1752`` mentions the string without defining it. ``Required checks passed`` is an aggregate, not a single check run. The ruleset @@ -672,21 +684,45 @@ edits it names rather than makes: a new ``pypi`` KTLO leg needs its own ``ktlo_*`` output for ``release_status``, and a pre-release leg needs non-blocking mechanics no workflow has yet, so the script refuses one. +**Python Versions** (:file:`cron-python-versions.yml`) proposes those edits. +Every Monday, or on dispatch, its ``detect`` job runs +:file:`util/update_python_versions.py` with no secret: it reads python.org's +release cycle, PyPy's releases, setup-python's ``versions-manifest.json`` and +the runner-images README, recomputes the tiers by the rules in that script's +docstring, rewrites the file and the prose these tests pin to it, and runs the +three audit modules on the result. Only when that changes something does ``pr`` +enter ``github-release``, so the owner approves only those runs. It mints a +release App token (:doc:`releasing`, "The Release App") with contents and pull +requests write, force-pushes ``bot/python-versions`` and opens or updates its +one pull request, whose body lists each move and why, the ``Compat`` contexts to +change in the ruleset by hand, and every renamed check run. It never pushes +``main``, edits the ruleset or writes a workflow file, which that token cannot +push, so workflow comments name the tiers rather than restate them. While the +branch's pull request is open, it does not re-push tiers a commit on the branch +already proposes, hand edits or merges of ``main`` on top or not, nor overwrite +commits it did not make: when the branch holds some and proposes other tiers, +``detect`` fails before asking for the approval. Once that pull request is +merged or closed, the next run with something to propose recreates the branch +from ``main``, a closed proposal's tiers included. It reports a pre-release +instead of writing it. + Deployment ``environment:`` ---------------------------- -Four jobs, all in ``create-release.yml``, declare a deployment -``environment:`` and can therefore pause for an approval: +Five jobs declare a deployment ``environment:`` and can therefore pause for an +approval, four in ``create-release.yml`` and one in ``cron-python-versions.yml``: -* ``github`` -- ``environment: github-release`` (``create-release.yml:449``) -* ``tag`` -- ``environment: conda-tag`` (``create-release.yml:635``) -* ``pypi`` -- ``environment: pypi`` (``create-release.yml:797``) -* ``conda`` -- ``environment: anaconda`` (``create-release.yml:1034``) +* ``github`` -- ``environment: github-release`` (``create-release.yml:497``) +* ``tag`` -- ``environment: conda-tag`` (``create-release.yml:683``) +* ``pypi`` -- ``environment: pypi`` (``create-release.yml:845``) +* ``conda`` -- ``environment: anaconda`` (``create-release.yml:1082``) +* ``pr`` -- ``environment: github-release``, for the release App's key (see + `Python Versions`_) No other workflow in scope here declares one -- including ``deploy-pages.yml``, whose ``deploy-pages`` job pushes straight to the ``gh-pages`` branch via ``JamesIves/github-pages-deploy-action`` rather than -through an ``environment:``-gated deployment. Of the four above, only +through an ``environment:``-gated deployment. Of the four environments, only ``github-release`` carries a required reviewer; :doc:`releasing` covers why one approval on that single environment is enough to gate the whole release graph, and is the page to read for the reasoning rather than diff --git a/tests/project/python_versions_fixtures/pypy-versions.json b/tests/project/python_versions_fixtures/pypy-versions.json new file mode 100644 index 0000000000..fa7c6064a7 --- /dev/null +++ b/tests/project/python_versions_fixtures/pypy-versions.json @@ -0,0 +1,447 @@ +[ + { + "pypy_version": "8.0.0", + "python_version": "3.12.14", + "stable": true, + "latest_pypy": true, + "date": "2026-09-19", + "files": [ + { + "filename": "pypy3.12-v8.0.0-linux64.tar.gz", + "arch": "x64", + "platform": "linux" + } + ] + }, + { + "pypy_version": "8.0.0", + "python_version": "3.11.16", + "stable": true, + "latest_pypy": true, + "date": "2026-09-19", + "files": [ + { + "filename": "pypy3.11-v8.0.0-linux64.tar.gz", + "arch": "x64", + "platform": "linux" + } + ] + }, + { + "pypy_version": "7.3.23", + "python_version": "3.11.15", + "stable": true, + "latest_pypy": true, + "date": "2026-05-26", + "files": [ + { + "filename": "pypy3.11-v7.3.23-linux64.tar.gz", + "arch": "x64", + "platform": "linux" + } + ] + }, + { + "pypy_version": "7.3.22", + "python_version": "3.11.15", + "stable": true, + "latest_pypy": false, + "date": "2026-04-28", + "files": [ + { + "filename": "pypy3.11-v7.3.22-linux64.tar.bz2", + "arch": "x64", + "platform": "linux" + } + ] + }, + { + "pypy_version": "7.3.21", + "python_version": "3.11.15", + "stable": true, + "latest_pypy": false, + "date": "2026-03-13", + "files": [ + { + "filename": "pypy3.11-v7.3.21-linux64.tar.bz2", + "arch": "x64", + "platform": "linux" + } + ] + }, + { + "pypy_version": "7.3.20", + "python_version": "3.11.13", + "stable": true, + "latest_pypy": true, + "date": "2025-07-04", + "files": [ + { + "filename": "pypy3.11-v7.3.20-linux64.tar.bz2", + "arch": "x64", + "platform": "linux" + } + ] + }, + { + "pypy_version": "7.3.19", + "python_version": "3.11.11", + "stable": true, + "latest_pypy": true, + "date": "2025-02-26", + "files": [ + { + "filename": "pypy3.11-v7.3.19-linux64.tar.bz2", + "arch": "x64", + "platform": "linux" + } + ] + }, + { + "pypy_version": "7.3.19", + "python_version": "3.10.16", + "stable": true, + "latest_pypy": true, + "date": "2025-02-26", + "files": [ + { + "filename": "pypy3.10-v7.3.19-linux64.tar.gz", + "arch": "x64", + "platform": "linux" + } + ] + }, + { + "pypy_version": "7.3.18", + "python_version": "3.11.11", + "stable": true, + "latest_pypy": false, + "date": "2025-02-06", + "files": [ + { + "filename": "pypy3.11-v7.3.18-linux64.tar.bz2", + "arch": "x64", + "platform": "linux" + } + ] + }, + { + "pypy_version": "7.3.18", + "python_version": "3.10.16", + "stable": true, + "latest_pypy": false, + "date": "2025-02-06", + "files": [ + { + "filename": "pypy3.10-v7.3.18-linux64.tar.bz2", + "arch": "x64", + "platform": "linux" + } + ] + }, + { + "pypy_version": "7.3.17", + "python_version": "3.10.14", + "stable": true, + "latest_pypy": true, + "date": "2024-08-28", + "files": [ + { + "filename": "pypy3.10-v7.3.17-linux64.tar.bz2", + "arch": "x64", + "platform": "linux" + } + ] + }, + { + "pypy_version": "7.3.16", + "python_version": "3.10.14", + "stable": true, + "latest_pypy": false, + "date": "2024-04-24", + "files": [ + { + "filename": "pypy3.10-v7.3.16-linux64.tar.bz2", + "arch": "x64", + "platform": "linux" + } + ] + }, + { + "pypy_version": "7.3.16", + "python_version": "3.9.19", + "stable": true, + "latest_pypy": true, + "date": "2024-04-24", + "files": [ + { + "filename": "pypy3.9-v7.3.16-linux64.tar.gz", + "arch": "x64", + "platform": "linux" + } + ] + }, + { + "pypy_version": "7.3.15", + "python_version": "3.10.13", + "stable": true, + "latest_pypy": true, + "date": "2024-01-15", + "files": [ + { + "filename": "pypy3.10-v7.3.15-linux64.tar.bz2", + "arch": "x64", + "platform": "linux" + } + ] + }, + { + "pypy_version": "7.3.15", + "python_version": "3.9.18", + "stable": true, + "latest_pypy": true, + "date": "2024-01-15", + "files": [ + { + "filename": "pypy3.9-v7.3.15-linux64.tar.bz2", + "arch": "x64", + "platform": "linux" + } + ] + }, + { + "pypy_version": "7.3.14", + "python_version": "3.10.13", + "stable": true, + "latest_pypy": false, + "date": "2023-12-25", + "files": [ + { + "filename": "pypy3.10-v7.3.14-linux64.tar.bz2", + "arch": "x64", + "platform": "linux" + } + ] + }, + { + "pypy_version": "7.3.14", + "python_version": "3.9.18", + "stable": true, + "latest_pypy": false, + "date": "2023-12-25", + "files": [ + { + "filename": "pypy3.9-v7.3.14-linux64.tar.bz2", + "arch": "x64", + "platform": "linux" + } + ] + }, + { + "pypy_version": "7.3.13", + "python_version": "3.10.13", + "stable": true, + "latest_pypy": false, + "date": "2023-09-29", + "files": [ + { + "filename": "pypy3.10-v7.3.13-linux64.tar.bz2", + "arch": "x64", + "platform": "linux" + } + ] + }, + { + "pypy_version": "7.3.13", + "python_version": "3.9.18", + "stable": true, + "latest_pypy": false, + "date": "2023-09-29", + "files": [ + { + "filename": "pypy3.9-v7.3.13-linux64.tar.bz2", + "arch": "x64", + "platform": "linux" + } + ] + }, + { + "pypy_version": "7.3.12", + "python_version": "3.10.12", + "stable": true, + "latest_pypy": true, + "date": "2023-06-16", + "files": [ + { + "filename": "pypy3.10-v7.3.12-linux64.tar.bz2", + "arch": "x64", + "platform": "linux" + } + ] + }, + { + "pypy_version": "7.3.12", + "python_version": "3.9.17", + "stable": true, + "latest_pypy": true, + "date": "2023-06-16", + "files": [ + { + "filename": "pypy3.9-v7.3.12-linux64.tar.bz2", + "arch": "x64", + "platform": "linux" + } + ] + }, + { + "pypy_version": "7.3.12rc1", + "python_version": "3.10.9", + "stable": false, + "latest_pypy": false, + "date": "2023-05-13", + "files": [ + { + "filename": "pypy3.10-v7.3.12rc1-linux64.tar.bz2", + "arch": "x64", + "platform": "linux" + } + ] + }, + { + "pypy_version": "7.3.12rc1", + "python_version": "3.9.16", + "stable": false, + "latest_pypy": false, + "date": "2023-05-13", + "files": [ + { + "filename": "pypy3.9-v7.3.12rc1-linux64.tar.bz2", + "arch": "x64", + "platform": "linux" + } + ] + }, + { + "pypy_version": "7.3.11", + "python_version": "3.9.16", + "stable": true, + "latest_pypy": true, + "date": "2022-12-29", + "files": [ + { + "filename": "pypy3.9-v7.3.11-linux64.tar.bz2", + "arch": "x64", + "platform": "linux" + } + ] + }, + { + "pypy_version": "7.3.11", + "python_version": "3.8.16", + "stable": true, + "latest_pypy": true, + "date": "2022-12-29", + "files": [ + { + "filename": "pypy3.8-v7.3.11-linux64.tar.gz", + "arch": "x64", + "platform": "linux" + } + ] + }, + { + "pypy_version": "7.3.10", + "python_version": "3.9.15", + "stable": true, + "latest_pypy": true, + "date": "2022-12-06", + "files": [ + { + "filename": "pypy3.9-v7.3.10-linux64.tar.bz2", + "arch": "x64", + "platform": "linux" + } + ] + }, + { + "pypy_version": "7.3.10", + "python_version": "3.8.15", + "stable": true, + "latest_pypy": true, + "date": "2022-12-06", + "files": [ + { + "filename": "pypy3.8-v7.3.10-linux64.tar.bz2", + "arch": "x64", + "platform": "linux" + } + ] + }, + { + "pypy_version": "7.3.10rc3", + "python_version": "3.9.15", + "stable": false, + "latest_pypy": false, + "date": "2022-11-24", + "files": [ + { + "filename": "pypy3.9-v7.3.10rc3-linux64.tar.bz2", + "arch": "x64", + "platform": "linux" + } + ] + }, + { + "pypy_version": "7.3.10rc3", + "python_version": "3.8.15", + "stable": false, + "latest_pypy": false, + "date": "2022-11-24", + "files": [ + { + "filename": "pypy3.8-v7.3.10rc3-linux64.tar.bz2", + "arch": "x64", + "platform": "linux" + } + ] + }, + { + "pypy_version": "nightly", + "python_version": "3.10", + "stable": false, + "latest_pypy": false, + "files": [ + { + "filename": "pypy-c-jit-latest-linux64.tar.bz2", + "arch": "x64", + "platform": "linux" + } + ] + }, + { + "pypy_version": "nightly", + "python_version": "3.11", + "stable": false, + "latest_pypy": false, + "files": [ + { + "filename": "pypy-c-jit-latest-linux64.tar.gz", + "arch": "x64", + "platform": "linux" + } + ] + }, + { + "pypy_version": "nightly", + "python_version": "3.12", + "stable": false, + "latest_pypy": false, + "files": [ + { + "filename": "pypy-c-jit-latest-linux64.tar.gz", + "arch": "x64", + "platform": "linux" + } + ] + } +] diff --git a/tests/project/python_versions_fixtures/python-versions.toml b/tests/project/python_versions_fixtures/python-versions.toml new file mode 100644 index 0000000000..e87cdff8db --- /dev/null +++ b/tests/project/python_versions_fixtures/python-versions.toml @@ -0,0 +1,59 @@ +# The Python version tiers (#1660). Every workflow matrix and every +# single-interpreter job's version is read from here through +# util/python_versions.py, and tests/project/test_python_versions_1660.py pins +# the classifiers and docs to it. cron-python-versions.yml recomputes it weekly +# and proposes each change, and the ruleset 23497679 edit it needs, as a PR. +# +# Tiers: supported (blocking), ktlo (end-of-life, non-blocking) and pre-release +# (non-blocking; none listed today). `runner` is the image a version needs when +# ubuntu-latest cannot provide it. `latest` is the newest supported CPython, +# which the single-interpreter jobs run on. See +# docs/source/contributing/workflows.rst. + +latest = "3.15" + +[versions.cpython] +"3.7" = { tier = "ktlo", runner = "ubuntu-22.04" } +"3.8" = { tier = "ktlo", runner = "ubuntu-24.04" } +"3.9" = { tier = "ktlo", runner = "ubuntu-24.04" } +"3.10" = { tier = "ktlo" } +"3.11" = { tier = "supported" } +"3.12" = { tier = "supported" } +"3.13" = { tier = "supported" } +"3.14" = { tier = "supported" } +"3.15" = { tier = "supported" } + +[versions.pypy] +"pypy3.10" = { tier = "ktlo" } +"pypy3.11" = { tier = "supported" } + +# The pins kept off the tiers, each with its reason in the comment above it. +# tests/project/test_python_versions_1660.py fails on a pin not listed here, on +# an entry that matches no pin, and on an entry without a comment. + +# The parity matrix's one supported leg saves the apt cache. Both legs install +# one set under one key, and a second saver fails with `Unable to reserve +# cache`. +[[exemptions]] +workflow = "unit-tests.yml" +job = "pypcap-parity" +where = "env.APT_CACHE_SAVE" +value = "3.11" + +# conda-forge's conda has no py315 build, so installing 3.15 into base fails +# with `RemoveError: Conda cannot remove itself` (#1633, conda 26.7.2). 3.15 +# leaves base on its own Python; `conda build --python` still builds for 3.15. +# Drop this once conda publishes a py315 build. +[[exemptions]] +workflow = "create-release.yml" +job = "conda" +where = "Conda environment creation and activation: with.python-version" +value = "3.15" + +# As for create-release.yml's conda job: conda-forge's conda has no py315 build +# yet. +[[exemptions]] +workflow = "cron-conda.yml" +job = "conda-dist" +where = "Conda environment creation and activation: with.python-version" +value = "3.15" diff --git a/tests/project/python_versions_fixtures/release-cycle.json b/tests/project/python_versions_fixtures/release-cycle.json new file mode 100644 index 0000000000..f7138fb642 --- /dev/null +++ b/tests/project/python_versions_fixtures/release-cycle.json @@ -0,0 +1,98 @@ +{ + "3.16": { + "branch": "main", + "pep": 826, + "status": "feature", + "first_release": "2027-10-06", + "end_of_life": "2032-10", + "release_manager": "Savannah Ostrowski" + }, + "3.15": { + "branch": "3.15", + "pep": 790, + "status": "bugfix", + "first_release": "2026-10-09", + "end_of_life": "2031-10", + "release_manager": "Hugo van Kemenade" + }, + "3.14": { + "branch": "3.14", + "pep": 745, + "status": "bugfix", + "first_release": "2025-10-07", + "end_of_life": "2030-10", + "release_manager": "Hugo van Kemenade" + }, + "3.13": { + "branch": "3.13", + "pep": 719, + "status": "security", + "first_release": "2024-10-07", + "end_of_life": "2029-10", + "release_manager": "Thomas Wouters" + }, + "3.12": { + "branch": "3.12", + "pep": 693, + "status": "security", + "first_release": "2023-10-02", + "end_of_life": "2028-10", + "release_manager": "Thomas Wouters" + }, + "3.11": { + "branch": "3.11", + "pep": 664, + "status": "security", + "first_release": "2022-10-24", + "end_of_life": "2027-10", + "release_manager": "Pablo Galindo Salgado" + }, + "3.10": { + "branch": "3.10", + "pep": 619, + "status": "end-of-life", + "first_release": "2021-10-04", + "end_of_life": "2026-10-01", + "release_manager": "Pablo Galindo Salgado" + }, + "3.9": { + "branch": "3.9", + "pep": 596, + "status": "end-of-life", + "first_release": "2020-10-05", + "end_of_life": "2025-10-31", + "release_manager": "\u0141ukasz Langa" + }, + "3.8": { + "branch": "3.8", + "pep": 569, + "status": "end-of-life", + "first_release": "2019-10-14", + "end_of_life": "2024-10-07", + "release_manager": "\u0141ukasz Langa" + }, + "3.7": { + "branch": "3.7", + "pep": 537, + "status": "end-of-life", + "first_release": "2018-06-27", + "end_of_life": "2023-06-27", + "release_manager": "Ned Deily" + }, + "3.6": { + "branch": "3.6", + "pep": 494, + "status": "end-of-life", + "first_release": "2016-12-23", + "end_of_life": "2021-12-23", + "release_manager": "Ned Deily" + }, + "3.5": { + "branch": "3.5", + "pep": 478, + "status": "end-of-life", + "first_release": "2015-09-13", + "end_of_life": "2020-09-30", + "release_manager": "Larry Hastings" + } +} diff --git a/tests/project/python_versions_fixtures/runner-images-readme.md b/tests/project/python_versions_fixtures/runner-images-readme.md new file mode 100644 index 0000000000..509a547775 --- /dev/null +++ b/tests/project/python_versions_fixtures/runner-images-readme.md @@ -0,0 +1,21 @@ +## Available Images +| Image | Architecture | YAML Label | Included Software | +| --------------------|--------------|---------------------|------------------| +| Ubuntu 26.04
![Endpoint Badge](https://img.shields.io/endpoint?url=https%3A%2F%2Fgist.githubusercontent.com%2Fhosted-runners-images-bot%2F79267492faab096d04cdd25ce7014cec%2Fraw%2Fubuntu26.json) | x64 | `ubuntu-26.04` | [ubuntu-26.04] | +| Ubuntu 26.04 Arm64
![Endpoint Badge](https://img.shields.io/endpoint?url=https%3A%2F%2Fgist.githubusercontent.com%2Fhosted-runners-images-bot%2F79267492faab096d04cdd25ce7014cec%2Fraw%2Fubuntu26-arm64.json) | arm64 | `ubuntu-26.04-arm` | [ubuntu-26.04-arm64] | +| Ubuntu 24.04
![Endpoint Badge](https://img.shields.io/endpoint?url=https%3A%2F%2Fgist.githubusercontent.com%2Fhosted-runners-images-bot%2F79267492faab096d04cdd25ce7014cec%2Fraw%2Fubuntu24.json) | x64 | `ubuntu-latest` or `ubuntu-24.04` | [ubuntu-24.04] | +| Ubuntu 24.04 Arm64
![Endpoint Badge](https://img.shields.io/endpoint?url=https%3A%2F%2Fgist.githubusercontent.com%2Fhosted-runners-images-bot%2F79267492faab096d04cdd25ce7014cec%2Fraw%2Fubuntu24-arm64.json) | arm64 | `ubuntu-24.04-arm` | [ubuntu-24.04-arm64] | +| Ubuntu 22.04
![Endpoint Badge](https://img.shields.io/endpoint?url=https%3A%2F%2Fgist.githubusercontent.com%2Fhosted-runners-images-bot%2F79267492faab096d04cdd25ce7014cec%2Fraw%2Fubuntu22.json) | x64 | `ubuntu-22.04` | [ubuntu-22.04] | +| Ubuntu 22.04 Arm64
![Endpoint Badge](https://img.shields.io/endpoint?url=https%3A%2F%2Fgist.githubusercontent.com%2Fhosted-runners-images-bot%2F79267492faab096d04cdd25ce7014cec%2Fraw%2Fubuntu22-arm64.json) | arm64 | `ubuntu-22.04-arm` | [ubuntu-22.04-arm64] | +| Ubuntu Slim
![Endpoint Badge](https://img.shields.io/endpoint?url=https%3A%2F%2Fgist.githubusercontent.com%2Fhosted-runners-images-bot%2F79267492faab096d04cdd25ce7014cec%2Fraw%2Fubuntu-slim.json) | x64 | `ubuntu-slim` | [ubuntu-slim] | +| Xcode 27 [![preview](https://img.shields.io/badge/preview-0969DA?style=flat&logoColor=white)](https://github.com/actions/runner-images/issues/14404)
![Endpoint Badge](https://img.shields.io/endpoint?url=https%3A%2F%2Fgist.githubusercontent.com%2Fhosted-runners-images-bot%2F79267492faab096d04cdd25ce7014cec%2Fraw%2Fxcode-27-arm64.json) | arm64 | `xcode-27` or `xcode-27-xlarge` | [xcode-27] | +| macOS 26
![Endpoint Badge](https://img.shields.io/endpoint?url=https%3A%2F%2Fgist.githubusercontent.com%2Fhosted-runners-images-bot%2F79267492faab096d04cdd25ce7014cec%2Fraw%2Fmacos-26.json) | x64 | `macos-latest-large`, `macos-26-intel`, `macos-26-large` | [macOS-26] | +| macOS 26 Arm64
![Endpoint Badge](https://img.shields.io/endpoint?url=https%3A%2F%2Fgist.githubusercontent.com%2Fhosted-runners-images-bot%2F79267492faab096d04cdd25ce7014cec%2Fraw%2Fmacos-26-arm64.json) | arm64 | `macos-latest`, `macos-26` or `macos-26-xlarge` | [macOS-26-arm64] | +| macOS 15
![Endpoint Badge](https://img.shields.io/endpoint?url=https%3A%2F%2Fgist.githubusercontent.com%2Fhosted-runners-images-bot%2F79267492faab096d04cdd25ce7014cec%2Fraw%2Fmacos-15.json) | x64 | `macos-15-large`, or `macos-15-intel` | [macOS-15] | +| macOS 15 Arm64
![Endpoint Badge](https://img.shields.io/endpoint?url=https%3A%2F%2Fgist.githubusercontent.com%2Fhosted-runners-images-bot%2F79267492faab096d04cdd25ce7014cec%2Fraw%2Fmacos-15-arm64.json) | arm64 | `macos-15`, or `macos-15-xlarge` | [macOS-15-arm64] | +| macOS 14 [![deprecated](https://img.shields.io/badge/deprecated-E5534B?style=flat&logoColor=white)](https://github.com/actions/runner-images/issues/13518)
![Endpoint Badge](https://img.shields.io/endpoint?url=https%3A%2F%2Fgist.githubusercontent.com%2Fhosted-runners-images-bot%2F79267492faab096d04cdd25ce7014cec%2Fraw%2Fmacos-14.json) | x64 | `macos-14-large` | [macOS-14] | +| macOS 14 Arm64 [![deprecated](https://img.shields.io/badge/deprecated-E5534B?style=flat&logoColor=white)](https://github.com/actions/runner-images/issues/13518)
![Endpoint Badge](https://img.shields.io/endpoint?url=https%3A%2F%2Fgist.githubusercontent.com%2Fhosted-runners-images-bot%2F79267492faab096d04cdd25ce7014cec%2Fraw%2Fmacos-14-arm64.json) | arm64 | `macos-14` or `macos-14-xlarge` | [macOS-14-arm64] | +| Windows Server 2025
![Endpoint Badge](https://img.shields.io/endpoint?url=https%3A%2F%2Fgist.githubusercontent.com%2Fhosted-runners-images-bot%2F79267492faab096d04cdd25ce7014cec%2Fraw%2Fwin25-vs2026.json) | x64 | `windows-latest`, `windows-2025`, or `windows-2025-vs2026` | [windows-2025-vs2026] | +| Windows Server 2022
![Endpoint Badge](https://img.shields.io/endpoint?url=https%3A%2F%2Fgist.githubusercontent.com%2Fhosted-runners-images-bot%2F79267492faab096d04cdd25ce7014cec%2Fraw%2Fwin22.json) | x64 | `windows-2022` | [windows-2022] | +| Windows 11 Arm64
![Endpoint Badge](https://img.shields.io/endpoint?url=https%3A%2F%2Fgist.githubusercontent.com%2Fhosted-runners-images-bot%2F79267492faab096d04cdd25ce7014cec%2Fraw%2Fwin11-arm64.json) | arm64 | `windows-11-arm` | [windows-11-arm64] | +| Windows 11 Arm64 with Visual Studio 2026
![Endpoint Badge](https://img.shields.io/endpoint?url=https%3A%2F%2Fgist.githubusercontent.com%2Fhosted-runners-images-bot%2F79267492faab096d04cdd25ce7014cec%2Fraw%2Fwin11-vs2026-arm64.json) | arm64 | `windows-11-vs2026-arm` | [windows-11-vs2026-arm64] | diff --git a/tests/project/python_versions_fixtures/versions-manifest.json b/tests/project/python_versions_fixtures/versions-manifest.json new file mode 100644 index 0000000000..0f7aa692bf --- /dev/null +++ b/tests/project/python_versions_fixtures/versions-manifest.json @@ -0,0 +1,844 @@ +[ + { + "version": "3.15.0", + "stable": true, + "files": [ + { + "filename": "python-3.15.0-darwin-arm64.tar.gz", + "arch": "arm64", + "platform": "darwin" + }, + { + "filename": "python-3.15.0-darwin-x64.tar.gz", + "arch": "x64", + "platform": "darwin" + }, + { + "filename": "python-3.15.0-linux-22.04-x64.tar.gz", + "arch": "x64", + "platform": "linux", + "platform_version": "22.04" + }, + { + "filename": "python-3.15.0-linux-24.04-x64.tar.gz", + "arch": "x64", + "platform": "linux", + "platform_version": "24.04" + }, + { + "filename": "python-3.15.0-linux-26.04-x64.tar.gz", + "arch": "x64", + "platform": "linux", + "platform_version": "26.04" + } + ] + }, + { + "version": "3.14.8", + "stable": true, + "files": [ + { + "filename": "python-3.14.8-darwin-arm64.tar.gz", + "arch": "arm64", + "platform": "darwin" + }, + { + "filename": "python-3.14.8-darwin-x64.tar.gz", + "arch": "x64", + "platform": "darwin" + }, + { + "filename": "python-3.14.8-linux-22.04-x64.tar.gz", + "arch": "x64", + "platform": "linux", + "platform_version": "22.04" + }, + { + "filename": "python-3.14.8-linux-24.04-x64.tar.gz", + "arch": "x64", + "platform": "linux", + "platform_version": "24.04" + }, + { + "filename": "python-3.14.8-linux-26.04-x64.tar.gz", + "arch": "x64", + "platform": "linux", + "platform_version": "26.04" + } + ] + }, + { + "version": "3.14.0-alpha.7", + "stable": false, + "files": [ + { + "filename": "python-3.14.0-alpha.7-linux-20.04-x64.tar.gz", + "arch": "x64", + "platform": "linux", + "platform_version": "20.04" + } + ] + }, + { + "version": "3.13.16", + "stable": true, + "files": [ + { + "filename": "python-3.13.16-darwin-arm64.tar.gz", + "arch": "arm64", + "platform": "darwin" + }, + { + "filename": "python-3.13.16-darwin-x64.tar.gz", + "arch": "x64", + "platform": "darwin" + }, + { + "filename": "python-3.13.16-linux-22.04-x64.tar.gz", + "arch": "x64", + "platform": "linux", + "platform_version": "22.04" + }, + { + "filename": "python-3.13.16-linux-24.04-x64.tar.gz", + "arch": "x64", + "platform": "linux", + "platform_version": "24.04" + }, + { + "filename": "python-3.13.16-linux-26.04-x64.tar.gz", + "arch": "x64", + "platform": "linux", + "platform_version": "26.04" + } + ] + }, + { + "version": "3.13.3", + "stable": true, + "files": [ + { + "filename": "python-3.13.3-darwin-arm64.tar.gz", + "arch": "arm64", + "platform": "darwin" + }, + { + "filename": "python-3.13.3-darwin-x64.tar.gz", + "arch": "x64", + "platform": "darwin" + }, + { + "filename": "python-3.13.3-linux-20.04-x64.tar.gz", + "arch": "x64", + "platform": "linux", + "platform_version": "20.04" + }, + { + "filename": "python-3.13.3-linux-22.04-x64.tar.gz", + "arch": "x64", + "platform": "linux", + "platform_version": "22.04" + }, + { + "filename": "python-3.13.3-linux-24.04-x64.tar.gz", + "arch": "x64", + "platform": "linux", + "platform_version": "24.04" + } + ] + }, + { + "version": "3.12.15", + "stable": true, + "files": [ + { + "filename": "python-3.12.15-linux-22.04-x64.tar.gz", + "arch": "x64", + "platform": "linux", + "platform_version": "22.04" + }, + { + "filename": "python-3.12.15-linux-24.04-x64.tar.gz", + "arch": "x64", + "platform": "linux", + "platform_version": "24.04" + }, + { + "filename": "python-3.12.15-linux-26.04-x64.tar.gz", + "arch": "x64", + "platform": "linux", + "platform_version": "26.04" + } + ] + }, + { + "version": "3.12.10", + "stable": true, + "files": [ + { + "filename": "python-3.12.10-darwin-arm64.tar.gz", + "arch": "arm64", + "platform": "darwin" + }, + { + "filename": "python-3.12.10-darwin-x64.tar.gz", + "arch": "x64", + "platform": "darwin" + }, + { + "filename": "python-3.12.10-linux-20.04-x64.tar.gz", + "arch": "x64", + "platform": "linux", + "platform_version": "20.04" + }, + { + "filename": "python-3.12.10-linux-22.04-x64.tar.gz", + "arch": "x64", + "platform": "linux", + "platform_version": "22.04" + }, + { + "filename": "python-3.12.10-linux-24.04-x64.tar.gz", + "arch": "x64", + "platform": "linux", + "platform_version": "24.04" + } + ] + }, + { + "version": "3.12.0-alpha.6", + "stable": false, + "files": [ + { + "filename": "python-3.12.0-alpha.6-linux-18.04-x64.tar.gz", + "arch": "x64", + "platform": "linux", + "platform_version": "18.04" + } + ] + }, + { + "version": "3.11.17", + "stable": true, + "files": [ + { + "filename": "python-3.11.17-linux-22.04-x64.tar.gz", + "arch": "x64", + "platform": "linux", + "platform_version": "22.04" + }, + { + "filename": "python-3.11.17-linux-24.04-x64.tar.gz", + "arch": "x64", + "platform": "linux", + "platform_version": "24.04" + }, + { + "filename": "python-3.11.17-linux-26.04-x64.tar.gz", + "arch": "x64", + "platform": "linux", + "platform_version": "26.04" + } + ] + }, + { + "version": "3.11.12", + "stable": true, + "files": [ + { + "filename": "python-3.11.12-linux-20.04-x64.tar.gz", + "arch": "x64", + "platform": "linux", + "platform_version": "20.04" + }, + { + "filename": "python-3.11.12-linux-22.04-x64.tar.gz", + "arch": "x64", + "platform": "linux", + "platform_version": "22.04" + }, + { + "filename": "python-3.11.12-linux-24.04-x64.tar.gz", + "arch": "x64", + "platform": "linux", + "platform_version": "24.04" + } + ] + }, + { + "version": "3.11.9", + "stable": true, + "files": [ + { + "filename": "python-3.11.9-darwin-arm64.tar.gz", + "arch": "arm64", + "platform": "darwin" + }, + { + "filename": "python-3.11.9-darwin-x64.tar.gz", + "arch": "x64", + "platform": "darwin" + }, + { + "filename": "python-3.11.9-linux-20.04-x64.tar.gz", + "arch": "x64", + "platform": "linux", + "platform_version": "20.04" + }, + { + "filename": "python-3.11.9-linux-22.04-x64.tar.gz", + "arch": "x64", + "platform": "linux", + "platform_version": "22.04" + }, + { + "filename": "python-3.11.9-linux-24.04-x64.tar.gz", + "arch": "x64", + "platform": "linux", + "platform_version": "24.04" + } + ] + }, + { + "version": "3.11.0-rc.2", + "stable": false, + "files": [ + { + "filename": "python-3.11.0-rc.2-linux-18.04-x64.tar.gz", + "arch": "x64", + "platform": "linux", + "platform_version": "18.04" + } + ] + }, + { + "version": "3.10.22", + "stable": true, + "files": [ + { + "filename": "python-3.10.22-darwin-x64.tar.gz", + "arch": "x64", + "platform": "darwin" + }, + { + "filename": "python-3.10.22-linux-22.04-x64.tar.gz", + "arch": "x64", + "platform": "linux", + "platform_version": "22.04" + }, + { + "filename": "python-3.10.22-linux-24.04-x64.tar.gz", + "arch": "x64", + "platform": "linux", + "platform_version": "24.04" + }, + { + "filename": "python-3.10.22-linux-26.04-x64.tar.gz", + "arch": "x64", + "platform": "linux", + "platform_version": "26.04" + } + ] + }, + { + "version": "3.10.17", + "stable": true, + "files": [ + { + "filename": "python-3.10.17-darwin-x64.tar.gz", + "arch": "x64", + "platform": "darwin" + }, + { + "filename": "python-3.10.17-linux-20.04-x64.tar.gz", + "arch": "x64", + "platform": "linux", + "platform_version": "20.04" + }, + { + "filename": "python-3.10.17-linux-22.04-x64.tar.gz", + "arch": "x64", + "platform": "linux", + "platform_version": "22.04" + }, + { + "filename": "python-3.10.17-linux-24.04-x64.tar.gz", + "arch": "x64", + "platform": "linux", + "platform_version": "24.04" + } + ] + }, + { + "version": "3.10.11", + "stable": true, + "files": [ + { + "filename": "python-3.10.11-darwin-arm64.tar.gz", + "arch": "arm64", + "platform": "darwin" + }, + { + "filename": "python-3.10.11-darwin-x64.tar.gz", + "arch": "x64", + "platform": "darwin" + }, + { + "filename": "python-3.10.11-linux-20.04-x64.tar.gz", + "arch": "x64", + "platform": "linux", + "platform_version": "20.04" + }, + { + "filename": "python-3.10.11-linux-22.04-x64.tar.gz", + "arch": "x64", + "platform": "linux", + "platform_version": "22.04" + }, + { + "filename": "python-3.10.11-linux-24.04-x64.tar.gz", + "arch": "x64", + "platform": "linux", + "platform_version": "24.04" + } + ] + }, + { + "version": "3.10.3", + "stable": true, + "files": [ + { + "filename": "python-3.10.3-darwin-x64.tar.gz", + "arch": "x64", + "platform": "darwin" + }, + { + "filename": "python-3.10.3-linux-18.04-x64.tar.gz", + "arch": "x64", + "platform": "linux", + "platform_version": "18.04" + }, + { + "filename": "python-3.10.3-linux-20.04-x64.tar.gz", + "arch": "x64", + "platform": "linux", + "platform_version": "20.04" + } + ] + }, + { + "version": "3.10.0-beta.3", + "stable": false, + "files": [ + { + "filename": "python-3.10.0-beta.3-linux-16.04-x64.tar.gz", + "arch": "x64", + "platform": "linux", + "platform_version": "16.04" + } + ] + }, + { + "version": "3.9.25", + "stable": true, + "files": [ + { + "filename": "python-3.9.25-darwin-x64.tar.gz", + "arch": "x64", + "platform": "darwin" + }, + { + "filename": "python-3.9.25-linux-22.04-x64.tar.gz", + "arch": "x64", + "platform": "linux", + "platform_version": "22.04" + }, + { + "filename": "python-3.9.25-linux-24.04-x64.tar.gz", + "arch": "x64", + "platform": "linux", + "platform_version": "24.04" + } + ] + }, + { + "version": "3.9.22", + "stable": true, + "files": [ + { + "filename": "python-3.9.22-darwin-x64.tar.gz", + "arch": "x64", + "platform": "darwin" + }, + { + "filename": "python-3.9.22-linux-20.04-x64.tar.gz", + "arch": "x64", + "platform": "linux", + "platform_version": "20.04" + }, + { + "filename": "python-3.9.22-linux-22.04-x64.tar.gz", + "arch": "x64", + "platform": "linux", + "platform_version": "22.04" + }, + { + "filename": "python-3.9.22-linux-24.04-x64.tar.gz", + "arch": "x64", + "platform": "linux", + "platform_version": "24.04" + } + ] + }, + { + "version": "3.9.13", + "stable": true, + "files": [ + { + "filename": "python-3.9.13-darwin-arm64.tar.gz", + "arch": "arm64", + "platform": "darwin" + }, + { + "filename": "python-3.9.13-darwin-x64.tar.gz", + "arch": "x64", + "platform": "darwin" + }, + { + "filename": "python-3.9.13-linux-20.04-x64.tar.gz", + "arch": "x64", + "platform": "linux", + "platform_version": "20.04" + }, + { + "filename": "python-3.9.13-linux-22.04-x64.tar.gz", + "arch": "x64", + "platform": "linux", + "platform_version": "22.04" + }, + { + "filename": "python-3.9.13-linux-24.04-x64.tar.gz", + "arch": "x64", + "platform": "linux", + "platform_version": "24.04" + } + ] + }, + { + "version": "3.9.11", + "stable": true, + "files": [ + { + "filename": "python-3.9.11-darwin-x64.tar.gz", + "arch": "x64", + "platform": "darwin" + }, + { + "filename": "python-3.9.11-linux-18.04-x64.tar.gz", + "arch": "x64", + "platform": "linux", + "platform_version": "18.04" + }, + { + "filename": "python-3.9.11-linux-20.04-x64.tar.gz", + "arch": "x64", + "platform": "linux", + "platform_version": "20.04" + } + ] + }, + { + "version": "3.9.5", + "stable": true, + "files": [ + { + "filename": "python-3.9.5-darwin-x64.tar.gz", + "arch": "x64", + "platform": "darwin" + }, + { + "filename": "python-3.9.5-linux-16.04-x64.tar.gz", + "arch": "x64", + "platform": "linux", + "platform_version": "16.04" + }, + { + "filename": "python-3.9.5-linux-18.04-x64.tar.gz", + "arch": "x64", + "platform": "linux", + "platform_version": "18.04" + }, + { + "filename": "python-3.9.5-linux-20.04-x64.tar.gz", + "arch": "x64", + "platform": "linux", + "platform_version": "20.04" + } + ] + }, + { + "version": "3.8.18", + "stable": true, + "files": [ + { + "filename": "python-3.8.18-darwin-x64.tar.gz", + "arch": "x64", + "platform": "darwin" + }, + { + "filename": "python-3.8.18-linux-20.04-x64.tar.gz", + "arch": "x64", + "platform": "linux", + "platform_version": "20.04" + }, + { + "filename": "python-3.8.18-linux-22.04-x64.tar.gz", + "arch": "x64", + "platform": "linux", + "platform_version": "22.04" + }, + { + "filename": "python-3.8.18-linux-24.04-x64.tar.gz", + "arch": "x64", + "platform": "linux", + "platform_version": "24.04" + } + ] + }, + { + "version": "3.8.11", + "stable": true, + "files": [ + { + "filename": "python-3.8.11-darwin-x64.tar.gz", + "arch": "x64", + "platform": "darwin" + }, + { + "filename": "python-3.8.11-linux-18.04-x64.tar.gz", + "arch": "x64", + "platform": "linux", + "platform_version": "18.04" + }, + { + "filename": "python-3.8.11-linux-20.04-x64.tar.gz", + "arch": "x64", + "platform": "linux", + "platform_version": "20.04" + } + ] + }, + { + "version": "3.8.10", + "stable": true, + "files": [ + { + "filename": "python-3.8.10-darwin-arm64.tar.gz", + "arch": "arm64", + "platform": "darwin" + }, + { + "filename": "python-3.8.10-darwin-x64.tar.gz", + "arch": "x64", + "platform": "darwin" + }, + { + "filename": "python-3.8.10-linux-20.04-x64.tar.gz", + "arch": "x64", + "platform": "linux", + "platform_version": "20.04" + } + ] + }, + { + "version": "3.8.9", + "stable": true, + "files": [ + { + "filename": "python-3.8.9-darwin-x64.tar.gz", + "arch": "x64", + "platform": "darwin" + }, + { + "filename": "python-3.8.9-linux-16.04-x64.tar.gz", + "arch": "x64", + "platform": "linux", + "platform_version": "16.04" + }, + { + "filename": "python-3.8.9-linux-18.04-x64.tar.gz", + "arch": "x64", + "platform": "linux", + "platform_version": "18.04" + }, + { + "filename": "python-3.8.9-linux-20.04-x64.tar.gz", + "arch": "x64", + "platform": "linux", + "platform_version": "20.04" + } + ] + }, + { + "version": "3.7.17", + "stable": true, + "files": [ + { + "filename": "python-3.7.17-darwin-x64.tar.gz", + "arch": "x64", + "platform": "darwin" + }, + { + "filename": "python-3.7.17-linux-20.04-x64.tar.gz", + "arch": "x64", + "platform": "linux", + "platform_version": "20.04" + }, + { + "filename": "python-3.7.17-linux-22.04-x64.tar.gz", + "arch": "x64", + "platform": "linux", + "platform_version": "22.04" + } + ] + }, + { + "version": "3.7.16", + "stable": true, + "files": [ + { + "filename": "python-3.7.16-darwin-x64.tar.gz", + "arch": "x64", + "platform": "darwin" + }, + { + "filename": "python-3.7.16-linux-18.04-x64.tar.gz", + "arch": "x64", + "platform": "linux", + "platform_version": "18.04" + }, + { + "filename": "python-3.7.16-linux-20.04-x64.tar.gz", + "arch": "x64", + "platform": "linux", + "platform_version": "20.04" + }, + { + "filename": "python-3.7.16-linux-22.04-x64.tar.gz", + "arch": "x64", + "platform": "linux", + "platform_version": "22.04" + } + ] + }, + { + "version": "3.7.10", + "stable": true, + "files": [ + { + "filename": "python-3.7.10-darwin-x64.tar.gz", + "arch": "x64", + "platform": "darwin" + }, + { + "filename": "python-3.7.10-linux-16.04-x64.tar.gz", + "arch": "x64", + "platform": "linux", + "platform_version": "16.04" + }, + { + "filename": "python-3.7.10-linux-18.04-x64.tar.gz", + "arch": "x64", + "platform": "linux", + "platform_version": "18.04" + }, + { + "filename": "python-3.7.10-linux-20.04-x64.tar.gz", + "arch": "x64", + "platform": "linux", + "platform_version": "20.04" + } + ] + }, + { + "version": "3.6.15", + "stable": true, + "files": [ + { + "filename": "python-3.6.15-darwin-x64.tar.gz", + "arch": "x64", + "platform": "darwin" + }, + { + "filename": "python-3.6.15-linux-18.04-x64.tar.gz", + "arch": "x64", + "platform": "linux", + "platform_version": "18.04" + }, + { + "filename": "python-3.6.15-linux-20.04-x64.tar.gz", + "arch": "x64", + "platform": "linux", + "platform_version": "20.04" + } + ] + }, + { + "version": "3.6.13", + "stable": true, + "files": [ + { + "filename": "python-3.6.13-darwin-x64.tar.gz", + "arch": "x64", + "platform": "darwin" + }, + { + "filename": "python-3.6.13-linux-16.04-x64.tar.gz", + "arch": "x64", + "platform": "linux", + "platform_version": "16.04" + }, + { + "filename": "python-3.6.13-linux-18.04-x64.tar.gz", + "arch": "x64", + "platform": "linux", + "platform_version": "18.04" + }, + { + "filename": "python-3.6.13-linux-20.04-x64.tar.gz", + "arch": "x64", + "platform": "linux", + "platform_version": "20.04" + } + ] + }, + { + "version": "3.5.10", + "stable": true, + "files": [ + { + "filename": "python-3.5.10-darwin-x64.tar.gz", + "arch": "x64", + "platform": "darwin" + }, + { + "filename": "python-3.5.10-linux-16.04-x64.tar.gz", + "arch": "x64", + "platform": "linux", + "platform_version": "16.04" + }, + { + "filename": "python-3.5.10-linux-18.04-x64.tar.gz", + "arch": "x64", + "platform": "linux", + "platform_version": "18.04" + }, + { + "filename": "python-3.5.10-linux-20.04-x64.tar.gz", + "arch": "x64", + "platform": "linux", + "platform_version": "20.04" + } + ] + } +] diff --git a/tests/project/test_python_matrix_tiers_1633.py b/tests/project/test_python_matrix_tiers_1633.py index 0aa762e737..25ad1cfc8a 100644 --- a/tests/project/test_python_matrix_tiers_1633.py +++ b/tests/project/test_python_matrix_tiers_1633.py @@ -321,13 +321,16 @@ def pypi_complete(self, files: 'list[str]') -> 'str': """``PCAPKIT_PYPI_COMPLETE`` for a release holding ``files``, ``curl`` stubbed. The supported wheel tags reach the step as ``PCAPKIT_PYPI_WHEELS``, read - from the tier file as the workflow reads it (#1660). + from the tier file as the workflow reads it (#1660): this run's, which + the ``released`` step passes on while the version is untagged. """ steps = job('create-release.yml', 'version_check')['steps'] step = next(step for step in steps if step.get('name') == 'Check PyPI publish completeness') script = step['run'] - wheels = resolve('create-release.yml', 'version_check', step['env']['PCAPKIT_PYPI_WHEELS']) + self.assertEqual(step['env']['PCAPKIT_PYPI_WHEELS'], '${{ steps.released.outputs.pypi-wheels }}') + released = next(step for step in steps if step.get('id') == 'released') + wheels = resolve('create-release.yml', 'version_check', released['env']['PCAPKIT_PYPI_WHEELS']) reply = self.tmp / 'reply.json' reply.write_text(json.dumps({'urls': [{'filename': name} for name in files]}), encoding='utf-8') bin_dir = self.tmp / 'bin' diff --git a/tests/project/test_python_versions_1660.py b/tests/project/test_python_versions_1660.py index 66d9cc1b5c..805e7cd766 100644 --- a/tests/project/test_python_versions_1660.py +++ b/tests/project/test_python_versions_1660.py @@ -27,7 +27,11 @@ script are facts about the engines, not pins, and are not read.) * **The prose follows the file.** The classifiers, README, SECURITY.md, the docs' version lists, the bug-report template, and the comments and docs - naming the coverage leg's version or the release checks' counts. + naming the coverage leg's version or the release checks' counts. None of + them is a workflow: the weekly job that regenerates them + (:file:`util/update_python_versions.py`) cannot push one, so workflow + comments name the tiers instead, as + :mod:`tests.project.test_python_versions_update_1660` pins. The script reads the file with :mod:`tomllib`, in the standard library from 3.11, so every test that reads it skips on the KTLO interpreters, 3.8-3.10 -- the @@ -133,6 +137,10 @@ def workflow_at_main(name: 'str') -> 'dict[str, Any]': def workflows(load: 'Callable[[str], dict[str, Any]]' = workflow) -> 'Iterator[tuple[str, dict[str, Any]]]': for path in sorted(WORKFLOWS.glob('*.yml')): + if load is workflow_at_main and subprocess.run( + ['git', 'cat-file', '-e', f'{MAIN}:.github/workflows/{path.name}'], cwd=ROOT, + capture_output=True, check=False).returncode: + continue # added since MAIN, as cron-python-versions.yml was (#1660) yield path.name, load(path.name) @@ -672,7 +680,8 @@ def add(name: 'str', job_id: 'str', where: 'str', value: 'str') -> None: SINGLE = (('unit-tests.yml', 'gate'), ('unit-tests.yml', 'unittest-ordering'), ('unit-tests.yml', 'changelog'), ('unit-tests.yml', 'project-tests'), ('create-release.yml', 'version_check'), ('cron-conda.yml', 'conda-update'), ('cron-vendor.yml', 'vendor-update'), - ('deploy-pages.yml', 'deploy-pages'), ('ci-timing.yml', 'report'), ('lint.yml', 'lint')) + ('deploy-pages.yml', 'deploy-pages'), ('ci-timing.yml', 'report'), ('lint.yml', 'lint'), + ('cron-python-versions.yml', 'detect')) class TestEveryVersionComesFromTheFile(unittest.TestCase): @@ -775,10 +784,17 @@ def setUp(self) -> None: self.skipTest('bash and python3 are needed to run the workflow shell') def complete(self, distributions: 'list[tuple[str, str]]') -> 'str': - """``PCAPKIT_CONDA_COMPLETE`` for a release holding these (platform, build) pairs.""" - step = next(step for step in workflow('create-release.yml')['jobs']['version_check']['steps'] - if step.get('name') == 'Check Anaconda publish completeness') - builds = resolve('create-release.yml', 'version_check', step['env']['PCAPKIT_CONDA_BUILDS']) + """``PCAPKIT_CONDA_COMPLETE`` for a release holding these (platform, build) pairs. + + The builds are this run's, which the ``released`` step passes on while the + version is untagged; ``test_release_tags_the_run_sha_1634`` runs it tagged. + + """ + steps = workflow('create-release.yml')['jobs']['version_check']['steps'] + step = next(step for step in steps if step.get('name') == 'Check Anaconda publish completeness') + self.assertEqual(step['env']['PCAPKIT_CONDA_BUILDS'], '${{ steps.released.outputs.conda-builds }}') + released = next(step for step in steps if step.get('id') == 'released') + builds = resolve('create-release.yml', 'version_check', released['env']['PCAPKIT_CONDA_BUILDS']) with tempfile.TemporaryDirectory(prefix='pcapkit-1660-') as tmp: scratch = pathlib.Path(tmp) reply = scratch / 'reply.json' @@ -849,24 +865,24 @@ def test_the_classifiers_name_the_supported_tier_and_both_implementations(self) ('pyproject.toml', r'\*\*(3\.\d+) through (3\.\d+)\*\* are supported', 'supported'), ('pyproject.toml', r'\*\*(3\.\d+) through (3\.\d+)\*\* are KTLO', 'ktlo'), ('pyproject.toml', r'the KTLO tier \((3\.\d+)-(3\.\d+)\)', 'ktlo'), - ('.github/workflows/lint.yml', r'Python Compatibility sweep (3\.\d+)-(3\.\d+),', 'supported'), - ('.github/workflows/lint.yml', r'the non-blocking KTLO legs (3\.\d+)-(3\.\d+)', 'ktlo'), ) def test_each_tier_range_names_the_files_tier(self) -> None: + """Its first and last version, and so every one between: "3.8 to 3.10" says 3.9 is in it.""" for path, pattern, name in self.RANGES: versions = self.supported if name == 'supported' else self.ktlo with self.subTest(path=path, claim=pattern): self.assertEqual(re.findall(pattern, prose(ROOT / path)), [(versions[0], versions[-1])]) + for name, versions in (('supported', self.supported), ('ktlo', self.ktlo)): + minors = [int(version.split('.')[1]) for version in versions] + with self.subTest(tier=name): + self.assertEqual(minors, list(range(minors[0], minors[0] + len(minors))), + f'a range cannot state the {name} tier {versions}; reword the claims by hand') def test_the_required_contexts_table_is_the_supported_tier(self) -> None: """The docs' copy of ruleset 23497679, which a tier change edits with it.""" page = prose(ROOT / 'docs' / 'source' / 'contributing' / 'workflows.rst') self.assertEqual(re.findall(r'\* - ``Compat Python (3\.\d+)``', page), self.supported) - comment = prose(WORKFLOWS / 'unit-tests.yml') - self.assertEqual(re.findall(r'and (\w+) for `Compat Python (3\.\d+)`-`(3\.\d+)`', comment), - [(['zero', 'one', 'two', 'three', 'four', 'five', 'six', 'seven'][len(self.supported)], - self.supported[0], self.supported[-1])]) def test_the_workflows_page_cites_each_version_on_its_line(self) -> None: """The required contexts' rows, and the two tiers' spans under them, cite the file by line.""" @@ -878,7 +894,7 @@ def test_the_workflows_page_cites_each_version_on_its_line(self) -> None: with self.subTest(version=version): self.assertTrue(lines[int(line) - 1].startswith(f'"{version}" = '), lines[int(line) - 1]) for label, versions in (('supported tier', self.supported), ('KTLO legs', self.ktlo)): - spans = re.findall(rf'its (?:\w+ )?{label} \(``:(\d+)-(\d+)``\)', page) + spans = re.findall(rf'its (?:\w+ )?{label} \(``python-versions\.toml:(\d+)-(\d+)``\)', page) with self.subTest(tier=label): self.assertEqual(len(spans), 1, spans) first, last = map(int, spans[0]) @@ -895,13 +911,10 @@ def test_the_bug_report_template_offers_supported_versions(self) -> None: #: (file, a claim naming the coverage leg's or the sdist leg's version) -- ``latest``. LATEST = ( - ('.github/workflows/coverage-comment.yml', r'\(unit tier, Python (\d+\.\d+), '), - ('.github/workflows/coverage-comment.yml', r"the `test` job's Python (\d+\.\d+) leg"), ('.github/coverage.toml', r'\(`test`, Python (\d+\.\d+)\)'), ('docs/source/contributing/workflows.rst', r'the ``Python (\d+\.\d+)`` leg of ``test`` \(matrix key ``coverage: true``\)'), ('docs/source/contributing/conventions/test-coverage.rst', r"the ``test`` job's Python (\d+\.\d+) cell"), - ('.github/workflows/create-release.yml', r'keeps only on the (\d+\.\d+) leg'), ('docs/source/contributing/releasing.rst', r'the sdist kept only on the (\d+\.\d+) leg'), ) @@ -912,14 +925,8 @@ def test_each_claim_about_the_latest_leg_names_latest(self) -> None: def test_the_release_checks_counts_follow_the_matrices(self) -> None: """version_check's own counts come from the file; the prose stating them must too.""" - release = prose(WORKFLOWS / 'create-release.yml') page = prose(ROOT / 'docs' / 'source' / 'contributing' / 'releasing.rst') claims = ( - (release, r'against the (\d+) expected', [str(self.wheels + 1)] * 2), - (release, r'expected -- the (\d+) supported legs', [str(self.wheels)]), - (release, r'the (\d+)-leg ``conda`` matrix \(2 OS x (\d+) Python versions\)', - [(str(2 * self.conda), str(self.conda))]), - (release, r'only (\d+) of (\d+) legs', [(str(2 * self.conda - 1), str(2 * self.conda))]), (page, r'against the (\d+) expected -- the (\d+) supported legs', [(str(self.wheels + 1), str(self.wheels))]), (page, r'against the (\d+) expected \(2 OS x (\d+) Python versions\)', diff --git a/tests/project/test_python_versions_update_1660.py b/tests/project/test_python_versions_update_1660.py new file mode 100644 index 0000000000..d644a8e977 --- /dev/null +++ b/tests/project/test_python_versions_update_1660.py @@ -0,0 +1,1258 @@ +# -*- coding: utf-8 -*- +"""#1660 Part B: a weekly job keeps :file:`.github/python-versions.toml` current. + +:file:`util/update_python_versions.py` recomputes the tiers from python.org's +release cycle, PyPy's releases, setup-python's ``versions-manifest.json`` and the +runner-images README, rewrites the file and the prose the audit pins to it, and +lists the check runs a tier move renames. :file:`.github/workflows/cron-python-versions.yml` +runs it weekly: ``detect`` with no secret, ``pr`` behind ``github-release`` with a +release App token of contents and pull requests write, pushing +``bot/python-versions`` alone. The owner's rulings on #1660: a pull request he +merges, never a push to ``main``; the release App, given Pull requests; the +renames in the body, never a ruleset edit. + +Nothing here touches the network. The sources are the snapshots in +:file:`tests/project/python_versions_fixtures/`, trimmed from what each URL +served on 2026-10-13, and each scenario edits a copy of them. The scenarios run +on a scratch copy of the files the script writes, with the tier file as it stood +then (the ``python-versions.toml`` there) and the prose regenerated to match it, +so a later tier change, this job's own included, leaves them as they are. The +publishing tests run :func:`update_python_versions.publish` against a scratch +``origin`` with ``gh`` stubbed, so what it pushes is read back from a real +repository. + +""" + +from __future__ import annotations + +import contextlib +import copy +import importlib.util +import io +import json +import os +import pathlib +import re +import shutil +import subprocess # nosec B404 +import sys +import tempfile +import unittest +from typing import TYPE_CHECKING +from unittest import mock + +from tests.project.test_release_gates import evaluate_condition + +try: + import tomllib +except ImportError: # Python 3.10 and older + tomllib = None # type: ignore[assignment] + +try: + import yaml +except ImportError: # pragma: no cover + yaml = None # type: ignore[assignment] + +if TYPE_CHECKING: + from typing import Any, Callable, Optional + +ROOT = pathlib.Path(__file__).resolve().parents[2] +SCRIPT = ROOT / 'util' / 'update_python_versions.py' +FIXTURES = pathlib.Path(__file__).resolve().with_name('python_versions_fixtures') +#: The tier file as the snapshot found it, which ``as_of_the_file`` reproduces. +FROZEN = FIXTURES / 'python-versions.toml' +WORKFLOW = ROOT / '.github' / 'workflows' / 'cron-python-versions.yml' +NO_TOMLLIB = 'tomllib, which reads .github/python-versions.toml, needs Python 3.11+' + +_UPDATER = [] # type: list[Any] + + +def updater() -> 'Any': + """:file:`util/update_python_versions.py`, loaded once; skipped without :mod:`tomllib` or PyYAML.""" + if tomllib is None: + raise unittest.SkipTest(NO_TOMLLIB) + if yaml is None: # pragma: no cover + raise unittest.SkipTest('PyYAML, from the test extra, is not installed') + if not SCRIPT.is_file(): + raise AssertionError(f'{SCRIPT.relative_to(ROOT)} is missing') + if not _UPDATER: + spec = importlib.util.spec_from_file_location('update_python_versions', SCRIPT) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) # type: ignore[union-attr] + _UPDATER.append(module) + return _UPDATER[0] + + +def workflow() -> 'dict[str, Any]': + if yaml is None: # pragma: no cover + raise unittest.SkipTest('PyYAML, from the test extra, is not installed') + return yaml.safe_load(WORKFLOW.read_text(encoding='utf-8')) + + +############################################################################### +# Sources and scratch trees +############################################################################### + + +def snapshot() -> 'dict[str, Any]': + """The four sources as saved on 2026-10-13, parsed.""" + return copy.deepcopy(updater().parse_sources(updater().read_sources(FIXTURES))) + + +def without_pypy(sources: 'dict[str, Any]', line: 'str') -> 'dict[str, Any]': + """PyPy's releases with every one of Python ``line`` (``'3.12'``) removed.""" + sources['pypy'] = [release for release in sources['pypy'] + if not release['python_version'].startswith(line + '.') and release['python_version'] != line] + return sources + + +def as_of_the_file(sources: 'dict[str, Any]') -> 'dict[str, Any]': + """The snapshot as it stood before PyPy 8.0.0 added 3.12 -- what the file encodes.""" + return without_pypy(sources, '3.12') + + +def set_status(sources: 'dict[str, Any]', version: 'str', status: 'str') -> 'dict[str, Any]': + sources['release-cycle'].setdefault(version, {'branch': version})['status'] = status + return sources + + +def add_build(sources: 'dict[str, Any]', version: 'str', stable: 'bool', + targets: 'tuple[tuple[str, Optional[str], str], ...]') -> 'dict[str, Any]': + """A setup-python release of ``version`` with a build for each ``(platform, platform_version, arch)``.""" + files = [] + for platform, platform_version, arch in targets: + file = {'filename': f'python-{version}-{platform}-{arch}.tar.gz', 'arch': arch, 'platform': platform} + if platform_version is not None: + file['platform_version'] = platform_version + files.append(file) + sources['manifest'].insert(0, {'version': version, 'stable': stable, 'files': files}) + return sources + + +def drop_build(sources: 'dict[str, Any]', minor: 'str', platform_version: 'str') -> 'dict[str, Any]': + for release in sources['manifest']: + if release['version'].startswith(minor + '.'): + release['files'] = [file for file in release['files'] if file.get('platform_version') != platform_version] + return sources + + +def drop_image(sources: 'dict[str, Any]', label: 'str') -> 'dict[str, Any]': + """The runner-images table without the row listing ``label``.""" + sources['runner-images'] = ''.join(line for line in sources['runner-images'].splitlines(True) + if f'`{label}`' not in line) + return sources + + +def move_latest(sources: 'dict[str, Any]', image: 'str') -> 'dict[str, Any]': + """``ubuntu-latest`` moved to the Ubuntu ``image`` (``'26.04'``) row, as on 2026-10-19.""" + lines = [] + for line in sources['runner-images'].splitlines(True): + line = line.replace('`ubuntu-latest` or ', '') + if line.startswith(f'| Ubuntu {image}
'): + line = line.replace(f'`ubuntu-{image}`', f'`ubuntu-latest` or `ubuntu-{image}`') + lines.append(line) + sources['runner-images'] = ''.join(lines) + return sources + + +#: Stable builds of a new CPython for every image ``ubuntu-latest`` may mean, and macOS. +EVERYWHERE = (('linux', '22.04', 'x64'), ('linux', '24.04', 'x64'), ('linux', '26.04', 'x64'), + ('darwin', None, 'arm64'), ('darwin', None, 'x64')) + + +def three_eleven_ends_and_three_sixteen_ships(sources: 'dict[str, Any]') -> 'dict[str, Any]': + """October 2027, roughly: 3.11 end-of-life, 3.16 released and built everywhere.""" + set_status(sources, '3.11', 'end-of-life') + set_status(sources, '3.16', 'bugfix') + return add_build(sources, '3.16.0', True, EVERYWHERE) + + +def frozen() -> 'dict[str, Any]': + """:data:`FROZEN`, through :func:`python_versions.load`.""" + return updater().validate(FROZEN.read_text(encoding='utf-8')) + + +def scratch(test: 'unittest.TestCase', regenerate: 'bool' = True) -> 'pathlib.Path': + """A copy of every file the script reads or writes, with :data:`FROZEN` as the tier file. + + With ``regenerate``, the prose is then rewritten to match :data:`FROZEN`, as a + run on the snapshot it reproduces would, so it starts from what the + checkout said on 2026-10-13 whatever the checkout says now. + + """ + tmp = tempfile.TemporaryDirectory(prefix='pcapkit-1660b-') # pylint: disable=consider-using-with + test.addCleanup(tmp.cleanup) + root = pathlib.Path(tmp.name) + for path in updater().TARGETS + (updater().TEMPLATE,): + (root / path).parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(ROOT / path, root / path) + shutil.copytree(ROOT / '.github' / 'workflows', root / '.github' / 'workflows') + shutil.copyfile(FROZEN, root / updater().TIER_FILE) + if regenerate: + plan, texts = updater().compute_plan(root, as_of_the_file(snapshot())) + test.assertEqual(plan['changes'], []) + for path, text in texts.items(): + (root / path).write_bytes(text.encode('utf-8')) + return root + + +def save_sources(test: 'unittest.TestCase', sources: 'dict[str, Any]') -> 'pathlib.Path': + """``sources`` written as the four files ``update --sources`` reads.""" + tmp = tempfile.TemporaryDirectory(prefix='pcapkit-1660b-sources-') # pylint: disable=consider-using-with + test.addCleanup(tmp.cleanup) + directory = pathlib.Path(tmp.name) + for name, source in updater().SOURCES.items(): + value = sources[name] + (directory / source.filename).write_text(value if isinstance(value, str) else json.dumps(value), + encoding='utf-8') + return directory + + +def run_main(root: 'pathlib.Path', argv: 'list[str]', output: 'Optional[pathlib.Path]' = None) -> 'tuple[int, str]': + """The command line run in this process over ``root``, with its status and stdout.""" + environ = {key: value for key, value in os.environ.items() if key not in ('GITHUB_OUTPUT', 'GITHUB_STEP_SUMMARY')} + if output is not None: + environ['GITHUB_OUTPUT'] = str(output) + stdout = io.StringIO() + with mock.patch.dict(os.environ, environ, clear=True), contextlib.redirect_stdout(stdout): + status = updater().main(argv, root=root) + return status, stdout.getvalue() + + +def plan_for(test: 'unittest.TestCase', sources: 'dict[str, Any]', + write: 'bool' = False) -> 'tuple[pathlib.Path, dict[str, Any], dict[str, str]]': + root = scratch(test) + plan, texts = updater().compute_plan(root, sources) + if write: + for path, text in texts.items(): + (root / path).write_bytes(text.encode('utf-8')) + return root, plan, texts + + +def changes(plan: 'dict[str, Any]') -> 'dict[str, tuple[Any, Any]]': + return {change['version']: (change['before'], change['after']) for change in plan['changes']} + + +def notes(plan: 'dict[str, Any]', kind: 'str') -> 'list[str]': + return [note['text'] for note in plan['notes'] if note['kind'] == kind] + + +def context(sources: 'dict[str, Any]') -> 'Any': + """The rules' :class:`Context` for ``sources``, with the labels the real workflows use.""" + module = updater() + return module.Context(sources, module.parse_images(sources['runner-images']), + module.workflow_labels(module.load_workflows(ROOT))) + + +############################################################################### +# The tiers +############################################################################### + + +class TestTheTiers(unittest.TestCase): + """The rules, each against the snapshot and an edit of it.""" + + def test_the_labels_come_from_the_workflows(self) -> None: + """``matrix.os || 'ubuntu-latest'``; the conda jobs' ``os`` axis; where ``latest`` is installed.""" + found = context(snapshot()).labels + self.assertEqual(found, {'default': ['ubuntu-latest'], 'supported': ['macos-latest', 'ubuntu-latest'], + 'latest': ['macos-latest', 'ubuntu-latest']}) + + def test_ubuntu_latest_counts_as_its_image_and_every_newer_one(self) -> None: + module = updater() + images = module.parse_images(snapshot()['runner-images']) + self.assertEqual(module.label_targets('ubuntu-latest', images), + [module.Target('linux', '24.04', 'x64'), module.Target('linux', '26.04', 'x64')]) + self.assertEqual(module.label_targets('ubuntu-24.04', images), [module.Target('linux', '24.04', 'x64')]) + self.assertEqual(module.label_targets('macos-latest', images), [module.Target('darwin', None, 'arm64')]) + moved = module.parse_images(move_latest(snapshot(), '26.04')['runner-images']) + self.assertEqual(module.label_targets('ubuntu-latest', moved), [module.Target('linux', '26.04', 'x64')]) + with self.assertRaisesRegex(ValueError, 'does not list'): + module.label_targets('ubuntu-20.04', images) + + def test_the_snapshot_reproduces_the_file_but_for_pypy_312(self) -> None: + """PyPy 8.0.0 (2026-09-19) ships 3.11 and 3.12: the first run proposes pypy3.12.""" + _, plan, texts = plan_for(self, snapshot()) + self.assertEqual(changes(plan), {'pypy3.12': (None, {'tier': 'supported'})}) + self.assertEqual(plan['latest'], ['3.15', '3.15']) + self.assertEqual(sorted(texts), ['.github/python-versions.toml', 'docs/source/contributing/releasing.rst']) + self.assertEqual(plan['required'], {'remove': [], 'add': []}) + self.assertEqual(plan['added'], ['PyPI distribution for Python pypy3.12']) + + def test_as_of_the_file_nothing_changes(self) -> None: + """Before PyPy 8.0.0, the sources gave the tier file exactly as #1677 wrote it.""" + plan, _ = updater().compute_plan(scratch(self, regenerate=False), as_of_the_file(snapshot())) + self.assertEqual((plan['changes'], plan['latest'], plan['notes']), ([], ['3.15', '3.15'], [])) + _, plan, texts = plan_for(self, as_of_the_file(snapshot())) + self.assertEqual((plan['changes'], texts, plan['notes']), ([], {}, [])) + + def test_security_to_end_of_life_is_ktlo_on_ubuntu_latest(self) -> None: + """3.11 has a 26.04 build, so its KTLO legs need no ``runner``; its Compat leg leaves the ruleset.""" + _, plan, _ = plan_for(self, set_status(as_of_the_file(snapshot()), '3.11', 'end-of-life')) + self.assertEqual(changes(plan), {'3.11': ({'tier': 'supported'}, {'tier': 'ktlo'})}) + self.assertEqual(plan['required'], {'remove': ['Compat Python 3.11'], 'add': []}) + for old in ('Python 3.11', 'Integration Python 3.11', 'Compat Python 3.11', + 'PyPCAP/PyPCAPFile parity Python 3.11', 'PyPI distribution for Python 3.11'): + with self.subTest(leg=old): + self.assertIn([old, f'{old} (KTLO)'], plan['renamed']) + self.assertIn('Conda deployment (release) on macos-latest with Python 3.11', plan['removed']) + self.assertTrue(any('ktlo_311' in text for text in notes(plan, 'manual')), plan['notes']) + + def test_end_of_life_with_no_build_left_is_dropped(self) -> None: + """ubuntu-22.04 retires (2027-04-17): 3.7, whose newest build is for it, leaves the matrices.""" + _, plan, texts = plan_for(self, drop_image(as_of_the_file(snapshot()), 'ubuntu-22.04')) + self.assertEqual(changes(plan), {'3.7': ({'tier': 'ktlo', 'runner': 'ubuntu-22.04'}, None)}) + self.assertIn('no hosted Ubuntu image', plan['changes'][0]['reason']) + self.assertIn('Python 3.7 (KTLO)', plan['removed']) + self.assertNotIn('"3.7"', texts['.github/python-versions.toml']) + + def test_a_version_dropped_from_the_middle_leaves_the_ranges_for_a_hand_edit(self) -> None: + """3.7's image retires and 3.9 has no build left: "3.8 to 3.10" would say 3.9 is still tested.""" + sources = drop_image(as_of_the_file(snapshot()), 'ubuntu-22.04') + for image in ('22.04', '24.04', '26.04'): + drop_build(sources, '3.9', image) + root, plan, texts = plan_for(self, sources) + self.assertEqual(changes(plan), {'3.7': ({'tier': 'ktlo', 'runner': 'ubuntu-22.04'}, None), + '3.9': ({'tier': 'ktlo', 'runner': 'ubuntu-24.04'}, None)}) + self.assertIn('README.md: `tests the end-of-life (3\\.\\d+) to (3\\.\\d+)` cannot state a tier that is empty ' + 'or has a gap', notes(plan, 'manual')) + for path, text in texts.items(): + with self.subTest(path=path): + self.assertNotRegex(text, r'3\.8 (?:to|through) 3\.10|3\.8-3\.10') + # Spanned anyway, the prose reads true to the audit's ends; the gap is what fails it. + with mock.patch.object(updater(), 'span', lambda tier: (tier[0], tier[-1]) if tier else None): + root, _, texts = plan_for(self, sources) + self.assertIn('3.8 to 3.10', texts['README.md']) + for path, text in texts.items(): + (root / path).write_text(text, encoding='utf-8') + result = run_prose_audit(root) + failed = {getattr(test, 'test_case', test)._testMethodName # pylint: disable=protected-access + for test, _ in result.failures + result.errors} + self.assertEqual(failed, {'test_each_tier_range_names_the_files_tier'}) + + def test_a_runner_is_the_newest_image_with_a_build(self) -> None: + sources = drop_build(as_of_the_file(snapshot()), '3.10', '26.04') + _, plan, texts = plan_for(self, sources) + self.assertEqual(changes(plan), {'3.10': ({'tier': 'ktlo'}, {'tier': 'ktlo', 'runner': 'ubuntu-24.04'})}) + self.assertIn('"3.10" = { tier = "ktlo", runner = "ubuntu-24.04" }\n', texts['.github/python-versions.toml']) + self.assertEqual((plan['renamed'], plan['required']), ([], {'remove': [], 'add': []})) + + def test_ubuntu_latest_moving_to_2604_changes_nothing(self) -> None: + """The file already pins 3.8 and 3.9 to 24.04 for the move on 2026-10-19.""" + _, plan, texts = plan_for(self, move_latest(as_of_the_file(snapshot()), '26.04')) + self.assertEqual((plan['changes'], texts), ([], {})) + + def test_a_feature_branch_without_a_build_is_not_listed(self) -> None: + sources = as_of_the_file(snapshot()) + self.assertEqual(sources['release-cycle']['3.16']['status'], 'feature') + result = updater().compute_cpython(frozen()['versions']['cpython'], context(sources)) + self.assertNotIn('3.16', result.entries) + _, plan, _ = plan_for(self, sources) + self.assertEqual(notes(plan, 'held'), []) + + def test_a_prerelease_with_a_build_is_held_back(self) -> None: + """The tier :func:`python_versions.load` refuses, until a workflow runs pre-release legs.""" + sources = add_build(as_of_the_file(snapshot()), '3.16.0-alpha.1', False, + (('linux', '24.04', 'x64'), ('linux', '26.04', 'x64'))) + result = updater().compute_cpython(frozen()['versions']['cpython'], context(sources)) + self.assertEqual(result.entries['3.16'], {'tier': 'pre-release'}) + _, plan, texts = plan_for(self, sources) + self.assertEqual((plan['changes'], texts), ([], {})) + self.assertEqual(len(notes(plan, 'held')), 1) + self.assertIn('3.16', notes(plan, 'held')[0]) + self.assertEqual(updater().HELD, ('pre-release',)) + listed = FROZEN.read_text(encoding='utf-8').replace( + '"3.15" = { tier = "supported" }\n', '"3.15" = { tier = "supported" }\n"3.16" = { tier = "pre-release" }\n') + with self.assertRaisesRegex(ValueError, 'no workflow runs pre-release legs'): + updater().validate(listed) + + def test_latest_moves_to_the_newest_bugfix_branch(self) -> None: + _, plan, texts = plan_for(self, three_eleven_ends_and_three_sixteen_ships(as_of_the_file(snapshot()))) + self.assertEqual(plan['latest'], ['3.15', '3.16']) + self.assertEqual(changes(plan), {'3.11': ({'tier': 'supported'}, {'tier': 'ktlo'}), + '3.16': (None, {'tier': 'supported'})}) + self.assertEqual(plan['required'], {'remove': ['Compat Python 3.11'], 'add': ['Compat Python 3.16']}) + self.assertIn('Compat Python 3.16', plan['added']) + self.assertIn('latest moves to 3.16', plan['title']) + self.assertIn('latest = "3.16"\n', texts['.github/python-versions.toml']) + + def test_latest_stays_while_the_newest_bugfix_branch_lacks_a_macos_build(self) -> None: + sources = set_status(as_of_the_file(snapshot()), '3.16', 'bugfix') + add_build(sources, '3.16.0', True, EVERYWHERE[:3]) + _, plan, _ = plan_for(self, sources) + self.assertEqual(plan['latest'], ['3.15', '3.15']) + self.assertEqual(changes(plan), {'3.16': (None, {'tier': 'supported'})}) + self.assertTrue(any('`latest` stays older' in text for text in notes(plan, 'warning')), plan['notes']) + self.assertTrue(any('supported 3.16' in text and 'darwin arm64' in text + for text in notes(plan, 'warning')), plan['notes']) + + def test_pypy_follows_its_newest_stable_release(self) -> None: + """A line the newest release drops is KTLO; one only in a numbered pre-release is held; a nightly is not.""" + sources = snapshot() + sources['pypy'] = [release for release in sources['pypy'] + if not (release['pypy_version'] == '8.0.0' and release['python_version'].startswith('3.11'))] + linux = [{'filename': 'pypy.tar.bz2', 'arch': 'x64', 'platform': 'linux'}] + sources['pypy'] += [{'pypy_version': '8.1.0rc1', 'python_version': '3.13.1', 'stable': False, 'files': linux}, + {'pypy_version': 'nightly', 'python_version': '3.14', 'stable': False, 'files': linux}] + result = updater().compute_pypy(frozen()['versions']['pypy'], context(sources)) + self.assertEqual(result.entries, {'pypy3.10': {'tier': 'ktlo'}, 'pypy3.11': {'tier': 'ktlo'}, + 'pypy3.12': {'tier': 'supported'}, 'pypy3.13': {'tier': 'pre-release'}}) + self.assertEqual(result.reasons['pypy3.11'], 'not in PyPy 8.0.0; its newest stable build is PyPy 7.3.23') + _, plan, _ = plan_for(self, sources) + self.assertEqual(changes(plan), {'pypy3.11': ({'tier': 'supported'}, {'tier': 'ktlo'}), + 'pypy3.12': (None, {'tier': 'supported'})}) + self.assertIn(['PyPI distribution for Python pypy3.11', 'PyPI distribution for Python pypy3.11 (KTLO)'], + plan['renamed']) + self.assertEqual(len(notes(plan, 'held')), 1) + + def test_an_end_of_life_version_the_file_does_not_list_is_not_added(self) -> None: + """3.6 and older are end-of-life; 3.6's builds are for retired images, 3.5's too.""" + sources = add_build(as_of_the_file(snapshot()), '3.6.15', True, (('linux', '24.04', 'x64'),)) + result = updater().compute_cpython(frozen()['versions']['cpython'], context(sources)) + self.assertNotIn('3.6', result.entries) + self.assertNotIn('3.5', result.entries) + + def test_each_implementation_is_one_entry(self) -> None: + """Another implementation is one more source and entry here, and its spelling in python_versions.py.""" + module = updater() + self.assertEqual(set(module.IMPLEMENTATIONS), set(module.pv.SPELLING)) + for name, implementation in module.IMPLEMENTATIONS.items(): + with self.subTest(implementation=name): + self.assertLessEqual(set(implementation.sources), set(module.SOURCES)) + self.assertEqual({name: source.filename for name, source in module.SOURCES.items()}, + {name: source.filename for name, source in module.SOURCES.items() + if (FIXTURES / source.filename).is_file()}) + self.assertEqual({source.url for source in module.SOURCES.values()}, { + 'https://peps.python.org/api/release-cycle.json', + 'https://downloads.python.org/pypy/versions.json', + 'https://raw.githubusercontent.com/actions/python-versions/main/versions-manifest.json', + 'https://raw.githubusercontent.com/actions/runner-images/main/README.md', + }) + + +############################################################################### +# The rewrite +############################################################################### + + +class TestTheRewrite(unittest.TestCase): + """The tier file keeps its comments and layout; the prose follows it; nothing else moves.""" + + def test_an_unchanged_tier_file_renders_byte_for_byte(self) -> None: + """The checkout's tier file, and :data:`FROZEN`.""" + module = updater() + for path in (ROOT / module.TIER_FILE, FROZEN): + text = path.read_bytes().decode('utf-8') + data = module.validate(text) + with self.subTest(path=path.name): + self.assertEqual(module.render_tier_file(text, data['latest'], data['versions']), text) + + def test_a_no_change_run_writes_nothing(self) -> None: + """The whole command, ``--write`` and ``--out`` included, over a scratch tree.""" + root = scratch(self) + before = {path: (root / path).read_bytes() for path in updater().TARGETS} + sources = save_sources(self, as_of_the_file(snapshot())) + out, output = root / 'proposal', root / 'github-output' + status, printed = run_main(root, ['update', '--sources', str(sources), '--write', '--out', str(out), + '--github-output'], output) + self.assertEqual(status, 0, printed) + self.assertEqual({path: (root / path).read_bytes() for path in updater().TARGETS}, before) + self.assertEqual(json.loads((out / 'plan.json').read_text(encoding='utf-8'))['changed_files'], []) + self.assertFalse((out / 'files').exists()) + self.assertIn('changed=false\npublish=false\n', output.read_text(encoding='utf-8')) + + def test_a_second_run_after_a_change_writes_nothing(self) -> None: + sources = three_eleven_ends_and_three_sixteen_ships(snapshot()) + root, plan, _ = plan_for(self, sources, write=True) + self.assertTrue(plan['changed_files']) + again, texts = updater().compute_plan(root, sources) + self.assertEqual((again['changes'], texts), ([], {})) + + def test_comments_exemptions_and_layout_survive_a_change(self) -> None: + _, _, texts = plan_for(self, three_eleven_ends_and_three_sixteen_ships(snapshot())) + old = FROZEN.read_text(encoding='utf-8').splitlines() + new = texts['.github/python-versions.toml'].splitlines() + entry = updater().ENTRY + self.assertEqual([line for line in new if not entry.match(line) and not line.startswith('latest = ')], + [line for line in old if not entry.match(line) and not line.startswith('latest = ')]) + self.assertEqual([line for line in new if entry.match(line)], [ + '"3.7" = { tier = "ktlo", runner = "ubuntu-22.04" }', + '"3.8" = { tier = "ktlo", runner = "ubuntu-24.04" }', + '"3.9" = { tier = "ktlo", runner = "ubuntu-24.04" }', + '"3.10" = { tier = "ktlo" }', + '"3.11" = { tier = "ktlo" }', + '"3.12" = { tier = "supported" }', + '"3.13" = { tier = "supported" }', + '"3.14" = { tier = "supported" }', + '"3.15" = { tier = "supported" }', + '"3.16" = { tier = "supported" }', + '"pypy3.10" = { tier = "ktlo" }', + '"pypy3.11" = { tier = "supported" }', + '"pypy3.12" = { tier = "supported" }', + ]) + self.assertEqual(updater().validate(texts['.github/python-versions.toml'])['exemptions'], + frozen()['exemptions']) + + def test_an_inline_comment_on_a_changed_entry_is_kept(self) -> None: + module = updater() + text = FROZEN.read_text(encoding='utf-8').replace( + '"3.11" = { tier = "supported" }', '"3.11" = { tier = "supported" } # until 2027-10') + data = frozen() + versions = copy.deepcopy(data['versions']) + versions['cpython']['3.11'] = {'tier': 'ktlo'} + self.assertIn('"3.11" = { tier = "ktlo" } # until 2027-10\n', + module.render_tier_file(text, data['latest'], versions)) + + def test_the_regenerated_tree_passes_the_audit(self) -> None: + """The prose the audit pins, against the scratch tree, before and after the prose is rewritten. + + With only the tier file moved, the audit fails, so it reads the scratch + tree; with everything the script writes, it passes. + """ + sources = three_eleven_ends_and_three_sixteen_ships(snapshot()) + root, plan, texts = plan_for(self, sources) + tier_file = root / '.github' / 'python-versions.toml' + tier_file.write_text(texts['.github/python-versions.toml'], encoding='utf-8') + stale = run_prose_audit(root) + self.assertFalse(stale.wasSuccessful(), 'the audit does not read the scratch tree') + for path, text in texts.items(): + (root / path).write_text(text, encoding='utf-8') + result = run_prose_audit(root) + self.assertEqual(result.testsRun, stale.testsRun) + self.assertTrue(result.wasSuccessful(), + '\n'.join(text for _, text in result.failures + result.errors)) + self.assertEqual([note for note in notes(plan, 'manual') if 'ktlo_311' not in note], []) + + def test_every_claim_and_rewriter_matches_once_on_main(self) -> None: + """So a reworded sentence fails here, not as a silent skip in a weekly run.""" + module = updater() + workflows = module.load_workflows(ROOT) + data = module.pv.load() + values = module.pv.outputs(data) + facts = module.make_facts(data, values, workflows, (ROOT / module.TIER_FILE).read_text(encoding='utf-8')) + for path in module.TARGETS: + if path == module.TIER_FILE: + continue + text = (ROOT / path).read_text(encoding='utf-8') + with self.subTest(path=path): + rewritten, problems = module.apply_claims(text, module.CLAIMS.get(path, ()), facts) + for rewrite in module.REWRITERS.get(path, ()): + rewritten, more = rewrite(rewritten, facts) + problems += more + self.assertEqual(problems, []) + self.assertEqual(rewritten, text) + + def test_a_count_keeps_its_case_and_a_claim_never_spans_a_break(self) -> None: + module = updater() + claim = module.Claim(r'(\w+) legs, (3\.\d+)', lambda facts: (module.Word(4), '3.16')) + self.assertEqual(module.apply_claims('Five legs, 3.15.', [claim], {}), ('Four legs, 3.16.', [])) + self.assertEqual(module.apply_claims('# five legs,\n# 3.15', [claim], {}), ('# four legs,\n# 3.16', [])) + broken = module.Claim(r'(legs, 3\.\d+)', lambda facts: ('x',)) + self.assertEqual(module.apply_claims('legs,\n3.15', [broken], {})[1], [ + '`(legs, 3\\.\\d+)` group 1 spans a line break']) + self.assertEqual(module.apply_claims('none', [claim], {})[1], ['`(\\w+) legs, (3\\.\\d+)` matches 0 times']) + + def test_no_file_under_workflows_is_written_or_restates_a_tier(self) -> None: + """An App token needs ``workflows: write`` to push a workflow change, and ``pr``'s has none. + + So the script writes no workflow, :func:`publish` refuses one, and the + workflow comments that restated the tiers before Part B now name them; + a restatement coming back would go stale on the first tier move. + """ + module = updater() + self.assertFalse([path for path in module.TARGETS if path.startswith('.github/workflows/')]) + with self.assertRaisesRegex(ValueError, 'not a file this script writes'): + module.check_targets(['.github/workflows/unit-tests.yml']) + with self.assertRaisesRegex(ValueError, 'not a file this script writes'): + module.check_targets(['setup.py']) + restated = re.compile('|'.join(( + r'sweep\s+3\.\d+-3\.\d+', r'KTLO legs 3\.\d+-3\.\d+', r"`test` job's Python 3\.\d+ leg", + r'\(unit tier, Python 3\.\d+,', r'keeps only on the 3\.\d+ leg', r'against the \d+ expected', + r'\d+-leg ``conda`` matrix', r'only \d+ of \d+ legs', r'\w+ for `Compat Python 3\.\d+`-`3\.\d+`', + r'requires (?:one|two|three|four|five|six|seven|eight|nine|ten) contexts', + ))) + for path in sorted((ROOT / '.github' / 'workflows').glob('*.yml')): + view, _ = module.prose_view(path.read_text(encoding='utf-8')) + with self.subTest(workflow=path.name): + self.assertEqual([match.group(0) for match in restated.finditer(view)], []) + + +def run_prose_audit(root: 'pathlib.Path') -> 'unittest.TestResult': + """The prose classes of the audit, reading ``root`` instead of the checkout. + + Imported here, as :mod:`tests.project.test_python_matrix_tiers_1633` reads the + tier file on import, which skips the whole module where :mod:`tomllib` is missing. + + """ + # pylint: disable=import-outside-toplevel + from tests.project import test_python_matrix_tiers_1633 as tiers + from tests.project import test_python_versions_1660 as versions + + script = versions.script() + original = script.load + tier_file = root / '.github' / 'python-versions.toml' + suite = unittest.TestSuite() + suite.addTests(unittest.TestLoader().loadTestsFromTestCase(versions.TestTheProseFollowsTheFile)) + suite.addTest(tiers.TestTheProseFollowsTheTiers('test_the_workflows_page_counts_each_matrix_jobs_legs')) + with contextlib.ExitStack() as stack: + stack.enter_context(mock.patch.object(versions, 'ROOT', root)) + stack.enter_context(mock.patch.object(versions, 'WORKFLOWS', root / '.github' / 'workflows')) + stack.enter_context(mock.patch.object(versions, 'TIER_FILE', tier_file)) + stack.enter_context(mock.patch.object(script, 'load', lambda path=tier_file: original(path))) + stack.enter_context(mock.patch.object(tiers, 'ROOT', root)) + return unittest.TextTestRunner(stream=io.StringIO(), verbosity=0).run(suite) + + +############################################################################### +# The pull request body +############################################################################### + + +#: Two lines of ``python -m unittest -v`` output for a failed and an errored test. +AUDIT_LOG = '''\ +test_x (tests.project.test_python_matrix_tiers_1633.TestPython37IsKTLO.test_x) ... FAIL +====================================================================== +FAIL: test_x (tests.project.test_python_matrix_tiers_1633.TestPython37IsKTLO.test_x) +---------------------------------------------------------------------- +ERROR: test_y (tests.project.test_ktlo_legs_stay_green_1672.TestAKTLOFailureIsAWarning.test_y) +''' + + +class TestThePullRequestBody(unittest.TestCase): + """The repository's template, ``ci`` ticked, the moves, the ruleset edits, the renames, the audit.""" + + def test_the_body_lists_what_the_owner_does_by_hand(self) -> None: + module = updater() + _, plan, _ = plan_for(self, set_status(as_of_the_file(snapshot()), '3.11', 'end-of-life')) + failures = module.audit_failures(AUDIT_LOG) + self.assertEqual(failures, ['tests.project.test_python_matrix_tiers_1633.TestPython37IsKTLO.test_x', + 'tests.project.test_ktlo_legs_stay_green_1672.TestAKTLOFailureIsAWarning.test_y']) + description = module.describe_plan(plan, ('failure', failures), 'https://example.invalid/run') + body = module.fill_template((ROOT / module.TEMPLATE).read_text(encoding='utf-8'), description, 'failure') + self.assertIn('- [x] `ci` — workflows or build tooling', body) + self.assertEqual(body.count('- [x]'), 1) + self.assertIn('regenerated `CHANGELOG.md`, if the change is user-visible — N/A — centralised in #657', body) + self.assertIn('### Description of your *pull request* and other information', body) + self.assertNotIn('  Explanation of your', body) + self.assertIn(f'| `3.11` | supported | KTLO | {plan["changes"][0]["reason"]} |', body) + self.assertIn('- remove `Compat Python 3.11`', body) + self.assertIn('`Compat Python 3.11` -> `Compat Python 3.11 (KTLO)`', body) + self.assertIn('`ktlo_311`', body) + self.assertIn('**failure**', body) + self.assertIn(f'- `{failures[0]}`', body) + self.assertIn('#1660', body) + self.assertLess(len(description.splitlines()), 40) + + def test_an_unchanged_ruleset_says_so(self) -> None: + module = updater() + _, plan, _ = plan_for(self, snapshot()) + description = module.describe_plan(plan, ('success', []), 'https://example.invalid/run') + self.assertIn('Ruleset 23497679 needs no edit', description) + self.assertIn('passed on this tree', description) + self.assertIn('| `pypy3.12` | -- | supported |', description) + + def test_the_commit_names_its_proposal(self) -> None: + module = updater() + _, plan, _ = plan_for(self, snapshot()) + message = module.commit_message(plan) + self.assertTrue(message.startswith('ci(python): pypy3.12 joins the supported tier (#1660)\n\n')) + self.assertEqual(module.proposal_of(message), plan['fingerprint']) + self.assertTrue(message.rstrip('\n').endswith(f'{module.TRAILER}: {plan["fingerprint"]}')) + + +############################################################################### +# Publishing +############################################################################### + + +#: ``gh``, stubbed: each call's arguments appended to ``$GH_STUB_LOG`` as JSON. +#: ``$GH_STUB_OPEN`` holds the branch's open pull request, if any, which ``pr create`` opens as #99. +GH_STUB = '''\ +import json, os, pathlib, sys +with open(os.environ['GH_STUB_LOG'], 'a', encoding='utf-8') as log: + log.write(json.dumps(sys.argv[1:]) + '\\n') +state = pathlib.Path(os.environ['GH_STUB_OPEN']) +if sys.argv[1:3] == ['pr', 'list']: + print(state.read_text(encoding='utf-8') if state.exists() else '') +elif sys.argv[1:3] == ['pr', 'create']: + state.write_text('99\\n', encoding='utf-8') + print('https://github.com/JarryShaw/PyPCAPKit/pull/99') +elif sys.argv[1:3] != ['pr', 'edit']: + sys.exit(3) +''' +#: An ``origin`` hook closing the pull request whose head branch is deleted, as GitHub does. +CLOSE_ON_DELETE = '''\ +#!/bin/sh +while read old new ref; do + if [ "$ref" = refs/heads/bot/python-versions ] && [ "$new" = 0000000000000000000000000000000000000000 ]; then + : > "$GH_STUB_OPEN" + fi +done +''' +#: An ``origin`` hook refusing what GitHub refuses an App token without ``workflows``. +REFUSE_WORKFLOWS = '''\ +#!/bin/sh +while read old new ref; do + if [ "$ref" = refs/heads/bot/python-versions ] && [ "$old" != 0000000000000000000000000000000000000000 ] \\ + && [ "$new" != 0000000000000000000000000000000000000000 ] \\ + && ! git diff --quiet "$old" "$new" -- .github/workflows; then + printf '%s %s\\n' 'refusing to allow a GitHub App to create or update workflow' \\ + '`.github/workflows/ci.yml` without `workflows` permission' >&2 + exit 1 + fi +done +''' + + +class ScratchOrigin(unittest.TestCase): + """A scratch ``origin`` whose ``main`` holds :meth:`seed_files`, ``gh`` stubbed, git isolated.""" + + def seed_files(self) -> 'dict[str, bytes]': + raise NotImplementedError + + def setUp(self) -> None: + if shutil.which('git') is None: # pragma: no cover + self.skipTest('git is needed') + self.module = updater() + tmp = tempfile.TemporaryDirectory(prefix='pcapkit-1660b-git-') # pylint: disable=consider-using-with + self.addCleanup(tmp.cleanup) + self.tmp = pathlib.Path(tmp.name) + config = self.tmp / 'gitconfig' + # #1644: git 2.55's auto-repack races the temporary directory's cleanup. + config.write_text('[user]\n\tname = Test\n\temail = test@example.invalid\n[init]\n\tdefaultBranch = main\n' + '[maintenance]\n\tauto = false\n[gc]\n\tauto = 0\n\tautoDetach = false\n' + '[commit]\n\tgpgsign = false\n', encoding='utf-8') + bin_dir = self.tmp / 'bin' + bin_dir.mkdir() + gh = bin_dir / 'gh' + gh.write_text(f'#!{sys.executable}\n' + GH_STUB, encoding='utf-8') + gh.chmod(0o755) + self.log = self.tmp / 'gh.log' + self.env = {'GIT_CONFIG_GLOBAL': str(config), 'GIT_CONFIG_NOSYSTEM': '1', 'HOME': str(self.tmp), + 'PATH': f'{bin_dir}{os.pathsep}{os.environ["PATH"]}', 'GH_STUB_LOG': str(self.log), + 'GH_STUB_OPEN': str(self.tmp / 'gh-open'), 'GITHUB_REPOSITORY': 'JarryShaw/PyPCAPKit', + 'APP_SLUG': 'pypcapkit-release', 'APP_BOT_ID': '123456'} + patcher = mock.patch.dict(os.environ, self.env) + patcher.start() + self.addCleanup(patcher.stop) + self.origin = self.tmp / 'origin.git' + self.git(self.tmp, 'init', '--quiet', '--bare', str(self.origin)) + # GitHub serves a commits-only fetch, as ahead() asks. + self.git(self.origin, 'config', 'uploadpack.allowFilter', 'true') + hook = self.origin / 'hooks' / 'post-receive' + hook.write_text(CLOSE_ON_DELETE, encoding='utf-8') + hook.chmod(0o755) + self.seed = self.tmp / 'seed' + self.git(self.tmp, 'clone', '--quiet', str(self.origin), str(self.seed)) + for path, data in self.seed_files().items(): + (self.seed / path).parent.mkdir(parents=True, exist_ok=True) + (self.seed / path).write_bytes(data) + self.git(self.seed, 'add', '-A') + self.git(self.seed, 'commit', '--quiet', '-m', 'seed') + self.git(self.seed, 'push', '--quiet', 'origin', 'HEAD:refs/heads/main') + + def git(self, cwd: 'pathlib.Path', *argv: 'str') -> 'str': + result = subprocess.run(['git', *argv], cwd=cwd, capture_output=True, text=True, check=False, timeout=60) + self.assertEqual(result.returncode, 0, f'git {argv}: {result.stderr}') + return result.stdout.strip() + + def refs(self) -> 'dict[str, str]': + listed = self.git(self.origin, 'for-each-ref', '--format=%(refname) %(objectname)') + return dict(line.split(' ', 1) for line in listed.splitlines()) + + def calls(self) -> 'list[list[str]]': + if not self.log.exists(): + return [] + return [json.loads(line) for line in self.log.read_text(encoding='utf-8').splitlines()] + + def checkout(self, shallow: 'bool' = False) -> 'pathlib.Path': + """A fresh clone of ``main``: full, as the ``pr`` job checks it out, or ``detect``'s depth 1.""" + work = self.tmp / f'work-{len(list(self.tmp.glob("work-*")))}' + if shallow: + self.git(self.tmp, 'clone', '--quiet', '--depth', '1', self.origin.as_uri(), str(work)) + else: + self.git(self.tmp, 'clone', '--quiet', str(self.origin), str(work)) + return work + + def hand_edit(self, path: 'str' = 'README.md') -> 'str': + """The owner's commit on top of ``bot/python-versions``, pushed; its sha.""" + hand = self.checkout() + self.git(hand, 'checkout', '--quiet', self.module.BRANCH) + (hand / path).write_text('fixed by hand\n', encoding='utf-8') + self.git(hand, 'commit', '--quiet', '-am', 'fix the prose by hand') + self.git(hand, 'push', '--quiet', 'origin', f'HEAD:refs/heads/{self.module.BRANCH}') + return self.git(hand, 'rev-parse', 'HEAD') + + def move_main(self) -> 'str': + """An unrelated commit on ``main``, as a week brings; its sha.""" + self.git(self.seed, 'pull', '--quiet', '--ff-only', 'origin', 'main') + (self.seed / 'NEWS').write_text(self.git(self.seed, 'rev-list', '--count', 'HEAD') + '\n', encoding='utf-8') + self.git(self.seed, 'add', 'NEWS') + self.git(self.seed, 'commit', '--quiet', '-m', 'meanwhile on main') + self.git(self.seed, 'push', '--quiet', 'origin', 'HEAD:refs/heads/main') + return self.git(self.seed, 'rev-parse', 'HEAD') + + def merge_main(self) -> 'str': + """``main`` merged into ``bot/python-versions``, as GitHub's "Update branch" does, pushed; its sha.""" + hand = self.checkout() + self.git(hand, 'checkout', '--quiet', self.module.BRANCH) + self.git(hand, 'merge', '--quiet', '--no-ff', '-m', f"Merge branch 'main' into {self.module.BRANCH}", + 'origin/main') + self.git(hand, 'push', '--quiet', 'origin', f'HEAD:refs/heads/{self.module.BRANCH}') + return self.git(hand, 'rev-parse', 'HEAD') + + def squash_merge(self) -> 'str': + """``bot/python-versions`` squash-merged into ``main``, its pull request closed, the branch kept; ``main``.""" + self.git(self.seed, 'pull', '--quiet', '--ff-only', 'origin', 'main') + self.git(self.seed, 'fetch', '--quiet', 'origin', self.module.BRANCH) + self.git(self.seed, 'merge', '--quiet', '--squash', 'FETCH_HEAD') + self.git(self.seed, 'commit', '--quiet', '-m', 'the proposal, squash-merged') + self.git(self.seed, 'push', '--quiet', 'origin', 'HEAD:refs/heads/main') + self.pull_request('') + return self.git(self.seed, 'rev-parse', 'HEAD') + + def pull_request(self, number: 'str') -> 'None': + """The branch's open pull request is ``number``; ``''``: none, merged or closed.""" + pathlib.Path(self.env['GH_STUB_OPEN']).write_text(number + '\n', encoding='utf-8') + + def contains(self, tip: 'str', commit: 'str') -> 'bool': + return commit in self.git(self.origin, 'rev-list', tip).split() + + def proposal(self, tier: 'str', changed: 'Optional[list[str]]' = None) -> 'pathlib.Path': + """A ``detect`` artifact proposing ``latest = tier``.""" + out = self.tmp / f'proposal-{len(list(self.tmp.glob("proposal-*")))}' + files = {self.module.TIER_FILE: f'latest = "{tier}"\n'} + plan = {'title': f'ci(python): latest moves to {tier} (#1660)', 'fingerprint': f'f{tier.replace(".", "")}', + 'changed_files': changed if changed is not None else list(files), 'changes': [], + 'latest': ['3.15', tier]} + for path, text in files.items(): + (out / 'files' / path).parent.mkdir(parents=True, exist_ok=True) + (out / 'files' / path).write_text(text, encoding='utf-8') + (out / 'plan.json').write_text(json.dumps(plan), encoding='utf-8') + (out / 'commit-message.txt').write_text(self.module.commit_message(plan), encoding='utf-8') + (out / 'pr-body.md').write_text('body\n', encoding='utf-8') + return out + + def publish(self, work: 'pathlib.Path', out: 'pathlib.Path') -> 'tuple[int, str]': + """``publish`` as the ``pr`` job runs it, in the checkout ``work``.""" + stdout = io.StringIO() + with contextlib.redirect_stdout(stdout): + status = self.module.main(['publish', str(out)], root=work) + return status, stdout.getvalue() + + +class TestPublishing(ScratchOrigin): + """``publish`` against a scratch ``origin``: only ``bot/python-versions`` moves.""" + + def seed_files(self) -> 'dict[str, bytes]': + return {self.module.TIER_FILE: b'latest = "3.15"\n', 'README.md': b'readme\n', + '.github/workflows/ci.yml': b'name: CI\n'} + + def test_it_opens_a_pull_request_from_its_branch_and_updates_it_after(self) -> None: + main = self.refs()['refs/heads/main'] + status, printed = self.publish(self.checkout(), self.proposal('3.16')) + self.assertEqual(status, 0, printed) + refs = self.refs() + self.assertEqual(set(refs), {'refs/heads/main', 'refs/heads/bot/python-versions'}) + self.assertEqual(refs['refs/heads/main'], main) + tip = refs['refs/heads/bot/python-versions'] + self.assertEqual(self.git(self.origin, 'rev-parse', f'{tip}^'), main) + self.assertEqual(self.git(self.origin, 'log', '-1', '--format=%an <%ae>', tip), + 'pypcapkit-release[bot] <123456+pypcapkit-release[bot]@users.noreply.github.com>') + self.assertEqual(self.git(self.origin, 'show', f'{tip}:.github/python-versions.toml'), 'latest = "3.16"') + self.assertEqual(self.module.proposal_of(self.git(self.origin, 'log', '-1', '--format=%B', tip)), 'f316') + self.assertEqual(self.calls()[-1][:2], ['pr', 'create']) + self.assertIn(['--base', 'main'], [self.calls()[-1][i:i + 2] for i in range(len(self.calls()[-1]))]) + self.assertIn(['--head', 'bot/python-versions'], + [self.calls()[-1][i:i + 2] for i in range(len(self.calls()[-1]))]) + + self.pull_request('7') + status, printed = self.publish(self.checkout(), self.proposal('3.17')) + self.assertEqual(status, 0, printed) + refs = self.refs() + self.assertEqual(refs['refs/heads/main'], main) + self.assertEqual(self.git(self.origin, 'rev-parse', 'refs/heads/bot/python-versions^'), main) + self.assertEqual(self.calls()[-1][:3], ['pr', 'edit', '7']) + self.assertEqual(self.module.proposed(self.checkout(shallow=True), self.module.BRANCH), ['f317']) + + def test_it_keeps_commits_it_did_not_make(self) -> None: + """While the pull request it opened is open.""" + self.assertEqual(self.publish(self.checkout(), self.proposal('3.16'))[0], 0) + self.hand_edit() + before, calls = self.refs(), len(self.calls()) + status, printed = self.publish(self.checkout(), self.proposal('3.17')) + self.assertEqual(status, 1) + self.assertIn('::error title=Not overwriting bot/python-versions::', printed) + self.assertEqual(self.refs(), before) + # It read whether #99 is open, and touched no pull request. + self.assertEqual([call[:2] for call in self.calls()[calls:]], [['pr', 'list']]) + + def test_a_branch_with_no_open_pull_request_is_recreated_from_main(self) -> None: + """Merged or closed, the branch stays (``delete_branch_on_merge`` is off); the next proposal replaces it.""" + self.assertEqual(self.publish(self.checkout(), self.proposal('3.16'))[0], 0) + hand = self.hand_edit() + self.pull_request('') + main = self.move_main() + status, printed = self.publish(self.checkout(), self.proposal('3.17')) + self.assertEqual(status, 0, printed) + self.assertIn('::notice title=Recreating bot/python-versions::it has no open pull request', printed) + self.assertNotIn('::error', printed) + tip = self.refs()['refs/heads/bot/python-versions'] + self.assertEqual(self.git(self.origin, 'rev-parse', f'{tip}^'), main) + self.assertEqual(self.module.proposals(self.origin, f'{main}..{tip}'), ['f317']) + self.assertFalse(self.contains(tip, hand)) + self.assertEqual(self.calls()[-1][:2], ['pr', 'create']) + + def test_a_branch_behind_mains_workflows_is_recreated(self) -> None: + """Moving it would change a workflow file, which the token cannot; a new branch does not.""" + hook = self.origin / 'hooks' / 'pre-receive' + hook.write_text(REFUSE_WORKFLOWS, encoding='utf-8') + hook.chmod(0o755) + self.assertEqual(self.publish(self.checkout(), self.proposal('3.16'))[0], 0) + (self.seed / '.github' / 'workflows' / 'ci.yml').write_text('name: CI 2\n', encoding='utf-8') + self.git(self.seed, 'commit', '--quiet', '-am', 'a workflow change on main') + self.git(self.seed, 'push', '--quiet', 'origin', 'HEAD:refs/heads/main') + main = self.refs()['refs/heads/main'] + status, printed = self.publish(self.checkout(), self.proposal('3.17')) + self.assertEqual(status, 0, printed) + self.assertIn('::warning title=Recreating bot/python-versions::', printed) + self.assertEqual(self.refs()['refs/heads/main'], main) + self.assertEqual(self.git(self.origin, 'rev-parse', 'refs/heads/bot/python-versions^'), main) + # Deleting the branch closed #99, so a new pull request opens. + self.assertEqual(self.calls()[-1][:2], ['pr', 'create']) + + def test_it_refuses_a_file_it_does_not_write(self) -> None: + before = self.refs() + status, printed = self.publish(self.checkout(), self.proposal('3.16', changed=['.github/workflows/ci.yml'])) + self.assertEqual(status, 1) + self.assertIn('::error title=Cannot publish the Python version tiers::ValueError', printed) + self.assertEqual((self.refs(), self.calls()), (before, [])) + + def test_detect_reads_the_branchs_proposal_without_credentials(self) -> None: + work = self.checkout(shallow=True) + self.assertIsNone(self.module.proposed(work, self.module.BRANCH)) + self.assertEqual(self.publish(self.checkout(), self.proposal('3.16'))[0], 0) + self.assertEqual(self.module.proposed(work, self.module.BRANCH), ['f316']) + + def test_detect_reads_every_commit_ahead_of_main_and_none_of_main(self) -> None: + """A hand edit on top does not hide the proposal under it, nor do ``main``'s commits since count.""" + self.assertEqual(self.publish(self.checkout(), self.proposal('3.16'))[0], 0) + self.hand_edit() + self.move_main() + for shallow in (True, False): + with self.subTest(shallow=shallow): + self.assertEqual(self.module.proposed(self.checkout(shallow), self.module.BRANCH), [None, 'f316']) + + def test_a_merge_of_main_hides_neither_the_proposal_nor_a_hand_edit(self) -> None: + """GitHub's "Update branch": the merge is left out, and nothing under it is lost.""" + self.assertEqual(self.publish(self.checkout(), self.proposal('3.16'))[0], 0) + self.move_main() + self.merge_main() + for shallow in (True, False): + with self.subTest(shallow=shallow): + self.assertEqual(self.module.proposed(self.checkout(shallow), self.module.BRANCH), ['f316']) + self.hand_edit() + self.move_main() + self.merge_main() + for shallow in (True, False): + with self.subTest(shallow=shallow, hand_edit=True): + self.assertEqual(self.module.proposed(self.checkout(shallow), self.module.BRANCH), [None, 'f316']) + + def test_a_merge_of_another_branch_is_a_hand_edit(self) -> None: + """Only a merge bringing in ``main``'s commits is left out.""" + self.assertEqual(self.publish(self.checkout(), self.proposal('3.16'))[0], 0) + hand = self.checkout() + self.git(hand, 'checkout', '--quiet', '-b', 'topic', 'origin/main') + (hand / 'TOPIC').write_text('topic\n', encoding='utf-8') + self.git(hand, 'add', 'TOPIC') + self.git(hand, 'commit', '--quiet', '-m', 'a topic of its own') + self.git(hand, 'checkout', '--quiet', self.module.BRANCH) + self.git(hand, 'merge', '--quiet', '--no-ff', '-m', 'Merge branch topic', 'topic') + self.git(hand, 'push', '--quiet', 'origin', f'HEAD:refs/heads/{self.module.BRANCH}') + # The merge and the topic's commit, in an order their equal dates leave open. + self.assertEqual(sorted(self.module.proposed(self.checkout(shallow=True), self.module.BRANCH), key=str), + [None, None, 'f316']) + + def test_a_branch_main_already_holds_proposes_nothing(self) -> None: + """Merged with a merge commit and not deleted.""" + self.assertEqual(self.publish(self.checkout(), self.proposal('3.16'))[0], 0) + self.git(self.seed, 'fetch', '--quiet', 'origin', self.module.BRANCH) + self.git(self.seed, 'push', '--quiet', 'origin', f'FETCH_HEAD:refs/heads/{self.module.BASE}') + self.assertEqual(self.module.proposed(self.checkout(shallow=True), self.module.BRANCH), []) + + +class TestTheProposalIsNotRepeated(unittest.TestCase): + """``publish`` is false while the branch's open pull request proposes these tiers, so no approval is asked.""" + + def test_publish_follows_the_branchs_fingerprint(self) -> None: + """Any commit ahead of ``main`` carrying it counts; a branch publish would refuse fails here instead. + + With no open pull request the branch was merged or closed, so whatever it + holds is proposed afresh, and ``publish`` recreates it. + + """ + root = scratch(self) + sources = save_sources(self, snapshot()) + fingerprint = updater().compute_plan(root, snapshot())[0]['fingerprint'] + other = '0123456789abcdef' + for held, opened, status, publish in ( + ([fingerprint], ['7'], 0, 'false'), ([None, fingerprint], ['7'], 0, 'false'), + ([other], ['7'], 0, 'true'), ([], ['7'], 0, 'true'), (None, [], 0, 'true'), + ([None, other], ['7'], 1, 'false'), ([None], ['7'], 1, 'false'), + ([fingerprint], [], 0, 'true'), ([None, other], [], 0, 'true'), ([None], [], 0, 'true')): + output = root / f'output-{len(list(root.glob("output-*")))}' + with self.subTest(held=held, opened=opened), \ + mock.patch.object(updater(), 'proposed', return_value=held), \ + mock.patch.object(updater(), 'open_pull_requests', return_value=opened) as lookup: + found, printed = run_main(root, ['update', '--sources', str(sources), '--proposal-branch', + 'bot/python-versions', '--github-output'], output) + self.assertEqual(found, status, printed) + self.assertEqual(output.read_text(encoding='utf-8'), + f'changed=true\npublish={publish}\nfingerprint={fingerprint}\n') + self.assertEqual('::error title=Not overwriting bot/python-versions::' in printed, bool(status)) + self.assertEqual('::notice title=Proposing afresh::' in printed, held is not None and not opened) + # No branch, no pull request to look up. + self.assertEqual(lookup.call_args_list, [] if held is None else [mock.call('bot/python-versions')]) + + +class TestDetectAgainstAHandEditedBranch(ScratchOrigin): + """The cross-review's case, end to end: ``update``, ``body`` and ``publish`` against a scratch ``origin``. + + The pull request body tells the owner to finish a failed audit by hand on the + branch. A ``detect`` reading only the tip then took his commit for no + proposal, asked for the approval every week, and ``publish`` refused it. + + """ + + def seed_files(self) -> 'dict[str, bytes]': + tree = scratch(self) + return {path.relative_to(tree).as_posix(): path.read_bytes() for path in sorted(tree.rglob('*')) + if path.is_file()} + + def detect(self, sources: 'dict[str, Any]') -> 'tuple[int, str, dict[str, str], pathlib.Path]': + """``detect``'s ``update`` step, in a depth-1 checkout: its status, log, outputs and proposal.""" + work = self.checkout(shallow=True) + out, output = work.with_name(work.name + '-proposal'), work.with_name(work.name + '-output') + status, printed = run_main(work, ['update', '--sources', str(save_sources(self, sources)), '--write', + '--out', str(out), '--proposal-branch', self.module.BRANCH, + '--github-output'], output) + written = dict(line.split('=', 1) for line in output.read_text(encoding='utf-8').splitlines()) + self.assertEqual(run_main(work, ['body', str(out)])[0], 0) + return status, printed, written, out + + def test_a_hand_edit_on_the_proposal_is_not_proposed_again(self) -> None: + status, printed, written, out = self.detect(snapshot()) + self.assertEqual((status, written['publish']), (0, 'true'), printed) + self.assertEqual(self.publish(self.checkout(), out)[0], 0) + self.hand_edit() + self.move_main() + before = self.refs() + status, printed, written, _ = self.detect(snapshot()) + self.assertEqual((status, written['changed'], written['publish']), (0, 'true', 'false'), printed) + self.assertIn('::notice title=Already proposed::bot/python-versions already proposes these tiers', printed) + self.assertEqual(self.refs(), before) + + def test_tiers_moving_again_under_a_hand_edit_fail_detect_not_publish(self) -> None: + status, printed, _, out = self.detect(snapshot()) + self.assertEqual(self.publish(self.checkout(), out)[0], 0) + self.hand_edit() + status, printed, written, out = self.detect(three_eleven_ends_and_three_sixteen_ships(snapshot())) + self.assertEqual((status, written['publish']), (1, 'false'), printed) + self.assertIn('::error title=Not overwriting bot/python-versions::it holds commits this workflow did not ' + 'make', printed) + # What publish says of the same branch, had the approval been asked for. + status, printed = self.publish(self.checkout(), out) + self.assertEqual(status, 1) + self.assertIn('::error title=Not overwriting bot/python-versions::', printed) + + def test_tiers_moving_again_under_its_own_commit_are_proposed(self) -> None: + status, printed, _, out = self.detect(snapshot()) + self.assertEqual(self.publish(self.checkout(), out)[0], 0) + self.move_main() + status, printed, written, out = self.detect(three_eleven_ends_and_three_sixteen_ships(snapshot())) + self.assertEqual((status, written['publish']), (0, 'true'), printed) + self.pull_request('7') + self.assertEqual(self.publish(self.checkout(), out)[0], 0) + tip = self.refs()['refs/heads/bot/python-versions'] + self.assertEqual(self.module.proposals(self.origin, f'{self.refs()["refs/heads/main"]}..{tip}'), + [json.loads((out / 'plan.json').read_text(encoding='utf-8'))['fingerprint']]) + + def test_a_merge_of_main_is_neither_a_proposal_nor_a_hand_edit(self) -> None: + """GitHub's "Update branch" on the open proposal: a depth-1 ``detect`` still finds it under the merge.""" + status, printed, _, out = self.detect(snapshot()) + self.assertEqual(self.publish(self.checkout(), out)[0], 0) + self.move_main() + self.merge_main() + before = self.refs() + status, printed, written, _ = self.detect(snapshot()) + self.assertEqual((status, written['changed'], written['publish']), (0, 'true', 'false'), printed) + self.assertIn('::notice title=Already proposed::bot/python-versions already proposes these tiers', printed) + self.assertEqual(self.refs(), before) + + status, printed, written, out = self.detect(three_eleven_ends_and_three_sixteen_ships(snapshot())) + self.assertEqual((status, written['publish']), (0, 'true'), printed) + self.assertNotIn('::error', printed) + status, printed = self.publish(self.checkout(), out) + self.assertEqual(status, 0, printed) + main, tip = self.refs()['refs/heads/main'], self.refs()['refs/heads/bot/python-versions'] + self.assertEqual(self.git(self.origin, 'rev-parse', f'{tip}^'), main) + self.assertEqual(self.module.proposals(self.origin, f'{main}..{tip}'), + [json.loads((out / 'plan.json').read_text(encoding='utf-8'))['fingerprint']]) + self.assertEqual(self.calls()[-1][:3], ['pr', 'edit', '99']) + + def test_a_squash_merged_proposal_leaves_a_branch_the_next_change_recreates(self) -> None: + """``delete_branch_on_merge`` is off, so the merged branch stays, hand edit and all.""" + status, printed, _, out = self.detect(snapshot()) + self.assertEqual(self.publish(self.checkout(), out)[0], 0) + hand = self.hand_edit() + self.squash_merge() + status, printed, written, _ = self.detect(snapshot()) + self.assertEqual((status, written['changed'], written['publish']), (0, 'false', 'false'), printed) + + status, printed, written, out = self.detect(three_eleven_ends_and_three_sixteen_ships(snapshot())) + self.assertEqual((status, written['publish']), (0, 'true'), printed) + self.assertIn('::notice title=Proposing afresh::bot/python-versions has no open pull request', printed) + self.assertNotIn('::error', printed) + status, printed = self.publish(self.checkout(), out) + self.assertEqual(status, 0, printed) + self.assertIn('::notice title=Recreating bot/python-versions::', printed) + main, tip = self.refs()['refs/heads/main'], self.refs()['refs/heads/bot/python-versions'] + self.assertEqual(self.git(self.origin, 'rev-parse', f'{tip}^'), main) + self.assertFalse(self.contains(tip, hand)) + self.assertEqual(self.calls()[-1][:2], ['pr', 'create']) + + +############################################################################### +# The workflow +############################################################################### + + +class TestTheWorkflow(unittest.TestCase): + """``detect`` holds no secret; ``pr`` mints the narrow token behind ``github-release``.""" + + @classmethod + def setUpClass(cls) -> None: + cls.spec = workflow() + cls.jobs = cls.spec['jobs'] + + def step(self, job: 'str', label: 'str') -> 'dict[str, Any]': + return next(step for step in self.jobs[job]['steps'] if step.get('name') == label) + + def test_it_runs_weekly_and_on_dispatch_only(self) -> None: + """No ``push``: the branch it pushes starts the pull request's checks, never this.""" + self.assertEqual(self.spec['name'], 'Python Versions') + triggers = self.spec.get('on', self.spec.get(True)) + self.assertEqual(set(triggers), {'schedule', 'workflow_dispatch'}) + self.assertEqual(len(triggers['schedule']), 1) + for path in sorted(WORKFLOW.parent.glob('*.yml')): + spec = yaml.safe_load(path.read_text(encoding='utf-8')) + on = spec.get('on', spec.get(True)) or {} + with self.subTest(workflow=path.name): + listed = (on.get('workflow_run') or {}).get('workflows', []) if isinstance(on, dict) else [] + self.assertNotIn('Python Versions', listed) + if path != WORKFLOW: + self.assertNotIn('bot/python-versions', path.read_text(encoding='utf-8')) + + def test_version_check_treats_its_merge_like_any_other(self) -> None: + """Create Release starts on a ``v*`` tag or Vendor Update, and its gate names no branch of this job's.""" + spec = yaml.safe_load((WORKFLOW.parent / 'create-release.yml').read_text(encoding='utf-8')) + on = spec.get('on', spec.get(True)) + self.assertEqual(set(on), {'push', 'workflow_run'}) + self.assertEqual(on['push'], {'tags': ['v*']}) + self.assertEqual(on['workflow_run']['workflows'], ['Vendor Update']) + self.assertEqual(' '.join(spec['jobs']['version_check']['if'].split()), + "${{ (github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == " + "'success') && !(github.event_name == 'push' && github.actor_id == " + "vars.RELEASE_APP_BOT_ID) }}") + + def test_permissions_are_nothing_by_default_and_little_per_job(self) -> None: + self.assertEqual(self.spec['permissions'], {}) + # detect reads the branch's pull request; pr's token is the App's. + self.assertEqual(self.jobs['detect']['permissions'], {'contents': 'read', 'pull-requests': 'read'}) + self.assertEqual(self.jobs['pr']['permissions'], {'actions': 'read'}) + self.assertEqual(set(self.jobs), {'detect', 'pr'}) + + def test_detect_reads_no_secret_and_names_no_environment(self) -> None: + text = json.dumps(self.jobs['detect']) + self.assertNotIn('secrets', text) + self.assertNotIn('environment', self.jobs['detect']) + checkout = self.jobs['detect']['steps'][0] + self.assertTrue(checkout['uses'].startswith('actions/checkout@')) + self.assertIs(checkout['with']['persist-credentials'], False) + + def test_detect_recomputes_audits_and_hands_over(self) -> None: + module = updater() + update = self.step('detect', 'Recompute the tiers') + self.assertEqual(update['run'].split(), ['python', 'util/update_python_versions.py', 'update', '--write', + '--out', '"$RUNNER_TEMP/proposal"', '--proposal-branch', + module.BRANCH, '--github-output']) + # For gh, to read whether the branch's pull request is open. + self.assertEqual(update['env'], {'GH_TOKEN': '${{ github.token }}'}) + audit = self.step('detect', 'Run the audit tests') + self.assertIs(audit['continue-on-error'], True) + self.assertEqual(audit['shell'], 'bash') + command = audit['run'].split('2>&1')[0].split() + self.assertEqual(command[:4], ['python', '-m', 'unittest', '-v']) + self.assertEqual(tuple(command[4:]), module.AUDIT) + self.assertEqual(self.jobs['detect']['outputs'], {'changed': '${{ steps.update.outputs.changed }}', + 'publish': '${{ steps.update.outputs.publish }}'}) + upload = self.step('detect', 'Upload the proposal') + self.assertEqual(upload['if'], "${{ steps.update.outputs.publish == 'true' }}") + # The proposal's files/.github/python-versions.toml is a hidden path. + self.assertIs(upload['with']['include-hidden-files'], True) + download = self.step('pr', 'Download the proposal') + self.assertIn(f'--name {upload["with"]["name"]}', ' '.join(download['run'].split())) + self.assertEqual(download['env'], {'GH_TOKEN': '${{ github.token }}'}) + + def test_pr_mints_contents_and_pull_requests_write_behind_github_release(self) -> None: + job = self.jobs['pr'] + self.assertEqual(job['environment'], 'github-release') + self.assertEqual(job['needs'], 'detect') + mint = self.step('pr', "Mint the release App's token") + self.assertEqual(mint['uses'], 'actions/create-github-app-token@v3') + self.assertEqual(mint['with'], { + 'client-id': '${{ vars.RELEASE_APP_CLIENT_ID }}', + 'private-key': '${{ secrets.RELEASE_APP_PRIVATE_KEY }}', + 'permission-contents': 'write', + 'permission-pull-requests': 'write', + }) + self.assertEqual([step for step in job['steps'] if 'secrets.' in json.dumps(step)], [mint]) + + def test_pr_runs_only_with_something_to_propose_the_app_and_main(self) -> None: + condition = self.jobs['pr']['if'] + for publish, client, ref, expected in (('true', 'Iv23example', 'refs/heads/main', True), + ('true', '', 'refs/heads/main', False), + ('false', 'Iv23example', 'refs/heads/main', False), + ('true', 'Iv23example', 'refs/heads/feature', False)): + with self.subTest(publish=publish, client=client, ref=ref): + state = {'needs': {'detect': {'outputs': {'publish': publish}}}, + 'vars': {'RELEASE_APP_CLIENT_ID': client}, 'github': {'ref': ref}} + self.assertIs(evaluate_condition(condition, state), expected) + + def test_pr_pushes_through_publish_only(self) -> None: + """No ``git push`` of its own; :class:`TestPublishing` shows ``publish`` moves only its branch.""" + job = self.jobs['pr'] + checkout = next(step for step in job['steps'] if str(step.get('uses', '')).startswith('actions/checkout@')) + self.assertEqual(checkout['with'], {'ref': '${{ github.sha }}', 'fetch-depth': 0, + 'token': '${{ steps.app.outputs.token }}'}) + self.assertNotIn('git push', WORKFLOW.read_text(encoding='utf-8')) + publish = self.step('pr', 'Push the proposal and open or update its pull request') + self.assertEqual(publish['run'].split(), ['python3', 'util/update_python_versions.py', 'publish', + '"$RUNNER_TEMP/proposal"']) + self.assertEqual(publish['env']['GH_TOKEN'], '${{ steps.app.outputs.token }}') + self.assertEqual(updater().BRANCH, 'bot/python-versions') + self.assertEqual(updater().BASE, 'main') + + def test_its_actions_are_pinned_as_the_other_workflows_pin_them(self) -> None: + others = set() + for path in sorted(WORKFLOW.parent.glob('*.yml')): + if path != WORKFLOW: + others.update(re.findall(r'(?m)^\s*(?:- )?uses: ([^\s#]+)', path.read_text(encoding='utf-8'))) + uses = re.findall(r'(?m)^\s*(?:- )?uses: ([^\s#]+)', WORKFLOW.read_text(encoding='utf-8')) + self.assertTrue(uses) + self.assertLessEqual(set(uses), others) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/project/test_release_tags_the_run_sha_1634.py b/tests/project/test_release_tags_the_run_sha_1634.py index 0136bc0a73..bfe95968a6 100644 --- a/tests/project/test_release_tags_the_run_sha_1634.py +++ b/tests/project/test_release_tags_the_run_sha_1634.py @@ -36,6 +36,9 @@ build number that would free is already tagged; * ``github``, ``pypi`` and ``conda`` refuse to publish under a tag that names any other commit, which is likewise run, not just read; +* once the version is tagged, ``version_check``'s PyPI and Anaconda counts are + of the tiers at the tag's commit, not this run's (#1660), run against a + scratch repository, so a tier added since cannot fail the check above; * every step handed ``PCAPKIT_RUN_SHA`` is handed ``github.sha``, and no expression names ``workflow_run.head_sha`` -- the scratch-repository tests inject their own values, so these are what tie them to the job; @@ -53,11 +56,13 @@ from __future__ import annotations +import json import os import pathlib import re import shutil import subprocess +import sys import tempfile import unittest from typing import TYPE_CHECKING @@ -394,13 +399,21 @@ def test_without_the_variable_nothing_is_minted_and_github_token_is_spent(self) def test_the_private_key_is_read_only_in_github_behind_its_environment(self) -> None: """An environment secret: "If the environment requires approval, a job cannot - access environment secrets until one of the required reviewers approves it".""" + access environment secrets until one of the required reviewers approves it". + + #1660's Python Versions ``pr`` job is the one other reader, behind the same + environment, minting the token it opens its pull request with. + """ + readers_by_workflow = {WORKFLOW.name: ['github'], 'cron-python-versions.yml': ['pr']} for path in sorted(WORKFLOW.parent.glob('*.yml')): text = strip_comments(path.read_text(encoding='utf-8')) - readers = sorted(job for job, block in job_blocks(text, path).items() if SECRET.search(block)) + blocks = job_blocks(text, path) + readers = sorted(job for job, block in blocks.items() if SECRET.search(block)) with self.subTest(workflow=path.name): - self.assertEqual(readers, ['github'] if path == WORKFLOW else []) + self.assertEqual(readers, readers_by_workflow.get(path.name, [])) self.assertNotRegex(text, r'(?i)toJSON\(\s*secrets\s*\)', 'every secret, this one included') + for job in readers: + self.assertEqual(declared_environment(blocks[job]), 'github-release') uses = [label(step) for step in self.steps['github'] if SECRET.search(repr(step))] self.assertEqual(uses, [MINT_STEP]) self.assertEqual(declared_environment(job_blocks(WORKFLOW.read_text(encoding='utf-8'))['github']), @@ -993,6 +1006,160 @@ def test_a_tag_pushed_after_the_checkout_is_still_seen(self) -> None: self.assertEqual(result.returncode, 0, result.stdout) +RELEASED_STEP = 'Read the released Python versions' +#: The tier file as #1677 added it, before the first weekly proposal, and its reader. +FIRST_TIERS = ROOT / 'tests' / 'project' / 'python_versions_fixtures' / 'python-versions.toml' +READER = ROOT / 'util' / 'python_versions.py' +#: A ``curl`` answering 200 with ``$REPLY`` as the body it writes to ``-o``. +FAKE_CURL = '#!/bin/sh\nwhile [ $# -gt 0 ]; do [ "$1" = -o ] && cp "$REPLY" "$2"; shift; done\nprintf 200\n' + + +@unittest.skipIf(sys.version_info < (3, 11), 'util/python_versions.py reads the tier file with tomllib, from 3.11') +class TestTheCountsAreOfTheReleasedTiers(ScratchRepository): + """``version_check`` counts the wheels and conda builds a tagged version was released with (#1660). + + ``pre`` is tagged before the tier file existed, ``added`` adds it as #1677 + did, and ``grown`` merges the first weekly proposal, pypy3.12; the run is on + ``grown``. Counting ``grown``'s tiers for a version tagged earlier read the + finished release as incomplete, ran the gate and ``pypi``, and failed the + tag check above on every run. + + """ + + VERSION = '1.5.0b12' + + def setUp(self) -> None: + super().setUp() + # #1644: no automatic maintenance to race the scratch directory's removal. + self.env.update({'GIT_CONFIG_COUNT': '3', 'GIT_CONFIG_KEY_0': 'maintenance.auto', 'GIT_CONFIG_VALUE_0': 'false', + 'GIT_CONFIG_KEY_1': 'gc.auto', 'GIT_CONFIG_VALUE_1': '0', + 'GIT_CONFIG_KEY_2': 'gc.autoDetach', 'GIT_CONFIG_VALUE_2': 'false'}) + self.bin = self.tmp / 'bin' + self.bin.mkdir() + for name, text in (('python3', f'#!/bin/sh\nexec "{sys.executable}" "$@"\n'), ('curl', FAKE_CURL)): + (self.bin / name).write_text(text, encoding='utf-8') + (self.bin / name).chmod(0o755) + self.env['PATH'] = f'{self.bin}{os.pathsep}{self.env["PATH"]}' + self.output = self.tmp / 'github-output' + self.env['GITHUB_OUTPUT'] = str(self.output) + + self.pre = self.commit(self.VERSION, '0', 'before the tier file') + tiers = self.seed / '.github' / 'python-versions.toml' + tiers.parent.mkdir() + (self.seed / 'util').mkdir() + shutil.copyfile(FIRST_TIERS, tiers) + shutil.copyfile(READER, self.seed / 'util' / 'python_versions.py') + self.added = self.commit(self.VERSION, '0', 'the tier file') + self.values = {self.added: self.read(tiers)} + line = '"pypy3.11" = { tier = "supported" }\n' + grown = tiers.read_text(encoding='utf-8').replace(line, line + '"pypy3.12" = { tier = "supported" }\n') + tiers.write_text(grown, encoding='utf-8') + self.grown = self.commit(self.VERSION, '0', 'pypy3.12 joins the supported tier') + self.values[self.grown] = self.read(tiers) + self.push(self.grown, 'refs/heads/main') + + @staticmethod + def read(tiers: 'pathlib.Path') -> 'dict[str, str]': + """``pypi-wheels`` and ``conda-builds`` as "Read the Python versions" writes them for ``tiers``.""" + names = ('pypi-wheels', 'conda-builds') + printed = subprocess.run([sys.executable, str(READER), '--file', str(tiers), *names], capture_output=True, + text=True, check=True, timeout=scale_timeout(60)).stdout + return dict(zip(names, printed.splitlines())) + + def released(self, exists: 'bool') -> 'tuple[subprocess.CompletedProcess[str], dict[str, str]]': + """The step, handed what this run's "Read the Python versions" and ``check_tag`` output.""" + self.output.write_text('', encoding='utf-8') + run = self.values[self.grown] + result = self.run_steps('version_check', (RELEASED_STEP,), { + 'PCAPKIT_TAG': f'v{self.VERSION}', 'PCAPKIT_TAG_EXISTS': str(exists).lower(), + 'PCAPKIT_PYPI_WHEELS': run['pypi-wheels'], 'PCAPKIT_CONDA_BUILDS': run['conda-builds']}) + self.assertEqual(result.returncode, 0, result.stdout) + return result, dict(line.split('=', 1) for line in self.output.read_text(encoding='utf-8').splitlines()) + + def complete(self, step: 'str', wheels: 'str', builds: 'str', reply: 'dict[str, Any]') -> 'str': + """The completeness check ``step`` for a release ``reply`` describes, given what the step above wrote.""" + script = self.step('version_check', step)['run'] + for path in ('/tmp/pypi_release.json', '/tmp/anaconda_release.json'): + script = script.replace(path, str(self.tmp / 'release.json')) + (self.tmp / 'reply.json').write_text(json.dumps(reply), encoding='utf-8') + (self.tmp / 'check.sh').write_text(script, encoding='utf-8') + self.output.write_text('', encoding='utf-8') + env = dict(self.env, REPLY=str(self.tmp / 'reply.json'), PCAPKIT_VERSION=self.VERSION, + PCAPKIT_PYPI_WHEELS=wheels, PCAPKIT_CONDA_BUILDS=builds) + result = subprocess.run([BASH, '-e', str(self.tmp / 'check.sh')], cwd=self.tmp, env=env, capture_output=True, + text=True, check=False, timeout=scale_timeout(60)) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + written = dict(line.split('=', 1) for line in self.output.read_text(encoding='utf-8').splitlines()) + return next(iter(written.values())) + + def test_the_checks_count_what_this_step_read(self) -> None: + ids = [step.get('id') for step in self.steps['version_check']] + self.assertLess(ids.index('check_tag'), ids.index('released')) + self.assertLess(ids.index('released'), min(ids.index('check_pypi'), ids.index('check_conda'))) + self.assertEqual(self.step('version_check', RELEASED_STEP)['env'], { + 'PCAPKIT_TAG': 'v${{ steps.get_version.outputs.PCAPKIT_VERSION }}', + 'PCAPKIT_TAG_EXISTS': '${{ steps.check_tag.outputs.exists }}', + 'PCAPKIT_PYPI_WHEELS': '${{ steps.python-versions.outputs.pypi-wheels }}', + 'PCAPKIT_CONDA_BUILDS': '${{ steps.python-versions.outputs.conda-builds }}', + }) + for step, name, output in (('Check PyPI publish completeness', 'PCAPKIT_PYPI_WHEELS', 'pypi-wheels'), + ('Check Anaconda publish completeness', 'PCAPKIT_CONDA_BUILDS', 'conda-builds')): + with self.subTest(step=step): + self.assertEqual(self.step('version_check', step)['env'][name], + f'${{{{ steps.released.outputs.{output} }}}}') + # git log and every tag: the whole history. + self.assertEqual(self.step('version_check', 'Checkout code')['with']['fetch-depth'], '0') + + def test_before_the_tag_this_runs_tiers_count(self) -> None: + self.checkout(self.grown) + _, written = self.released(exists=False) + self.assertEqual(written, self.values[self.grown]) + self.assertIn('pp312', written['pypi-wheels'].split()) + + def test_a_tag_on_this_runs_commit_counts_this_runs_tiers(self) -> None: + self.push(self.grown, f'refs/tags/v{self.VERSION}') + self.checkout(self.grown) + self.assertEqual(self.released(exists=True)[1], self.values[self.grown]) + + def test_a_tag_on_an_earlier_commit_counts_its_tiers(self) -> None: + self.push(self.added, f'refs/tags/v{self.VERSION}') + self.checkout(self.grown) + _, written = self.released(exists=True) + self.assertEqual(written, self.values[self.added]) + self.assertNotIn('pp312', written['pypi-wheels'].split()) + + def test_a_tag_pushed_after_the_checkout_is_fetched(self) -> None: + self.checkout(self.grown) + self.push(self.added, f'refs/tags/v{self.VERSION}') + self.assertEqual(self.released(exists=True)[1], self.values[self.added]) + + def test_a_tag_older_than_the_tier_file_counts_the_tiers_it_was_added_with(self) -> None: + """As ``v1.5.0b12`` (``2025da5e5``) is: tagged before #1677 (``727c0fc60``) added the file.""" + self.push(self.pre, f'refs/tags/v{self.VERSION}') + self.checkout(self.grown) + result, written = self.released(exists=True) + self.assertEqual(written, self.values[self.added]) + self.assertIn(f'::notice title=Released before the tier file::v{self.VERSION} predates ' + f'.github/python-versions.toml; counting the tiers {self.added} added it with.', result.stdout) + + def test_a_finished_release_reads_complete_after_the_tiers_grow(self) -> None: + """The two checks, fed the step's answer and, as before it, this run's tiers.""" + self.push(self.added, f'refs/tags/v{self.VERSION}') + self.checkout(self.grown) + _, written = self.released(exists=True) + released, run = self.values[self.added], self.values[self.grown] + files = [f'pypcapkit-{self.VERSION}-{tag}-none-any.whl' for tag in released['pypi-wheels'].split()] + pypi = {'urls': [{'filename': name} for name in files + [f'pypcapkit-{self.VERSION}.tar.gz']]} + conda = {'distributions': [{'attrs': {'subdir': platform, 'build': f'{build}_0'}} + for platform in ('linux-64', 'osx-64') + for build in released['conda-builds'].split()]} + step = 'Check PyPI publish completeness' + self.assertEqual(self.complete(step, written['pypi-wheels'], '', pypi), 'true') + self.assertEqual(self.complete(step, run['pypi-wheels'], '', pypi), 'false') + step = 'Check Anaconda publish completeness' + self.assertEqual(self.complete(step, '', written['conda-builds'], conda), 'true') + + @unittest.skipIf(not BASH, 'needs bash on PATH') class TestTheRefusalExplanation(WorkflowMixin, unittest.TestCase): """``github``'s "Explain a refused release tag", run as the job runs it.""" diff --git a/tests/project/test_unit_tests_queue.py b/tests/project/test_unit_tests_queue.py index d11d4d9430..26a3b33a0b 100644 --- a/tests/project/test_unit_tests_queue.py +++ b/tests/project/test_unit_tests_queue.py @@ -585,7 +585,7 @@ def test_the_scan_is_not_blind(self) -> None: 'docs/source/contributing/workflows.rst': [ ('# Deliberately *not* gated on ``gate-only``', '# this is neither expensive'), ('name: Required checks passed', None), - ('five for `Compat Python 3.11`', None), + ('and a `Compat Python` one per supported version', None), ("# Ruleset 23497679's required_status_checks", '# them.'), (' required-checks:', 'echo "All required jobs reported an accepted result."'), ('if: ${{ always() && inputs.gate-only != true }}', None), @@ -654,8 +654,6 @@ def test_the_scan_is_not_blind(self) -> None: 'docs/source/contributing/workflows.rst': { '60': '.github/workflows/python-compatibility.yml', '74': '.github/workflows/python-compatibility.yml', - '20-24': '.github/python-versions.toml', - '16-19': '.github/python-versions.toml', }, } diff --git a/util/update_python_versions.py b/util/update_python_versions.py new file mode 100644 index 0000000000..b49693dc81 --- /dev/null +++ b/util/update_python_versions.py @@ -0,0 +1,1511 @@ +# -*- coding: utf-8 -*- +"""Recompute the Python version tiers in :file:`.github/python-versions.toml` (#1660). + +:file:`.github/workflows/cron-python-versions.yml` runs this weekly. It reads +four sources: + +``release-cycle`` + python.org's release cycle, ``https://peps.python.org/api/release-cycle.json``: + each CPython branch's ``status``. +``pypy`` + PyPy's releases, ``https://downloads.python.org/pypy/versions.json``. +``manifest`` + setup-python's builds, ``actions/python-versions``' ``versions-manifest.json``, + per ``platform``, ``platform_version`` and ``arch``. +``runner-images`` + ``actions/runner-images``' README, whose image table says which images GitHub + hosts and which one ``ubuntu-latest`` is. The manifest alone cannot say either: + it still lists builds for retired images. + +The labels whose builds count are read from the workflows, not written here: the +label the Python matrix jobs default to (``matrix.os || 'ubuntu-latest'``), the +labels of an ``os`` axis, which run the supported tier, and the ``runs-on`` of +each job that installs ``latest``. A ``-latest`` label counts as its image and +every newer one in the table, since GitHub moves the label to the newest image. + +The rules are the owner's, from #1633 and #1660: + +``latest`` + The newest ``bugfix`` branch with a stable build on every label a job installs + ``latest`` on. +supported + ``bugfix`` and ``security``. +KTLO + ``end-of-life`` and still installable. A version with a stable build on every + image ``ubuntu-latest`` may mean has no ``runner``; otherwise it names the + newest hosted Ubuntu image that has one. +dropped + ``end-of-life`` with no build for any hosted Ubuntu image. +pre-release + ``feature`` or ``prerelease`` once setup-python has a build. No workflow runs + pre-release legs yet, and :func:`python_versions.load` refuses the tier, so + such a version is reported (:data:`HELD`) rather than written. + +PyPy has no release cycle of its own: the Python versions of its newest stable +release are supported, an older one the file lists stays KTLO while a stable +build of it remains, and one found only in a numbered pre-release is a +pre-release. A version enters the file as supported or pre-release; an +end-of-life version the file does not list is never added. + +Each implementation is one entry of :data:`IMPLEMENTATIONS`, with the sources it +reads, so adding one (GraalPy, free-threaded CPython) is one more source and one +more entry, besides its spelling in :file:`util/python_versions.py`. + +The tier file is rewritten line by line: a version whose entry is unchanged keeps +its line byte for byte, so comments, the exemptions and the layout survive, and a +run that changes nothing writes nothing. The prose the audit tests pin to the file +is regenerated with it (:data:`CLAIMS`, :data:`REWRITERS`), but nothing under +:file:`.github/workflows/`: an App installation token needs the ``workflows`` +permission to push a change there, and the job's token has contents and pull +requests only. Workflow comments name the tiers instead of restating them. + +Three commands: + +``update`` + Recompute, print what changed, and with ``--write`` rewrite the files. + ``--out`` writes the proposal (the changed files, the plan, the commit + message) for the ``pr`` job. ``--proposal-branch`` reads every commit that + branch holds ahead of ``main``, merges of ``main`` left out, while its pull + request is open: when one carries this proposal, hand edits on top or not, + it is not proposed again, and when the branch holds commits this job did + not make and proposes other tiers, ``update`` refuses as ``publish`` would, + rather than have an approval asked for a refusal. A branch with no open + pull request was merged or closed, and is proposed afresh. +``body`` + Render the pull request body from a proposal, with the audit's result. +``publish`` + In the ``pr`` job: commit the proposal on :data:`BRANCH`, force-push that + branch only, or recreate it from ``main`` when it has no open pull request, + and open its pull request or update the open one. + +""" + +from __future__ import annotations + +import argparse +import difflib +import hashlib +import importlib.util +import json +import os +import pathlib +import re +import shutil +import subprocess # nosec B404 +import sys +import tempfile +import time +import tomllib +import urllib.error +import urllib.request +from typing import TYPE_CHECKING, NamedTuple + +if TYPE_CHECKING: + from typing import Any, Callable, Iterable, Optional + +ROOT = pathlib.Path(__file__).resolve().parents[1] +#: Repository-relative paths. +TIER_FILE = '.github/python-versions.toml' +WORKFLOW_DIR = '.github/workflows' +TEMPLATE = '.github/PULL_REQUEST_TEMPLATE.md' + +#: The branch the proposal lives on, and the pull request's base. +BRANCH = 'bot/python-versions' +BASE = 'main' +#: The commit trailer naming the proposal, by :func:`fingerprint`. +TRAILER = 'Python-Versions-Proposal' +#: The audit the ``detect`` job runs on the rewritten tree. +AUDIT = ('tests.project.test_python_versions_1660', 'tests.project.test_python_matrix_tiers_1633', + 'tests.project.test_ktlo_legs_stay_green_1672') +#: Tiers :func:`python_versions.load` refuses, so a version in one is reported, not written. +HELD = ('pre-release',) +#: The job whose blocking legs ruleset 23497679 requires by name, besides the aggregate. +REQUIRED_JOB = ('python-compatibility.yml', 'compatibility') +REQUIRED_AGGREGATE = 'Required checks passed' +#: What GitHub answers a push that changes a workflow file without the permission. +WORKFLOWS_REFUSAL = 'without `workflows` permission' + +#: How a workflow runs :file:`util/python_versions.py`, and reads its outputs and labels. +SCRIPT_STEP = 'python3 util/python_versions.py --github-output' +READ = re.compile(r'\$\{\{\s*(fromJSON\(\s*)?((?:needs|steps)\.[\w-]+\.outputs\.[\w-]+)\s*\)?\s*\}\}') +REF = re.compile(r'(needs|steps)\.([\w-]+)\.outputs\.([\w-]+)') +DEFAULT_LABEL = re.compile(r"\$\{\{\s*matrix\.os\s*\|\|\s*'([^']+)'\s*\}\}") +AXIS_LABEL = re.compile(r'\$\{\{\s*matrix\.os\s*\}\}') + + +def _load_python_versions() -> 'Any': + """:file:`util/python_versions.py`, the tier file's reader, beside this script.""" + spec = importlib.util.spec_from_file_location('python_versions', pathlib.Path(__file__).with_name( + 'python_versions.py')) + assert spec is not None and spec.loader is not None # nosec B101 + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +pv = _load_python_versions() + + +############################################################################### +# Sources +############################################################################### + + +class Source(NamedTuple): + """Where one source is fetched from, the file it is saved as, and its format.""" + + url: str + filename: str + kind: str + + +SOURCES = { + 'release-cycle': Source('https://peps.python.org/api/release-cycle.json', 'release-cycle.json', 'json'), + 'pypy': Source('https://downloads.python.org/pypy/versions.json', 'pypy-versions.json', 'json'), + 'manifest': Source('https://raw.githubusercontent.com/actions/python-versions/main/versions-manifest.json', + 'versions-manifest.json', 'json'), + 'runner-images': Source('https://raw.githubusercontent.com/actions/runner-images/main/README.md', + 'runner-images-readme.md', 'text'), +} + + +def fetch(name: 'str', attempts: 'int' = 3) -> 'str': + """Source ``name``'s text, retried twice on a network error.""" + source = SOURCES[name] + request = urllib.request.Request(source.url, headers={'User-Agent': 'PyPCAPKit cron-python-versions (#1660)'}) + for attempt in range(attempts): + try: + with urllib.request.urlopen(request, timeout=60) as response: # nosec B310 -- fixed https URLs + return response.read().decode('utf-8') + except (urllib.error.URLError, OSError) as error: + if attempt + 1 == attempts: + raise OSError(f'{source.url}: {error}') from error + time.sleep(5 * (attempt + 1)) + raise AssertionError('unreachable') # pragma: no cover + + +def read_sources(directory: 'Optional[pathlib.Path]' = None) -> 'dict[str, str]': + """Every source's text: from ``directory``'s saved files, or fetched.""" + if directory is None: + return {name: fetch(name) for name in SOURCES} + return {name: (directory / source.filename).read_text(encoding='utf-8') for name, source in SOURCES.items()} + + +def parse_sources(texts: 'dict[str, str]') -> 'dict[str, Any]': + return {name: json.loads(texts[name]) if source.kind == 'json' else texts[name] + for name, source in SOURCES.items()} + + +############################################################################### +# Runner images and labels +############################################################################### + + +class Image(NamedTuple): + """One hosted image: ``('ubuntu', '24.04', 'x64', ('ubuntu-latest', 'ubuntu-24.04'))``.""" + + family: str + version: str + arch: str + labels: 'tuple[str, ...]' + + +class Target(NamedTuple): + """What a setup-python build is for: the manifest's ``platform``, ``platform_version`` and ``arch``. + + macOS builds carry no ``platform_version``, so a macOS target's is :data:`None`. + + """ + + platform: str + version: 'Optional[str]' + arch: str + + +#: An image's family and version, at the start of the table's Image cell. +IMAGE = re.compile(r'(Ubuntu|macOS)\s+(\d+(?:\.\d+)?)\b') +PLATFORM = {'ubuntu': 'linux', 'macos': 'darwin'} + + +def numeric(version: 'str') -> 'tuple[int, ...]': + """``'24.04'`` as ``(24, 4)``, ``'7.3.12rc1'`` as ``(7, 3, 12)``.""" + match = re.match(r'\d+(?:\.\d+)*', version) + if match is None: + raise ValueError(f'not a version: {version!r}') + return tuple(map(int, match.group(0).split('.'))) + + +def parse_images(text: 'str') -> 'list[Image]': + """The Ubuntu and macOS rows of the runner-images README's image table.""" + rows = [line.strip() for line in text.splitlines() if line.strip().startswith('|')] + header = next((index for index, row in enumerate(rows) if 'YAML Label' in row), None) + if header is None: + raise ValueError('the runner-images README has no table with a "YAML Label" column') + columns = [cell.strip() for cell in rows[header].strip('|').split('|')] + try: + image, arch, label = columns.index('Image'), columns.index('Architecture'), columns.index('YAML Label') + except ValueError as error: + raise ValueError(f'the runner-images table has columns {columns}') from error + images = [] + for row in rows[header + 1:]: + cells = [cell.strip() for cell in row.strip('|').split('|')] + if len(cells) < len(columns): + continue + match = IMAGE.match(cells[image]) + if match is None: + continue + images.append(Image(match.group(1).lower(), match.group(2), cells[arch], + tuple(re.findall(r'`([^`]+)`', cells[label])))) + if not any(found.family == 'ubuntu' for found in images): + raise ValueError('the runner-images README lists no Ubuntu image') + return images + + +def target_of(image: 'Image') -> 'Target': + return Target(PLATFORM[image.family], image.version if image.family == 'ubuntu' else None, image.arch) + + +def label_targets(label: 'str', images: 'list[Image]') -> 'list[Target]': + """What a job on ``label`` installs from: its image, and for ``-latest`` every newer one.""" + image = next((found for found in images if label in found.labels), None) + if image is None: + raise ValueError(f'{label!r} is a label the runner-images README does not list') + chosen = [image] + if label.endswith('-latest'): + chosen += [found for found in images if (found.family, found.arch) == (image.family, image.arch) + and numeric(found.version) > numeric(image.version)] + return sorted({target_of(found) for found in chosen}, key=lambda t: (t.platform, t.version or '', t.arch)) + + +def runner_images(images: 'list[Image]', arch: 'str') -> 'list[tuple[Image, str]]': + """The hosted Ubuntu images a version may name as its ``runner``, newest first, with that label.""" + found = [] + for image in images: + label = next((name for name in image.labels if pv.RUNNER.fullmatch(name)), None) + if image.family == 'ubuntu' and image.arch == arch and label is not None: + found.append((image, label)) + return sorted(found, key=lambda pair: numeric(pair[0].version), reverse=True) + + +def describe(target: 'Target') -> 'str': + return f'{target.platform} {target.version} {target.arch}' if target.version else \ + f'{target.platform} {target.arch}' + + +############################################################################### +# The workflows +############################################################################### + + +def load_workflows(root: 'pathlib.Path') -> 'dict[str, dict[str, Any]]': + """Every workflow under ``root``, parsed.""" + import yaml # pylint: disable=import-outside-toplevel + + return {path.name: yaml.safe_load(path.read_text(encoding='utf-8')) + for path in sorted((root / WORKFLOW_DIR).glob('*.yml'))} + + +def script_output(workflows: 'dict[str, Any]', name: 'str', job_id: 'str', ref: 'str') -> 'Optional[str]': + """The :file:`util/python_versions.py` output reference ``ref`` in job ``job_id`` ends at, if any.""" + jobs = workflows[name].get('jobs') or {} + match = REF.fullmatch(ref) + if match is None: + return None + kind, target, output = match.groups() + if kind == 'needs': + value = ((jobs.get(target) or {}).get('outputs') or {}).get(output) + inner = READ.fullmatch(str(value).strip()) if value is not None else None + if inner is None or inner.group(1): + return None + return script_output(workflows, name, target, inner.group(2)) + step = next((step for step in (jobs.get(job_id) or {}).get('steps', []) if step.get('id') == target), None) + if step is None or str(step.get('run', '')).strip() != SCRIPT_STEP: + return None + return output + + +#: :func:`resolve`'s answer for text that reads no script output. +NOTHING = object() + + +def resolve(workflows: 'dict[str, Any]', name: 'str', job_id: 'str', text: 'Any', values: 'dict[str, str]') -> 'Any': + """What ``text`` reads from the script's outputs ``values``, or :data:`NOTHING`.""" + match = READ.fullmatch(str(text).strip()) + if match is None: + return NOTHING + output = script_output(workflows, name, job_id, match.group(2)) + if output is None: + return NOTHING + return json.loads(values[output]) if match.group(1) else values[output] + + +def matrix_spec(workflows: 'dict[str, Any]', name: 'str', job_id: 'str', + values: 'dict[str, str]') -> 'Optional[dict[str, Any]]': + """Job ``job_id``'s ``strategy.matrix`` read from ``values``, or :data:`None` if it reads none.""" + raw = ((workflows[name]['jobs'][job_id].get('strategy') or {}).get('matrix')) + if isinstance(raw, str): + found = resolve(workflows, name, job_id, raw, values) + return found if isinstance(found, dict) else None + if not isinstance(raw, dict): + return None + spec, reads = {}, False + for key, value in raw.items(): + found = resolve(workflows, name, job_id, value, values) if isinstance(value, str) else NOTHING + reads = reads or found is not NOTHING + spec[key] = value if found is NOTHING else found + return spec if reads else None + + +def expand(spec: 'dict[str, Any]') -> 'list[dict[str, Any]]': + """The legs GitHub expands ``spec`` to, in its order. + + The base axes' product, then each ``include`` entry: merged into every + combination whose original values it matches, or added as one of its own. + + """ + spec = dict(spec) + include = spec.pop('include', []) + combos = [{}] # type: list[dict[str, Any]] + for key, axis in spec.items(): + combos = [dict(combo, **{key: str(value)}) for combo in combos for value in axis] + for entry in include: + entry = {key: str(value) if key in spec else value for key, value in entry.items()} + matched = [combo for combo in combos + if all(combo.get(key) == value for key, value in entry.items() if key in spec)] + added = False + for combo in matched: + if all(key in spec or key not in combo for key in entry): + combo.update(entry) + added = True + if not added: + combos.append(dict(entry)) + return combos + + +def axes(spec: 'dict[str, Any]') -> 'tuple[str, ...]': + return tuple(key for key in spec if key != 'include') + + +EXPRESSION = re.compile(r'\$\{\{\s*(.*?)\s*\}\}') + + +def evaluate(expression: 'str', leg: 'dict[str, Any]') -> 'str': + """The three expression shapes job names use: ``matrix.K``, ``matrix.K && 'A' || 'B'``, ``matrix.K || 'A'``.""" + match = re.fullmatch(r'matrix\.([\w-]+)', expression) + if match: + value = leg.get(match.group(1)) + return '' if value is None else str(value) + match = re.fullmatch(r"matrix\.([\w-]+)\s*&&\s*'([^']*)'\s*\|\|\s*'([^']*)'", expression) + if match: + return match.group(2) if leg.get(match.group(1)) not in (None, False, '', 0) else match.group(3) + match = re.fullmatch(r"matrix\.([\w-]+)\s*\|\|\s*'([^']*)'", expression) + if match: + value = leg.get(match.group(1)) + return match.group(2) if value in (None, False, '', 0) else str(value) + raise ValueError(f'cannot evaluate ${{{{ {expression} }}}} in a job name') + + +def render_name(template: 'str', leg: 'dict[str, Any]') -> 'str': + return EXPRESSION.sub(lambda match: evaluate(match.group(1), leg), template) + + +def matrix_legs(workflows: 'dict[str, Any]', values: 'dict[str, str]') -> 'dict[tuple[str, str], list[dict[str, Any]]]': + """Every job whose matrix reads the script, as ``(workflow, job)``: its legs.""" + found = {} + for name, spec in workflows.items(): + for job_id in (spec.get('jobs') or {}): + matrix = matrix_spec(workflows, name, job_id, values) + if matrix is not None: + found[(name, job_id)] = expand(matrix) + return found + + +def leg_names(workflows: 'dict[str, Any]', values: 'dict[str, str]') -> 'dict[tuple[str, str], dict[tuple, str]]': + """Each such job's check-run names, keyed by the leg's base-axis values.""" + found = {} + for (name, job_id), legs in matrix_legs(workflows, values).items(): + template = str(workflows[name]['jobs'][job_id].get('name', job_id)) + base = axes(matrix_spec(workflows, name, job_id, values) or {}) + found[(name, job_id)] = {tuple((key, leg.get(key)) for key in base): render_name(template, leg) + for leg in legs} + return found + + +def required_contexts(workflows: 'dict[str, Any]', values: 'dict[str, str]') -> 'list[str]': + """Ruleset 23497679's required contexts for ``values``: the aggregate, and :data:`REQUIRED_JOB`'s blocking legs.""" + name, job_id = REQUIRED_JOB + template = str(workflows[name]['jobs'][job_id]['name']) + legs = matrix_legs(workflows, values)[REQUIRED_JOB] + return [REQUIRED_AGGREGATE] + [render_name(template, leg) for leg in legs if leg.get('ktlo') is not True] + + +def setup_steps(job: 'dict[str, Any]') -> 'list[dict[str, Any]]': + return [step for step in job.get('steps', []) if str(step.get('uses', '')).startswith('actions/setup-python@')] + + +def workflow_labels(workflows: 'dict[str, Any]') -> 'dict[str, list[str]]': + """The runner labels each tier is installed on, read from the jobs' ``runs-on``. + + ``default`` + Where a version without ``runner`` runs: the label a ``matrix.os || '...'`` + falls back to, or the literal ``runs-on`` of a job installing a tier's list. + ``supported`` + ``default``, plus each label of a literal ``os`` axis (the conda jobs). + ``latest`` + The ``runs-on`` of every job whose setup-python reads ``latest``. + + """ + default, axis, latest = set(), set(), set() # type: set[str], set[str], set[str] + for name, spec in workflows.items(): + for job_id, job in (spec.get('jobs') or {}).items(): + runs_on = str(job.get('runs-on', '')) + for step in setup_steps(job): + text = str((step.get('with') or {}).get('python-version', '')).strip() + read = READ.fullmatch(text) + output = None if read is None else script_output(workflows, name, job_id, read.group(2)) + if output is None and 'matrix.python-version' not in text: + continue # a pin the tier file exempts + if output == 'latest': + if '${{' in runs_on: + raise ValueError(f'{name} {job_id} installs latest on {runs_on!r}, which this cannot read') + latest.add(runs_on) + continue + match = DEFAULT_LABEL.fullmatch(runs_on) + if match: + default.add(match.group(1)) + elif AXIS_LABEL.fullmatch(runs_on): + labels = ((job.get('strategy') or {}).get('matrix') or {}).get('os') + if not isinstance(labels, list): + raise ValueError(f'{name} {job_id} runs on matrix.os, which is not a literal list') + axis.update(map(str, labels)) + elif '${{' not in runs_on: + default.add(runs_on) + else: + raise ValueError(f'{name} {job_id} runs on {runs_on!r}, which this cannot read') + if not default or not latest: + raise ValueError('found no job installing the tiers, or none installing latest') + return {'default': sorted(default), 'supported': sorted(default | axis), 'latest': sorted(latest)} + + +############################################################################### +# The tiers +############################################################################### + + +class Note(NamedTuple): + """Something a run reports: ``held`` (a tier the file cannot hold), ``warning``, or ``manual``.""" + + kind: str + text: str + + +class Result(NamedTuple): + """One implementation's tiers: each version's entry, the reason for it, and what to report.""" + + entries: 'dict[str, dict[str, str]]' + reasons: 'dict[str, str]' + notes: 'list[Note]' + latest: 'Optional[str]' = None + + +class Context(NamedTuple): + """What every implementation's rule reads: the parsed sources, the images, the labels.""" + + sources: 'dict[str, Any]' + images: 'list[Image]' + labels: 'dict[str, list[str]]' + + def targets(self, kind: 'str') -> 'list[Target]': + found = set() # type: set[Target] + for label in self.labels[kind]: + found.update(label_targets(label, self.images)) + return sorted(found, key=lambda t: (t.platform, t.version or '', t.arch)) + + +def cpython_builds(manifest: 'list[dict[str, Any]]') -> 'dict[str, dict[Target, bool]]': + """Each minor version's build targets, each mapped to whether a stable build exists.""" + builds = {} # type: dict[str, dict[Target, bool]] + for release in manifest: + match = re.match(r'(\d+)\.(\d+)\.\d+', str(release.get('version', ''))) + if match is None: + continue + found = builds.setdefault(f'{match.group(1)}.{match.group(2)}', {}) + for file in release.get('files', []): + platform = file.get('platform') + target = Target(platform, file.get('platform_version') if platform == 'linux' else None, file.get('arch')) + found[target] = found.get(target, False) or bool(release.get('stable')) + return builds + + +def has(builds: 'dict[str, dict[Target, bool]]', version: 'str', targets: 'Iterable[Target]', + stable: 'bool' = True) -> 'bool': + """Whether setup-python builds ``version`` for every target, stable or, with ``stable=False``, any.""" + found = builds.get(version, {}) + return all(target in found and (found[target] or not stable) for target in targets) + + +def compute_cpython(current: 'dict[str, dict[str, str]]', context: 'Context') -> 'Result': + """CPython's tiers, from python.org's statuses and setup-python's builds.""" + cycle = context.sources['release-cycle'] + builds = cpython_builds(context.sources['manifest']) + default, supported = context.targets('default'), context.targets('supported') + arch = {target.arch for target in default if target.platform == 'linux'} + if len(arch) != 1: + raise ValueError(f'the default labels {context.labels["default"]} are not one Linux architecture') + candidates = runner_images(context.images, arch.pop()) + entries, reasons, notes = {}, {}, [] # type: dict[str, dict[str, str]], dict[str, str], list[Note] + for version in current: + if version not in cycle: + raise ValueError(f'CPython {version} is not in python.org\'s release cycle') + for version, info in cycle.items(): + if not pv.SPELLING['cpython'].fullmatch(version): + continue + status = info.get('status') + if status in ('bugfix', 'security'): + entries[version] = {'tier': 'supported'} + reasons[version] = f'python.org status `{status}`' + missing = [describe(target) for target in supported if not has(builds, version, [target])] + if missing: + notes.append(Note('warning', f'setup-python has no stable build of supported {version} for ' + f'{", ".join(missing)}')) + elif status == 'end-of-life': + if version not in current: + continue + if has(builds, version, default): + entries[version] = {'tier': 'ktlo'} + reasons[version] = 'end-of-life; setup-python builds it for `ubuntu-latest`' + continue + image = next(((found, label) for found, label in candidates + if has(builds, version, [target_of(found)])), None) + if image is None: + reasons[version] = 'end-of-life, and setup-python builds it for no hosted Ubuntu image' + else: + entries[version] = {'tier': 'ktlo', 'runner': image[1]} + reasons[version] = (f'end-of-life; setup-python builds it for {image[1]} but not for every image ' + '`ubuntu-latest` may mean') + elif status in ('feature', 'prerelease'): + if has(builds, version, default, stable=False): + entries[version] = {'tier': 'pre-release'} + reasons[version] = f'python.org status `{status}`, and setup-python has a build' + else: + raise ValueError(f'CPython {version}: unknown status {status!r}') + + latest, targets = None, context.targets('latest') + bugfix = sorted((version for version in entries if cycle[version].get('status') == 'bugfix'), + key=pv.key, reverse=True) + for version in bugfix: + if has(builds, version, targets): + latest = version + break + notes.append(Note('warning', f'{version} is the newest bugfix branch but setup-python has no stable build ' + f'of it for {", ".join(map(describe, targets))}, so `latest` stays older')) + if latest is None: + raise ValueError('no bugfix branch has a stable build on every label that installs latest') + return Result(entries, reasons, notes, latest) + + +def pypy_line(release: 'dict[str, Any]') -> 'Optional[str]': + """A PyPy release's Python version as ``'3.11'``, or :data:`None` for Python 2.""" + match = re.match(r'3\.(\d+)', str(release.get('python_version', ''))) + return f'3.{match.group(1)}' if match else None + + +def compute_pypy(current: 'dict[str, dict[str, str]]', context: 'Context') -> 'Result': + """PyPy's tiers, from its releases: the newest stable release's versions are supported.""" + def usable(release: 'dict[str, Any]') -> 'bool': + return pypy_line(release) is not None and re.fullmatch(r'\d+(?:\.\d+)*(?:(?:a|b|rc)\d+)?', + str(release.get('pypy_version', ''))) is not None \ + and any(file.get('platform') == 'linux' and file.get('arch') == 'x64' for file in release.get('files', [])) + + releases = [release for release in context.sources['pypy'] if usable(release)] + stable = [release for release in releases if release.get('stable')] + if not stable: + raise ValueError('PyPy lists no stable Linux x64 release') + newest = max(stable, key=lambda release: numeric(release['pypy_version']))['pypy_version'] + last = {} # type: dict[str, str] + for release in stable: + line = pypy_line(release) + if line is None: # pragma: no cover -- usable() keeps Python 3 only + continue + if line not in last or numeric(release['pypy_version']) > numeric(last[line]): + last[line] = release['pypy_version'] + entries, reasons = {}, {} # type: dict[str, dict[str, str]], dict[str, str] + for line, pypy_version in last.items(): + if numeric(pypy_version) == numeric(newest): + entries[f'pypy{line}'] = {'tier': 'supported'} + reasons[f'pypy{line}'] = f'in PyPy {newest}, the newest stable release' + for version in current: + if version in entries: + continue + line = version[len('pypy'):] + if line in last: + entries[version] = {'tier': 'ktlo'} + reasons[version] = f'not in PyPy {newest}; its newest stable build is PyPy {last[line]}' + else: + reasons[version] = 'PyPy has no stable Linux x64 build of it' + for release in releases: + line = pypy_line(release) + if not release.get('stable') and line not in last and f'pypy{line}' not in entries: + entries[f'pypy{line}'] = {'tier': 'pre-release'} + reasons[f'pypy{line}'] = f'only in PyPy {release["pypy_version"]}, a pre-release' + return Result(entries, reasons, []) + + +class Implementation(NamedTuple): + """An implementation's sources, from :data:`SOURCES`, and its rule.""" + + sources: 'tuple[str, ...]' + compute: 'Callable[[dict[str, dict[str, str]], Context], Result]' + + +#: One entry per implementation, keyed as in :data:`python_versions.SPELLING`. +IMPLEMENTATIONS = { + 'cpython': Implementation(('release-cycle', 'manifest', 'runner-images'), compute_cpython), + 'pypy': Implementation(('pypy',), compute_pypy), +} + + +############################################################################### +# The tier file +############################################################################### + + +ENTRY = re.compile(r'^"(?P[^"]+)" = \{[^{}]*\}(?P[ \t]*#.*)?$') +SECTION = re.compile(r'^\[versions\.(?P[\w-]+)\]$') +LATEST = re.compile(r'^latest = "[^"]*"(?P.*)$') + + +def entry_line(version: 'str', entry: 'dict[str, str]', comment: 'str' = '') -> 'str': + """One version's line, as the file spells it: ``"3.7" = { tier = "ktlo", runner = "ubuntu-22.04" }``.""" + fields = [f'tier = {json.dumps(entry["tier"])}'] + if 'runner' in entry: + fields.append(f'runner = {json.dumps(entry["runner"])}') + return f'"{version}" = {{ {", ".join(fields)} }}{comment}' + + +def render_tier_file(text: 'str', latest: 'str', versions: 'dict[str, dict[str, dict[str, str]]]') -> 'str': + """``text`` with ``latest`` and each implementation's versions replaced, every other line kept. + + A version whose entry is unchanged keeps its line byte for byte. Each + ``[versions.]`` table's entries must be the lines right below it; a new + version is placed in order among them. + + """ + old = tomllib.loads(text) + lines = text.splitlines(keepends=True) + result = [] + seen = set() # type: set[str] + index = 0 + while index < len(lines): + line = lines[index] + bare = line.rstrip('\r\n') + ending = line[len(bare):] + match = LATEST.match(bare) + if match and not seen: + if old.get('latest') != latest: + line = f'latest = {json.dumps(latest)}{match["rest"]}{ending}' + result.append(line) + index += 1 + continue + section = SECTION.match(bare) + if section is None: + result.append(line) + index += 1 + continue + impl = section['impl'] + result.append(line) + index += 1 + block = {} # type: dict[str, tuple[str, str]] + eol = ending or '\n' + while index < len(lines): + found = ENTRY.match(lines[index].rstrip('\r\n')) + if found is None: + break + block[found['version']] = (lines[index], found['comment'] or '') + eol = lines[index][len(lines[index].rstrip('\r\n')):] or eol + index += 1 + for version, entry in versions[impl].items(): + kept = block.get(version) + if kept is not None and old['versions'][impl].get(version) == entry: + result.append(kept[0] if kept[0].endswith(('\n', '\r')) else kept[0] + eol) + else: + result.append(entry_line(version, entry, kept[1] if kept else '') + eol) + seen.add(impl) + if seen != set(versions): + raise ValueError(f'{TIER_FILE} has tables for {sorted(seen)}, not {sorted(versions)}') + rendered = ''.join(result) + parsed = tomllib.loads(rendered) + if parsed.get('latest') != latest or parsed.get('versions') != versions or \ + {k: v for k, v in parsed.items() if k not in ('latest', 'versions')} != \ + {k: v for k, v in old.items() if k not in ('latest', 'versions')}: + raise ValueError(f'{TIER_FILE}: a version entry sits outside the line block below its table') + return rendered + + +def validate(rendered: 'str') -> 'dict[str, Any]': + """``rendered`` through :func:`python_versions.load`, which every workflow runs.""" + with tempfile.TemporaryDirectory(prefix='python-versions-') as tmp: + path = pathlib.Path(tmp) / 'python-versions.toml' + path.write_text(rendered, encoding='utf-8') + return pv.load(path) + + +def fingerprint(latest: 'str', versions: 'dict[str, Any]') -> 'str': + """A proposal's identity: its tiers, whatever else moved on ``main``.""" + canonical = json.dumps({'latest': latest, 'versions': versions}, sort_keys=True, separators=(',', ':')) + return hashlib.sha256(canonical.encode('utf-8')).hexdigest()[:16] + + +############################################################################### +# The prose +############################################################################### + + +#: A line break, and a comment marker after it, as the audit tests' ``prose()`` reads them. +SEPARATOR = re.compile(r'[ \t]*\n[ \t]*(?:#:?[ \t]*)?') +WORDS = ('zero', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', 'ten', 'eleven', + 'twelve', 'thirteen', 'fourteen', 'fifteen', 'sixteen', 'seventeen', 'eighteen', 'nineteen', 'twenty') + + +class Word(int): + """A count written as a word, in the case of the word it replaces.""" + + +def prose_view(text: 'str') -> 'tuple[str, list[int]]': + """``text`` as the audit tests read it, and each character's offset in ``text`` (-1 for a joined break).""" + pieces, origin = [], [] # type: list[str], list[int] + position = 0 + for match in SEPARATOR.finditer(text): + pieces.append(text[position:match.start()]) + origin.extend(range(position, match.start())) + pieces.append(' ') + origin.append(-1) + position = match.end() + pieces.append(text[position:]) + origin.extend(range(position, len(text))) + return ''.join(pieces), origin + + +def spell(value: 'Any', old: 'str') -> 'str': + if isinstance(value, Word): + if not 0 <= value < len(WORDS): + raise ValueError(f'no word for {int(value)}') + word = WORDS[value] + return word.capitalize() if old[:1].isupper() else word + return str(value) + + +class Claim(NamedTuple): + """A sentence restating the tiers: its pattern on the prose view, and each group's value.""" + + pattern: str + values: 'Callable[[dict[str, Any]], Optional[tuple[Any, ...]]]' + + +def apply_claims(text: 'str', claims: 'Iterable[Claim]', facts: 'dict[str, Any]') -> 'tuple[str, list[str]]': + """``text`` with each claim's groups rewritten, and what could not be.""" + view, origin = prose_view(text) + edits, problems = [], [] # type: list[tuple[int, int, str]], list[str] + for claim in claims: + matches = list(re.finditer(claim.pattern, view)) + if len(matches) != 1: + problems.append(f'`{claim.pattern}` matches {len(matches)} times') + continue + values = claim.values(facts) + if values is None: + problems.append(f'`{claim.pattern}` cannot state a tier that is empty or has a gap') + continue + for group, value in enumerate(values, 1): + start, end = matches[0].span(group) + offsets = origin[start:end] + if -1 in offsets or offsets != list(range(offsets[0], offsets[0] + len(offsets))): + problems.append(f'`{claim.pattern}` group {group} spans a line break') + break + first, last = offsets[0], offsets[0] + len(offsets) + edits.append((first, last, spell(value, text[first:last]))) + for start, end, value in sorted(edits, reverse=True): + text = text[:start] + value + text[end:] + return text, problems + + +def consecutive(numbers: 'list[int]') -> 'bool': + return bool(numbers) and numbers == list(range(numbers[0], numbers[0] + len(numbers))) + + +def span(tier: 'list[str]') -> 'Optional[tuple[str, str]]': + """A tier's first and last version, if it is every minor version between them. + + "3.8 to 3.10" says 3.9 is in the tier, so a tier with a version dropped from + its middle is not a span; the sentence is then left for a hand edit. + + """ + return (tier[0], tier[-1]) if consecutive([pv.key(version)[1] for version in tier]) else None + + +def lines_of(facts: 'dict[str, Any]', tier: 'str') -> 'Optional[tuple[int, int]]': + """The tier file lines of a CPython tier, if they are one run of lines.""" + numbers = [facts['tier_lines'][version] for version in facts[tier]] + return (numbers[0], numbers[-1]) if consecutive(numbers) else None + + +def counted(count: 'int', ends: 'Optional[tuple[Any, Any]]') -> 'Optional[tuple[Any, ...]]': + """``count`` as a word, then ``ends``; :data:`None` when ``ends`` is.""" + return None if ends is None else (Word(count),) + ends + + +#: The sentences restating the tiers, by file. Never a workflow (see the module docstring). +CLAIMS = { + 'pyproject.toml': ( + Claim(r'\*\*(3\.\d+) through (3\.\d+)\*\* are supported', lambda f: span(f['supported'])), + Claim(r'\*\*(3\.\d+) through (3\.\d+)\*\* are KTLO', lambda f: span(f['ktlo'])), + Claim(r'the KTLO tier \((3\.\d+)-(3\.\d+)\)', lambda f: span(f['ktlo'])), + ), + 'README.md': ( + Claim(r'CI supports (3\.\d+) to (3\.\d+)', lambda f: span(f['supported'])), + Claim(r'tests the end-of-life (3\.\d+) to (3\.\d+)', lambda f: span(f['ktlo'])), + ), + 'docs/source/index.rst': ( + Claim(r'CI supports (3\.\d+) to (3\.\d+)', lambda f: span(f['supported'])), + Claim(r'tests the end-of-life (3\.\d+) to (3\.\d+)', lambda f: span(f['ktlo'])), + Claim(r'(3\.\d+) to (3\.\d+) are end-of-life and best-effort', lambda f: span(f['ktlo'])), + ), + 'docs/source/contributing/pep.rst': ( + Claim(r'in CI against Python (3\.\d+) through (3\.\d+)', lambda f: span(f['supported'])), + Claim(r'against the end-of-life (3\.\d+) through (3\.\d+)', lambda f: span(f['ktlo'])), + ), + 'docs/source/contributing/workflows.rst': ( + Claim(r'The \*\*supported\*\* tier, (3\.\d+)-(3\.\d+),', lambda f: span(f['supported'])), + Claim(r'end-of-life (3\.\d+)-(3\.\d+), still built', lambda f: span(f['ktlo'])), + Claim(r'requires (\w+) contexts', lambda f: (Word(len(f['required'])),)), + Claim(r'the (\w+) required legs are its supported tier \(``python-versions\.toml:(\d+)-(\d+)``\)', + lambda f: counted(len(f['required']) - 1, lines_of(f, 'supported'))), + Claim(r'its (\w+) KTLO legs \(``python-versions\.toml:(\d+)-(\d+)``\)', + lambda f: counted(len(f['ktlo']), lines_of(f, 'ktlo'))), + Claim(r'and the (\w+) ``Compat Python (3\.\d+)``-``(3\.\d+)`` legs', + lambda f: counted(len(f['required']) - 1, span(f['supported']))), + Claim(r'``test`` \((\w+) Python legs: (\w+) supported, (\w+) KTLO\)', + lambda f: (Word(f['legs']['test']), Word(len(f['supported'])), Word(len(f['ktlo'])))), + Claim(r'``integration`` \((\w+)\)', lambda f: (Word(f['legs']['integration']),)), + Claim(r'each looping over (\w+) Python versions', lambda f: (Word(f['engines']),)), + Claim(r'``pypcap-parity`` \((\w+)\)', lambda f: (Word(f['legs']['pypcap-parity']),)), + Claim(r'the ``Python (3\.\d+)`` leg of ``test`` \(matrix key ``coverage: true``\)', + lambda f: (f['latest'],)), + ), + 'SECURITY.md': ( + Claim(r'blocking tier: CPython (3\.\d+) through (3\.\d+)', lambda f: span(f['supported'])), + Claim(r'but (3\.\d+) to (3\.\d+) are best-effort', lambda f: span(f['ktlo'])), + ), + '.github/coverage.toml': ( + Claim(r'\(`test`, Python (\d+\.\d+)\)', lambda f: (f['latest'],)), + ), + 'docs/source/contributing/conventions/test-coverage.rst': ( + Claim(r"the ``test`` job's Python (\d+\.\d+) cell", lambda f: (f['latest'],)), + ), + 'docs/source/contributing/releasing.rst': ( + Claim(r'the sdist kept only on the (\d+\.\d+) leg', lambda f: (f['latest'],)), + Claim(r'against the (\d+) expected -- the (\d+) supported legs', lambda f: (f['wheels'] + 1, f['wheels'])), + Claim(r'against the (\d+) expected \(2 OS x (\d+) Python versions\)', + lambda f: (2 * f['conda'], f['conda'])), + ), +} # type: dict[str, tuple[Claim, ...]] + +#: ``"Programming Language :: Python :: 3.N",``, one line per supported version. +CLASSIFIER = re.compile(r'^(?P[ \t]*)"Programming Language :: Python :: 3\.\d+",$', re.M) +#: One row of the workflows page's required-contexts table. +COMPAT_ROW = re.compile(r'^(?P[ \t]*)\* - ``Compat Python (?P3\.\d+)``\n' + r'(?P[ \t]*)- job ``compatibility``, matrix leg ``(?P=version)``\n' + r'(?P=inner)- (?P``[^`\n]+``), ``\.github/python-versions\.toml:\d+``\n', re.M) +BUG_VERSIONS = re.compile(r'(Python Version: \[e\.g\. )([^\]\n]+)(\])') + + +def adjacent(matches: 'list[re.Match[str]]', text: 'str') -> 'bool': + """Whether ``matches`` are adjacent: each starts where the last one ended, or one newline after.""" + return all(text[a.end():b.start()] in ('', '\n') for a, b in zip(matches, matches[1:])) + + +def rewrite_classifiers(text: 'str', facts: 'dict[str, Any]') -> 'tuple[str, list[str]]': + matches = list(CLASSIFIER.finditer(text)) + if not matches or not adjacent(matches, text): + return text, ['the version classifiers are not one run of lines'] + block = '\n'.join(f'{matches[0]["indent"]}"Programming Language :: Python :: {version}",' + for version in facts['supported']) + return text[:matches[0].start()] + block + text[matches[-1].end():], [] + + +def rewrite_compat_rows(text: 'str', facts: 'dict[str, Any]') -> 'tuple[str, list[str]]': + matches = list(COMPAT_ROW.finditer(text)) + if not matches or not adjacent(matches, text): + return text, ['the required-contexts table has no run of `Compat Python` rows'] + first = matches[0] + block = ''.join(f'{first["indent"]}* - ``Compat Python {version}``\n' + f'{first["inner"]}- job ``compatibility``, matrix leg ``{version}``\n' + f'{first["inner"]}- {first["where"]}, ' + f'``.github/python-versions.toml:{facts["tier_lines"][version]}``\n' + for version in facts['supported']) + return text[:first.start()] + block + text[matches[-1].end():], [] + + +def rewrite_bug_template(text: 'str', facts: 'dict[str, Any]') -> 'tuple[str, list[str]]': + """The bug report's examples: ``latest``, the middle supported version, the oldest.""" + supported = facts['supported'] + examples = list(dict.fromkeys([facts['latest'], supported[len(supported) // 2], supported[0]])) + matches = list(BUG_VERSIONS.finditer(text)) + if len(matches) != 1: + return text, [f'the Python Version line matches {len(matches)} times'] + return text[:matches[0].start(2)] + ', '.join(examples) + text[matches[0].end(2):], [] + + +#: Rewriters for what is a list rather than a sentence, by file. +REWRITERS = { + 'pyproject.toml': (rewrite_classifiers,), + 'docs/source/contributing/workflows.rst': (rewrite_compat_rows,), + '.github/ISSUE_TEMPLATE/bug_report.md': (rewrite_bug_template,), +} # type: dict[str, tuple[Callable[[str, dict[str, Any]], tuple[str, list[str]]], ...]] + +#: Every file this script may write. :func:`publish` refuses any other. +TARGETS = tuple(sorted({TIER_FILE} | set(CLAIMS) | set(REWRITERS))) + + +def make_facts(data: 'dict[str, Any]', values: 'dict[str, str]', workflows: 'dict[str, Any]', + tier_file: 'str') -> 'dict[str, Any]': + """What the prose states, computed from the new tiers and the workflows' matrices.""" + legs = matrix_legs(workflows, values) + engine = workflows['unit-tests.yml']['jobs']['engine-tests'] + engines = set() # type: set[str] + for step in setup_steps(engine): + found = resolve(workflows, 'unit-tests.yml', 'engine-tests', step['with']['python-version'], values) + if found is not NOTHING: + engines.update(str(found).split()) + tier_lines = {} + for number, line in enumerate(tier_file.splitlines(), 1): + match = ENTRY.match(line) + if match and pv.SPELLING['cpython'].fullmatch(match['version']): + tier_lines[match['version']] = number + return { + 'latest': data['latest'], + 'supported': pv.tier(data, 'cpython', 'supported'), + 'ktlo': pv.tier(data, 'cpython', 'ktlo'), + 'wheels': len(values['pypi-wheels'].split()), + 'conda': len(json.loads(values['conda'])), + 'legs': {job_id: len(found) for (name, job_id), found in legs.items() if name == 'unit-tests.yml'}, + 'engines': len(engines), + 'required': required_contexts(workflows, values), + 'tier_lines': tier_lines, + } + + +############################################################################### +# The plan +############################################################################### + + +def tier_changes(before: 'dict[str, Any]', after: 'dict[str, Any]', + reasons: 'dict[str, dict[str, str]]') -> 'list[dict[str, Any]]': + """Each version whose entry differs, oldest first per implementation.""" + found = [] + for impl in IMPLEMENTATIONS: + old, new = before['versions'][impl], after['versions'][impl] + for version in sorted(set(old) | set(new), key=pv.key): + if old.get(version) != new.get(version): + found.append({'impl': impl, 'version': version, 'before': old.get(version), + 'after': new.get(version), 'reason': reasons[impl].get(version, '')}) + return found + + +def phrase(change: 'dict[str, Any]') -> 'str': + version, before, after = change['version'], change['before'], change['after'] + if after is None: + return f'{version} leaves CI' + if before is None: + return f'{version} joins the {after["tier"]} tier' + if before['tier'] != after['tier']: + return f'{version} moves to {"KTLO" if after["tier"] == "ktlo" else after["tier"]}' + return f'{version} runs on {after.get("runner", "ubuntu-latest")}' + + +def title_of(changes: 'list[dict[str, Any]]', latest: 'tuple[str, str]') -> 'str': + phrases = [phrase(change) for change in changes] + if latest[0] != latest[1]: + phrases.append(f'latest moves to {latest[1]}') + if not phrases: + return 'ci(python): regenerate the prose that restates the Python version tiers (#1660)' + title = f'ci(python): {", ".join(phrases)} (#1660)' + return title if len(title) <= 100 else 'ci(python): update the Python version tiers (#1660)' + + +def compute_plan(root: 'pathlib.Path', sources: 'dict[str, Any]') -> 'tuple[dict[str, Any], dict[str, str]]': + """What a run proposes, and the new text of each file it changes.""" + old_tier_text = (root / TIER_FILE).read_bytes().decode('utf-8') + before = validate(old_tier_text) + workflows = load_workflows(root) + before_values = pv.outputs(before) + context = Context(sources, parse_images(sources['runner-images']), workflow_labels(workflows)) + + versions, reasons, notes, latest = {}, {}, [], before['latest'] + for impl, implementation in IMPLEMENTATIONS.items(): + result = implementation.compute(before['versions'][impl], context) + kept = {} + for version in sorted(result.entries, key=pv.key): + entry = result.entries[version] + if entry['tier'] in HELD: + notes.append(Note('held', f'{version} ({result.reasons[version]}) is a {entry["tier"]}, which no ' + 'workflow runs yet, so it is not written; wire non-blocking legs for the ' + 'tier first (#1660)')) + continue + kept[version] = entry + versions[impl], reasons[impl] = kept, result.reasons + notes.extend(result.notes) + if result.latest is not None: + latest = result.latest + after = dict(before, latest=latest, versions=versions) + new_tier_text = render_tier_file(old_tier_text, latest, versions) + validate(new_tier_text) + after_values = pv.outputs(after) + + facts = make_facts(after, after_values, workflows, new_tier_text) + texts = {TIER_FILE: new_tier_text} + for path in TARGETS: + if path == TIER_FILE: + continue + text = (root / path).read_bytes().decode('utf-8') + text, problems = apply_claims(text, CLAIMS.get(path, ()), facts) + for rewrite in REWRITERS.get(path, ()): + text, more = rewrite(text, facts) + problems += more + notes.extend(Note('manual', f'{path}: {problem}') for problem in problems) + texts[path] = text + changed = [path for path in TARGETS if texts[path] != (root / path).read_bytes().decode('utf-8')] + + names_before, names_after = leg_names(workflows, before_values), leg_names(workflows, after_values) + renamed, added, removed = [], [], [] + for job in sorted(set(names_before) | set(names_after)): + old, new = names_before.get(job, {}), names_after.get(job, {}) + renamed += [[old[key], new[key]] for key in old if key in new and old[key] != new[key]] + added += [new[key] for key in new if key not in old] + removed += [old[key] for key in old if key not in new] + required_before = required_contexts(workflows, before_values) + required_after = facts['required'] + pypi = (workflows['create-release.yml']['jobs']['pypi'].get('outputs') or {}) + for leg in matrix_legs(workflows, after_values).get(('create-release.yml', 'pypi'), []): + key = 'ktlo_' + leg['python-version'].replace('.', '') + if leg.get('ktlo') is True and key not in pypi: + notes.append(Note('manual', f'create-release.yml: the `pypi` KTLO leg {leg["python-version"]} needs ' + f'its own `{key}` output for `release_status`')) + + changes = tier_changes(before, after, reasons) + plan = { + 'fingerprint': fingerprint(latest, versions), + 'title': title_of(changes, (before['latest'], latest)), + 'changed_files': changed, + 'changes': changes, + 'latest': [before['latest'], latest], + 'required': {'remove': [name for name in required_before if name not in required_after], + 'add': [name for name in required_after if name not in required_before]}, + 'renamed': renamed, + 'added': added, + 'removed': removed, + 'notes': [note._asdict() for note in notes], + } + return plan, {path: texts[path] for path in changed} + + +def commit_message(plan: 'dict[str, Any]') -> 'str': + lines = [plan['title'], ''] + lines += [f'- {phrase(change)} ({change["reason"]})' for change in plan['changes']] + if plan['latest'][0] != plan['latest'][1]: + lines.append(f'- latest: {plan["latest"][0]} -> {plan["latest"][1]}') + lines += ['', 'Recomputed by .github/workflows/cron-python-versions.yml.', '', f'{TRAILER}: {plan["fingerprint"]}'] + return '\n'.join(lines) + '\n' + + +############################################################################### +# Pull request body +############################################################################### + + +def tier_text(entry: 'Optional[dict[str, str]]') -> 'str': + if entry is None: + return '--' + name = 'KTLO' if entry['tier'] == 'ktlo' else entry['tier'] + return f'{name} on `{entry["runner"]}`' if 'runner' in entry else name + + +def describe_plan(plan: 'dict[str, Any]', audit: 'tuple[str, list[str]]', run_url: 'str') -> 'str': + """The body's description: what moved and why, what the owner edits by hand, the audit.""" + out = [f'The weekly [Python Versions run]({run_url}) recomputed `{TIER_FILE}` from python.org\'s release ' + 'cycle, PyPy\'s releases, setup-python\'s builds and the hosted runner images (#1660), and regenerated ' + 'the prose that restates it.', ''] + if plan['changes'] or plan['latest'][0] != plan['latest'][1]: + out += ['| Version | Before | After | Why |', '|---|---|---|---|'] + out += [f'| `{c["version"]}` | {tier_text(c["before"])} | {tier_text(c["after"])} | {c["reason"]} |' + for c in plan['changes']] + if plan['latest'][0] != plan['latest'][1]: + out.append(f'| `latest` | {plan["latest"][0]} | {plan["latest"][1]} | the newest bugfix branch |') + out.append('') + required = plan['required'] + if required['remove'] or required['add']: + out.append('**Before merging, edit ruleset 23497679 (`main`)** -- the workflow never edits it:') + out += [f'- remove `{name}`' for name in required['remove']] + out += [f'- add `{name}`' for name in required['add']] + else: + out.append('Ruleset 23497679 needs no edit: its required contexts are unchanged.') + out.append('') + if plan['renamed']: + out.append('Renamed check runs: ' + ', '.join(f'`{a}` -> `{b}`' for a, b in plan['renamed']) + '.') + if plan['added']: + out.append('New check runs: ' + ', '.join(f'`{name}`' for name in plan['added']) + '.') + if plan['removed']: + out.append('Removed check runs: ' + ', '.join(f'`{name}`' for name in plan['removed']) + '.') + if plan['notes']: + out += ['', 'Not done here:'] + [f'- {note["text"]}' for note in plan['notes']] + outcome, failures = audit + names = ', '.join(f'`{module.rsplit(".", 1)[-1]}`' for module in AUDIT) + if outcome == 'success': + out += ['', f'Audit ({names}): passed on this tree.'] + else: + out += ['', f'Audit ({names}): **{outcome}** on this tree; finish the hand edits on this branch -- the ' + 'next run keeps commits that are not its own.'] + out += [f'- `{failure}`' for failure in failures[:15]] + if len(failures) > 15: + out.append(f'- and {len(failures) - 15} more; see the run\'s log') + out += ['', 'Regenerated: ' + ', '.join(f'`{path}`' for path in plan['changed_files']) + '.'] + return '\n'.join(out) + + +def fill_template(template: 'str', description: 'str', audit_outcome: 'str') -> 'str': + """The repository's pull request template, ``ci`` ticked, the description filled in.""" + text = re.sub(r'(?m)^- \[ \] `ci`', '- [x] `ci`', template) + audit = {'success': 'passed', 'failure': 'failed'}.get(audit_outcome, audit_outcome) + text = re.sub(r'(?m)^(- \[ \] `make test` passes.*)$', rf'\1 -- not run; the audit {audit} (below)', text) + text = re.sub(r'(?m)^(- \[ \] Added a \[changelog entry\].*)$', r'\1 — N/A — centralised in #657', text) + placeholder = re.search(r'(?m)^  Explanation of your \*pull request\*.*$', text) + if placeholder is None: + return text.rstrip('\n') + '\n\n' + description + '\n' + return text[:placeholder.start()] + description + text[placeholder.end():].rstrip('\n') + '\n' + + +def audit_failures(log: 'str') -> 'list[str]': + """The failing tests in a ``python -m unittest`` log, as ``module.Class.test``.""" + found = [] + for match in re.finditer(r'(?m)^(?:FAIL|ERROR): (\S+) \(([\w.]+)\)', log): + name = f'{match.group(2)}.{match.group(1)}' if not match.group(2).endswith(match.group(1)) else match.group(2) + if name not in found: + found.append(name) + return found + + +############################################################################### +# Git +############################################################################### + + +def git(root: 'pathlib.Path', *argv: 'str', check: 'bool' = True) -> 'subprocess.CompletedProcess[str]': + result = subprocess.run(['git', *argv], cwd=root, capture_output=True, text=True, check=False) # nosec B603 B607 + if check and result.returncode: + raise RuntimeError(f'git {" ".join(argv)} exited {result.returncode}: {result.stderr.strip()}') + return result + + +def proposal_of(message: 'str') -> 'Optional[str]': + match = re.search(rf'(?m)^{TRAILER}: (\w+)\s*$', message) + return match.group(1) if match else None + + +def proposals(root: 'pathlib.Path', *revisions: 'str') -> 'list[Optional[str]]': + """What each commit ``git log `` lists proposes, newest first; :data:`None` for one of none. + + A merge with a parent the range leaves out is left out too. In + ``main..branch`` that parent is :data:`BASE`'s: the merge is GitHub's + "Update branch", or ``git merge main`` to resolve a conflict, so it is + neither a proposal nor a hand edit, and what it brings in is :data:`BASE`'s. + + """ + listed = git(root, 'log', '-z', '--format=%H %P%n%B', *revisions).stdout + commits = [] + for record in listed.split('\0'): + if record.strip(): + head, _, message = record.partition('\n') + sha, *parents = head.split() + commits.append((sha, parents, message)) + shas = {sha for sha, _, _ in commits} + return [proposal_of(message) for _, parents, message in commits + if len(parents) < 2 or all(parent in shas for parent in parents)] + + +def foreign(held: 'list[Optional[str]]') -> 'bool': + """Whether a branch holding these commits ahead of :data:`BASE` holds one this workflow did not make. + + The workflow's own is one commit, carrying :data:`TRAILER`, on :data:`BASE`. + While the branch's pull request is open, ``publish`` will not overwrite + anything else, and ``update`` refuses first. + + """ + return len(held) > 1 or None in held + + +def ahead(root: 'pathlib.Path', branch: 'str') -> 'Optional[tuple[str, list[Optional[str]]]]': + """``origin``'s ``branch``: its tip, and :func:`proposals` of what it holds ahead of :data:`BASE`. + + :data:`None` when there is no such branch. Both branches are fetched whole + into a scratch repository, commits only (``--filter=tree:0``; 2,917 commits, + 2.4 MB, under a second on 2026-10-13). That is anonymous for a public + repository and needs no history in ``root``, so ``detect``'s depth-1 + checkout serves and is left as it was. A fetch cut at :data:`BASE` + (``--shallow-exclude``) stops at a merge of it, and loses the proposal + under that merge. + + """ + url = git(root, 'remote', 'get-url', 'origin').stdout.strip() + with tempfile.TemporaryDirectory(prefix='update-python-versions-') as tmp: + scratch = pathlib.Path(tmp) + git(scratch, 'init', '--quiet', '--bare') + # A named remote: a filtered fetch records it as the promisor, which a path cannot be. + git(scratch, 'remote', 'add', 'origin', url) + listed = git(scratch, 'ls-remote', '--exit-code', '--heads', 'origin', f'refs/heads/{branch}', check=False) + if listed.returncode == 2: + return None + if listed.returncode: + raise RuntimeError(f'git ls-remote exited {listed.returncode}: {listed.stderr.strip()}') + # #1644: no maintenance left running as the directory is removed. + git(scratch, '-c', 'maintenance.auto=false', '-c', 'gc.auto=0', '-c', 'gc.autoDetach=false', 'fetch', + '--quiet', '--no-tags', '--filter=tree:0', 'origin', f'+refs/heads/{BASE}:refs/heads/{BASE}', + f'+refs/heads/{branch}:refs/heads/{branch}') + tip = git(scratch, 'rev-parse', f'refs/heads/{branch}').stdout.strip() + return tip, proposals(scratch, f'refs/heads/{BASE}..refs/heads/{branch}') + + +def proposed(root: 'pathlib.Path', branch: 'str') -> 'Optional[list[Optional[str]]]': + """What each commit ``origin``'s ``branch`` holds ahead of :data:`BASE` proposes, newest first (:func:`ahead`).""" + found = ahead(root, branch) + return None if found is None else found[1] + + +def repository() -> 'str': + repo = os.environ.get('GITHUB_REPOSITORY', '') + if not re.fullmatch(r'[\w.-]+/[\w.-]+', repo): + raise RuntimeError(f'$GITHUB_REPOSITORY {repo!r} names no repository') + return repo + + +def open_pull_requests(branch: 'str') -> 'list[str]': + """The open pull requests from :func:`repository`'s own ``branch`` into :data:`BASE`, by number; read-only. + + ``gh pr list --head`` also matches a fork's branch of that name, which is not this one. + + """ + return gh('pr', 'list', '--repo', repository(), '--head', branch, '--base', BASE, '--state', 'open', + '--json', 'number,isCrossRepository', '--jq', '.[] | select(.isCrossRepository | not) | .number').split() + + +############################################################################### +# Commands +############################################################################### + + +def escape(text: 'str', title: 'bool' = False) -> 'str': + """``text`` for a workflow command: data escapes, and in a title ``,`` and ``:`` too.""" + text = text.replace('%', '%25').replace('\r', '%0D').replace('\n', '%0A') + return text.replace(':', '%3A').replace(',', '%2C') if title else text + + +def annotate(notes: 'Iterable[dict[str, str]]') -> 'None': + levels = {'held': 'notice', 'warning': 'warning', 'manual': 'warning'} + for note in notes: + print(f'::{levels[note["kind"]]} title={escape("Python version tiers", title=True)}::{escape(note["text"])}') + + +def write_output(path: 'Optional[str]', values: 'dict[str, str]') -> 'None': + if not path: + raise RuntimeError('--github-output needs $GITHUB_OUTPUT') + with open(path, 'a', encoding='utf-8') as file: + for name, value in values.items(): + file.write(f'{name}={value}\n') + + +def command_update(args: 'argparse.Namespace', root: 'pathlib.Path' = ROOT) -> 'int': + texts = read_sources(args.sources) + plan, new_texts = compute_plan(root, parse_sources(texts)) + for change in plan['changes']: + print(f'{change["version"]}: {tier_text(change["before"])} -> {tier_text(change["after"])} ' + f'({change["reason"]})') + if plan['latest'][0] != plan['latest'][1]: + print(f'latest: {plan["latest"][0]} -> {plan["latest"][1]}') + for old, new in plan['renamed']: + print(f'renamed: {old} -> {new}') + for kind in ('add', 'remove'): + for name in plan['required'][kind]: + print(f'ruleset 23497679: {kind} {name}') + annotate(plan['notes']) + changed = bool(plan['changed_files']) + publish, status = changed, 0 + if changed and args.proposal_branch: + held = proposed(root, args.proposal_branch) + branch = escape(args.proposal_branch) + if held is not None and not open_pull_requests(args.proposal_branch): + print(f'::notice title=Proposing afresh::{branch} has no open pull request, so what it holds was merged ' + 'or closed; publish recreates it from main.') + held = None + held = held or [] + if plan['fingerprint'] in held: + publish = False + print(f'::notice title=Already proposed::{branch} already proposes these tiers ({plan["fingerprint"]}); ' + 'close its pull request to have them proposed again.') + elif foreign(held): + # publish would refuse, so no approval is asked for that. + publish, status = False, 1 + older = ', '.join(sorted({found for found in held if found})) or 'none' + print(f'::error title=Not overwriting {escape(args.proposal_branch, title=True)}::it holds commits this ' + f'workflow did not make and proposes {older}, not these tiers ({plan["fingerprint"]}); merge or ' + 'close its pull request, or delete the branch, to have them proposed.') + if args.diff: + for path, text in new_texts.items(): + old = (root / path).read_bytes().decode('utf-8') + sys.stdout.writelines(difflib.unified_diff(old.splitlines(True), text.splitlines(True), + f'a/{path}', f'b/{path}')) + if args.write: + for path, text in new_texts.items(): + (root / path).write_bytes(text.encode('utf-8')) + if args.out is not None: + out = pathlib.Path(args.out) + out.mkdir(parents=True, exist_ok=True) + for path, text in new_texts.items(): + target = out / 'files' / path + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(text.encode('utf-8')) + (out / 'sources').mkdir(exist_ok=True) + for name, source in SOURCES.items(): + (out / 'sources' / source.filename).write_text(texts[name], encoding='utf-8') + (out / 'plan.json').write_text(json.dumps(plan, indent=1) + '\n', encoding='utf-8') + (out / 'commit-message.txt').write_text(commit_message(plan), encoding='utf-8') + print(f'{len(plan["changed_files"])} file(s) to change: {" ".join(plan["changed_files"]) or "none"}') + if args.github_output: + write_output(os.environ.get('GITHUB_OUTPUT'), {'changed': str(changed).lower(), + 'publish': str(publish).lower(), + 'fingerprint': plan['fingerprint']}) + return status + + +def command_body(args: 'argparse.Namespace', root: 'pathlib.Path' = ROOT) -> 'int': + out = pathlib.Path(args.proposal) + plan = json.loads((out / 'plan.json').read_text(encoding='utf-8')) + log = pathlib.Path(args.audit_log).read_text(encoding='utf-8', errors='replace') if args.audit_log else '' + description = describe_plan(plan, (args.audit_outcome, audit_failures(log)), args.run_url) + body = fill_template((root / TEMPLATE).read_text(encoding='utf-8'), description, args.audit_outcome) + (out / 'pr-body.md').write_text(body, encoding='utf-8') + summary = os.environ.get('GITHUB_STEP_SUMMARY') + if summary: + with open(summary, 'a', encoding='utf-8') as file: + file.write(f'## {plan["title"]}\n\n{description}\n') + return 0 + + +def check_targets(paths: 'Iterable[str]') -> 'None': + for path in paths: + if path not in TARGETS or path.startswith(WORKFLOW_DIR + '/'): + raise ValueError(f'{path!r} is not a file this script writes') + + +def gh(*argv: 'str') -> 'str': + result = subprocess.run(['gh', *argv], capture_output=True, text=True, check=False) # nosec B603 B607 + if result.returncode: + raise RuntimeError(f'gh {argv[0]} {argv[1]} exited {result.returncode}: {result.stderr.strip()}') + return result.stdout + + +def push(root: 'pathlib.Path', branch: 'str', lease: 'str') -> 'subprocess.CompletedProcess[str]': + """``HEAD`` to ``origin``'s ``branch`` and nowhere else, if ``branch`` is still at ``lease`` (``''``: absent).""" + return git(root, 'push', f'--force-with-lease=refs/heads/{branch}:{lease}', 'origin', + f'HEAD:refs/heads/{branch}', check=False) + + +def command_publish(args: 'argparse.Namespace', root: 'pathlib.Path' = ROOT) -> 'int': + """Commit the proposal on :data:`BRANCH`, push that branch, and open or update its pull request.""" + out = pathlib.Path(args.proposal) + plan = json.loads((out / 'plan.json').read_text(encoding='utf-8')) + check_targets(plan['changed_files']) + slug = os.environ.get('APP_SLUG', '') + if not re.fullmatch(r'[\w-]+', slug): + raise ValueError(f'APP_SLUG {slug!r} is not an App slug') + bot_id = os.environ.get('APP_BOT_ID', '') + email = f'{bot_id}+{slug}[bot]@users.noreply.github.com' if bot_id.isdigit() else \ + f'{slug}[bot]@users.noreply.github.com' + repo = repository() + + for path in plan['changed_files']: + shutil.copyfile(out / 'files' / path, root / path) + git(root, 'add', '--', *plan['changed_files']) + if not git(root, 'diff', '--cached', '--quiet', check=False).returncode: + print('::notice title=Nothing to propose::this commit already holds the proposal') + return 0 + git(root, '-c', f'user.name={slug}[bot]', '-c', f'user.email={email}', 'commit', '--quiet', + '-F', str(out / 'commit-message.txt')) + + # The lease is the tip whose commits were read, so a push since fails it. + found = ahead(root, BRANCH) + lease = '' if found is None else found[0] + recreate = found is not None and not open_pull_requests(BRANCH) + if recreate: + print(f'::notice title=Recreating {escape(BRANCH, title=True)}::it has no open pull request, so what it ' + 'holds was merged or closed; it restarts from main.') + elif found is not None and foreign(found[1]): + print(f'::error title=Not overwriting {escape(BRANCH, title=True)}::it holds commits this workflow ' + 'did not make; merge or close its pull request, or delete the branch, and re-run.') + return 1 + if not recreate: + pushed = push(root, BRANCH, lease) + # main's workflows moved since the branch was cut, so moving the branch + # would change them, which this token may not do; a new branch does not. + recreate = bool(pushed.returncode and lease and WORKFLOWS_REFUSAL in pushed.stderr) + if recreate: + print(f'::warning title=Recreating {escape(BRANCH, title=True)}::moving it would change workflow files ' + 'main changed since, which needs the workflows permission; its pull request closes and a new one ' + 'opens.') + if recreate: + git(root, 'push', f'--force-with-lease=refs/heads/{BRANCH}:{lease}', 'origin', f':refs/heads/{BRANCH}') + pushed = push(root, BRANCH, '') + if pushed.returncode: + raise RuntimeError(f'git push exited {pushed.returncode}: {pushed.stderr.strip()}') + + number = open_pull_requests(BRANCH) + body = str(out / 'pr-body.md') + if number: + gh('pr', 'edit', number[0], '--repo', repo, '--title', plan['title'], '--body-file', body) + print(f'::notice title=Updated the proposal::pull request #{number[0]}') + else: + url = gh('pr', 'create', '--repo', repo, '--base', BASE, '--head', BRANCH, '--title', plan['title'], + '--body-file', body).strip() + print(f'::notice title=Opened the proposal::{escape(url)}') + return 0 + + +def main(argv: 'Optional[list[str]]' = None, root: 'pathlib.Path' = ROOT) -> 'int': + """The command line; ``root`` is the checkout it reads and writes.""" + parser = argparse.ArgumentParser(description=__doc__.split('\n', 1)[0]) + commands = parser.add_subparsers(dest='command', required=True) + update = commands.add_parser('update', help='recompute the tiers and the prose') + update.add_argument('--sources', type=pathlib.Path, help='read the sources from this directory, not the network') + update.add_argument('--write', action='store_true', help='rewrite the files in place') + update.add_argument('--diff', action='store_true', help='print the change as a unified diff') + update.add_argument('--out', help='write the proposal here') + update.add_argument('--proposal-branch', help='skip publishing while this branch has an open pull request and ' + 'already proposes the same tiers') + update.add_argument('--github-output', action='store_true', help='write changed, publish, fingerprint') + body = commands.add_parser('body', help="render a proposal's pull request body") + body.add_argument('proposal') + body.add_argument('--audit-outcome', default='success') + body.add_argument('--audit-log') + body.add_argument('--run-url', default='') + publish = commands.add_parser('publish', help="push a proposal's branch and open or update its pull request") + publish.add_argument('proposal') + args = parser.parse_args(argv) + run = {'update': command_update, 'body': command_body, 'publish': command_publish}[args.command] + try: + return run(args, root) + except (OSError, ValueError, RuntimeError, KeyError) as error: + message = escape(f'{type(error).__name__}: {error}') + print(f'::error title=Cannot {args.command} the Python version tiers::{message}') + return 1 + + +if __name__ == '__main__': + sys.exit(main())