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