1import base64
2from contextlib import redirect_stderr, redirect_stdout
3from datetime import datetime, timezone
4import hashlib
5import io
6import json
7from pathlib import Path
8import runpy
9import stat
10import tempfile
11import unittest
12from unittest.mock import Mock, patch
13
14release = runpy.run_path(str(Path(__file__).resolve().parent / 'release.py'))
15
16
17def utc(text):
18 return datetime.fromisoformat(text).replace(tzinfo=timezone.utc)
19
20
21class ReleaseTest(unittest.TestCase):
22 def test_download_bytes_survive_rejected_permissions(self):
23 with tempfile.TemporaryDirectory() as folder:
24 source, destination = Path(folder) / 'source', Path(folder) / 'download'
25 source.write_bytes(b'an executable')
26 warning = io.StringIO()
27 with patch.object(release['shutil'], 'copymode', side_effect=PermissionError('chmod denied')), \
28 redirect_stderr(warning):
29 release['copy_download'](source, destination)
30 self.assertEqual(destination.read_bytes(), source.read_bytes())
31 self.assertIn(str(destination), warning.getvalue())
32 self.assertIn('could not preserve file permissions', warning.getvalue())
33
34 def test_download_preserves_supported_permissions(self):
35 with tempfile.TemporaryDirectory() as folder:
36 source, destination = Path(folder) / 'source', Path(folder) / 'download'
37 source.write_bytes(b'an executable')
38 source.chmod(0o750)
39 release['copy_download'](source, destination)
40 self.assertEqual(destination.read_bytes(), source.read_bytes())
41 self.assertEqual(stat.S_IMODE(destination.stat().st_mode), 0o750)
42
43 def test_download_content_failure_stops_before_permissions(self):
44 for error in (PermissionError('write denied'), OSError('disk full')):
45 with self.subTest(error=error), \
46 patch.object(release['shutil'], 'copyfile', side_effect=error), \
47 patch.object(release['shutil'], 'copymode') as copymode:
48 with self.assertRaises(type(error)) as raised:
49 release['copy_download'](Path('source'), Path('download'))
50 self.assertIs(raised.exception, error)
51 copymode.assert_not_called()
52
53 def test_download_other_mode_copy_errors_propagate(self):
54 with tempfile.TemporaryDirectory() as folder:
55 source, destination = Path(folder) / 'source', Path(folder) / 'download'
56 source.write_bytes(b'an executable')
57 with patch.object(release['shutil'], 'copymode', side_effect=OSError('missing source')):
58 with self.assertRaises(OSError):
59 release['copy_download'](source, destination)
60
61 def test_publication_finishes_when_share_rejects_permissions(self):
62 main, scope = release['main'], release['main'].__globals__
63 with tempfile.TemporaryDirectory() as folder:
64 root = Path(folder)
65 published = root / 'published'
66 published.mkdir()
67 source = root / 'snowbound'
68 source.write_bytes(b'an executable')
69 source.chmod(0o750)
70 denied = []
71 copymode = release['shutil'].copymode
72
73 def share_modes(source, destination, **kwargs):
74 if Path(destination).is_relative_to(published):
75 denied.append(Path(destination))
76 raise PermissionError('share rejects chmod')
77 return copymode(source, destination, **kwargs)
78
79 def minisign(files):
80 for file in files:
81 Path(f'{file}.minisig').write_bytes(b'archive signature')
82
83 def split_debug(executable, debug):
84 debug.write_bytes(b'debug symbols')
85
86 overrides = {
87 'ROOT': root, 'PUBLISHED': published, 'FIRST': 'a',
88 'jj': Mock(return_value='a'), 'clean': Mock(), 'run': Mock(),
89 'history': lambda: {'a': ([], utc('2026-10-05T12:00:00'), 'fix: publish')},
90 'build_linux': lambda architectures: {'linux-x86_64': source},
91 'split_debug': split_debug, 'sign': lambda files: ['00' for _ in files],
92 'minisign': minisign,
93 }
94 warning = io.StringIO()
95 with patch.dict(scope, overrides), \
96 patch.object(release['sys'], 'argv', ['release.py', '--platforms', 'linux-x86_64']), \
97 patch.dict(release['os'].environ), \
98 patch.object(release['subprocess'], 'run', return_value=Mock(stdout='')), \
99 patch.object(release['shutil'], 'copymode', side_effect=share_modes), \
100 redirect_stderr(warning), redirect_stdout(io.StringIO()):
101 main()
102 target = published / '2026-10-05.r1'
103 archive = 'snowbound-2026-10-05-r1-linux-x86_64'
104 stable = published / 'latest' / 'snowbound-linux-x86_64'
105 self.assertEqual((target / archive).read_bytes(), source.read_bytes())
106 self.assertEqual((target / f'{archive}.minisig').read_bytes(), b'archive signature')
107 build = json.loads((target / 'build.json').read_text())
108 self.assertEqual(build['archives']['linux-x86_64']['sha256'],
109 hashlib.sha256(source.read_bytes()).hexdigest())
110 self.assertEqual((target / 'build.json.sig').read_text(), '00\n')
111 self.assertEqual(stable.read_bytes(), source.read_bytes())
112 self.assertEqual(Path(f'{stable}.minisig').read_bytes(), b'archive signature')
113 self.assertEqual(json.loads((published / 'latest.json').read_text()),
114 {'linux-x86_64': '2026-10-05-r1'})
115 self.assertIn(published / '.2026-10-05.r1.partial' / archive, denied)
116 self.assertIn(stable.with_name(f'.{stable.name}.partial'), denied)
117 self.assertEqual(warning.getvalue().count('could not preserve file permissions'), len(denied))
118
119 def test_a_build_counts_the_commits_of_its_day_in_los_angeles(self):
120 # 07:34 UTC on the 29th is 00:34 in Los Angeles; 05:00 UTC on the 30th is 22:00 on the 29th.
121 commits = [utc('2026-09-30T05:00:00'), utc('2026-09-29T19:23:00'), utc('2026-09-29T07:34:00'),
122 utc('2026-09-29T06:29:00'), utc('2026-09-28T12:43:00')]
123 self.assertEqual(release['derive'](commits[0], commits), ('2026-09-29', 3))
124 self.assertEqual(release['derive'](commits[3], commits[3:]), ('2026-09-28', 2))
125 self.assertEqual(release['name'](('2026-09-29', 10)), '2026-09-29-r10')
126 self.assertEqual(release['parse']('2026-09-29-r10'), ('2026-09-29', 10))
127 self.assertEqual(release['folder'](('2026-09-29', 10)), '2026-09-29.r10')
128
129 def test_a_commit_counts_once_by_its_prefix_or_once_per_bullet(self):
130 entries = release['entries']
131 self.assertEqual(entries('fix(update): accept an archive of exactly its size\n\nureq refuses.\n'),
132 [('fix', 'Accept an archive of exactly its size')])
133 self.assertEqual(entries('docs: center the row'), [('other', 'Center the row')])
134 self.assertEqual(entries('Initial import'), [('other', 'Initial import')])
135 self.assertEqual(entries('feat!: macOS icon'), [('feature', 'macOS icon')])
136 self.assertEqual(
137 entries('feat: icon, builds\n\nIntro.\n\n- macOS ships an icon so Tahoe\n shows it.\n'
138 '* pinch zoom.\n\nAssisted-by: claude-opus-5.5\n'),
139 [('feature', 'macOS ships an icon so Tahoe shows it'), ('feature', 'Pinch zoom')])
140
141 def test_changes_start_after_the_build_before_and_carry_their_commits_versions(self):
142 commits = {'a': ([], utc('2026-09-30T10:00:00'), 'fix: published first'),
143 'b': (['a'], utc('2026-09-30T11:00:00'), 'feat: pinch zoom'),
144 'c': (['b'], utc('2026-09-30T12:00:00'), 'fix: two\n\n- one\n- two\n')}
145 self.assertEqual(release['changes'](commits, 'c', 'a'), [
146 {'version': '2026-09-30-r2', 'kind': 'feature', 'title': 'Pinch zoom'},
147 {'version': '2026-09-30-r3', 'kind': 'fix', 'title': 'One'},
148 {'version': '2026-09-30-r3', 'kind': 'fix', 'title': 'Two'}])
149 self.assertEqual(release['changes'](commits, 'c', 'b'), [
150 {'version': '2026-09-30-r3', 'kind': 'fix', 'title': 'One'},
151 {'version': '2026-09-30-r3', 'kind': 'fix', 'title': 'Two'}])
152
153 def test_builds_are_the_published_folders_oldest_first(self):
154 with tempfile.TemporaryDirectory() as published:
155 for entry in ['2026-10-02.r34', '2026-09-30.r2', '2026-10-02.r7', '.2026-10-03.r1.partial', 'latest']:
156 (Path(published) / entry).mkdir()
157 (Path(published) / 'latest.json').touch()
158 self.assertEqual(release['builds'](Path(published)),
159 [('2026-09-30', 2), ('2026-10-02', 7), ('2026-10-02', 34)])
160
161 def test_latest_only_moves_forward(self):
162 latest = {'macos-aarch64': '2026-09-29-r9', 'linux-x86_64': '2026-09-30-r1'}
163 moved = release['newest'](latest, {'macos-aarch64': {}, 'linux-x86_64': {}, 'macos-10.6': {}},
164 ('2026-09-29', 10))
165 self.assertEqual(moved, {'macos-aarch64': '2026-09-29-r10', 'linux-x86_64': '2026-09-30-r1',
166 'macos-10.6': '2026-09-29-r10'})
167 self.assertEqual(release['newest'](latest, {'macos-aarch64': {}}, ('2026-09-29', 9)), latest)
168
169 def test_minisig_signs_the_files_blake2b_then_that_with_its_comment(self):
170 minisign, scope = release['minisign'], release['minisign'].__globals__
171 messages = []
172
173 def sign(paths):
174 messages.extend(Path(path).read_bytes() for path in paths)
175 return [bytes([len(messages)]).hex() * 64 for _ in paths]
176
177 real, scope['sign'] = scope['sign'], sign
178 try:
179 with tempfile.TemporaryDirectory() as folder:
180 file = Path(folder) / 'snowbound-linux-x86_64'
181 file.write_bytes(b'an executable')
182 minisign([file])
183 untrusted, signature, trusted, global_signature = Path(f'{file}.minisig').read_text().splitlines()
184 finally:
185 scope['sign'] = real
186 key_id = base64.b64decode(release['MINISIGN'].read_text().splitlines()[1])[2:10]
187 self.assertTrue(untrusted.startswith('untrusted comment: '))
188 self.assertEqual(base64.b64decode(signature), b'ED' + key_id + bytes([1]) * 64)
189 self.assertRegex(trusted, r'^trusted comment: timestamp:\d+\tfile:snowbound-linux-x86_64\thashed$')
190 self.assertEqual(base64.b64decode(global_signature), bytes([2]) * 64)
191 self.assertEqual(messages, [hashlib.blake2b(b'an executable').digest(),
192 bytes([1]) * 64 + trusted.removeprefix('trusted comment: ').encode()])
193
194 def test_build_macos_signs_each_mac_app(self):
195 build_mac, scope = release['build_mac'], release['build_mac'].__globals__
196 commands = []
197
198 def record(command, **_):
199 commands.append(list(map(str, command)))
200 if command[0] == 'ditto':
201 Path(command[-1]).touch()
202
203 run, scope['run'] = scope['run'], record
204 try:
205 def signing(platform, developer_id, notarize):
206 commands.clear()
207 with tempfile.TemporaryDirectory() as stage:
208 build_mac(platform, Path(stage), developer_id, notarize, Path(stage) / 'symbols.zip')
209 build = commands[0]
210 return (build[build.index(f'{stage}/Snowbound.dSYM') + 1:],
211 [command[1] for command in commands[1:]])
212
213 self.assertEqual(signing('macos-aarch64', True, ['--key-id', 'K']),
214 (['--sign', 'developer-id', '--sign-identity', release['IDENTITY'], '--arch', 'aarch64'],
215 ['-c', '-c', 'notarytool', 'stapler', '-c']))
216 self.assertEqual(signing('macos-x86_64', True, ['--key-id', 'K']),
217 (['--sign', 'developer-id', '--sign-identity', release['IDENTITY'], '--arch', 'x86_64'],
218 ['-c', '-c', 'notarytool', 'stapler', '-c']))
219 self.assertEqual(signing('macos-aarch64', False, None), (['--sign', 'ad-hoc', '--arch', 'aarch64'], ['-c', '-c']))
220 self.assertEqual(signing('macos-10.6', True, ['--key-id', 'K']), (['--snow-leopard'], ['-c', '-c']))
221 finally:
222 scope['run'] = run
223
224
225if __name__ == '__main__':
226 unittest.main()