diff --git a/.github/scripts/cycle_config.py b/.github/scripts/cycle_config.py index 15ea30ab..01052d63 100644 --- a/.github/scripts/cycle_config.py +++ b/.github/scripts/cycle_config.py @@ -25,6 +25,9 @@ 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. + * a flavor's optional post suffix ("_post1"): a rebuild of the same + lockfile under new file names. It can join a release that is already + published, since nothing there gets replaced. Paths are relative to the working directory, which in CI is the checkout root. """ @@ -40,6 +43,9 @@ # parts a hand-written tag realistically trips over. TAG_FORBIDDEN = set(" ~^:?*[\\\x7f") | {chr(c) for c in range(32)} +# a post suffix lands in file names and URLs +POST_ALLOWED = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-") + def python_facts(requested: str, entry: dict) -> dict: """What every flavor of one Python shares. @@ -86,6 +92,10 @@ def flavor_entry(cfg: dict, flavor: dict, python: dict) -> dict | None: def optional(path: Path) -> str: return path.as_posix() if path.is_file() else "" + post = flavor.get("post", "") + if set(post) - POST_ALLOWED: + raise SystemExit(f"{flavor['name']}: post {post!r} must be letters, digits, '.', '_' or '-'") + leg = {key: value for key, value in python.items() if key != "archdet"} leg.update( name=flavor["name"], @@ -94,7 +104,8 @@ def optional(path: Path) -> str: 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}", + post=post, + winpyver=f"{python['ver2']}{flavor['name']}{level}{post}", artifact_name=f"publish_{python['python_version']}{flavor['name']}", ) return leg @@ -208,6 +219,8 @@ def build_config(cfg: dict, requested: str, cycle_name: str, flavors: str = "all "release_level": cfg.get("release_level", ""), "release_tag": release_tag(cfg, cycle_name), "release_title": release_title(cfg, cycle_name), + # every leg renamed by a post suffix: none of its files can replace one + "adds_only": "true" if all(leg["post"] for leg in legs) else "false", "pandoc_source": cfg["pandoc"]["source"], "pandoc_sha256": cfg["pandoc"]["sha256"], # consumed by the build job as strategy.matrix via fromJSON diff --git a/.github/workflows/build_winpython_cycle.yml b/.github/workflows/build_winpython_cycle.yml index f5cce9d3..9b0e0003 100644 --- a/.github/workflows/build_winpython_cycle.yml +++ b/.github/workflows/build_winpython_cycle.yml @@ -63,6 +63,7 @@ jobs: release_level: ${{ steps.cfg.outputs.release_level }} release_tag: ${{ steps.cfg.outputs.release_tag }} release_title: ${{ steps.cfg.outputs.release_title }} + adds_only: ${{ steps.cfg.outputs.adds_only }} matrix: ${{ steps.cfg.outputs.matrix }} steps: - uses: actions/checkout@v6 @@ -93,6 +94,7 @@ jobs: TITLE: ${{ needs.config.outputs.release_title }} CYCLE: ${{ inputs.cycle }} OVERWRITE: ${{ inputs.overwrite_published }} + ADDS_ONLY: ${{ needs.config.outputs.adds_only }} run: | if gh release view "$TAG" >/dev/null 2>&1; then # Adding a missing flavor to a draft is the normal path. Landing on @@ -100,7 +102,8 @@ jobs: # release_level bump, and the upload replaces files people have # downloaded -- so stop here, before an hour of build time. published=$(gh release view "$TAG" --json isDraft --jq '.isDraft | not') - if [ "$published" = "true" ] && [ "$OVERWRITE" != "true" ]; then + # a post respin only adds new file names, so it may join a published one + if [ "$published" = "true" ] && [ "$OVERWRITE" != "true" ] && [ "$ADDS_ONLY" != "true" ]; then echo "::error::$TAG is already published. Bump release_level in cycles/$CYCLE.toml, or re-dispatch with overwrite_published ticked to replace its files." exit 1 fi @@ -183,7 +186,7 @@ jobs: winpy_arch: ${{ env.WINPYARCH }} winpy_ver: ${{ env.WINPYVER }} winpy_ver2: ${{ env.WINPYVER2 }} - release_level: ${{ needs.config.outputs.release_level }} + release_level: ${{ needs.config.outputs.release_level }}${{ matrix.leg.post }} dotwheelhouse: ${{ env.dotwheelhouse }} winpy_requirements_whl: ${{ matrix.leg.requirements_wheels }} format_zip: ${{ matrix.leg.formats.zip }} diff --git a/cycles/2026_04.toml b/cycles/2026_04.toml index e35acf5d..ab90b78a 100644 --- a/cycles/2026_04.toml +++ b/cycles/2026_04.toml @@ -56,6 +56,7 @@ name = "slim" PANDOC = "1" WINPYARCHDET = "64" formats = { zip = false, "7z" = true, exe = true } +post = "_post1" # -mx9 re-spin, to fit VirusTotal's 650 MB [[flavors]] name = "whl" diff --git a/tests/test_cycle_config.py b/tests/test_cycle_config.py index 561e790a..693fb289 100644 --- a/tests/test_cycle_config.py +++ b/tests/test_cycle_config.py @@ -185,6 +185,31 @@ def test_an_unknown_flavor_is_refused(self, cycle_config, at_repo_root, cycle_fi 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."""