Skip to content

Commit 37b51ef

Browse files
authored
fix(desktop): decode/encode changelog JSON as UTF-8 regardless of host locale (#9717) (#9885)
desktop-changelog.py called Path.read_text()/write_text() without an explicit encoding, so native Windows Python with a non-UTF-8 default locale (e.g. GBK) crashed with UnicodeDecodeError before reporting a changelog validation result. Pass encoding="utf-8" on both calls to match CI and the checked-in files.
1 parent d761b72 commit 37b51ef

3 files changed

Lines changed: 72 additions & 2 deletions

File tree

.github/checks-manifest.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,11 @@ checks:
3434
triggers: [".github/schemas/desktop-release-evidence-v1.schema.json", ".github/scripts/desktop_release_doctor.py", ".github/scripts/desktop_release_doctor_report.py", ".github/scripts/test_desktop_release_doctor.py", ".github/workflows/desktop_release_doctor.yml"]
3535
lanes: ["local", "ci"]
3636
reason: "advisory desktop release evidence and drift report"
37+
- id: desktop-changelog-io-tests
38+
command: ["python3", ".github/scripts/test_desktop_changelog.py"]
39+
triggers: [".github/scripts/desktop-changelog.py", ".github/scripts/test_desktop_changelog.py", ".github/checks-manifest.yaml"]
40+
lanes: ["local", "ci"]
41+
reason: "#9717: changelog JSON I/O must decode/encode as UTF-8 regardless of contributor host locale"
3742
- id: agents-md-lean
3843
command: ["python3", ".github/scripts/check_agents_md_lean.py"]
3944
triggers: ["AGENTS.md", ".github/scripts/check_agents_md_lean.py"]

.github/scripts/desktop-changelog.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,14 +24,14 @@ class ChangelogError(Exception):
2424

2525
def read_json(path: Path) -> object:
2626
try:
27-
return json.loads(path.read_text())
27+
return json.loads(path.read_text(encoding="utf-8"))
2828
except json.JSONDecodeError as exc:
2929
raise ChangelogError(f"{path} is not valid JSON: {exc}") from exc
3030

3131

3232
def write_json(path: Path, data: object) -> None:
3333
path.parent.mkdir(parents=True, exist_ok=True)
34-
path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n")
34+
path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
3535

3636

3737
def normalize_changes(raw: object, path: Path) -> list[str]:
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
#!/usr/bin/env python3
2+
"""Unit tests for desktop-changelog.py I/O encoding (stdlib unittest).
3+
4+
Regression coverage for #9717: read_json/write_json must always use UTF-8 so a
5+
contributor on a non-UTF-8 host locale (e.g. GBK on native Windows Python) does
6+
not crash with a UnicodeDecodeError. The CI host is UTF-8, so a plain round-trip
7+
would not catch a missing encoding; these tests assert the encoding is forwarded
8+
on every platform.
9+
"""
10+
11+
from __future__ import annotations
12+
13+
import importlib.util
14+
import tempfile
15+
import unittest
16+
import unittest.mock
17+
from pathlib import Path
18+
19+
_SPEC = importlib.util.spec_from_file_location(
20+
"desktop_changelog", Path(__file__).with_name("desktop-changelog.py")
21+
)
22+
changelog = importlib.util.module_from_spec(_SPEC)
23+
_SPEC.loader.exec_module(changelog)
24+
25+
26+
class EncodingTests(unittest.TestCase):
27+
def test_read_json_forces_utf8(self) -> None:
28+
captured: dict[str, object] = {}
29+
real_read_text = Path.read_text
30+
31+
def spy(self: Path, *args: object, **kwargs: object) -> str:
32+
captured["encoding"] = kwargs.get("encoding")
33+
return real_read_text(self, *args, **kwargs)
34+
35+
with tempfile.TemporaryDirectory() as tmp:
36+
path = Path(tmp) / "changelog.json"
37+
path.write_text('{"note": "“curly”"}', encoding="utf-8")
38+
with unittest.mock.patch.object(Path, "read_text", spy):
39+
self.assertEqual(changelog.read_json(path), {"note": "“curly”"})
40+
self.assertEqual(captured["encoding"], "utf-8")
41+
42+
def test_write_json_forces_utf8(self) -> None:
43+
captured: dict[str, object] = {}
44+
real_write_text = Path.write_text
45+
46+
def spy(self: Path, *args: object, **kwargs: object) -> int:
47+
captured["encoding"] = kwargs.get("encoding")
48+
return real_write_text(self, *args, **kwargs)
49+
50+
with tempfile.TemporaryDirectory() as tmp:
51+
path = Path(tmp) / "out" / "changelog.json"
52+
with unittest.mock.patch.object(Path, "write_text", spy):
53+
changelog.write_json(path, {"note": "“curly”"})
54+
self.assertEqual(captured["encoding"], "utf-8")
55+
56+
def test_round_trip_preserves_non_ascii(self) -> None:
57+
with tempfile.TemporaryDirectory() as tmp:
58+
path = Path(tmp) / "changelog.json"
59+
payload = {"note": "“curly” — café"}
60+
changelog.write_json(path, payload)
61+
self.assertEqual(changelog.read_json(path), payload)
62+
63+
64+
if __name__ == "__main__":
65+
unittest.main()

0 commit comments

Comments
 (0)