Skip to content

Commit 4b95a0f

Browse files
claudeluhenry
authored andcommitted
Run build dispatches under a parallelism cap with dry-run
Replaces the serial 'max_dispatches' input with 'max_parallelism' (default 10) on check-releases.yml. The Python script now: - Uses a ThreadPoolExecutor sized to MAX_PARALLELISM to keep that many build-python.yml runs in flight at once. - After each dispatch, locates the newly-created workflow_run and prints its html_url, then polls until it completes before releasing the pool slot. - Supports a DRY_RUN env flag (surfaced as a 'dry_run' boolean input on the workflow) that lists queued candidates without triggering any builds.
1 parent 0ba4eb3 commit 4b95a0f

2 files changed

Lines changed: 174 additions & 57 deletions

File tree

.github/scripts/check-releases.py

Lines changed: 164 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,27 @@
11
#!/usr/bin/env python3
22
"""Scan python/cpython tags and dispatch build-python.yml for any not yet released.
33
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.
4+
Reads ``GH_TOKEN``, ``GITHUB_REPOSITORY``, ``MAX_PARALLELISM`` and ``DRY_RUN``
5+
from the environment. For each CPython ``v3.Y.Z[a|b|rc]N`` tag from 3.10
6+
onward, checks whether a matching release (tag ``<normalised>-<run_id>``)
7+
already exists here, and if not, dispatches the ``build-python.yml`` workflow.
8+
Free-threaded is enabled automatically for minor >= 13.
9+
10+
``MAX_PARALLELISM`` is the number of build-python.yml runs to have in flight
11+
concurrently. Each worker dispatches a run, waits for it to finish, then moves
12+
on to the next candidate.
913
"""
1014

1115
from __future__ import annotations
1216

17+
import concurrent.futures as cf
18+
import datetime
1319
import json
1420
import os
1521
import re
1622
import sys
23+
import threading
24+
import time
1725
import urllib.error
1826
import urllib.request
1927

@@ -28,31 +36,58 @@
2836
(re.compile(r"b([0-9]+)$"), r"-beta.\1"),
2937
(re.compile(r"rc([0-9]+)$"), r"-rc.\1"),
3038
)
39+
BUILD_WORKFLOW = "build-python.yml"
40+
41+
# Poll cadence (seconds)
42+
RUN_DISCOVERY_POLL = 5
43+
RUN_DISCOVERY_ATTEMPTS = 60 # up to 5 minutes
44+
RUN_STATUS_POLL = 30
45+
46+
_claim_lock = threading.Lock()
47+
_claimed_run_ids: set[int] = set()
48+
_print_lock = threading.Lock()
49+
3150

51+
def log(msg: str) -> None:
52+
with _print_lock:
53+
print(msg, flush=True)
3254

33-
def gh_get(path: str, token: str) -> list[dict]:
55+
56+
def _request(url: str, token: str, method: str = "GET", body: bytes | None = None) -> urllib.request.Request:
57+
return urllib.request.Request(
58+
url,
59+
data=body,
60+
method=method,
61+
headers={
62+
"Authorization": f"Bearer {token}",
63+
"Accept": "application/vnd.github+json",
64+
"X-GitHub-Api-Version": "2022-11-28",
65+
"Content-Type": "application/json",
66+
"User-Agent": "check-releases-script",
67+
},
68+
)
69+
70+
71+
def gh_get_paginated(path: str, token: str) -> list[dict]:
3472
"""Paginate a GitHub REST API list endpoint."""
3573
results: list[dict] = []
3674
url: str | None = f"{API_ROOT}{path}"
3775
sep = "&" if "?" in url else "?"
3876
url = f"{url}{sep}per_page=100"
3977
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:
78+
with urllib.request.urlopen(_request(url, token)) as resp:
5079
results.extend(json.load(resp))
5180
link = resp.headers.get("Link", "")
5281
url = _next_link(link)
5382
return results
5483

5584

