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
1115from __future__ import annotations
1216
17+ import concurrent .futures as cf
18+ import datetime
1319import json
1420import os
1521import re
1622import sys
23+ import threading
24+ import time
1725import urllib .error
1826import urllib .request
1927
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+
5691def _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+
114190def 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
0 commit comments