Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .github/scripts/cycle_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@

Paths are relative to the working directory, which in CI is the checkout root.
"""
import datetime
import json
import os
import sys
Expand Down Expand Up @@ -128,6 +129,31 @@ def release_tag(cfg: dict, cycle_name: str) -> str:
return tag


def ordinal(day: int) -> str:
"""1st, 2nd, 3rd, 4th ... and the 11th/12th/13th exceptions."""
if 11 <= day % 100 <= 13:
return f"{day}th"
return f"{day}{ {1: 'st', 2: 'nd', 3: 'rd'}.get(day % 10, 'th') }"


def release_title(cfg: dict, cycle_name: str, today: datetime.date | None = None) -> str:
""""WinPython 2026-04 b1 of September 6th 2026", the shape releases have used.

Kept deliberately close to the titles written by hand before this: cycle,
then the level after a space -- the tag runs them together, the title does
not -- then the date, no comma, which is how all but one of them read.

The date is the day the draft is opened, since that is the only day the
build knows about. A cycle publishing much later than it was built can
still have the title edited; the tag, which is what URLs are built on,
does not move.
"""
today = today or datetime.date.today()
level = cfg.get("release_level", "")
name = f"{cycle_name.replace('_', '-')} {level}".strip()
return f"WinPython {name} of {today:%B} {ordinal(today.day)} {today.year}"


def build_config(cfg: dict, requested: str, cycle_name: str) -> dict:
pythons = cfg["pythons"]
if requested == "all":
Expand Down Expand Up @@ -156,6 +182,7 @@ def build_config(cfg: dict, requested: str, cycle_name: str) -> dict:
"cycle_dir": cfg["cycle_dir"],
"release_level": cfg.get("release_level", ""),
"release_tag": release_tag(cfg, cycle_name),
"release_title": release_title(cfg, cycle_name),
"pandoc_source": cfg["pandoc"]["source"],
"pandoc_sha256": cfg["pandoc"]["sha256"],
# consumed by the build job as strategy.matrix via fromJSON
Expand Down
7 changes: 5 additions & 2 deletions .github/workflows/build_winpython_cycle.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ jobs:
pandoc_sha256: ${{ steps.cfg.outputs.pandoc_sha256 }}
release_level: ${{ steps.cfg.outputs.release_level }}
release_tag: ${{ steps.cfg.outputs.release_tag }}
release_title: ${{ steps.cfg.outputs.release_title }}
matrix: ${{ steps.cfg.outputs.matrix }}
steps:
- uses: actions/checkout@v6
Expand All @@ -78,15 +79,17 @@ jobs:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
TAG: ${{ needs.config.outputs.release_tag }}
TITLE: ${{ needs.config.outputs.release_title }}
CYCLE: ${{ inputs.cycle }}
run: |
if gh release view "$TAG" >/dev/null 2>&1; then
# re-running one missing flavor is normal; say which release its files land on
# re-running one missing flavor is normal; say which release its files
# land on, and leave the title alone in case it was edited by hand
state=$(gh release view "$TAG" --json isDraft --jq 'if .isDraft then "still a draft" else "ALREADY PUBLISHED" end')
echo "release $TAG exists ($state); this run adds its files to it"
else
gh release create "$TAG" --draft \
--title "WinPython $TAG" \
--title "$TITLE" \
--notes "Draft opened by the build workflow for cycle $CYCLE. Files land as each build finishes; publish once they are all here."
echo "opened draft release $TAG"
fi
Expand Down
53 changes: 53 additions & 0 deletions tests/test_cycle_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,54 @@ def test_every_cycle_has_a_usable_tag(self, cycle_config, at_repo_root, cycle_fi
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
Expand Down Expand Up @@ -260,6 +308,11 @@ def test_publishing_is_gated_both_ways(self, workflow_text):
assert "if: ${{ inputs.publish }}" in workflow_text
assert "if: ${{ !inputs.publish }}" in workflow_text

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.

Expand Down
Loading