Skip to content

Commit 0ba4eb3

Browse files
claudeluhenry
authored andcommitted
Rewrite check-releases logic in Python
Moves the tag-enumeration/dispatch script from inline bash to .github/scripts/check-releases.py, called from check-releases.yml. The Python version uses the GitHub REST API directly via urllib (stdlib only, no pip installs).
1 parent e219cfb commit 0ba4eb3

2 files changed

Lines changed: 189 additions & 59 deletions

File tree

.github/scripts/check-releases.py

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
#!/usr/bin/env python3
2+
"""Scan python/cpython tags and dispatch build-python.yml for any not yet released.
3+
4+
Reads ``GH_TOKEN``, ``GITHUB_REPOSITORY`` and ``MAX_DISPATCHES`` from the
5+
environment. For each CPython ``v3.Y.Z[a|b|rc]N`` tag from 3.10 onward, checks
6+
whether a matching release (tag ``<normalised>-<run_id>``) already exists here,
7+
and if not, dispatches the ``build-python.yml`` workflow. Free-threaded is
8+
enabled automatically for minor >= 13.
9+
"""
10+
11+
from __future__ import annotations
12+
13+
import json
14+
import os
15+
import re
16+
import sys
17+
import urllib.error
18+
import urllib.request
19+
20+
API_ROOT = "https://api.github.com"
21+
REF_PREFIX = "refs/tags/"
22+
CPYTHON_TAG_RE = re.compile(
23+
r"^v(?P<full_minor>3\.(?:1[0-9]|[2-9][0-9]))\.(?P<patch>[0-9]+)"
24+
r"(?P<pre>a[0-9]+|b[0-9]+|rc[0-9]+)?$"
25+
)
26+
PRE_SUBS = (
27+
(re.compile(r"a([0-9]+)$"), r"-alpha.\1"),
28+
(re.compile(r"b([0-9]+)$"), r"-beta.\1"),
29+
(re.compile(r"rc([0-9]+)$"), r"-rc.\1"),
30+
)
31+
32+
33+
def gh_get(path: str, token: str) -> list[dict]:
34+
"""Paginate a GitHub REST API list endpoint."""
35+
results: list[dict] = []
36+
url: str | None = f"{API_ROOT}{path}"
37+
sep = "&" if "?" in url else "?"
38+
url = f"{url}{sep}per_page=100"
39+
while url:
40+
req = urllib.request.Request(
41+
url,
42+
headers={
43+
"Authorization": f"Bearer {token}",
44+
"Accept": "application/vnd.github+json",
45+
"X-GitHub-Api-Version": "2022-11-28",
46+
"User-Agent": "check-releases-script",
47+
},
48+
)
49+
with urllib.request.urlopen(req) as resp:
50+
results.extend(json.load(resp))
51+
link = resp.headers.get("Link", "")
52+
url = _next_link(link)
53+
return results
54+
55+
56+
def _next_link(link_header: str) -> str | None:
57+
for part in link_header.split(","):
58+
section = part.strip()
59+
if section.endswith('rel="next"'):
60+
return section.split(";", 1)[0].strip().lstrip("<").rstrip(">")
61+
return None
62+
63+
64+
def normalise(cpython_tag: str) -> str:
65+
version = cpython_tag.lstrip("v")
66+
for pattern, repl in PRE_SUBS:
67+
version = pattern.sub(repl, version)
68+
return version
69+
70+
71+
def version_sort_key(cpython_tag: str) -> tuple[int, int, int, int, int]:
72+
match = CPYTHON_TAG_RE.match(cpython_tag)
73+
assert match is not None
74+
minor = int(match.group("full_minor").split(".")[1])
75+
patch = int(match.group("patch"))
76+
pre = match.group("pre")
77+
if pre is None:
78+
stage_order, stage_num = 3, 0
79+
elif pre.startswith("a"):
80+
stage_order, stage_num = 0, int(pre[1:])
81+
elif pre.startswith("b"):
82+
stage_order, stage_num = 1, int(pre[1:])
83+
else:
84+
stage_order, stage_num = 2, int(pre[2:])
85+
return (3, minor, patch, stage_order, stage_num)
86+
87+
88+
def dispatch_workflow(repo: str, token: str, cpython_tag: str, ft: bool) -> None:
89+
body = json.dumps(
90+
{
91+
"ref": "main",
92+
"inputs": {
93+
"cpython_tag": cpython_tag,
94+
"freethreaded": "true" if ft else "false",
95+
},
96+
}
97+
).encode("utf-8")
98+
req = urllib.request.Request(
99+
f"{API_ROOT}/repos/{repo}/actions/workflows/build-python.yml/dispatches",
100+
data=body,
101+
method="POST",
102+
headers={
103+
"Authorization": f"Bearer {token}",
104+
"Accept": "application/vnd.github+json",
105+
"X-GitHub-Api-Version": "2022-11-28",
106+
"Content-Type": "application/json",
107+
"User-Agent": "check-releases-script",
108+
},
109+
)
110+
with urllib.request.urlopen(req) as resp:
111+
resp.read()
112+
113+
114+
def main() -> int:
115+
token = os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN")
116+
repo = os.environ.get("GITHUB_REPOSITORY")
117+
if not token or not repo:
118+
print("GH_TOKEN and GITHUB_REPOSITORY must be set", file=sys.stderr)
119+
return 1
120+
try:
121+
max_dispatches = int(os.environ.get("MAX_DISPATCHES", "20"))
122+
except ValueError:
123+
print("MAX_DISPATCHES must be an integer", file=sys.stderr)
124+
return 1
125+
126+
# Fetch all v* tags from python/cpython
127+
refs = gh_get("/repos/python/cpython/git/matching-refs/tags/v", token)
128+
candidates: list[str] = []
129+
for entry in refs:
130+
ref = entry.get("ref", "")
131+
if not ref.startswith(REF_PREFIX):
132+
continue
133+
tag = ref[len(REF_PREFIX):]
134+
if CPYTHON_TAG_RE.match(tag):
135+
candidates.append(tag)
136+
# Sort newest first
137+
candidates.sort(key=version_sort_key, reverse=True)
138+
139+
# Fetch all existing release tags on this repo
140+
releases = gh_get(f"/repos/{repo}/releases", token)
141+
existing_tags = {r.get("tag_name") or "" for r in releases}
142+
143+
dispatched = 0
144+
dispatched_list: list[str] = []
145+
146+
for tag in candidates:
147+
match = CPYTHON_TAG_RE.match(tag)
148+
assert match is not None
149+
minor = int(match.group("full_minor").split(".")[1])
150+
normalised = normalise(tag)
151+
print(f"Tag {tag} -> release {normalised}")
152+
153+
version_re = re.compile(rf"^{re.escape(normalised)}-[0-9]+$")
154+
if any(version_re.match(t) for t in existing_tags):
155+
print(" already released, skipping")
156+
continue
157+
158+
if dispatched >= max_dispatches:
159+
print(" would dispatch (cap reached)")
160+
continue
161+
162+
ft = minor >= 13
163+
print(f" dispatching build-python.yml (freethreaded={str(ft).lower()})")
164+
dispatch_workflow(repo, token, tag, ft)
165+
dispatched += 1
166+
dispatched_list.append(f"{tag} -> {normalised} (ft={str(ft).lower()})")
167+
168+
print("")
169+
print(f"=== Dispatched {dispatched} build(s) ===")
170+
for line in dispatched_list:
171+
print(line)
172+
return 0
173+
174+
175+
if __name__ == "__main__":
176+
sys.exit(main())