85+
def gh_get_json(path: str, token: str) -> dict:
86+
"""Single GET returning parsed JSON."""
87+
with urllib.request.urlopen(_request(f"{API_ROOT}{path}", token)) as resp:
88+
return json.load(resp)
89+
90+
5691
def _next_link(link_header: str) -> str | None:
5792
for part in link_header.split(","):
5893
section = part.strip()
@@ -95,36 +130,81 @@ def dispatch_workflow(repo: str, token: str, cpython_tag: str, ft: bool) -> None
95130
},
96131
}
97132
).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:
133+
url = f"{API_ROOT}/repos/{repo}/actions/workflows/{BUILD_WORKFLOW}/dispatches"
134+
with urllib.request.urlopen(_request(url, token, method="POST", body=body)) as resp:
111135
resp.read()
112136

113137

138+
def find_dispatched_run(repo: str, token: str, baseline: set[int], after_ts: str) -> dict | None:
139+
"""Find a workflow run dispatched after ``after_ts`` that isn't baselined or claimed.
140+
141+
Called once per dispatch. Claims the oldest eligible run so that concurrent
142+
workers each grab a distinct run_id.
143+
"""
144+
path = f"/repos/{repo}/actions/workflows/{BUILD_WORKFLOW}/runs?per_page=50&event=workflow_dispatch"
145+
for _ in range(RUN_DISCOVERY_ATTEMPTS):
146+
time.sleep(RUN_DISCOVERY_POLL)
147+
data = gh_get_json(path, token)
148+
runs = sorted(data.get("workflow_runs", []), key=lambda r: r["created_at"])
149+
with _claim_lock:
150+
for r in runs:
151+
rid = r["id"]
152+
if rid in baseline:
153+
continue
154+
if rid in _claimed_run_ids:
155+
continue
156+
if r["created_at"] < after_ts:
157+
continue
158+
_claimed_run_ids.add(rid)
159+
return r
160+
return None
161+
162+
163+
def wait_run_complete(repo: str, token: str, run_id: int) -> str | None:
164+
path = f"/repos/{repo}/actions/runs/{run_id}"
165+
while True:
166+
time.sleep(RUN_STATUS_POLL)
167+
data = gh_get_json(path, token)
168+
if data.get("status") == "completed":
169+
return data.get("conclusion")
170+
171+
172+
def run_build(repo: str, token: str, tag: str, ft: bool, baseline: set[int]) -> tuple[str, str | None]:
173+
log(f"[{tag}] dispatching build-python.yml (freethreaded={str(ft).lower()})")
174+
after_ts = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
175+
dispatch_workflow(repo, token, tag, ft)
176+
run = find_dispatched_run(repo, token, baseline, after_ts)
177+
if run is None:
178+
log(f"[{tag}] WARN: could not locate dispatched run within timeout")
179+
return tag, None
180+
log(f"[{tag}] run {run['id']}: {run['html_url']}")
181+
conclusion = wait_run_complete(repo, token, run["id"])
182+
log(f"[{tag}] run {run['id']} completed: {conclusion}")
183+
return tag, conclusion
184+
185+
186+
def parse_bool(value: str | None) -> bool:
187+
return (value or "").strip().lower() in {"1", "true", "yes", "y"}
188+
189+
114190
def main() -> int:
115191
token = os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN")
116192
repo = os.environ.get("GITHUB_REPOSITORY")
117193
if not token or not repo:
118194
print("GH_TOKEN and GITHUB_REPOSITORY must be set", file=sys.stderr)
119195
return 1
120196
try:
121-
max_dispatches = int(os.environ.get("MAX_DISPATCHES", "20"))
197+
max_parallelism = int(os.environ.get("MAX_PARALLELISM", "4"))
122198
except ValueError:
123-
print("MAX_DISPATCHES must be an integer", file=sys.stderr)
199+
print("MAX_PARALLELISM must be an integer", file=sys.stderr)
124200
return 1
201+
if max_parallelism < 1:
202+
print("MAX_PARALLELISM must be >= 1", file=sys.stderr)
203+
return 1
204+
dry_run = parse_bool(os.environ.get("DRY_RUN"))
125205

126206
# Fetch all v* tags from python/cpython
127-
refs = gh_get("/repos/python/cpython/git/matching-refs/tags/v", token)
207+
refs = gh_get_paginated("/repos/python/cpython/git/matching-refs/tags/v", token)
128208
candidates: list[str] = []
129209
for entry in refs:
130210
ref = entry.get("ref", "")
@@ -133,42 +213,73 @@ def main() -> int:
133213
tag = ref[len(REF_PREFIX):]
134214
if CPYTHON_TAG_RE.match(tag):
135215
candidates.append(tag)
136-
# Sort newest first
137216
candidates.sort(key=version_sort_key, reverse=True)
138217

