Skip to content

Commit f640865

Browse files
authored
Merge pull request winpython#2103 from stonebig/changelog-pull-request
File a cycle's changelogs as a pull request
2 parents a8d294c + dab14ef commit f640865

4 files changed

Lines changed: 359 additions & 0 deletions

File tree

‎.github/scripts/changelog_files.py‎

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
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+
"""
17+
import re
18+
import shutil
19+
import sys
20+
from pathlib import Path
21+
22+
# Running a script puts the script's own directory on sys.path, not the
23+
# checkout root, so wppm has to be found deliberately: this file is
24+
# .github/scripts/changelog_files.py, hence two levels up. Without it the
25+
# import finds whatever wppm happens to be installed, or -- as on a CI runner,
26+
# which installs none -- nothing at all.
27+
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
28+
29+
from wppm import diff # noqa: E402 the path above has to be set first
30+
from wppm.diff import version # noqa: E402 packaging, or pip's vendored copy
31+
32+
# WinPythonslim-64bit-3.15.0.5b1.md -- the flavor may be empty, and the version
33+
# may carry a release level, which is why the parser decides and not the regex
34+
CHANGELOG = re.compile(r"^WinPython(?P<flavor>[A-Za-z0-9]*)-(?P<arch>\d+)bit-(?P<version>.+)\.md$")
35+
36+
37+
def parse_changelog_name(name: str):
38+
"""(flavor, architecture, version) for a package index, else None."""
39+
match = CHANGELOG.match(name)
40+
if not match:
41+
return None
42+
try:
43+
version.parse(match.group("version"))
44+
except version.InvalidVersion:
45+
return None # the _History companions land here, as they should
46+
return match.group("flavor"), int(match.group("arch")), match.group("version")
47+
48+
49+
def files_to_file(source: Path):
50+
"""The build output that belongs in changelogs/.
51+
52+
The package index, the lock file and the requirements -- and so not
53+
hashes_<winpyver>.md, which describes one build's binaries rather than the
54+
release. It is excluded by being neither: it is not named for a version,
55+
and it is not a pylock or a requir.
56+
"""
57+
for path in sorted(source.iterdir()):
58+
if not path.is_file():
59+
continue
60+
if parse_changelog_name(path.name) or path.name.startswith(("pylock.", "requir.")):
61+
yield path
62+
63+
64+
def main(argv: list[str]) -> None:
65+
if len(argv) != 3:
66+
raise SystemExit(f"usage: {Path(argv[0]).name} <metadata dir> <changelogs dir>")
67+
source, changelogs = Path(argv[1]), Path(argv[2])
68+
if not source.is_dir():
69+
raise SystemExit(f"no such directory: {source}")
70+
if not changelogs.is_dir():
71+
raise SystemExit(f"no such directory: {changelogs}")
72+
73+
filed = []
74+
for path in files_to_file(source):
75+
shutil.copyfile(path, changelogs / path.name)
76+
filed.append(path.name)
77+
if not filed:
78+
raise SystemExit(f"{source} held no changelog, lock file or requirements")
79+
for name in filed:
80+
print(f"filed {name}")
81+
82+
# every package index has to be in place before any history is written: a
83+
# history reads the index of the release it compares against, which for the
84+
# second flavor of a cycle may well be the one just copied
85+
histories = 0
86+
for name in filed:
87+
parsed = parse_changelog_name(name)
88+
if not parsed:
89+
continue
90+
flavor, architecture, ver = parsed
91+
previous = diff.find_previous_version(ver, changelogs, flavor, architecture)
92+
diff.write_changelog(ver, None, changelogs, flavor, architecture)
93+
histories += 1
94+
against = "nothing earlier" if previous == ver else previous
95+
print(f"history WinPython{flavor}-{architecture}bit-{ver} vs {against}")
96+
print(f"\n{len(filed)} file(s) filed, {histories} history file(s) written")
97+
98+
99+
if __name__ == "__main__":
100+
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: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
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 os
15+
import shutil
16+
import subprocess
17+
import sys
18+
from pathlib import Path
19+
20+
import pytest
21+
22+
REPO = Path(__file__).resolve().parents[1]
23+
SCRIPT = REPO / ".github/scripts/changelog_files.py"
24+
25+
needs_script = pytest.mark.skipif(not SCRIPT.is_file(), reason="changelog_files.py is gone")
26+
27+
28+
@pytest.fixture(scope="module")
29+
def changelog_files():
30+
if not SCRIPT.is_file():
31+
pytest.skip("changelog_files.py is gone")
32+
spec = importlib.util.spec_from_file_location("changelog_files", SCRIPT)
33+
module = importlib.util.module_from_spec(spec)
34+
spec.loader.exec_module(module)
35+
return module
36+
37+
38+
# what one leg of 2026-04 b1 actually uploaded, names verbatim
39+
LEG_OUTPUT = [
40+
"WinPythonslim-64bit-3.15.0.5b1.md",
41+
"pylock.64-3_15_0_5slimb1.toml",
42+
"requir.64-3_15_0_5slimb1.txt",
43+
"hashes_3.15.0.5slimb1.md",
44+
]
45+
46+
47+
@needs_script
48+
class TestParseChangelogName:
49+
@pytest.mark.parametrize("name,expected", [
50+
("WinPythonslim-64bit-3.15.0.5b1.md", ("slim", 64, "3.15.0.5b1")),
51+
("WinPythondotf-64bit-3.14.7.1b1.md", ("dotf", 64, "3.14.7.1b1")),
52+
("WinPythondot-64bit-3.13.15.0.md", ("dot", 64, "3.13.15.0")),
53+
("WinPython-64bit-3.9.8.0.md", ("", 64, "3.9.8.0")), # the unflavored ones
54+
("WinPythondot-32bit-3.9.0.0b1.md", ("dot", 32, "3.9.0.0b1")),
55+
])
56+
def test_reads_flavor_architecture_and_version(self, changelog_files, name, expected):
57+
assert changelog_files.parse_changelog_name(name) == expected
58+
59+
@pytest.mark.parametrize("name", [
60+
"WinPythonslim-64bit-3.15.0.5b1_History.md", # the companion, not an index
61+
"hashes_3.15.0.5slimb1.md",
62+
"pylock.64-3_15_0_5slimb1.toml",
63+
"README.md",
64+
"WinPythonslim-64bit-.md",
65+
])
66+
def test_rejects_everything_that_is_not_a_package_index(self, changelog_files, name):
67+
assert changelog_files.parse_changelog_name(name) is None
68+
69+
70+
@needs_script
71+
class TestSelection:
72+
def test_hashes_are_left_behind(self, changelog_files, tmp_path):
73+
"""They describe one build's binaries; changelogs/ has never held them."""
74+
for name in LEG_OUTPUT:
75+
(tmp_path / name).write_text("", encoding="utf-8")
76+
chosen = sorted(p.name for p in changelog_files.files_to_file(tmp_path))
77+
assert chosen == [
78+
"WinPythonslim-64bit-3.15.0.5b1.md",
79+
"pylock.64-3_15_0_5slimb1.toml",
80+
"requir.64-3_15_0_5slimb1.txt",
81+
]
82+
83+
def test_directories_are_skipped(self, changelog_files, tmp_path):
84+
(tmp_path / "WinPythonslim-64bit-3.15.0.5b1.md").write_text("", encoding="utf-8")
85+
(tmp_path / "pylock.64-nested").mkdir()
86+
assert [p.name for p in changelog_files.files_to_file(tmp_path)] == [
87+
"WinPythonslim-64bit-3.15.0.5b1.md"
88+
]
89+
90+
91+
def run_script(*args, cwd, env=None):
92+
"""The script by absolute path, from an unrelated directory.
93+
94+
It must not need to be run from the checkout: the workflow's own step and
95+
these tests both invoke it as a path, which puts .github/scripts on
96+
sys.path rather than the repository root.
97+
"""
98+
return subprocess.run(
99+
[sys.executable, str(SCRIPT), *map(str, args)],
100+
cwd=str(cwd), capture_output=True, text=True, env=env,
101+
)
102+
103+
104+
@needs_script
105+
class TestEndToEnd:
106+
"""Run the script the way the workflow runs it."""
107+
108+
@pytest.fixture
109+
def cycle(self, tmp_path):
110+
"""A metadata directory and a changelogs/ holding one earlier release."""
111+
source = tmp_path / "release_metadata"
112+
source.mkdir()
113+
changelogs = tmp_path / "changelogs"
114+
changelogs.mkdir()
115+
116+
# a real package index makes a real diff; reuse two the repo already has
117+
previous = REPO / "changelogs" / "WinPythonslim-64bit-3.15.0.4.md"
118+
current = REPO / "changelogs" / "WinPythonslim-64bit-3.14.7.0.md"
119+
if not (previous.is_file() and current.is_file()):
120+
pytest.skip("the changelogs this test reads from are gone")
121+
shutil.copyfile(previous, changelogs / previous.name)
122+
shutil.copyfile(current, source / "WinPythonslim-64bit-3.15.0.5b1.md")
123+
(source / "pylock.64-3_15_0_5slimb1.toml").write_text("x", encoding="utf-8")
124+
(source / "requir.64-3_15_0_5slimb1.txt").write_text("x", encoding="utf-8")
125+
(source / "hashes_3.15.0.5slimb1.md").write_text("x", encoding="utf-8")
126+
return source, changelogs
127+
128+
def test_wppm_comes_from_the_checkout(self, cycle, tmp_path):
129+
"""A decoy wppm on PYTHONPATH must lose to the one being released.
130+
131+
Running a script puts the script's directory on sys.path, not the
132+
checkout root, so without a deliberate insert the import falls through
133+
to whatever else is reachable. On a machine with wppm installed that
134+
looks fine -- which is how it once slipped past a green local run --
135+
and on a CI runner, which installs none, it is ModuleNotFoundError.
136+
The decoy makes the difference visible either way.
137+
"""
138+
source, changelogs = cycle
139+
decoy = tmp_path / "decoy"
140+
(decoy / "wppm").mkdir(parents=True)
141+
(decoy / "wppm" / "__init__.py").write_text(
142+
"raise RuntimeError('decoy wppm imported')", encoding="utf-8"
143+
)
144+
env = {**os.environ, "PYTHONPATH": str(decoy)}
145+
proc = run_script(source, changelogs, cwd=tmp_path, env=env)
146+
assert proc.returncode == 0, proc.stderr
147+
assert "decoy" not in proc.stderr
148+
149+
def test_files_the_three_and_writes_the_history(self, cycle, tmp_path):
150+
source, changelogs = cycle
151+
proc = run_script(source, changelogs, cwd=tmp_path)
152+
assert proc.returncode == 0, proc.stderr
153+
landed = sorted(p.name for p in changelogs.iterdir())
154+
assert landed == [
155+
"WinPythonslim-64bit-3.15.0.4.md", # was already there
156+
"WinPythonslim-64bit-3.15.0.5b1.md", # filed
157+
"WinPythonslim-64bit-3.15.0.5b1_History.md", # written
158+
"pylock.64-3_15_0_5slimb1.toml",
159+
"requir.64-3_15_0_5slimb1.txt",
160+
]
161+
162+
def test_the_history_names_the_release_it_compares_against(self, cycle, tmp_path):
163+
source, changelogs = cycle
164+
proc = run_script(source, changelogs, cwd=tmp_path)
165+
assert proc.returncode == 0, proc.stderr
166+
history = (changelogs / "WinPythonslim-64bit-3.15.0.5b1_History.md").read_text(
167+
encoding="utf-8"
168+
)
169+
assert "since version 3.15.0.4slim" in history
170+
assert "3.15.0.5b1slim" in history
171+
172+
def test_an_empty_metadata_directory_is_an_error(self, tmp_path):
173+
"""Silence here would commit nothing and call it a success."""
174+
source, changelogs = tmp_path / "src", tmp_path / "changelogs"
175+
source.mkdir()
176+
changelogs.mkdir()
177+
proc = run_script(source, changelogs, cwd=tmp_path)
178+
assert proc.returncode != 0
179+
assert "held no changelog" in proc.stdout + proc.stderr
180+
181+
def test_a_missing_directory_is_an_error(self, tmp_path):
182+
proc = run_script(tmp_path / "nope", tmp_path, cwd=tmp_path)
183+
assert proc.returncode != 0
184+
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)