Visitar URL original
File a cycle's changelogs as a pull request · stonebig/winpython@95e806e · GitHub
Skip to content

Commit 95e806e

Browse files
stonebigclaude
andcommitted
File a cycle's changelogs as a pull request
The build already produced everything changelogs/ needs, and it was still copied in by hand: nine package indexes, nine lock files, nine requirements, and nine _History.md written one wppm.diff call at a time. A changelogs job now does it. It takes the metadata artifacts the legs produced, files the three kinds that belong in changelogs/, writes the histories from the checkout -- a history compares against the previous release, which only the repository has -- and opens one pull request for the cycle. changelog_files.py decides what goes where; wppm.diff still decides what a history says. Whole-cycle publish runs only. Re-running one leg would otherwise reduce the branch to that leg's files, and it has nothing to add: the same lockfile builds the same package list, so a rebuilt leg cannot change a changelog. Opened with git and gh rather than a third-party action, to keep a repo that hash-pins its build inputs from taking a new action with write permissions for the sake of nine files. Checked against the real thing: the 27 metadata files of 2026-04 b1, run over a copy of changelogs/, file 27 and write 9 histories against the right baselines -- 3.15.0.5b1 against 3.15.0.4 -- adding 36 files and modifying none. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MBk5k7WdpPvx4SUFyEk3U3
1 parent a8d294c commit 95e806e

4 files changed

Lines changed: 331 additions & 0 deletions

File tree

‎.github/scripts/changelog_files.py‎

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
"""File a cycle's build output into changelogs/, and write the histories.
2+
3+
python .github/scripts/changelog_files.py <downloaded metadata> changelogs
4+
5+
A build leg produces four metadata files. Three of them belong in changelogs/
6+
and are copied there as they are: the package index
7+
(WinPython<flavor>-64bit-<version>.md), the lock file and the requirements.
8+
The fourth, hashes_<winpyver>.md, describes the binaries of one build rather
9+
than the release, and stays out.
10+
11+
The _History.md companions are then written here rather than shipped from the
12+
build, because a history is a comparison against the *previous* release, and
13+
only a checkout of the repository has that to compare against. Ordering is
14+
`wppm.diff`'s job; this decides what to hand it.
15+
16+
Run from the checkout root, so `from wppm import diff` finds the wppm being
17+
released rather than an installed one.
18+
"""
19+
import re
20+
import shutil
21+
import sys
22+
from pathlib import Path
23+
24+
from wppm import diff
25+
from wppm.diff import version # packaging, or pip's vendored copy
26+
27+
# WinPythonslim-64bit-3.15.0.5b1.md -- the flavor may be empty, and the version
28+
# may carry a release level, which is why the parser decides and not the regex
29+
CHANGELOG = re.compile(r"^WinPython(?P<flavor>[A-Za-z0-9]*)-(?P<arch>\d+)bit-(?P<version>.+)\.md$")
30+
31+
32+
def parse_changelog_name(name: str):
33+
"""(flavor, architecture, version) for a package index, else None."""
34+
match = CHANGELOG.match(name)
35+
if not match:
36+
return None
37+
try:
38+
version.parse(match.group("version"))
39+
except version.InvalidVersion:
40+
return None # the _History companions land here, as they should
41+
return match.group("flavor"), int(match.group("arch")), match.group("version")
42+
43+
44+
def files_to_file(source: Path):
45+
"""The build output that belongs in changelogs/.
46+
47+
The package index, the lock file and the requirements -- and so not
48+
hashes_<winpyver>.md, which describes one build's binaries rather than the
49+
release. It is excluded by being neither: it is not named for a version,
50+
and it is not a pylock or a requir.
51+
"""
52+
for path in sorted(source.iterdir()):
53+
if not path.is_file():
54+
continue
55+
if parse_changelog_name(path.name) or path.name.startswith(("pylock.", "requir.")):
56+
yield path
57+
58+
59+
def main(argv: list[str]) -> None:
60+
if len(argv) != 3:
61+
raise SystemExit(f"usage: {Path(argv[0]).name} <metadata dir> <changelogs dir>")
62+
source, changelogs = Path(argv[1]), Path(argv[2])
63+
if not source.is_dir():
64+
raise SystemExit(f"no such directory: {source}")
65+
if not changelogs.is_dir():
66+
raise SystemExit(f"no such directory: {changelogs}")
67+
68+
filed = []
69+
for path in files_to_file(source):
70+
shutil.copyfile(path, changelogs / path.name)
71+
filed.append(path.name)
72+
if not filed:
73+
raise SystemExit(f"{source} held no changelog, lock file or requirements")
74+
for name in filed:
75+
print(f"filed {name}")
76+
77+
# every package index has to be in place before any history is written: a
78+
# history reads the index of the release it compares against, which for the
79+
# second flavor of a cycle may well be the one just copied
80+
histories = 0
81+
for name in filed:
82+
parsed = parse_changelog_name(name)
83+
if not parsed:
84+
continue
85+
flavor, architecture, ver = parsed
86+
previous = diff.find_previous_version(ver, changelogs, flavor, architecture)
87+
diff.write_changelog(ver, None, changelogs, flavor, architecture)
88+
histories += 1
89+
against = "nothing earlier" if previous == ver else previous
90+
print(f"history WinPython{flavor}-{architecture}bit-{ver} vs {against}")
91+
print(f"\n{len(filed)} file(s) filed, {histories} history file(s) written")
92+
93+
94+
if __name__ == "__main__":
95+
main(sys.argv)