139-
# Fetch all existing release tags on this repo
140-
releases = gh_get(f"/repos/{repo}/releases", token)
218+
# Existing release tags
219+
releases = gh_get_paginated(f"/repos/{repo}/releases", token)
141220
existing_tags = {r.get("tag_name") or "" for r in releases}
142221

143-
dispatched = 0
144-
dispatched_list: list[str] = []
145-
222+
# Decide what to dispatch
223+
to_dispatch: list[tuple[str, bool]] = []
146224
for tag in candidates:
147225
match = CPYTHON_TAG_RE.match(tag)
148226
assert match is not None
149227
minor = int(match.group("full_minor").split(".")[1])
150228
normalised = normalise(tag)
151-
print(f"Tag {tag} -> release {normalised}")
229+
log(f"Tag {tag} -> release {normalised}")
152230

153231
version_re = re.compile(rf"^{re.escape(normalised)}-[0-9]+$")
154232
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)")
233+
log(" already released, skipping")
160234
continue
161235

162236
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)
237+
log(f" queued for dispatch (freethreaded={str(ft).lower()})")
238+
to_dispatch.append((tag, ft))
239+
240+
log("")
241+
log(f"=== {len(to_dispatch)} candidate(s) to build ===")
242+
for tag, ft in to_dispatch:
243+
log(f" {tag} (ft={str(ft).lower()})")
244+
245+
if dry_run:
246+
log("")
247+
log("DRY_RUN=true: not dispatching anything")
248+
return 0
249+
250+
if not to_dispatch:
251+
return 0
252+
253+
# Baseline of existing build-python.yml runs so we can identify newly-created ones
254+
baseline_data = gh_get_json(
255+
f"/repos/{repo}/actions/workflows/{BUILD_WORKFLOW}/runs?per_page=100", token
256+
)
257+
baseline = {r["id"] for r in baseline_data.get("workflow_runs", [])}
258+
259+
log("")
260+
log(f"=== Running up to {max_parallelism} build(s) in parallel ===")
261+
failures: list[str] = []
262+
with cf.ThreadPoolExecutor(max_workers=max_parallelism) as pool:
263+
futures = [
264+
pool.submit(run_build, repo, token, tag, ft, baseline)
265+
for tag, ft in to_dispatch
266+
]
267+
for fut in cf.as_completed(futures):
268+
try:
269+
tag, conclusion = fut.result()
270+
except Exception as exc: # noqa: BLE001
271+
log(f"task raised: {exc}")
272+
failures.append(str(exc))
273+
continue
274+
if conclusion != "success":
275+
failures.append(f"{tag}: {conclusion}")
276+
277+
log("")
278+
log(f"=== Finished: {len(to_dispatch) - len(failures)}/{len(to_dispatch)} succeeded ===")
279+
if failures:
280+
for f in failures:
281+
log(f" FAIL {f}")
282+
return 1
172283
return 0
173284

174285

.github/workflows/check-releases.yml

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,16 @@ on:
55
- cron: '0 6 * * *'
66
workflow_dispatch:
77
inputs:
8-
max_dispatches:
9-
description: "Maximum number of build dispatches (0 = dry run)"
8+
max_parallelism:
9+
description: "Maximum number of build-python.yml runs in flight at once"
1010
required: false
11-
default: "20"
11+
default: "10"
1212
type: string
13+
dry_run:
14+
description: "List candidates without dispatching any builds"
15+
required: false
16+
default: false
17+
type: boolean
1318

1419
permissions:
1520
contents: read
@@ -25,7 +30,8 @@ jobs:
2530
env:
2631
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
2732
GITHUB_REPOSITORY: ${{ github.repository }}
28-
MAX_DISPATCHES: ${{ inputs.max_dispatches || '20' }}
33+
MAX_PARALLELISM: ${{ inputs.max_parallelism || '10' }}
34+
DRY_RUN: ${{ inputs.dry_run && 'true' || 'false' }}
2935
steps:
3036
- name: Checkout repo
3137
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

0 commit comments

Comments
 (0)