1import base64
2from datetime import datetime, timezone
3import hashlib
4from pathlib import Path
5import runpy
6import tempfile
7import unittest
8
9release = runpy.run_path(str(Path(__file__).resolve().parent / 'release.py'))
10
11
12def utc(text):
13 return datetime.fromisoformat(text).replace(tzinfo=timezone.utc)
14
15
16class ReleaseTest(unittest.TestCase):
17 def test_a_build_counts_the_commits_of_its_day_in_los_angeles(self):
18 # 07:34 UTC on the 29th is 00:34 in Los Angeles; 05:00 UTC on the 30th is 22:00 on the 29th.
19 commits = [utc('2026-09-30T05:00:00'), utc('2026-09-29T19:23:00'), utc('2026-09-29T07:34:00'),
20 utc('2026-09-29T06:29:00'), utc('2026-09-28T12:43:00')]
21 self.assertEqual(release['derive'](commits[0], commits), ('2026-09-29', 3))
22 self.assertEqual(release['derive'](commits[3], commits[3:]), ('2026-09-28', 2))
23 self.assertEqual(release['name'](('2026-09-29', 10)), '2026-09-29-r10')
24 self.assertEqual(release['parse']('2026-09-29-r10'), ('2026-09-29', 10))
25 self.assertEqual(release['folder'](('2026-09-29', 10)), '2026-09-29.r10')
26
27 def test_a_commit_counts_once_by_its_prefix_or_once_per_bullet(self):
28 entries = release['entries']
29 self.assertEqual(entries('fix(update): accept an archive of exactly its size\n\nureq refuses.\n'),
30 [('fix', 'Accept an archive of exactly its size')])
31 self.assertEqual(entries('docs: center the row'), [('other', 'Center the row')])
32 self.assertEqual(entries('Initial import'), [('other', 'Initial import')])
33 self.assertEqual(entries('feat!: macOS icon'), [('feature', 'macOS icon')])
34 self.assertEqual(
35 entries('feat: icon, builds\n\nIntro.\n\n- macOS ships an icon so Tahoe\n shows it.\n'
36 '* pinch zoom.\n\nAssisted-by: claude-opus-5.5\n'),
37 [('feature', 'macOS ships an icon so Tahoe shows it'), ('feature', 'Pinch zoom')])
38
39 def test_changes_start_after_the_build_before_and_carry_their_commits_versions(self):
40 commits = {'a': ([], utc('2026-09-30T10:00:00'), 'fix: published first'),
41 'b': (['a'], utc('2026-09-30T11:00:00'), 'feat: pinch zoom'),
42 'c': (['b'], utc('2026-09-30T12:00:00'), 'fix: two\n\n- one\n- two\n')}
43 self.assertEqual(release['changes'](commits, 'c', 'a'), [
44 {'version': '2026-09-30-r2', 'kind': 'feature', 'title': 'Pinch zoom'},
45 {'version': '2026-09-30-r3', 'kind': 'fix', 'title': 'One'},
46 {'version': '2026-09-30-r3', 'kind': 'fix', 'title': 'Two'}])
47 self.assertEqual(release['changes'](commits, 'c', 'b'), [
48 {'version': '2026-09-30-r3', 'kind': 'fix', 'title': 'One'},
49 {'version': '2026-09-30-r3', 'kind': 'fix', 'title': 'Two'}])
50
51 def test_builds_are_the_published_folders_oldest_first(self):
52 with tempfile.TemporaryDirectory() as published:
53 for entry in ['2026-10-02.r34', '2026-09-30.r2', '2026-10-02.r7', '.2026-10-03.r1.partial', 'latest']:
54 (Path(published) / entry).mkdir()
55 (Path(published) / 'latest.json').touch()
56 self.assertEqual(release['builds'](Path(published)),
57 [('2026-09-30', 2), ('2026-10-02', 7), ('2026-10-02', 34)])
58
59 def test_latest_only_moves_forward(self):
60 latest = {'macos-aarch64': '2026-09-29-r9', 'linux-x86_64': '2026-09-30-r1'}
61 moved = release['newest'](latest, {'macos-aarch64': {}, 'linux-x86_64': {}, 'macos-10.6': {}},
62 ('2026-09-29', 10))
63 self.assertEqual(moved, {'macos-aarch64': '2026-09-29-r10', 'linux-x86_64': '2026-09-30-r1',
64 'macos-10.6': '2026-09-29-r10'})
65 self.assertEqual(release['newest'](latest, {'macos-aarch64': {}}, ('2026-09-29', 9)), latest)
66
67 def test_minisig_signs_the_files_blake2b_then_that_with_its_comment(self):
68 minisign, scope = release['minisign'], release['minisign'].__globals__
69 messages = []
70
71 def sign(paths):
72 messages.extend(Path(path).read_bytes() for path in paths)
73 return [bytes([len(messages)]).hex() * 64 for _ in paths]
74
75 real, scope['sign'] = scope['sign'], sign
76 try:
77 with tempfile.TemporaryDirectory() as folder:
78 file = Path(folder) / 'snowbound-linux-x86_64'
79 file.write_bytes(b'an executable')
80 minisign([file])
81 untrusted, signature, trusted, global_signature = Path(f'{file}.minisig').read_text().splitlines()
82 finally:
83 scope['sign'] = real
84 key_id = base64.b64decode(release['MINISIGN'].read_text().splitlines()[1])[2:10]
85 self.assertTrue(untrusted.startswith('untrusted comment: '))
86 self.assertEqual(base64.b64decode(signature), b'ED' + key_id + bytes([1]) * 64)
87 self.assertRegex(trusted, r'^trusted comment: timestamp:\d+\tfile:snowbound-linux-x86_64\thashed$')
88 self.assertEqual(base64.b64decode(global_signature), bytes([2]) * 64)
89 self.assertEqual(messages, [hashlib.blake2b(b'an executable').digest(),
90 bytes([1]) * 64 + trusted.removeprefix('trusted comment: ').encode()])
91
92 def test_build_macos_signs_each_mac_app(self):
93 build_mac, scope = release['build_mac'], release['build_mac'].__globals__
94 commands = []
95
96 def record(command, **_):
97 commands.append(list(map(str, command)))
98 if command[0] == 'ditto':
99 Path(command[-1]).touch()
100
101 run, scope['run'] = scope['run'], record
102 try:
103 def signing(platform, developer_id, notarize):
104 commands.clear()
105 with tempfile.TemporaryDirectory() as stage:
106 build_mac(platform, Path(stage), developer_id, notarize, Path(stage) / 'symbols.zip')
107 build = commands[0]
108 return (build[build.index(f'{stage}/Snowbound.dSYM') + 1:],
109 [command[1] for command in commands[1:]])
110
111 self.assertEqual(signing('macos-aarch64', True, ['--key-id', 'K']),
112 (['--sign', 'developer-id', '--sign-identity', release['IDENTITY'], '--arch', 'aarch64'],
113 ['-c', '-c', 'notarytool', 'stapler', '-c']))
114 self.assertEqual(signing('macos-x86_64', True, ['--key-id', 'K']),
115 (['--sign', 'developer-id', '--sign-identity', release['IDENTITY'], '--arch', 'x86_64'],
116 ['-c', '-c', 'notarytool', 'stapler', '-c']))
117 self.assertEqual(signing('macos-aarch64', False, None), (['--sign', 'ad-hoc', '--arch', 'aarch64'], ['-c', '-c']))
118 self.assertEqual(signing('macos-10.6', True, ['--key-id', 'K']), (['--snow-leopard'], ['-c', '-c']))
119 finally:
120 scope['run'] = run
121
122
123if __name__ == '__main__':
124 unittest.main()