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):...@@ -236,6 +236,15 @@ def build_windows(architectures):
236 return built236 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
239def main():248def main():
240 parser = argparse.ArgumentParser(description=__doc__)249 parser = argparse.ArgumentParser(description=__doc__)
241 parser.add_argument('--dry-run', action='store_true',250 parser.add_argument('--dry-run', action='store_true',
...@@ -330,8 +339,7 @@ def main():...@@ -330,8 +339,7 @@ def main():
330 shutil.rmtree(partial, ignore_errors=True)339 shutil.rmtree(partial, ignore_errors=True)
331 partial.mkdir()340 partial.mkdir()
332 for file in [*downloads, *(Path(f'{file}.minisig') for file in downloads), stage / 'build.json.sig']:341 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.342 copy_download(file, partial / file.name)
334 shutil.copy(file, partial / file.name)
335 partial.rename(target)343 partial.rename(target)
336 shutil.rmtree(stage)344 shutil.rmtree(stage)
337 print(f'Published {target}')345 print(f'Published {target}')
...@@ -359,7 +367,7 @@ def main():...@@ -359,7 +367,7 @@ def main():
359 for published_name in (file, f'{file}.minisig'):367 for published_name in (file, f'{file}.minisig'):
360 stable = downloads / published_name.replace(f'-{name(version)}', '')368 stable = downloads / published_name.replace(f'-{name(version)}', '')
361 partial = stable.with_name(f'.{stable.name}.partial')369 partial = stable.with_name(f'.{stable.name}.partial')
362 shutil.copy(target / published_name, partial)370 copy_download(target / published_name, partial)
363 os.replace(partial, stable)371 os.replace(partial, stable)
364 if not args.dry_run:372 if not args.dry_run:
365 print(f'{URL}{folder(version)}/')373 print(f'{URL}{folder(version)}/')
tools/test_release.py+102
...@@ -1,10 +1,15 @@...@@ -1,10 +1,15 @@
1import base641import base64
2from contextlib import redirect_stderr, redirect_stdout
2from datetime import datetime, timezone3from datetime import datetime, timezone
3import hashlib4import hashlib
5import io
6import json
4from pathlib import Path7from pathlib import Path
5import runpy8import runpy
9import stat
6import tempfile10import tempfile
7import unittest11import unittest
12from unittest.mock import Mock, patch
813
9release = runpy.run_path(str(Path(__file__).resolve().parent / 'release.py'))14release = runpy.run_path(str(Path(__file__).resolve().parent / 'release.py'))
1015
...@@ -14,6 +19,103 @@ def utc(text):...@@ -14,6 +19,103 @@ def utc(text):
1419
1520
16class ReleaseTest(unittest.TestCase):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
17 def test_a_build_counts_the_commits_of_its_day_in_los_angeles(self):119 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.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 commits = [utc('2026-09-30T05:00:00'), utc('2026-09-29T19:23:00'), utc('2026-09-29T07:34:00'),121 commits = [utc('2026-09-30T05:00:00'), utc('2026-09-29T19:23:00'), utc('2026-09-29T07:34:00'),