-
Notifications
You must be signed in to change notification settings - Fork 351
Expand file tree
/
Copy pathcycle_config.py
More file actions
212 lines (175 loc) · 8.42 KB
/
Copy pathcycle_config.py
File metadata and controls
212 lines (175 loc) · 8.42 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
"""Read a cycle TOML and emit its build configuration as GITHUB_OUTPUT lines.
python .github/scripts/cycle_config.py cycles/2026_04.toml all
python .github/scripts/cycle_config.py cycles/2026_04.toml 3.14
Writes to $GITHUB_OUTPUT when set, otherwise stdout, so it can be run locally
to see exactly what a workflow run would get. tomllib is stdlib from 3.11, and
GitHub runners are newer than that, so this needs no dependency.
Everything the build job needs is decided here rather than re-derived per
matrix leg in PowerShell:
* which (python, flavor) pairs this cycle can build -- right architecture,
and a pylock present in the cycle directory. A flavor the cycle declares
but has no lockfile for costs nothing, so it can stay declared until it
comes back, and "all" skips a Python whose lockfiles are not committed yet
rather than failing the cycle over it.
* the version, tarball and paths each pair needs, carried by the leg itself.
A leg naming its own Python is what lets "all" build the whole cycle in one
dispatch instead of one per Python version.
* the tag its release goes under. Derived from the cycle name and release
level -- 2026_04 at level b1 publishes under "2026-04b1" -- so a respin is
a level bump rather than a tag to invent. A cycle wanting some other tag
says so with a release_tag key.
* a cycle with nothing to build fails here, loudly, instead of starting
runners whose every step is then skipped.
Paths are relative to the working directory, which in CI is the checkout root.
"""
import datetime
import json
import os
import sys
import tomllib
from pathlib import Path
# git refuses these in a ref name; catching them here beats failing at upload
# time, an hour into a build. Not the whole of git-check-ref-format, just the
# parts a hand-written tag realistically trips over.
TAG_FORBIDDEN = set(" ~^:?*[\\\x7f") | {chr(c) for c in range(32)}
def python_facts(requested: str, entry: dict) -> dict:
"""What every flavor of one Python shares.
A trailing F marks the free-threaded build: it picks a different tarball
and a different set of flavors, but it is not part of the version number.
"""
ver2 = entry["ver2"]
# the check the PowerShell version did: ver2's first 3 parts must appear in
# the tarball URL, so a copy-paste slip between the two cannot go unnoticed
short = ".".join(ver2.split(".")[:3])
if short not in entry["src"]:
raise SystemExit(f"{requested}: '{short}' not found in src {entry['src']}")
build_location = f"WPy64-{ver2.replace('.', '')}"
return {
"python_versionf": requested,
"python_version": requested[:-1] if requested.endswith("F") else requested,
"archdet": "64F" if requested.endswith("F") else "64",
"ver2": ver2,
"src": entry["src"],
"sha": entry["sha"],
"build_location": build_location,
"destwheelhouse": rf"{build_location}\wheelhouse\included.wheels",
}
def flavor_entry(cfg: dict, flavor: dict, python: dict) -> dict | None:
"""One matrix leg, or None when this flavor has no lockfile to build.
Names follow the layout the publish step writes: pylock.64-<ver2 with
underscores><flavor><release level>.toml, plus the _wheels variants the
wheelhouse flavor adds.
"""
cycle_dir = Path(cfg["cycle_dir"])
level = cfg.get("release_level", "")
stem = f"64-{python['ver2'].replace('.', '_')}{flavor['name']}{level}"
lockfile = cycle_dir / f"pylock.{stem}.toml"
if not lockfile.is_file():
return None
def optional(path: Path) -> str:
return path.as_posix() if path.is_file() else ""
leg = {key: value for key, value in python.items() if key != "archdet"}
leg.update(
name=flavor["name"],
PANDOC=flavor["PANDOC"],
formats=flavor["formats"],
lockfile=lockfile.as_posix(),
lockfile_wheels=optional(cycle_dir / f"pylock.{stem}_wheels.toml"),
requirements_wheels=optional(cycle_dir / f"requir.{stem}_wheels.txt"),
winpyver=f"{python['ver2']}{flavor['name']}{level}",
artifact_name=f"publish_{python['python_version']}{flavor['name']}",
)
return leg
def legs_for(cfg: dict, requested: str) -> list[dict]:
"""Every buildable (flavor) leg of one Python of this cycle."""
python = python_facts(requested, cfg["pythons"][requested])
found = []
for flavor in cfg["flavors"]:
if str(flavor.get("WINPYARCHDET", "")) != python["archdet"]:
continue
leg = flavor_entry(cfg, flavor, python)
if leg is not None:
found.append(leg)
return found
def release_tag(cfg: dict, cycle_name: str) -> str:
"""The tag this cycle's release goes under.
"2026_04" at release level "b1" gives "2026-04b1", the name the cycle goes
by in public. No date in it on purpose: a tag has to stay put so that
re-running one missing flavor lands on the release the others are already
on, and so that the download URLs the site publishes keep resolving. A
second build of the same cycle is a release_level bump, which says more
than a date would.
"""
tag = cfg.get("release_tag") or f"{cycle_name.replace('_', '-')}{cfg.get('release_level', '')}"
bad = sorted(TAG_FORBIDDEN.intersection(tag))
if bad or ".." in tag or tag.startswith("/") or tag.endswith(("/", ".", ".lock")):
raise SystemExit(f"release tag {tag!r} is not a usable git ref name")
return tag
def ordinal(day: int) -> str:
"""1st, 2nd, 3rd, 4th ... and the 11th/12th/13th exceptions."""
if 11 <= day % 100 <= 13:
return f"{day}th"
return f"{day}{ {1: 'st', 2: 'nd', 3: 'rd'}.get(day % 10, 'th') }"
def release_title(cfg: dict, cycle_name: str, today: datetime.date | None = None) -> str:
""""WinPython 2026-04 b1 of September 6th 2026", the shape releases have used.
Kept deliberately close to the titles written by hand before this: cycle,
then the level after a space -- the tag runs them together, the title does
not -- then the date, no comma, which is how all but one of them read.
The date is the day the draft is opened, since that is the only day the
build knows about. A cycle publishing much later than it was built can
still have the title edited; the tag, which is what URLs are built on,
does not move.
"""
today = today or datetime.date.today()
level = cfg.get("release_level", "")
name = f"{cycle_name.replace('_', '-')} {level}".strip()
return f"WinPython {name} of {today:%B} {ordinal(today.day)} {today.year}"
def build_config(cfg: dict, requested: str, cycle_name: str) -> dict:
pythons = cfg["pythons"]
if requested == "all":
wanted = list(pythons)
elif requested in pythons:
wanted = [requested]
else:
raise SystemExit(
f"no entry for python {requested!r}; this cycle offers "
f"{', '.join(sorted(pythons))} (or 'all')"
)
legs = [leg for python in wanted for leg in legs_for(cfg, python)]
if not legs:
missing = ", ".join(
f"pylock.64-{pythons[p]['ver2'].replace('.', '_')}<flavor>"
f"{cfg.get('release_level', '')}.toml"
for p in wanted
)
raise SystemExit(
f"{requested}: no {missing} under {cfg['cycle_dir']}; "
"commit the lockfiles for this cycle before dispatching"
)
return {
"cycle_dir": cfg["cycle_dir"],
"release_level": cfg.get("release_level", ""),
"release_tag": release_tag(cfg, cycle_name),
"release_title": release_title(cfg, cycle_name),
"pandoc_source": cfg["pandoc"]["source"],
"pandoc_sha256": cfg["pandoc"]["sha256"],
# consumed by the build job as strategy.matrix via fromJSON
"matrix": json.dumps({"leg": legs}, separators=(",", ":")),
}
def main(argv: list[str]) -> None:
if len(argv) != 3:
raise SystemExit(f"usage: {Path(argv[0]).name} <cycle.toml> <python_version|all>")
cfg_path = Path(argv[1])
if not cfg_path.is_file():
raise SystemExit(f"no such cycle file: {cfg_path}")
with cfg_path.open("rb") as fh:
cfg = tomllib.load(fh)
config = build_config(cfg, argv[2], cfg_path.stem)
rendered = "".join(f"{k}={v}\n" for k, v in config.items())
out = os.environ.get("GITHUB_OUTPUT")
if out:
with open(out, "a", encoding="utf-8") as fh:
fh.write(rendered)
sys.stdout.write(rendered)
if __name__ == "__main__":
main(sys.argv)