Skip to content

Commit 25e3685

Browse files
stonebigclaude
andcommitted
Test that the cycle workflow and its config script agree
Actions resolves an expression naming a key nothing emits to the empty string, without a word about it. So a renamed matrix.leg key does not fail at the line that reads it: it fails later, in PowerShell, an hour into a Windows runner, with an empty path. tests/test_cycle_config.py reads the workflow as text and runs cycle_config.py for real over whatever cycle files are committed, so a new cycle is covered the moment its TOML lands. It checks that the two sides name the same keys, that every leg points at a lockfile that is there, that artifact names cannot collide, that ver2 matches the tarball it downloads, that a free-threaded Python builds only free-threaded flavors -- 3.14 and 3.14F share a ver2, so nothing else separates them -- and the release tag rules, including the names git would refuse as a ref. Nothing new to install: the workflow is text rather than parsed YAML, and the pinned test dependencies stay pytest and packaging. test_wppm.yml ran none of it, having no trigger on the paths these tests guard, which is the commit where the answer is worth having. It does now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MBk5k7WdpPvx4SUFyEk3U3
1 parent fa2a118 commit 25e3685

2 files changed

Lines changed: 277 additions & 0 deletions

File tree

‎.github/workflows/test_wppm.yml‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,23 @@ on:
1414
- 'tests/**'
1515
- 'pyproject.toml'
1616
- '.github/workflows/test_wppm.yml'
17+
# tests/test_cycle_config.py checks these two agree with each other,
18+
# which is worth knowing on the commit that changes one of them
19+
- '.github/scripts/**'
20+
- '.github/workflows/build_winpython_cycle.yml'
21+
- 'cycles/**'
1722
pull_request:
1823
paths:
1924
- 'wppm/**'
2025
- 'winpython/**'
2126
- 'tests/**'
2227
- 'pyproject.toml'
2328
- '.github/workflows/test_wppm.yml'
29+
# tests/test_cycle_config.py checks these two agree with each other,
30+
# which is worth knowing on the commit that changes one of them
31+
- '.github/scripts/**'
32+
- '.github/workflows/build_winpython_cycle.yml'
33+
- 'cycles/**'
2434
workflow_dispatch:
2535

2636
permissions: {}

