11"""Read a cycle TOML and emit its build configuration as GITHUB_OUTPUT lines.
22
3+ python .github/scripts/cycle_config.py cycles/2026_04.toml all
34 python .github/scripts/cycle_config.py cycles/2026_04.toml 3.14
45
56Writes to $GITHUB_OUTPUT when set, otherwise stdout, so it can be run locally
910Everything the build job needs is decided here rather than re-derived per
1011matrix leg in PowerShell:
1112
12- * which flavors this Python can build -- right architecture, and a pylock
13- present in the cycle directory. A flavor the cycle declares but has no
14- lockfile for costs nothing, so it can stay declared until it comes back.
15- * the file names those flavors use, so a leg never has to look one up.
16- * a cycle with no lockfiles at all fails here, loudly, instead of starting
13+ * which (python, flavor) pairs this cycle can build -- right architecture,
14+ and a pylock present in the cycle directory. A flavor the cycle declares
15+ but has no lockfile for costs nothing, so it can stay declared until it
16+ comes back, and "all" skips a Python whose lockfiles are not committed yet
17+ rather than failing the cycle over it.
18+ * the version, tarball and paths each pair needs, carried by the leg itself.
19+ A leg naming its own Python is what lets "all" build the whole cycle in one
20+ dispatch instead of one per Python version.
21+ * the tag its release goes under. Derived from the cycle name and release
22+ level -- 2026_04 at level b1 publishes under "2026-04b1" -- so a respin is
23+ a level bump rather than a tag to invent. A cycle wanting some other tag
24+ says so with a release_tag key.
25+ * a cycle with nothing to build fails here, loudly, instead of starting
1726 runners whose every step is then skipped.
1827
1928Paths are relative to the working directory, which in CI is the checkout root.
2433import tomllib
2534from pathlib import Path
2635
36+ # git refuses these in a ref name; catching them here beats failing at upload
37+ # time, an hour into a build. Not the whole of git-check-ref-format, just the
38+ # parts a hand-written tag realistically trips over.
39+ TAG_FORBIDDEN = set (" ~^:?*[\\ \x7f " ) | {chr (c ) for c in range (32 )}
2740
28- def flavor_entry (cfg : dict , flavor : dict , ver2 : str , python_version : str ) -> dict | None :
41+
42+ def python_facts (requested : str , entry : dict ) -> dict :
43+ """What every flavor of one Python shares.
44+
45+ A trailing F marks the free-threaded build: it picks a different tarball
46+ and a different set of flavors, but it is not part of the version number.
47+ """
48+ ver2 = entry ["ver2" ]
49+
50+ # the check the PowerShell version did: ver2's first 3 parts must appear in
51+ # the tarball URL, so a copy-paste slip between the two cannot go unnoticed
52+ short = "." .join (ver2 .split ("." )[:3 ])
53+ if short not in entry ["src" ]:
54+ raise SystemExit (f"{ requested } : '{ short } ' not found in src { entry ['src' ]} " )
55+
56+ build_location = f"WPy64-{ ver2 .replace ('.' , '' )} "
57+ return {
58+ "python_versionf" : requested ,
59+ "python_version" : requested [:- 1 ] if requested .endswith ("F" ) else requested ,
60+ "archdet" : "64F" if requested .endswith ("F" ) else "64" ,
61+ "ver2" : ver2 ,
62+ "src" : entry ["src" ],
63+ "sha" : entry ["sha" ],
64+ "build_location" : build_location ,
65+ "destwheelhouse" : rf"{ build_location } \wheelhouse\included.wheels" ,
66+ }
67+
68+
69+ def flavor_entry (cfg : dict , flavor : dict , python : dict ) -> dict | None :
2970 """One matrix leg, or None when this flavor has no lockfile to build.
3071
3172 Names follow the layout the publish step writes: pylock.64-<ver2 with
@@ -34,7 +75,7 @@ def flavor_entry(cfg: dict, flavor: dict, ver2: str, python_version: str) -> dic
3475 """
3576 cycle_dir = Path (cfg ["cycle_dir" ])
3677 level = cfg .get ("release_level" , "" )
37- stem = f"64-{ ver2 .replace ('.' , '_' )} { flavor ['name' ]} { level } "
78+ stem = f"64-{ python [ ' ver2' ] .replace ('.' , '_' )} { flavor ['name' ]} { level } "
3879
3980 lockfile = cycle_dir / f"pylock.{ stem } .toml"
4081 if not lockfile .is_file ():
@@ -43,78 +84,96 @@ def flavor_entry(cfg: dict, flavor: dict, ver2: str, python_version: str) -> dic
4384 def optional (path : Path ) -> str :
4485 return path .as_posix () if path .is_file () else ""
4586
46- return {
47- "name" : flavor ["name" ],
48- "PANDOC" : flavor ["PANDOC" ],
49- "formats" : flavor ["formats" ],
50- "lockfile" : lockfile .as_posix (),
51- "lockfile_wheels" : optional (cycle_dir / f"pylock.{ stem } _wheels.toml" ),
52- "requirements_wheels" : optional (cycle_dir / f"requir.{ stem } _wheels.txt" ),
53- "winpyver" : f"{ ver2 } { flavor ['name' ]} { level } " ,
54- "artifact_name" : f"publish_{ python_version } { flavor ['name' ]} " ,
55- }
87+ leg = {key : value for key , value in python .items () if key != "archdet" }
88+ leg .update (
89+ name = flavor ["name" ],
90+ PANDOC = flavor ["PANDOC" ],
91+ formats = flavor ["formats" ],
92+ lockfile = lockfile .as_posix (),
93+ lockfile_wheels = optional (cycle_dir / f"pylock.{ stem } _wheels.toml" ),
94+ requirements_wheels = optional (cycle_dir / f"requir.{ stem } _wheels.txt" ),
95+ winpyver = f"{ python ['ver2' ]} { flavor ['name' ]} { level } " ,
96+ artifact_name = f"publish_{ python ['python_version' ]} { flavor ['name' ]} " ,
97+ )
98+ return leg
99+
100+
101+ def legs_for (cfg : dict , requested : str ) -> list [dict ]:
102+ """Every buildable (flavor) leg of one Python of this cycle."""
103+ python = python_facts (requested , cfg ["pythons" ][requested ])
104+ found = []
105+ for flavor in cfg ["flavors" ]:
106+ if str (flavor .get ("WINPYARCHDET" , "" )) != python ["archdet" ]:
107+ continue
108+ leg = flavor_entry (cfg , flavor , python )
109+ if leg is not None :
110+ found .append (leg )
111+ return found
112+
113+
114+ def release_tag (cfg : dict , cycle_name : str ) -> str :
115+ """The tag this cycle's release goes under.
116+
117+ "2026_04" at release level "b1" gives "2026-04b1", the name the cycle goes
118+ by in public. No date in it on purpose: a tag has to stay put so that
119+ re-running one missing flavor lands on the release the others are already
120+ on, and so that the download URLs the site publishes keep resolving. A
121+ second build of the same cycle is a release_level bump, which says more
122+ than a date would.
123+ """
124+ tag = cfg .get ("release_tag" ) or f"{ cycle_name .replace ('_' , '-' )} { cfg .get ('release_level' , '' )} "
125+ bad = sorted (TAG_FORBIDDEN .intersection (tag ))
126+ if bad or ".." in tag or tag .startswith ("/" ) or tag .endswith (("/" , "." , ".lock" )):
127+ raise SystemExit (f"release tag { tag !r} is not a usable git ref name" )
128+ return tag
56129
57130
58- def build_config (cfg : dict , requested : str ) -> dict :
131+ def build_config (cfg : dict , requested : str , cycle_name : str ) -> dict :
59132 pythons = cfg ["pythons" ]
60- if requested not in pythons :
133+ if requested == "all" :
134+ wanted = list (pythons )
135+ elif requested in pythons :
136+ wanted = [requested ]
137+ else :
61138 raise SystemExit (
62- f"no entry for python { requested !r} ; this cycle offers { ', ' .join (sorted (pythons ))} "
139+ f"no entry for python { requested !r} ; this cycle offers "
140+ f"{ ', ' .join (sorted (pythons ))} (or 'all')"
63141 )
64- entry = pythons [requested ]
65142
66- # the check the PowerShell version did: ver2's first 3 parts must appear in
67- # the tarball URL, so a copy-paste slip between the two cannot go unnoticed
68- ver2 = entry ["ver2" ]
69- short = "." .join (ver2 .split ("." )[:3 ])
70- if short not in entry ["src" ]:
71- raise SystemExit (f"{ requested } : '{ short } ' not found in src { entry ['src' ]} " )
72-
73- # a trailing F marks the free-threaded build; it is not part of the version
74- python_version = requested [:- 1 ] if requested .endswith ("F" ) else requested
75- arch = "64F" if requested .endswith ("F" ) else "64"
76-
77- flavors = []
78- for flavor in cfg ["flavors" ]:
79- if str (flavor .get ("WINPYARCHDET" , "" )) != arch :
80- continue
81- leg = flavor_entry (cfg , flavor , ver2 , python_version )
82- if leg is not None :
83- flavors .append (leg )
84- if not flavors :
143+ legs = [leg for python in wanted for leg in legs_for (cfg , python )]
144+ if not legs :
145+ missing = ", " .join (
146+ f"pylock.64-{ pythons [p ]['ver2' ].replace ('.' , '_' )} <flavor>"
147+ f"{ cfg .get ('release_level' , '' )} .toml"
148+ for p in wanted
149+ )
85150 raise SystemExit (
86- f"{ requested } : no pylock.64-{ ver2 .replace ('.' , '_' )} <flavor>"
87- f"{ cfg .get ('release_level' , '' )} .toml under { cfg ['cycle_dir' ]} ; "
151+ f"{ requested } : no { missing } under { cfg ['cycle_dir' ]} ; "
88152 "commit the lockfiles for this cycle before dispatching"
89153 )
90154
91- build_location = f"WPy64-{ ver2 .replace ('.' , '' )} "
92155 return {
93- "ver2" : ver2 ,
94- "python_version" : python_version ,
95- "src" : entry ["src" ],
96- "sha" : entry ["sha" ],
97156 "cycle_dir" : cfg ["cycle_dir" ],
98157 "release_level" : cfg .get ("release_level" , "" ),
99- "build_location" : build_location ,
100- "destwheelhouse" : f"{ build_location } \\ wheelhouse\\ included.wheels" ,
158+ "release_tag" : release_tag (cfg , cycle_name ),
101159 "pandoc_source" : cfg ["pandoc" ]["source" ],
102160 "pandoc_sha256" : cfg ["pandoc" ]["sha256" ],
103161 # consumed by the build job as strategy.matrix via fromJSON
104- "matrix" : json .dumps ({"flavor " : flavors }, separators = ("," , ":" )),
162+ "matrix" : json .dumps ({"leg " : legs }, separators = ("," , ":" )),
105163 }
106164
107165
108166def main (argv : list [str ]) -> None :
109167 if len (argv ) != 3 :
110- raise SystemExit (f"usage: { Path (argv [0 ]).name } <cycle.toml> <python_version>" )
168+ raise SystemExit (f"usage: { Path (argv [0 ]).name } <cycle.toml> <python_version|all >" )
111169 cfg_path = Path (argv [1 ])
112170 if not cfg_path .is_file ():
113171 raise SystemExit (f"no such cycle file: { cfg_path } " )
114172 with cfg_path .open ("rb" ) as fh :
115173 cfg = tomllib .load (fh )
116174
117- rendered = "" .join (f"{ k } ={ v } \n " for k , v in build_config (cfg , argv [2 ]).items ())
175+ config = build_config (cfg , argv [2 ], cfg_path .stem )
176+ rendered = "" .join (f"{ k } ={ v } \n " for k , v in config .items ())
118177 out = os .environ .get ("GITHUB_OUTPUT" )
119178 if out :
120179 with open (out , "a" , encoding = "utf-8" ) as fh :
0 commit comments