Repository navigation
Expand file tree
/
Copy pathtest_cycle_config.py
More file actions
404 lines (324 loc) · 18 KB
/
Copy pathtest_cycle_config.py
File metadata and controls
404 lines (324 loc) · 18 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
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
# -*- coding: utf-8 -*-
"""The cycle build workflow and the script that configures it must agree.
`.github/scripts/cycle_config.py` decides the whole shape of a cycle build,
and `build_winpython_cycle.yml` consumes that decision as `matrix.leg.<key>`
and `needs.config.outputs.<key>`. GitHub Actions resolves an expression naming
a key nothing emits to the empty string, silently: the build does not fail at
that line, it fails much later, in PowerShell, an hour into a Windows runner,
with an empty path. That is expensive to find and slow to retry, so this
module checks the two sides line up before any of it starts.
Nothing here needs pytest plugins, a YAML parser or a network: the workflow is
read as text and the script is imported and run for real, against whatever
cycle files the repository currently has. A new cycle is therefore covered the
moment its TOML is committed.
"""
import importlib.util
import json
import re
from pathlib import Path
import pytest
REPO = Path(__file__).resolve().parents[1]
CYCLES = REPO / "cycles"
SCRIPT = REPO / ".github/scripts/cycle_config.py"
WORKFLOW = REPO / ".github/workflows/build_winpython_cycle.yml"
cycle_files = sorted(CYCLES.glob("*.toml")) if CYCLES.is_dir() else []
needs_script = pytest.mark.skipif(not SCRIPT.is_file(), reason="cycle_config.py is gone")
needs_workflow = pytest.mark.skipif(
not WORKFLOW.is_file(), reason="build_winpython_cycle.yml is gone"
)
needs_cycles = pytest.mark.skipif(not cycle_files, reason="no cycle files to check")
def load_script():
spec = importlib.util.spec_from_file_location("cycle_config", SCRIPT)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
@pytest.fixture(scope="module")
def cycle_config():
if not SCRIPT.is_file():
pytest.skip("cycle_config.py is gone")
return load_script()
@pytest.fixture
def at_repo_root(monkeypatch):
"""A cycle file names its lockfile directory relative to the checkout root.
CI runs the script from there; pytest may be invoked from anywhere.
"""
monkeypatch.chdir(REPO)
@pytest.fixture(scope="module")
def workflow_text():
if not WORKFLOW.is_file():
pytest.skip("build_winpython_cycle.yml is gone")
return WORKFLOW.read_text(encoding="utf-8")
def config_for(cycle_config, cycle_file: Path, requested: str = "all") -> dict:
import tomllib
with cycle_file.open("rb") as fh:
cfg = tomllib.load(fh)
return cycle_config.build_config(cfg, requested, cycle_file.stem)
def legs_of(config: dict) -> list[dict]:
return json.loads(config["matrix"])["leg"]
@needs_script
@needs_cycles
@pytest.mark.parametrize("cycle_file", cycle_files, ids=lambda p: p.stem)
class TestCycleFile:
"""Every committed cycle must still describe a build that could run."""
def test_builds_at_least_one_leg(self, cycle_config, at_repo_root, cycle_file):
"""A cycle whose lockfiles are all missing would start runners for nothing.
The script is meant to refuse that here, so an empty matrix means the
cycle directory and the flavor names have drifted apart.
"""
assert legs_of(config_for(cycle_config, cycle_file))
def test_every_leg_names_a_lockfile_that_exists(self, cycle_config, at_repo_root, cycle_file):
"""The leg is what pip is pointed at; a stale name fails only on the runner."""
for leg in legs_of(config_for(cycle_config, cycle_file)):
assert Path(leg["lockfile"]).is_file(), f"{leg['winpyver']}: {leg['lockfile']}"
for optional in ("lockfile_wheels", "requirements_wheels"):
if leg[optional]:
assert Path(leg[optional]).is_file(), f"{leg['winpyver']}: {leg[optional]}"
def test_artifact_names_are_unique(self, cycle_config, at_repo_root, cycle_file):
"""Two legs sharing an artifact name would race to upload the same one."""
names = [leg["artifact_name"] for leg in legs_of(config_for(cycle_config, cycle_file))]
assert len(names) == len(set(names)), sorted(names)
def test_version_matches_the_tarball_it_downloads(self, cycle_config, cycle_file):
"""ver2 and src are hand-edited together; a copy-paste slip must not pass.
The script raises on the mismatch, so reaching a leg at all is the check.
"""
import tomllib
with cycle_file.open("rb") as fh:
cfg = tomllib.load(fh)
for requested, entry in cfg["pythons"].items():
facts = cycle_config.python_facts(requested, entry)
assert ".".join(facts["ver2"].split(".")[:3]) in entry["src"]
def test_free_threaded_pythons_build_only_free_threaded_flavors(
self, cycle_config, at_repo_root, cycle_file
):
"""3.14 and 3.14F share a ver2, so only the flavor set separates them.
If the WINPYARCHDET filter ever stopped applying, a free-threaded run
would silently rebuild the ordinary flavors under the same file names.
"""
for leg in legs_of(config_for(cycle_config, cycle_file)):
free_threaded = leg["python_versionf"].endswith("F")
assert leg["name"].endswith("f") == free_threaded, leg["winpyver"]
@needs_script
@needs_cycles
class TestDispatchChoices:
@needs_workflow
@pytest.mark.parametrize("cycle_file", cycle_files, ids=lambda p: p.stem)
def test_every_python_can_be_dispatched_on_its_own(self, cycle_file, workflow_text):
"""The choice list is typed by hand; a cycle's new Python must be added to it.
Without this, a cycle can offer 3.16 that "all" builds but that nobody
can re-run alone when one flavor fails.
"""
import tomllib
with cycle_file.open("rb") as fh:
pythons = set(tomllib.load(fh)["pythons"])
block = re.search(r"\n options:\n((?:\s+- '[^']+'\n)+)", workflow_text)
assert block, "the python_versionf choice list moved; this test needs updating"
offered = set(re.findall(r"- '([^']+)'", block.group(1)))
assert "all" in offered
assert pythons <= offered, f"{cycle_file.stem} offers {pythons - offered}, the workflow does not"
@needs_script
@needs_cycles
@pytest.mark.parametrize("cycle_file", cycle_files, ids=lambda p: p.stem)
class TestFlavorFilter:
"""Re-running only some flavors, e.g. slim after a size fix."""
def test_all_is_no_filter(self, cycle_config, at_repo_root, cycle_file):
everything = legs_of(config_for(cycle_config, cycle_file))
import tomllib
with cycle_file.open("rb") as fh:
cfg = tomllib.load(fh)
assert legs_of(cycle_config.build_config(cfg, "all", cycle_file.stem, "all")) == everything
def test_keeps_only_the_named_flavors(self, cycle_config, at_repo_root, cycle_file):
import tomllib
with cycle_file.open("rb") as fh:
cfg = tomllib.load(fh)
built = {leg["name"] for leg in legs_of(config_for(cycle_config, cycle_file))}
some = sorted(built)[:2]
legs = legs_of(cycle_config.build_config(cfg, "all", cycle_file.stem, ", ".join(some)))
assert legs and {leg["name"] for leg in legs} == set(some)
def test_an_unknown_flavor_is_refused(self, cycle_config, at_repo_root, cycle_file):
import tomllib
with cycle_file.open("rb") as fh:
cfg = tomllib.load(fh)
with pytest.raises(SystemExit):
cycle_config.build_config(cfg, "all", cycle_file.stem, "slimm")
@needs_script
class TestPostSuffix:
"""A re-spin of one flavor under new file names, the lockfile unchanged."""
CFG = {
"cycle_dir": "nowhere",
"pythons": {},
"flavors": [{"name": "slim", "PANDOC": "1", "formats": {}, "post": "_post1"}],
}
PYTHON = {"ver2": "3.14.10.0", "python_version": "3.14", "archdet": "64"}
def test_renames_the_output_not_the_lockfile(self, cycle_config, tmp_path):
(tmp_path / "pylock.64-3_14_10_0slim.toml").write_text("")
cfg = dict(self.CFG, cycle_dir=str(tmp_path))
leg = cycle_config.flavor_entry(cfg, cfg["flavors"][0], self.PYTHON)
assert leg["winpyver"] == "3.14.10.0slim_post1"
assert leg["lockfile"].endswith("pylock.64-3_14_10_0slim.toml")
def test_a_suffix_unfit_for_a_url_is_refused(self, cycle_config, tmp_path):
(tmp_path / "pylock.64-3_14_10_0slim.toml").write_text("")
flavor = dict(self.CFG["flavors"][0], post=" post/1")
with pytest.raises(SystemExit):
cycle_config.flavor_entry(dict(self.CFG, cycle_dir=str(tmp_path)), flavor, self.PYTHON)
@needs_script
class TestReleaseTag:
"""The tag is what the download URLs the website publishes are built on."""
@pytest.mark.parametrize("cycle,cfg,expected", [
("2026_04", {"release_level": "b1"}, "2026-04b1"),
("2026_03", {"release_level": ""}, "2026-03"),
("2026_04", {}, "2026-04"),
("2027_01", {"release_level": "rc2"}, "2027-01rc2"),
])
def test_derived_from_the_cycle_name_and_level(self, cycle_config, cycle, cfg, expected):
"""A respin is a release_level bump, not a tag to invent.
Deliberately no date in it: a date would tie the tag to a run, so
re-running one missing flavor would open a second release instead of
adding to the one the other legs are already on.
"""
assert cycle_config.release_tag(cfg, cycle) == expected
def test_an_explicit_tag_wins(self, cycle_config):
"""Kept for continuity with the tags releases used before this scheme."""
cfg = {"release_level": "b1", "release_tag": "17.12.20260522/WinPython"}
assert cycle_config.release_tag(cfg, "2026_04") == "17.12.20260522/WinPython"
@pytest.mark.parametrize("bad", [
"2026 04", "2026-04^b1", "2026~04", "cycle..04", "2026-04/", "2026-04.lock",
"2026-04:b1", "back\\slash", "tab\there",
])
def test_a_tag_git_would_refuse_is_caught_here(self, cycle_config, bad):
"""Five seconds into the config job beats an hour into a build."""
with pytest.raises(SystemExit):
cycle_config.release_tag({"release_tag": bad}, "2026_04")
@needs_cycles
@pytest.mark.parametrize("cycle_file", cycle_files, ids=lambda p: p.stem)
def test_every_cycle_has_a_usable_tag(self, cycle_config, at_repo_root, cycle_file):
tag = config_for(cycle_config, cycle_file)["release_tag"]
assert tag and not set(tag) & cycle_config.TAG_FORBIDDEN
@needs_script
class TestReleaseTitle:
"""The title people actually read on the releases page.
Reproduced from the titles written by hand for years rather than invented:
cycle, then the level after a space -- the tag runs them together, the
title does not -- then the date with no comma, which is how all but one of
the previous titles read.
"""
@pytest.mark.parametrize("cycle,level,date,expected", [
# the real titles of these releases, to the letter
("2026_03", "", (2026, 8, 22), "WinPython 2026-03 of August 22nd 2026"),
("2026_03", "b3", (2026, 8, 8), "WinPython 2026-03 b3 of August 8th 2026"),
("2026_02", "b2", (2026, 5, 1), "WinPython 2026-02 b2 of May 1st 2026"),
("2026_01", "final", (2026, 3, 10), "WinPython 2026-01 final of March 10th 2026"),
("2025_05", "rc", (2025, 12, 22), "WinPython 2025-05 rc of December 22nd 2025"),
("2026_01", "b3", (2026, 2, 24), "WinPython 2026-01 b3 of February 24th 2026"),
])
def test_matches_the_titles_used_before(self, cycle_config, cycle, level, date, expected):
import datetime
cfg = {"release_level": level} if level else {}
assert cycle_config.release_title(cfg, cycle, datetime.date(*date)) == expected
def test_no_double_space_when_there_is_no_level(self, cycle_config):
"""Some older titles read "2026-02 of May 17th": a level that was empty."""
import datetime
title = cycle_config.release_title({"release_level": ""}, "2026_04", datetime.date(2026, 5, 17))
assert " " not in title
@pytest.mark.parametrize("day,expected", [
(1, "1st"), (2, "2nd"), (3, "3rd"), (4, "4th"),
(11, "11th"), (12, "12th"), (13, "13th"), # not 11st/12nd/13rd
(21, "21st"), (22, "22nd"), (23, "23rd"), (30, "30th"), (31, "31st"),
])
def test_ordinals(self, cycle_config, day, expected):
assert cycle_config.ordinal(day) == expected
@needs_cycles
@pytest.mark.parametrize("cycle_file", cycle_files, ids=lambda p: p.stem)
def test_every_cycle_produces_a_title(self, cycle_config, at_repo_root, cycle_file):
title = config_for(cycle_config, cycle_file)["release_title"]
assert title.startswith("WinPython ") and " of " in title
assert "\n" not in title, "a GITHUB_OUTPUT value has to stay on one line"
@needs_script
@needs_workflow
@needs_cycles
class TestWorkflowMatchesConfig:
"""What the workflow reads, the script has to emit -- for every cycle."""
@pytest.fixture(scope="class")
def emitted(self, request):
"""Leg keys and config outputs common to every cycle file."""
module = load_script()
monkey = pytest.MonkeyPatch()
monkey.chdir(REPO)
try:
leg_keys, output_keys, formats = None, None, set()
for cycle_file in cycle_files:
config = config_for(module, cycle_file)
output_keys = set(config) if output_keys is None else output_keys & set(config)
for leg in legs_of(config):
leg_keys = set(leg) if leg_keys is None else leg_keys & set(leg)
formats |= set(leg["formats"])
finally:
monkey.undo()
return {"leg": leg_keys, "outputs": output_keys, "formats": formats}
def test_every_matrix_key_the_workflow_reads_is_emitted(self, workflow_text, emitted):
"""An unknown matrix.leg.<key> resolves to "" and breaks far from here."""
used = set(re.findall(r"matrix\.leg\.([A-Za-z_][A-Za-z0-9_]*)", workflow_text))
used |= set(re.findall(r"matrix\.leg\['([^']+)'\]", workflow_text))
assert used - {"formats"} <= emitted["leg"], used - {"formats"} - emitted["leg"]
def test_every_archive_format_the_workflow_reads_is_emitted(self, workflow_text, emitted):
used = set(re.findall(r"matrix\.leg\.formats\.([A-Za-z0-9_]+)", workflow_text))
used |= set(re.findall(r"matrix\.leg\.formats\['([^']+)'\]", workflow_text))
assert used, "the workflow stopped reading the archive formats"
assert used <= emitted["formats"], used - emitted["formats"]
def test_declared_job_outputs_come_from_the_script(self, workflow_text, emitted):
"""`outputs:` forwards steps.cfg.outputs.<key>; the script must write it."""
declared = dict(re.findall(
r"^ ([a-z0-9_]+): \$\{\{ steps\.cfg\.outputs\.([a-z0-9_]+) \}\}$",
workflow_text, re.MULTILINE,
))
assert declared, "the config job's outputs block moved; this test needs updating"
assert set(declared.values()) <= emitted["outputs"], (
set(declared.values()) - emitted["outputs"]
)
def test_every_output_the_workflow_reads_is_declared(self, workflow_text):
declared = set(re.findall(
r"^ ([a-z0-9_]+): \$\{\{ steps\.cfg\.outputs\.[a-z0-9_]+ \}\}$",
workflow_text, re.MULTILINE,
))
used = set(re.findall(r"needs\.config\.outputs\.([a-z0-9_]+)", workflow_text))
assert used <= declared, used - declared
def test_nothing_still_reads_the_pre_cycle_matrix(self, workflow_text):
"""`matrix.flavor` was the one-Python-per-dispatch shape; it emits nothing now."""
assert "matrix.flavor" not in workflow_text
def test_publishing_is_gated_both_ways(self, workflow_text):
"""The binaries go either to the release or to an artifact -- never neither.
Both gates have to exist: if the negative one were dropped, a run with
publish off would build gigabytes and upload none of it.
"""
assert "if: ${{ inputs.publish }}" in workflow_text
assert "if: ${{ !inputs.publish }}" in workflow_text
def test_the_changelog_pr_is_whole_cycle_only(self, workflow_text):
"""A single-leg re-run would reduce the branch to that leg's files.
It has nothing to add either way: the same lockfile builds the same
package list, so a rebuilt leg cannot change a changelog.
"""
assert (
"if: ${{ inputs.publish && inputs.python_versionf == 'all' && inputs.flavors == 'all' }}"
in workflow_text
)
assert ".github/scripts/changelog_files.py release_metadata changelogs" in workflow_text
assert (REPO / ".github/scripts/changelog_files.py").is_file()
def test_the_release_title_is_built_by_the_script(self, workflow_text):
"""Ordinal dates are miserable in shell, and untestable there."""
assert 'TITLE: ${{ needs.config.outputs.release_title }}' in workflow_text
assert '--title "$TITLE"' in workflow_text
def test_the_changelog_name_carries_the_release_level(self, workflow_text):
"""A beta and the final it becomes share a ver2.
Without the level in the name, a b1's package list is written to the
file the final release wants, and `changelogs/` briefly describes a
beta under the final's name. The level has to reach the composite
action for that, so both halves are checked.
"""
assert "release_level: ${{ needs.config.outputs.release_level }}" in workflow_text
action = (REPO / ".github/actions/publish-winpython/action.yml").read_text(encoding="utf-8")
assert re.search(r"^ release_level:$", action, re.MULTILINE), "action input is missing"
assert "${{ inputs.winpy_ver2 }}${{ inputs.release_level }}.md" in action
def test_the_release_tag_is_never_taken_from_a_dispatch_input(self, workflow_text):
"""One source of truth: the cycle file. An input would let them disagree."""
assert "inputs.release_tag" not in workflow_text
for env_line in re.findall(r"^ TAG: (.+)$", workflow_text, re.MULTILINE):
assert env_line == "${{ needs.config.outputs.release_tag }}", env_line