Skip to content

Commit dbbd17e

Browse files
edvilmeCopilot
andcommitted
Add Python version update workflow
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0f98b77b-049b-4c68-953f-648bbde06698
1 parent a9759d1 commit dbbd17e

3 files changed

Lines changed: 304 additions & 0 deletions

File tree

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
name: Check supported Python versions
2+
3+
on:
4+
schedule:
5+
- cron: '0 12 * * 1'
6+
workflow_dispatch:
7+
8+
permissions:
9+
contents: read
10+
issues: write
11+
12+
concurrency:
13+
group: check-supported-python-versions
14+
cancel-in-progress: false
15+
16+
jobs:
17+
check:
18+
if: github.repository_owner == 'microsoft'
19+
runs-on: ubuntu-latest
20+
steps:
21+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
22+
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
23+
with:
24+
python-version: '3.15'
25+
- name: Check for a new Python version
26+
id: check
27+
shell: bash
28+
run: |
29+
version="$(python build/update_python_versions.py)"
30+
echo "version=$version" >> "$GITHUB_OUTPUT"
31+
- name: Create issue
32+
if: steps.check.outputs.version != ''
33+
env:
34+
GH_TOKEN: ${{ github.token }}
35+
VERSION: ${{ steps.check.outputs.version }}
36+
shell: bash
37+
run: |
38+
title="CI Python Versions: Update to Python $VERSION"
39+
issue_number="$(
40+
gh api --paginate \
41+
-H "X-GitHub-Api-Version: 2022-11-28" \
42+
"/repos/$GITHUB_REPOSITORY/issues?state=all&per_page=100" \
43+
--jq ".[] | select(has(\"pull_request\") | not) | select(.title == \"$title\") | .number"
44+
)"
45+
if [[ -z "$issue_number" ]]; then
46+
gh issue create \
47+
--title "$title" \
48+
--label debt \
49+
--label python-version \
50+
--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.
51+
52+
### Requested changes
53+
- [ ] Ensure every explicit-version \`python\` matrix in \`.github/workflows/pr-check.yml\` includes Python $VERSION.
54+
- [ ] Ensure every explicit-version \`python\` matrix in \`.github/workflows/build.yml\` includes Python $VERSION.
55+
- [ ] Run the tests on Python $VERSION and resolve any compatibility failures.
56+
57+
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.
58+
59+
Keep minimum-supported-version and build-runtime Python pins unchanged; adding CI coverage does not require raising the minimum supported Python version."
60+
fi
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
# Copyright (c) Microsoft Corporation. All rights reserved.
2+
# Licensed under the MIT License.
3+
4+
import datetime
5+
6+
import pytest
7+
import update_python_versions
8+
9+
NOW = datetime.datetime(2026, 8, 2, tzinfo=datetime.timezone.utc)
10+
WORKFLOWS = ("pr-check.yml", "build.yml")
11+
12+
13+
def release(name="Python 3.15.0rc1", release_date="2026-08-01T00:00:00Z", **overrides):
14+
result = {
15+
"name": name,
16+
"release_date": release_date,
17+
"is_published": True,
18+
"pre_release": True,
19+
"version": 3,
20+
}
21+
result.update(overrides)
22+
return result
23+
24+
25+
def test_latest_release_candidate_filters_and_orders_releases():
26+
releases = [
27+
release("Python 3.9.0rc10"),
28+
release("Python 3.10.0rc2"),
29+
release("Python 3.11.0b1"),
30+
release("Python 3.11.0rc1", "2027-08-01T00:00:00Z"),
31+
release("Python 3.12.0rc1", is_published=False),
32+
release("Python 3.13.0rc1", pre_release=False),
33+
None,
34+
]
35+
36+
assert update_python_versions.latest_release_candidate(releases, NOW) == "3.10"
37+
38+
39+
@pytest.mark.parametrize("release_date", [None, "invalid", "2026-08-01"])
40+
def test_latest_release_candidate_rejects_malformed_dates(release_date):
41+
with pytest.raises(ValueError, match="release_date"):
42+
update_python_versions.latest_release_candidate(
43+
[release(release_date=release_date)], NOW
44+
)
45+
46+
47+
def test_latest_release_candidate_requires_a_published_rc():
48+
with pytest.raises(ValueError, match="released Python RC"):
49+
update_python_versions.latest_release_candidate(
50+
[release("Python 3.15.0"), release("Python 3.16.0a1")], NOW
51+
)
52+
53+
54+
@pytest.mark.parametrize("key", ["python", "python-version"])
55+
def test_configured_versions_normalizes_explicit_series(tmp_path, key):
56+
workflow = tmp_path / "workflow.yml"
57+
workflow.write_text(
58+
f"{key}: ['3.10.9', '3.11-dev', '3.12.0rc1', '3.x']\n", encoding="utf-8"
59+
)
60+
61+
assert update_python_versions.configured_versions(workflow) == {
62+
"3.10",
63+
"3.11",
64+
"3.12",
65+
}
66+
67+
68+
@pytest.mark.parametrize("text", ["matrix: {}\n", "python: ['3.x']\n"])
69+
def test_configured_versions_requires_explicit_matrices(tmp_path, text):
70+
workflow = tmp_path / "workflow.yml"
71+
workflow.write_text(text, encoding="utf-8")
72+
73+
with pytest.raises(RuntimeError, match="Python version"):
74+
update_python_versions.configured_versions(workflow)
75+
76+
77+
@pytest.mark.parametrize("key", ["python", "python-version"])
78+
@pytest.mark.parametrize(
79+
"first, second, expected",
80+
[
81+
("3.15", "3.14", set()),
82+
("3.14", "3.15", set()),
83+
("3.15.1", "3.15-dev", {"3.15"}),
84+
],
85+
)
86+
def test_configured_versions_requires_coverage_in_each_matrix(
87+
tmp_path, key, first, second, expected
88+
):
89+
workflow = tmp_path / "workflow.yml"
90+
workflow.write_text(
91+
f"{key}: ['{first}']\n{key}: ['{second}']\n{key}: ['3.x']\n",
92+
encoding="utf-8",
93+
)
94+
95+
assert update_python_versions.configured_versions(workflow) == expected
96+
97+
98+
@pytest.mark.parametrize(
99+
"pr_version, push_version, expected",
100+
[
101+
("3.14", "3.14", "3.15"),
102+
("3.15", "3.14", "3.15"),
103+
("3.14", "3.15", "3.15"),
104+
("3.15", "3.15", None),
105+
("3.15.1", "3.15.0-rc.2", None),
106+
("3.150", "3.15", "3.15"),
107+
],
108+
)
109+
def test_unconfigured_version(tmp_path, pr_version, push_version, expected):
110+
workflows = [tmp_path / name for name in WORKFLOWS]
111+
for workflow, version in zip(workflows, (pr_version, push_version)):
112+
workflow.write_text(f"python: ['{version}']\n", encoding="utf-8")
113+
114+
assert (
115+
update_python_versions.unconfigured_version([release()], workflows, NOW)
116+
== expected
117+
)
118+
119+
120+
@pytest.mark.parametrize(
121+
"push_version, expected", [("3.14", "3.15\n"), ("3.15-dev", "")]
122+
)
123+
def test_main_checks_both_workflows(
124+
tmp_path, monkeypatch, capsys, push_version, expected
125+
):
126+
workflow_dir = tmp_path / ".github" / "workflows"
127+
workflow_dir.mkdir(parents=True)
128+
for name, version in zip(WORKFLOWS, ("3.15.1", push_version)):
129+
(workflow_dir / name).write_text(f"python: ['{version}']\n", encoding="utf-8")
130+
monkeypatch.setattr(
131+
update_python_versions,
132+
"__file__",
133+
str(tmp_path / "build" / "update_python_versions.py"),
134+
)
135+
monkeypatch.setattr(
136+
update_python_versions,
137+
"fetch_releases",
138+
lambda: [release(release_date="2020-08-01T00:00:00Z")],
139+
)
140+
141+
update_python_versions.main()
142+
143+
assert capsys.readouterr().out == expected

‎build/update_python_versions.py‎

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
# Copyright (c) Microsoft Corporation. All rights reserved.
2+
# Licensed under the MIT License.
3+
4+
import datetime
5+
import json
6+
import pathlib
7+
import re
8+
import urllib.request
9+
10+
DOWNLOADS_URL = (
11+
"https://www.python.org/api/v2/downloads/release/"
12+
"?is_published=true&pre_release=true&version=3"
13+
)
14+
RC_NAME = re.compile(r"Python (?P<series>3\.\d+)\.0rc(?P<candidate>\d+)")
15+
VERSION_MATRIX = re.compile(
16+
r"^\s*python(?:-version)?:\s*\[(?P<versions>[^\]\n]+)\]\s*$", re.MULTILINE
17+
)
18+
19+
20+
def fetch_releases():
21+
"""Fetch published Python prereleases from Python.org."""
22+
request = urllib.request.Request(
23+
DOWNLOADS_URL, headers={"User-Agent": "vscode-python-tools-version-check"}
24+
)
25+
with urllib.request.urlopen(request, timeout=30) as response:
26+
return json.load(response)
27+
28+
29+
def latest_release_candidate(releases, now=None):
30+
"""Return the newest Python 3 series with a published release candidate."""
31+
now = now or datetime.datetime.now(datetime.timezone.utc)
32+
candidates = []
33+
for release in releases:
34+
if (
35+
not isinstance(release, dict)
36+
or release.get("is_published") is not True
37+
or release.get("pre_release") is not True
38+
or release.get("version") != 3
39+
or not isinstance(release.get("name"), str)
40+
):
41+
continue
42+
match = RC_NAME.fullmatch(release["name"])
43+
if not match:
44+
continue
45+
try:
46+
release_date = datetime.datetime.fromisoformat(
47+
release["release_date"].replace("Z", "+00:00")
48+
)
49+
except (AttributeError, KeyError, TypeError, ValueError) as exc:
50+
raise ValueError(f"Invalid release_date for {release['name']}") from exc
51+
if release_date.tzinfo is None:
52+
raise ValueError(f"Missing timezone in release_date for {release['name']}")
53+
if release_date <= now:
54+
series = match.group("series")
55+
candidates.append(
56+
(
57+
tuple(map(int, series.split("."))),
58+
int(match.group("candidate")),
59+
series,
60+
)
61+
)
62+
if not candidates:
63+
raise ValueError("The downloads API did not contain a released Python RC")
64+
return max(candidates)[2]
65+
66+
67+
def configured_versions(workflow):
68+
"""Return major.minor versions covered by every explicit Python matrix."""
69+
matches = VERSION_MATRIX.findall(workflow.read_text(encoding="utf-8"))
70+
if not matches:
71+
raise RuntimeError(f"Expected a Python version matrix in {workflow}")
72+
matrices = [
73+
set(re.findall(r"['\"](\d+\.\d+)[^'\"]*['\"]", match)) for match in matches
74+
]
75+
# Floating-only matrices such as ['3.x'] do not declare RC-series coverage.
76+
explicit_matrices = [versions for versions in matrices if versions]
77+
if not explicit_matrices:
78+
raise RuntimeError(f"No explicit Python versions found in {workflow}")
79+
return set.intersection(*explicit_matrices)
80+
81+
82+
def unconfigured_version(releases, workflows, now=None):
83+
"""Return the newest RC series when any explicit test matrix lacks it."""
84+
latest = latest_release_candidate(releases, now)
85+
for workflow in workflows:
86+
if latest not in configured_versions(workflow):
87+
return latest
88+
return None
89+
90+
91+
def main():
92+
"""Print the newest RC series if it is missing from the CI test matrices."""
93+
workflow_dir = pathlib.Path(__file__).parent.parent / ".github" / "workflows"
94+
workflows = [workflow_dir / name for name in ("pr-check.yml", "build.yml")]
95+
version = unconfigured_version(fetch_releases(), workflows)
96+
if version:
97+
print(version)
98+
99+
100+
if __name__ == "__main__":
101+
main()

0 commit comments

Comments
 (0)