| 1 | import base64 |
| 2 | from contextlib import redirect_stderr, redirect_stdout |
| 3 | from datetime import datetime, timezone |
| 4 | import hashlib |
| 5 | import io |
| 6 | import json |
| 7 | from pathlib import Path |
| 8 | import runpy |
| 9 | import stat |
| 10 | import tempfile |
| 11 | import unittest |
| 12 | from unittest.mock import Mock, patch |
| 13 | |
| 14 | release = runpy.run_path(str(Path(__file__).resolve().parent / 'release.py')) |
| 15 | |
| 16 | |
| 17 | def utc(text): |
| 18 | return datetime.fromisoformat(text).replace(tzinfo=timezone.utc) |
| 19 | |
| 20 | |
| 21 | class 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 | |
| 225 | if __name__ == '__main__': |
| 226 | unittest.main() |