.github/workflows/check-releases.yml

Lines changed: 13 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -15,71 +15,25 @@ permissions:
1515
contents: read
1616
actions: write
1717

18+
defaults:
19+
run:
20+
shell: bash --noprofile --norc -euxo pipefail {0}
21+
1822
jobs:
1923
discover:
2024
runs-on: ubuntu-latest
2125
env:
2226
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
27+
GITHUB_REPOSITORY: ${{ github.repository }}
2328
MAX_DISPATCHES: ${{ inputs.max_dispatches || '20' }}
2429
steps:
25-
- name: Enumerate and dispatch
26-
run: |
27-
set -euo pipefail
28-
max="${MAX_DISPATCHES}"
29-
dispatched=0
30-
dispatched_list=""
31-
32-
# Fetch all v* tags from python/cpython, ordered by version, descending (newer first)
33-
mapfile -t refs < <(gh api --paginate repos/python/cpython/git/matching-refs/tags/v -q '.[].ref' | sort -V --reverse)
34-
35-
# Fetch all existing release tags in this repo once
36-
mapfile -t existing_tags < <(gh api --paginate "repos/$GITHUB_REPOSITORY/releases" -q '.[].tag_name')
37-
38-
for ref in "${refs[@]}"; do
39-
tag="${ref#refs/tags/}"
40-
# Filter: 3.10+, with optional a/b/rc suffix, no dev/post
41-
if [[ ! "$tag" =~ ^v(3\.(1[0-9]|[2-9][0-9]))\.([0-9]+)(a[0-9]+|b[0-9]+|rc[0-9]+)?$ ]]; then
42-
continue
43-
fi
44-
minor_int="${BASH_REMATCH[2]}"
45-
46-
# Normalise
47-
normalised="${tag#v}"
48-
normalised="$(echo "$normalised" | sed -E 's/a([0-9]+)$/-alpha.\1/; s/b([0-9]+)$/-beta.\1/; s/rc([0-9]+)$/-rc.\1/')"
30+
- name: Checkout repo
31+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
4932

50-
echo "Tag $tag -> release $normalised"
33+
- name: Set up Python
34+
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
35+
with:
36+
python-version: '3.12'
5137

52-
normalised_re="${normalised//./\\.}"
53-
already=0
54-
for t in "${existing_tags[@]}"; do
55-
if [[ "$t" =~ ^${normalised_re}-[0-9]+$ ]]; then
56-
already=1
57-
break
58-
fi
59-
done
60-
if [ "$already" = 1 ]; then
61-
echo " already released, skipping"
62-
continue
63-
fi
64-
65-
if [ "$dispatched" -ge "$max" ]; then
66-
echo " would dispatch (cap reached)"
67-
continue
68-
fi
69-
70-
ft="false"
71-
if [ "$minor_int" -ge 13 ]; then
72-
ft="true"
73-
fi
74-
75-
echo " dispatching build-python.yml (freethreaded=$ft)"
76-
gh workflow run build-python.yml --repo "$GITHUB_REPOSITORY" \
77-
-f cpython_tag="$tag" \
78-
-f freethreaded="$ft"
79-
dispatched=$((dispatched + 1))
80-
dispatched_list="${dispatched_list}${tag} -> ${normalised} (ft=${ft})"$'\n'
81-
done
82-
83-
echo ""
84-
echo "=== Dispatched $dispatched build(s) ==="
85-
printf '%s' "$dispatched_list"
38+
- name: Enumerate and dispatch
39+
run: python .github/scripts/check-releases.py

0 commit comments

Comments
 (0)