‎tests/test_cycle_config.py‎

Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,267 @@
1+
# -*- coding: utf-8 -*-
2+
"""The cycle build workflow and the script that configures it must agree.
3+
4+
`.github/scripts/cycle_config.py` decides the whole shape of a cycle build,
5+
and `build_winpython_cycle.yml` consumes that decision as `matrix.leg.<key>`
6+
and `needs.config.outputs.<key>`. GitHub Actions resolves an expression naming
7+
a key nothing emits to the empty string, silently: the build does not fail at
8+
that line, it fails much later, in PowerShell, an hour into a Windows runner,
9+
with an empty path. That is expensive to find and slow to retry, so this
10+
module checks the two sides line up before any of it starts.
11+
12+
Nothing here needs pytest plugins, a YAML parser or a network: the workflow is
13+
read as text and the script is imported and run for real, against whatever
14+
cycle files the repository currently has. A new cycle is therefore covered the
15+
moment its TOML is committed.
16+
"""
17+
import importlib.util
18+
import json
19+
import re
20+
from pathlib import Path
21+
22+
import pytest
23+
24+
REPO = Path(__file__).resolve().parents[1]
25+
CYCLES = REPO / "cycles"
26+
SCRIPT = REPO / ".github/scripts/cycle_config.py"
27+
WORKFLOW = REPO / ".github/workflows/build_winpython_cycle.yml"
28+
29+
cycle_files = sorted(CYCLES.glob("*.toml")) if CYCLES.is_dir() else []
30+
31+
needs_script = pytest.mark.skipif(not SCRIPT.is_file(), reason="cycle_config.py is gone")
32+
needs_workflow = pytest.mark.skipif(
33+
not WORKFLOW.is_file(), reason="build_winpython_cycle.yml is gone"
34+
)
35+
needs_cycles = pytest.mark.skipif(not cycle_files, reason="no cycle files to check")
36+
37+
38+
def load_script():
39+
spec = importlib.util.spec_from_file_location("cycle_config", SCRIPT)
40+
module = importlib.util.module_from_spec(spec)
41+
spec.loader.exec_module(module)
42+
return module
43+
44+
45+
@pytest.fixture(scope="module")
46+
def cycle_config():
47+
if not SCRIPT.is_file():
48+
pytest.skip("cycle_config.py is gone")
49+
return load_script()
50+
51+
52+
@pytest.fixture
53+
def at_repo_root(monkeypatch):
54+
"""A cycle file names its lockfile directory relative to the checkout root.
55+
56+
CI runs the script from there; pytest may be invoked from anywhere.
57+
"""
58+
monkeypatch.chdir(REPO)
59+
60+
61+
@pytest.fixture(scope="module")
62+
def workflow_text():
63+
if not WORKFLOW.is_file():
64+
pytest.skip("build_winpython_cycle.yml is gone")
65+
return WORKFLOW.read_text(encoding="utf-8")
66+
67+
68+
def config_for(cycle_config, cycle_file: Path, requested: str = "all") -> dict:
69+
import tomllib
70+
71+
with cycle_file.open("rb") as fh:
72+
cfg = tomllib.load(fh)
73+
return cycle_config.build_config(cfg, requested, cycle_file.stem)
74+
75+
76+
def legs_of(config: dict) -> list[dict]:
77+
return json.loads(config["matrix"])["leg"]
78+
79+
80+
@needs_script
81+
@needs_cycles
82+
@pytest.mark.parametrize("cycle_file", cycle_files, ids=lambda p: p.stem)
83+
class TestCycleFile:
84+
"""Every committed cycle must still describe a build that could run."""
85+
86+
def test_builds_at_least_one_leg(self, cycle_config, at_repo_root, cycle_file):
87+
"""A cycle whose lockfiles are all missing would start runners for nothing.
88+
89+
The script is meant to refuse that here, so an empty matrix means the
90+
cycle directory and the flavor names have drifted apart.
91+
"""
92+
assert legs_of(config_for(cycle_config, cycle_file))
93+
94+
def test_every_leg_names_a_lockfile_that_exists(self, cycle_config, at_repo_root, cycle_file):
95+
"""The leg is what pip is pointed at; a stale name fails only on the runner."""
96+
for leg in legs_of(config_for(cycle_config, cycle_file)):
97+
assert Path(leg["lockfile"]).is_file(), f"{leg['winpyver']}: {leg['lockfile']}"
98+
for optional in ("lockfile_wheels", "requirements_wheels"):
99+
if leg[optional]:
100+
assert Path(leg[optional]).is_file(), f"{leg['winpyver']}: {leg[optional]}"
101+
102+
def test_artifact_names_are_unique(self, cycle_config, at_repo_root, cycle_file):
103+
"""Two legs sharing an artifact name would race to upload the same one."""
104+
names = [leg["artifact_name"] for leg in legs_of(config_for(cycle_config, cycle_file))]
105+
assert len(names) == len(set(names)), sorted(names)
106+
107+
def test_version_matches_the_tarball_it_downloads(self, cycle_config, cycle_file):
108+
"""ver2 and src are hand-edited together; a copy-paste slip must not pass.
109+
110+
The script raises on the mismatch, so reaching a leg at all is the check.
111+
"""
112+
import tomllib
113+
114+
with cycle_file.open("rb") as fh:
115+
cfg = tomllib.load(fh)
116+
for requested, entry in cfg["pythons"].items():
117+
facts = cycle_config.python_facts(requested, entry)
118+
assert ".".join(facts["ver2"].split(".")[:3]) in entry["src"]
119+
120+
def test_free_threaded_pythons_build_only_free_threaded_flavors(
121+
self, cycle_config, at_repo_root, cycle_file
122+
):
123+
"""3.14 and 3.14F share a ver2, so only the flavor set separates them.
124+
125+
If the WINPYARCHDET filter ever stopped applying, a free-threaded run
126+
would silently rebuild the ordinary flavors under the same file names.
127+
"""
128+
for leg in legs_of(config_for(cycle_config, cycle_file)):
129+
free_threaded = leg["python_versionf"].endswith("F")
130+
assert leg["name"].endswith("f") == free_threaded, leg["winpyver"]
131+
132+
133+
@needs_script
134+
@needs_cycles
135+
class TestDispatchChoices:
136+
@needs_workflow
137+
@pytest.mark.parametrize("cycle_file", cycle_files, ids=lambda p: p.stem)
138+
def test_every_python_can_be_dispatched_on_its_own(self, cycle_file, workflow_text):
139+
"""The choice list is typed by hand; a cycle's new Python must be added to it.
140+
141+
Without this, a cycle can offer 3.16 that "all" builds but that nobody
142+
can re-run alone when one flavor fails.
143+
"""
144+
import tomllib
145+
146+
with cycle_file.open("rb") as fh:
147+
pythons = set(tomllib.load(fh)["pythons"])
148+
block = re.search(r"\n options:\n((?:\s+- '[^']+'\n)+)", workflow_text)
149+
assert block, "the python_versionf choice list moved; this test needs updating"
150+
offered = set(re.findall(r"- '([^']+)'", block.group(1)))
151+
assert "all" in offered
152+
assert pythons <= offered, f"{cycle_file.stem} offers {pythons - offered}, the workflow does not"
153+
154+
155+
@needs_script
156+
class TestReleaseTag:
157+
"""The tag is what the download URLs the website publishes are built on."""
158+
159+
@pytest.mark.parametrize("cycle,cfg,expected", [
160+
("2026_04", {"release_level": "b1"}, "2026-04b1"),
161+
("2026_03", {"release_level": ""}, "2026-03"),
162+
("2026_04", {}, "2026-04"),
163+
("2027_01", {"release_level": "rc2"}, "2027-01rc2"),
164+
])
165+
def test_derived_from_the_cycle_name_and_level(self, cycle_config, cycle, cfg, expected):
166+
"""A respin is a release_level bump, not a tag to invent.
167+
168+
Deliberately no date in it: a date would tie the tag to a run, so
169+
re-running one missing flavor would open a second release instead of
170+
adding to the one the other legs are already on.
171+
"""
172+
assert cycle_config.release_tag(cfg, cycle) == expected
173+
174+
def test_an_explicit_tag_wins(self, cycle_config):
175+
"""Kept for continuity with the tags releases used before this scheme."""
176+
cfg = {"release_level": "b1", "release_tag": "17.12.20260522/WinPython"}
177+
assert cycle_config.release_tag(cfg, "2026_04") == "17.12.20260522/WinPython"
178+
179+
@pytest.mark.parametrize("bad", [
180+
"2026 04", "2026-04^b1", "2026~04", "cycle..04", "2026-04/", "2026-04.lock",
181+
"2026-04:b1", "back\\slash", "tab\there",
182+
])
183+
def test_a_tag_git_would_refuse_is_caught_here(self, cycle_config, bad):
184+
"""Five seconds into the config job beats an hour into a build."""
185+
with pytest.raises(SystemExit):
186+
cycle_config.release_tag({"release_tag": bad}, "2026_04")
187+
188+
@needs_cycles
189+
@pytest.mark.parametrize("cycle_file", cycle_files, ids=lambda p: p.stem)
190+
def test_every_cycle_has_a_usable_tag(self, cycle_config, at_repo_root, cycle_file):
191+
tag = config_for(cycle_config, cycle_file)["release_tag"]
192+
assert tag and not set(tag) & cycle_config.TAG_FORBIDDEN
193+
194+
195+
@needs_script
196+
@needs_workflow
197+
@needs_cycles
198+
class TestWorkflowMatchesConfig:
199+
"""What the workflow reads, the script has to emit -- for every cycle."""
200+
201+
@pytest.fixture(scope="class")
202+
def emitted(self, request):
203+
"""Leg keys and config outputs common to every cycle file."""
204+
module = load_script()
205+
monkey = pytest.MonkeyPatch()
206+
monkey.chdir(REPO)
207+
try:
208+
leg_keys, output_keys, formats = None, None, set()
209+
for cycle_file in cycle_files:
210+
config = config_for(module, cycle_file)
211+
output_keys = set(config) if output_keys is None else output_keys & set(config)
212+
for leg in legs_of(config):
213+
leg_keys = set(leg) if leg_keys is None else leg_keys & set(leg)
214+
formats |= set(leg["formats"])
215+
finally:
216+
monkey.undo()
217+
return {"leg": leg_keys, "outputs": output_keys, "formats": formats}
218+
219+
def test_every_matrix_key_the_workflow_reads_is_emitted(self, workflow_text, emitted):
220+
"""An unknown matrix.leg.<key> resolves to "" and breaks far from here."""
221+
used = set(re.findall(r"matrix\.leg\.([A-Za-z_][A-Za-z0-9_]*)", workflow_text))
222+
used |= set(re.findall(r"matrix\.leg\['([^']+)'\]", workflow_text))
223+
assert used - {"formats"} <= emitted["leg"], used - {"formats"} - emitted["leg"]
224+
225+
def test_every_archive_format_the_workflow_reads_is_emitted(self, workflow_text, emitted):
226+
used = set(re.findall(r"matrix\.leg\.formats\.([A-Za-z0-9_]+)", workflow_text))
227+
used |= set(re.findall(r"matrix\.leg\.formats\['([^']+)'\]", workflow_text))
228+
assert used, "the workflow stopped reading the archive formats"
229+
assert used <= emitted["formats"], used - emitted["formats"]
230+
231+
def test_declared_job_outputs_come_from_the_script(self, workflow_text, emitted):
232+
"""`outputs:` forwards steps.cfg.outputs.<key>; the script must write it."""
233+
declared = dict(re.findall(
234+
r"^ ([a-z0-9_]+): \$\{\{ steps\.cfg\.outputs\.([a-z0-9_]+) \}\}$",
235+
workflow_text, re.MULTILINE,
236+
))
237+
assert declared, "the config job's outputs block moved; this test needs updating"
238+
assert set(declared.values()) <= emitted["outputs"], (
239+
set(declared.values()) - emitted["outputs"]
240+
)
241+
242+
def test_every_output_the_workflow_reads_is_declared(self, workflow_text):
243+
declared = set(re.findall(
244+
r"^ ([a-z0-9_]+): \$\{\{ steps\.cfg\.outputs\.[a-z0-9_]+ \}\}$",
245+
workflow_text, re.MULTILINE,
246+
))
247+
used = set(re.findall(r"needs\.config\.outputs\.([a-z0-9_]+)", workflow_text))
248+
assert used <= declared, used - declared
249+
250+
def test_nothing_still_reads_the_pre_cycle_matrix(self, workflow_text):
251+
"""`matrix.flavor` was the one-Python-per-dispatch shape; it emits nothing now."""
252+
assert "matrix.flavor" not in workflow_text
253+
254+
def test_publishing_is_gated_both_ways(self, workflow_text):
255+
"""The binaries go either to the release or to an artifact -- never neither.
256+
257+
Both gates have to exist: if the negative one were dropped, a run with
258+
publish off would build gigabytes and upload none of it.
259+
"""
260+
assert "if: ${{ inputs.publish }}" in workflow_text
261+
assert "if: ${{ !inputs.publish }}" in workflow_text
262+
263+
def test_the_release_tag_is_never_taken_from_a_dispatch_input(self, workflow_text):
264+
"""One source of truth: the cycle file. An input would let them disagree."""
265+
assert "inputs.release_tag" not in workflow_text
266+
for env_line in re.findall(r"^ TAG: (.+)$", workflow_text, re.MULTILINE):
267+
assert env_line == "${{ needs.config.outputs.release_tag }}", env_line

0 commit comments

Comments
 (0)