Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions .github/workflows/check-python-versions.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
name: Check supported Python versions

on:
schedule:
- cron: '0 12 * * 1'
workflow_dispatch:

permissions:
contents: read
issues: write

concurrency:
group: check-supported-python-versions
cancel-in-progress: false

jobs:
check:
if: github.repository_owner == 'microsoft'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.15'
- name: Check for a new Python version
id: check
shell: bash
run: |
version="$(python build/update_python_versions.py)"
echo "version=$version" >> "$GITHUB_OUTPUT"
- name: Create issue
if: steps.check.outputs.version != ''
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ steps.check.outputs.version }}
shell: bash
run: |
title="CI Python Versions: Update to Python $VERSION"
issue_number="$(
gh api --paginate \
-H "X-GitHub-Api-Version: 2022-11-28" \
"/repos/$GITHUB_REPOSITORY/issues?state=all&per_page=100" \
--jq ".[] | select(has(\"pull_request\") | not) | select(.title == \"$title\") | .number"
)"
if [[ -z "$issue_number" ]]; then
gh issue create \
--title "$title" \
--label debt \
--label python-version \
--body "Python $VERSION has a published release candidate according to the [Python.org downloads API](https://www.python.org/api/v2/downloads/release/?is_published=true&pre_release=true&version=3), but is missing from at least one CI test matrix.