‎.github/workflows/build_winpython_cycle.yml‎

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,3 +208,68 @@ jobs:
208208
name: ${{ matrix.leg.artifact_name }}
209209
path: publish_output
210210
retention-days: 66 # keeps artifact for 66 days
211+
212+
changelogs:
213+
# A cycle's changelogs, filed and offered as one reviewable pull request.
214+
#
215+
# Whole-cycle builds only. A re-run of a single leg would otherwise reduce
216+
# the branch to that leg's files, and it has nothing to add anyway: the
217+
# same lockfile produces the same package list, so a rebuilt leg cannot
218+
# change a changelog.
219+
needs: [config, build-winpython]
220+
if: ${{ inputs.publish && inputs.python_versionf == 'all' }}
221+
runs-on: ubuntu-latest
222+
permissions:
223+
contents: write
224+
pull-requests: write
225+
steps:
226+
- name: Checkout repository
227+
# credentials are kept here, unlike in the build legs: this job pushes
228+
uses: actions/checkout@v6
229+
230+
- name: Install Python
231+
uses: actions/setup-python@v6
232+
with:
233+
python-version: '3.13'
234+
235+
- name: Install pinned dependencies
236+
# the same hash-pinned set the test suite uses; diff.py wants packaging
237+
run: python -m pip install --no-deps --require-hashes -r tests/requir.wppmtest.txt
238+
239+
- name: Collect the metadata every leg produced
240+
uses: actions/download-artifact@v6
241+
with:
242+
pattern: publish_*
243+
merge-multiple: true
244+
path: release_metadata
245+
246+
- name: File the changelogs and write the histories
247+
run: python .github/scripts/changelog_files.py release_metadata changelogs
248+
249+
- name: Open the changelog pull request
250+
env:
251+
GH_TOKEN: ${{ github.token }}
252+
GH_REPO: ${{ github.repository }}
253+
TAG: ${{ needs.config.outputs.release_tag }}
254+
TITLE: ${{ needs.config.outputs.release_title }}
255+
BASE: ${{ github.event.repository.default_branch }}
256+
run: |
257+
branch="changelogs/$TAG"
258+
git config user.name "github-actions[bot]"
259+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
260+
git switch -c "$branch"
261+
git add changelogs
262+
if git diff --cached --quiet; then
263+
echo "changelogs/ already holds this cycle; nothing to open"
264+
exit 0
265+
fi
266+
git commit -m "Changelogs for $TITLE"
267+
# the branch is generated wholly by this job, so a re-run replaces it
268+
git push --force origin "$branch"
269+
if gh pr view "$branch" --json number >/dev/null 2>&1; then
270+
echo "pull request for $branch is open; the push updated it"
271+
else
272+
gh pr create --base "$BASE" --head "$branch" \
273+
--title "Changelogs for $TITLE" \
274+
--body "Package indexes, lock files and requirements for every leg of \`$TAG\`, with the \`_History.md\` companions written against the previous release. Filed by the build that produced them, rather than copied by hand."
275+
fi

