authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-05 23:19:03-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-05 23:23:24-07:00
log2c99e71ccf170b16592314fda8f6de56584acd3a
tree5bb7e11fd2999cea6bc687e60527a1d0848e1476
parenta31dfda0e9162d43429aa87a8f7f99cc2273a2cc
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

fix: publish downloads when the share rejects chmod

Copy archive bytes before preserving modes. Warn when the share refuses mode changes while retaining fatal content-copy failures. fixes #102 Assisted-by: gpt-6.1-sol

2 files changed, 113 insertions(+), 3 deletions(-)

tools/release.py+11-3
......@@ -236,6 +236,15 @@ def build_windows(architectures):
236236 return built
237237
238238
239def copy_download(source, destination):
240 shutil.copyfile(source, destination)
241 try:
242 shutil.copymode(source, destination)
243 except PermissionError as error:
244 print(f'Warning: copied {destination}, but could not preserve file permissions: {error}',
245 file=sys.stderr)
246
247
239248def main():
240249 parser = argparse.ArgumentParser(description=__doc__)
241250 parser.add_argument('--dry-run', action='store_true',
......@@ -330,8 +339,7 @@ def main():
330339 shutil.rmtree(partial, ignore_errors=True)
331340 partial.mkdir()
332341 for file in [*downloads, *(Path(f'{file}.minisig') for file in downloads), stage / 'build.json.sig']:
333 # copy() keeps the Linux executables executable for anyone running them off the share.
334 shutil.copy(file, partial / file.name)
342 copy_download(file, partial / file.name)
335343 partial.rename(target)
336344 shutil.rmtree(stage)
337345 print(f'Published {target}')
......@@ -359,7 +367,7 @@ def main():
359367 for published_name in (file, f'{file}.minisig'):
360368 stable = downloads / published_name.replace(f'-{name(version)}', '')
361369 partial = stable.with_name(f'.{stable.name}.partial')
362 shutil.copy(target / published_name, partial)
370 copy_download(target / published_name, partial)
363371 os.replace(partial, stable)
364372 if not args.dry_run:
365373 print(f'{URL}{folder(version)}/')
tools/test_release.py+102
......@@ -1,10 +1,15 @@
11import base64
2from contextlib import redirect_stderr, redirect_stdout
23from datetime import datetime, timezone
34import hashlib
5import io
6import json
47from pathlib import Path
58import runpy
9import stat
610import tempfile
711import unittest
12from unittest.mock import Mock, patch
813
914release = runpy.run_path(str(Path(__file__).resolve().parent / 'release.py'))
1015
......@@ -14,6 +19,103 @@ def utc(text):
1419
1520
1621class 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
17119 def test_a_build_counts_the_commits_of_its_day_in_los_angeles(self):
18120 # 07:34 UTC on the 29th is 00:34 in Los Angeles; 05:00 UTC on the 30th is 22:00 on the 29th.
19121 commits = [utc('2026-09-30T05:00:00'), utc('2026-09-29T19:23:00'), utc('2026-09-29T07:34:00'),