Skip to content

Commit fa2a118

Browse files
authored
Merge pull request winpython#2098 from stonebig/cycle-build-publishes-itself
Cycle build publishes itself
2 parents 7a93976 + c1f0048 commit fa2a118

9 files changed

Lines changed: 4299 additions & 152 deletions

‎.github/scripts/cycle_config.py‎

Lines changed: 111 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
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
56
Writes to $GITHUB_OUTPUT when set, otherwise stdout, so it can be run locally
@@ -9,11 +10,19 @@
910
Everything the build job needs is decided here rather than re-derived per
1011
matrix 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
1928
Paths are relative to the working directory, which in CI is the checkout root.
@@ -24,8 +33,40 @@
2433
import tomllib
2534
from 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

108166
def 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

Comments
 (0)