‎tests/test_changelog_files.py‎

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
# -*- coding: utf-8 -*-
2+
"""What a build's output contributes to changelogs/, and what it must not.
3+
4+
A leg produces four metadata files. Three belong in `changelogs/`; the fourth,
5+
`hashes_<winpyver>.md`, describes one build's binaries rather than the release
6+
and has never been kept there. Getting that wrong is quiet: the wrong file is
7+
committed and nothing complains, so the selection is pinned here.
8+
9+
The `_History.md` companions are written from the checkout rather than shipped
10+
by the build, because a history compares against the *previous* release, which
11+
only the repository has.
12+
"""
13+
import importlib.util
14+
import shutil
15+
import subprocess
16+
import sys
17+
from pathlib import Path
18+
19+
import pytest
20+
21+
REPO = Path(__file__).resolve().parents[1]
22+
SCRIPT = REPO / ".github/scripts/changelog_files.py"
23+
24+
needs_script = pytest.mark.skipif(not SCRIPT.is_file(), reason="changelog_files.py is gone")
25+
26+
27+
@pytest.fixture(scope="module")
28+
def changelog_files():
29+
if not SCRIPT.is_file():
30+
pytest.skip("changelog_files.py is gone")
31+
spec = importlib.util.spec_from_file_location("changelog_files", SCRIPT)
32+
module = importlib.util.module_from_spec(spec)
33+
spec.loader.exec_module(module)
34+
return module
35+
36+
37+
# what one leg of 2026-04 b1 actually uploaded, names verbatim
38+
LEG_OUTPUT = [
39+
"WinPythonslim-64bit-3.15.0.5b1.md",
40+
"pylock.64-3_15_0_5slimb1.toml",
41+
"requir.64-3_15_0_5slimb1.txt",
42+
"hashes_3.15.0.5slimb1.md",
43+
]
44+
45+
46+
@needs_script
47+
class TestParseChangelogName:
48+
@pytest.mark.parametrize("name,expected", [
49+
("WinPythonslim-64bit-3.15.0.5b1.md", ("slim", 64, "3.15.0.5b1")),
50+
("WinPythondotf-64bit-3.14.7.1b1.md", ("dotf", 64, "3.14.7.1b1")),
51+
("WinPythondot-64bit-3.13.15.0.md", ("dot", 64, "3.13.15.0")),
52+
("WinPython-64bit-3.9.8.0.md", ("", 64, "3.9.8.0")), # the unflavored ones
53+
("WinPythondot-32bit-3.9.0.0b1.md", ("dot", 32, "3.9.0.0b1")),
54+
])
55+
def test_reads_flavor_architecture_and_version(self, changelog_files, name, expected):
56+
assert changelog_files.parse_changelog_name(name) == expected
57+
58+
@pytest.mark.parametrize("name", [
59+
"WinPythonslim-64bit-3.15.0.5b1_History.md", # the companion, not an index
60+
"hashes_3.15.0.5slimb1.md",
61+
"pylock.64-3_15_0_5slimb1.toml",
62+
"README.md",
63+
"WinPythonslim-64bit-.md",
64+
])
65+
def test_rejects_everything_that_is_not_a_package_index(self, changelog_files, name):
66+
assert changelog_files.parse_changelog_name(name) is None
67+
68+
69+
@needs_script
70+
class TestSelection:
71+
def test_hashes_are_left_behind(self, changelog_files, tmp_path):
72+
"""They describe one build's binaries; changelogs/ has never held them."""
73+
for name in LEG_OUTPUT:
74+
(tmp_path / name).write_text("", encoding="utf-8")
75+
chosen = sorted(p.name for p in changelog_files.files_to_file(tmp_path))
76+
assert chosen == [
77+
"WinPythonslim-64bit-3.15.0.5b1.md",
78+
"pylock.64-3_15_0_5slimb1.toml",
79+
"requir.64-3_15_0_5slimb1.txt",
80+
]
81+
82+
def test_directories_are_skipped(self, changelog_files, tmp_path):
83+
(tmp_path / "WinPythonslim-64bit-3.15.0.5b1.md").write_text("", encoding="utf-8")
84+
(tmp_path / "pylock.64-nested").mkdir()
85+
assert [p.name for p in changelog_files.files_to_file(tmp_path)] == [
86+
"WinPythonslim-64bit-3.15.0.5b1.md"
87+
]
88+
89+
90+
@needs_script
91+
class TestEndToEnd:
92+
"""Run the script the way the workflow runs it."""
93+
94+
@pytest.fixture
95+
def cycle(self, tmp_path):
96+
"""A metadata directory and a changelogs/ holding one earlier release."""
97+
source = tmp_path / "release_metadata"
98+
source.mkdir()
99+
changelogs = tmp_path / "changelogs"
100+
changelogs.mkdir()
101+
102+
# a real package index makes a real diff; reuse two the repo already has
103+
previous = REPO / "changelogs" / "WinPythonslim-64bit-3.15.0.4.md"
104+
current = REPO / "changelogs" / "WinPythonslim-64bit-3.14.7.0.md"
105+
if not (previous.is_file() and current.is_file()):
106+
pytest.skip("the changelogs this test reads from are gone")
107+
shutil.copyfile(previous, changelogs / previous.name)
108+
shutil.copyfile(current, source / "WinPythonslim-64bit-3.15.0.5b1.md")
109+
(source / "pylock.64-3_15_0_5slimb1.toml").write_text("x", encoding="utf-8")
110+
(source / "requir.64-3_15_0_5slimb1.txt").write_text("x", encoding="utf-8")
111+
(source / "hashes_3.15.0.5slimb1.md").write_text("x", encoding="utf-8")
112+
return source, changelogs
113+
114+
def test_files_the_three_and_writes_the_history(self, cycle):
115+
source, changelogs = cycle
116+
proc = subprocess.run(
117+
[sys.executable, ".github/scripts/changelog_files.py", str(source), str(changelogs)],
118+
cwd=REPO, capture_output=True, text=True,
119+
)
120+
assert proc.returncode == 0, proc.stderr
121+
landed = sorted(p.name for p in changelogs.iterdir())
122+
assert landed == [
123+
"WinPythonslim-64bit-3.15.0.4.md", # was already there
124+
"WinPythonslim-64bit-3.15.0.5b1.md", # filed
125+
"WinPythonslim-64bit-3.15.0.5b1_History.md", # written
126+
"pylock.64-3_15_0_5slimb1.toml",
127+
"requir.64-3_15_0_5slimb1.txt",
128+
]
129+
130+
def test_the_history_names_the_release_it_compares_against(self, cycle):
131+
source, changelogs = cycle
132+
subprocess.run(
133+
[sys.executable, ".github/scripts/changelog_files.py", str(source), str(changelogs)],
134+
cwd=REPO, capture_output=True, text=True, check=True,
135+
)
136+
history = (changelogs / "WinPythonslim-64bit-3.15.0.5b1_History.md").read_text(
137+
encoding="utf-8"
138+
)
139+
assert "since version 3.15.0.4slim" in history
140+
assert "3.15.0.5b1slim" in history
141+
142+
def test_an_empty_metadata_directory_is_an_error(self, tmp_path):
143+
"""Silence here would commit nothing and call it a success."""
144+
source, changelogs = tmp_path / "src", tmp_path / "changelogs"
145+
source.mkdir()
146+
changelogs.mkdir()
147+
proc = subprocess.run(
148+
[sys.executable, ".github/scripts/changelog_files.py", str(source), str(changelogs)],
149+
cwd=REPO, capture_output=True, text=True,
150+
)
151+
assert proc.returncode != 0
152+
assert "held no changelog" in proc.stdout + proc.stderr
153+
154+
def test_a_missing_directory_is_an_error(self, tmp_path):
155+
proc = subprocess.run(
156+
[sys.executable, ".github/scripts/changelog_files.py",
157+
str(tmp_path / "nope"), str(tmp_path)],
158+
cwd=REPO, capture_output=True, text=True,
159+
)
160+
assert proc.returncode != 0
161+
assert "no such directory" in proc.stdout + proc.stderr

‎tests/test_cycle_config.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,16 @@ def test_publishing_is_gated_both_ways(self, workflow_text):
308308
assert "if: ${{ inputs.publish }}" in workflow_text
309309
assert "if: ${{ !inputs.publish }}" in workflow_text
310310

311+
def test_the_changelog_pr_is_whole_cycle_only(self, workflow_text):
312+
"""A single-leg re-run would reduce the branch to that leg's files.
313+
314+
It has nothing to add either way: the same lockfile builds the same
315+
package list, so a rebuilt leg cannot change a changelog.
316+
"""
317+
assert "if: ${{ inputs.publish && inputs.python_versionf == 'all' }}" in workflow_text
318+
assert ".github/scripts/changelog_files.py release_metadata changelogs" in workflow_text
319+
assert (REPO / ".github/scripts/changelog_files.py").is_file()
320+
311321
def test_the_release_title_is_built_by_the_script(self, workflow_text):
312322
"""Ordinal dates are miserable in shell, and untestable there."""
313323
assert 'TITLE: ${{ needs.config.outputs.release_title }}' in workflow_text

0 commit comments

Comments
 (0)