| ... | ... | @@ -1,10 +1,15 @@ |
| 1 | 1 | import base64 |
| 2 | from contextlib import redirect_stderr, redirect_stdout |
| 2 | 3 | from datetime import datetime, timezone |
| 3 | 4 | import hashlib |
| 5 | import io |
| 6 | import json |
| 4 | 7 | from pathlib import Path |
| 5 | 8 | import runpy |
| 9 | import stat |
| 6 | 10 | import tempfile |
| 7 | 11 | import unittest |
| 12 | from unittest.mock import Mock, patch |
| 8 | 13 | |
| 9 | 14 | release = runpy.run_path(str(Path(__file__).resolve().parent / 'release.py')) |
| 10 | 15 | |
| ... | ... | @@ -14,6 +19,103 @@ def utc(text): |
| 14 | 19 | |
| 15 | 20 | |
| 16 | 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 | |
| 17 | 119 | def test_a_build_counts_the_commits_of_its_day_in_los_angeles(self): |
| 18 | 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. |
| 19 | 121 | commits = [utc('2026-09-30T05:00:00'), utc('2026-09-29T19:23:00'), utc('2026-09-29T07:34:00'), |