### Requested changes
- [ ] Ensure every explicit-version \`python\` matrix in \`.github/workflows/pr-check.yml\` includes Python $VERSION.
- [ ] Ensure every explicit-version \`python\` matrix in \`.github/workflows/build.yml\` includes Python $VERSION.
- [ ] Run the tests on Python $VERSION and resolve any compatibility failures.

Compare matrix entries by major.minor; patch and prerelease/dev variants of $VERSION already count as coverage. Add the version only where missing and preserve existing entries. Floating \`3.x\` entries do not declare release-candidate coverage.

Keep minimum-supported-version and build-runtime Python pins unchanged; adding CI coverage does not require raising the minimum supported Python version."
fi
143 changes: 143 additions & 0 deletions build/test_update_python_versions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License.

import datetime

import pytest
import update_python_versions

NOW = datetime.datetime(2026, 8, 2, tzinfo=datetime.timezone.utc)
WORKFLOWS = ("pr-check.yml", "build.yml")


def release(name="Python 3.15.0rc1", release_date="2026-08-01T00:00:00Z", **overrides):
result = {
"name": name,
"release_date": release_date,
"is_published": True,
"pre_release": True,
"version": 3,
}
result.update(overrides)
return result


def test_latest_release_candidate_filters_and_orders_releases():
releases = [
release("Python 3.9.0rc10"),
release("Python 3.10.0rc2"),
release("Python 3.11.0b1"),
release("Python 3.11.0rc1", "2027-08-01T00:00:00Z"),
release("Python 3.12.0rc1", is_published=False),
release("Python 3.13.0rc1", pre_release=False),
None,
]

assert update_python_versions.latest_release_candidate(releases, NOW) == "3.10"


@pytest.mark.parametrize("release_date", [None, "invalid", "2026-08-01"])
def test_latest_release_candidate_rejects_malformed_dates(release_date):
with pytest.raises(ValueError, match="release_date"):
update_python_versions.latest_release_candidate(
[release(release_date=release_date)], NOW
)


def test_latest_release_candidate_requires_a_published_rc():
with pytest.raises(ValueError, match="released Python RC"):
update_python_versions.latest_release_candidate(
[release("Python 3.15.0"), release("Python 3.16.0a1")], NOW
)


@pytest.mark.parametrize("key", ["python", "python-version"])
def test_configured_versions_normalizes_explicit_series(tmp_path, key):
workflow = tmp_path / "workflow.yml"
workflow.write_text(
f"{key}: ['3.10.9', '3.11-dev', '3.12.0rc1', '3.x']\n", encoding="utf-8"
)

assert update_python_versions.configured_versions(workflow) == {
"3.10",
"3.11",
"3.12",
}


@pytest.mark.parametrize("text", ["matrix: {}\n", "python: ['3.x']\n"])
def test_configured_versions_requires_explicit_matrices(tmp_path, text):
workflow = tmp_path / "workflow.yml"
workflow.write_text(text, encoding="utf-8")

with pytest.raises(RuntimeError, match="Python version"):
update_python_versions.configured_versions(workflow)


@pytest.mark.parametrize("key", ["python", "python-version"])
@pytest.mark.parametrize(
"first, second, expected",
[
("3.15", "3.14", set()),
("3.14", "3.15", set()),
("3.15.1", "3.15-dev", {"3.15"}),
],
)
def test_configured_versions_requires_coverage_in_each_matrix(
tmp_path, key, first, second, expected
):
workflow = tmp_path / "workflow.yml"
workflow.write_text(
f"{key}: ['{first}']\n{key}: ['{second}']\n{key}: ['3.x']\n",
encoding="utf-8",
)

assert update_python_versions.configured_versions(workflow) == expected


@pytest.mark.parametrize(
"pr_version, push_version, expected",
[
("3.14", "3.14", "3.15"),
("3.15", "3.14", "3.15"),
("3.14", "3.15", "3.15"),
("3.15", "3.15", None),
("3.15.1", "3.15.0-rc.2", None),
("3.150", "3.15", "3.15"),
],
)
def test_unconfigured_version(tmp_path, pr_version, push_version, expected):
workflows = [tmp_path / name for name in WORKFLOWS]
for workflow, version in zip(workflows, (pr_version, push_version)):
workflow.write_text(f"python: ['{version}']\n", encoding="utf-8")

assert (
update_python_versions.unconfigured_version([release()], workflows, NOW)
== expected
)


@pytest.mark.parametrize(
"push_version, expected", [("3.14", "3.15\n"), ("3.15-dev", "")]
)
def test_main_checks_both_workflows(
tmp_path, monkeypatch, capsys, push_version, expected
):
workflow_dir = tmp_path / ".github" / "workflows"
workflow_dir.mkdir(parents=True)
for name, version in zip(WORKFLOWS, ("3.15.1", push_version)):
(workflow_dir / name).write_text(f"python: ['{version}']\n", encoding="utf-8")
monkeypatch.setattr(
update_python_versions,
"__file__",
str(tmp_path / "build" / "update_python_versions.py"),
)
monkeypatch.setattr(
update_python_versions,
"fetch_releases",
lambda: [release(release_date="2020-08-01T00:00:00Z")],
)

update_python_versions.main()

assert capsys.readouterr().out == expected
101 changes: 101 additions & 0 deletions build/update_python_versions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License.

import datetime
import json
import pathlib
import re
import urllib.request

DOWNLOADS_URL = (
"https://www.python.org/api/v2/downloads/release/"
"?is_published=true&pre_release=true&version=3"
)
RC_NAME = re.compile(r"Python (?P<series>3\.\d+)\.0rc(?P<candidate>\d+)")
VERSION_MATRIX = re.compile(
r"^\s*python(?:-version)?:\s*\[(?P<versions>[^\]\n]+)\]\s*$", re.MULTILINE
)


def fetch_releases():
"""Fetch published Python prereleases from Python.org."""
request = urllib.request.Request(
DOWNLOADS_URL, headers={"User-Agent": "vscode-python-tools-version-check"}
)
with urllib.request.urlopen(request, timeout=30) as response:
return json.load(response)


def latest_release_candidate(releases, now=None):
"""Return the newest Python 3 series with a published release candidate."""
now = now or datetime.datetime.now(datetime.timezone.utc)
candidates = []
for release in releases:
if (
not isinstance(release, dict)
or release.get("is_published") is not True
or release.get("pre_release") is not True
or release.get("version") != 3
or not isinstance(release.get("name"), str)
):
continue
match = RC_NAME.fullmatch(release["name"])
if not match:
continue
try:
release_date = datetime.datetime.fromisoformat(
release["release_date"].replace("Z", "+00:00")
)
except (AttributeError, KeyError, TypeError, ValueError) as exc:
raise ValueError(f"Invalid release_date for {release['name']}") from exc
if release_date.tzinfo is None:
raise ValueError(f"Missing timezone in release_date for {release['name']}")
if release_date <= now:
series = match.group("series")
candidates.append(
(
tuple(map(int, series.split("."))),
int(match.group("candidate")),
series,
)
)
if not candidates:
raise ValueError("The downloads API did not contain a released Python RC")
return max(candidates)[2]


def configured_versions(workflow):
"""Return major.minor versions covered by every explicit Python matrix."""
matches = VERSION_MATRIX.findall(workflow.read_text(encoding="utf-8"))
if not matches:
raise RuntimeError(f"Expected a Python version matrix in {workflow}")
matrices = [
set(re.findall(r"['\"](\d+\.\d+)[^'\"]*['\"]", match)) for match in matches
]
# Floating-only matrices such as ['3.x'] do not declare RC-series coverage.
explicit_matrices = [versions for versions in matrices if versions]
if not explicit_matrices:
raise RuntimeError(f"No explicit Python versions found in {workflow}")
return set.intersection(*explicit_matrices)


def unconfigured_version(releases, workflows, now=None):
"""Return the newest RC series when any explicit test matrix lacks it."""
latest = latest_release_candidate(releases, now)
for workflow in workflows:
if latest not in configured_versions(workflow):
return latest
return None


def main():
"""Print the newest RC series if it is missing from the CI test matrices."""
workflow_dir = pathlib.Path(__file__).parent.parent / ".github" / "workflows"
workflows = [workflow_dir / name for name in ("pr-check.yml", "build.yml")]
version = unconfigured_version(fetch_releases(), workflows)
if version:
print(version)


if __name__ == "__main__":
main()
Loading