diff --git a/tools/release.py b/tools/release.py index cdd69e037404b395e480d9967ef2611d2e38fb8c..3e38a2731d0823cd289255206678337c6de62ff3 100755 --- a/tools/release.py +++ b/tools/release.py @@ -236,6 +236,15 @@ def build_windows(architectures): return built +def copy_download(source, destination): + shutil.copyfile(source, destination) + try: + shutil.copymode(source, destination) + except PermissionError as error: + print(f'Warning: copied {destination}, but could not preserve file permissions: {error}', + file=sys.stderr) + + def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--dry-run', action='store_true', @@ -330,8 +339,7 @@ def main(): shutil.rmtree(partial, ignore_errors=True) partial.mkdir() for file in [*downloads, *(Path(f'{file}.minisig') for file in downloads), stage / 'build.json.sig']: - # copy() keeps the Linux executables executable for anyone running them off the share. - shutil.copy(file, partial / file.name) + copy_download(file, partial / file.name) partial.rename(target) shutil.rmtree(stage) print(f'Published {target}') @@ -359,7 +367,7 @@ def main(): for published_name in (file, f'{file}.minisig'): stable = downloads / published_name.replace(f'-{name(version)}', '') partial = stable.with_name(f'.{stable.name}.partial') - shutil.copy(target / published_name, partial) + copy_download(target / published_name, partial) os.replace(partial, stable) if not args.dry_run: print(f'{URL}{folder(version)}/') diff --git a/tools/test_release.py b/tools/test_release.py index 68968f78df90a32cfa1ebaac5dade0ede765885e..248a615e7f0f9801bb649980e22afb6bcf53c808 100644 --- a/tools/test_release.py +++ b/tools/test_release.py @@ -1,10 +1,15 @@ import base64 +from contextlib import redirect_stderr, redirect_stdout from datetime import datetime, timezone import hashlib +import io +import json from pathlib import Path import runpy +import stat import tempfile import unittest +from unittest.mock import Mock, patch release = runpy.run_path(str(Path(__file__).resolve().parent / 'release.py')) @@ -14,6 +19,103 @@ def utc(text): class ReleaseTest(unittest.TestCase): + def test_download_bytes_survive_rejected_permissions(self): + with tempfile.TemporaryDirectory() as folder: + source, destination = Path(folder) / 'source', Path(folder) / 'download' + source.write_bytes(b'an executable') + warning = io.StringIO() + with patch.object(release['shutil'], 'copymode', side_effect=PermissionError('chmod denied')), \ + redirect_stderr(warning): + release['copy_download'](source, destination) + self.assertEqual(destination.read_bytes(), source.read_bytes()) + self.assertIn(str(destination), warning.getvalue()) + self.assertIn('could not preserve file permissions', warning.getvalue()) + + def test_download_preserves_supported_permissions(self): + with tempfile.TemporaryDirectory() as folder: + source, destination = Path(folder) / 'source', Path(folder) / 'download' + source.write_bytes(b'an executable') + source.chmod(0o750) + release['copy_download'](source, destination) + self.assertEqual(destination.read_bytes(), source.read_bytes()) + self.assertEqual(stat.S_IMODE(destination.stat().st_mode), 0o750) + + def test_download_content_failure_stops_before_permissions(self): + for error in (PermissionError('write denied'), OSError('disk full')): + with self.subTest(error=error), \ + patch.object(release['shutil'], 'copyfile', side_effect=error), \ + patch.object(release['shutil'], 'copymode') as copymode: + with self.assertRaises(type(error)) as raised: + release['copy_download'](Path('source'), Path('download')) + self.assertIs(raised.exception, error) + copymode.assert_not_called() + + def test_download_other_mode_copy_errors_propagate(self): + with tempfile.TemporaryDirectory() as folder: + source, destination = Path(folder) / 'source', Path(folder) / 'download' + source.write_bytes(b'an executable') + with patch.object(release['shutil'], 'copymode', side_effect=OSError('missing source')): + with self.assertRaises(OSError): + release['copy_download'](source, destination) + + def test_publication_finishes_when_share_rejects_permissions(self): + main, scope = release['main'], release['main'].__globals__ + with tempfile.TemporaryDirectory() as folder: + root = Path(folder) + published = root / 'published' + published.mkdir() + source = root / 'snowbound' + source.write_bytes(b'an executable') + source.chmod(0o750) + denied = [] + copymode = release['shutil'].copymode + + def share_modes(source, destination, **kwargs): + if Path(destination).is_relative_to(published): + denied.append(Path(destination)) + raise PermissionError('share rejects chmod') + return copymode(source, destination, **kwargs) + + def minisign(files): + for file in files: + Path(f'{file}.minisig').write_bytes(b'archive signature') + + def split_debug(executable, debug): + debug.write_bytes(b'debug symbols') + + overrides = { + 'ROOT': root, 'PUBLISHED': published, 'FIRST': 'a', + 'jj': Mock(return_value='a'), 'clean': Mock(), 'run': Mock(), + 'history': lambda: {'a': ([], utc('2026-10-05T12:00:00'), 'fix: publish')}, + 'build_linux': lambda architectures: {'linux-x86_64': source}, + 'split_debug': split_debug, 'sign': lambda files: ['00' for _ in files], + 'minisign': minisign, + } + warning = io.StringIO() + with patch.dict(scope, overrides), \ + patch.object(release['sys'], 'argv', ['release.py', '--platforms', 'linux-x86_64']), \ + patch.dict(release['os'].environ), \ + patch.object(release['subprocess'], 'run', return_value=Mock(stdout='')), \ + patch.object(release['shutil'], 'copymode', side_effect=share_modes), \ + redirect_stderr(warning), redirect_stdout(io.StringIO()): + main() + target = published / '2026-10-05.r1' + archive = 'snowbound-2026-10-05-r1-linux-x86_64' + stable = published / 'latest' / 'snowbound-linux-x86_64' + self.assertEqual((target / archive).read_bytes(), source.read_bytes()) + self.assertEqual((target / f'{archive}.minisig').read_bytes(), b'archive signature') + build = json.loads((target / 'build.json').read_text()) + self.assertEqual(build['archives']['linux-x86_64']['sha256'], + hashlib.sha256(source.read_bytes()).hexdigest()) + self.assertEqual((target / 'build.json.sig').read_text(), '00\n') + self.assertEqual(stable.read_bytes(), source.read_bytes()) + self.assertEqual(Path(f'{stable}.minisig').read_bytes(), b'archive signature') + self.assertEqual(json.loads((published / 'latest.json').read_text()), + {'linux-x86_64': '2026-10-05-r1'}) + self.assertIn(published / '.2026-10-05.r1.partial' / archive, denied) + self.assertIn(stable.with_name(f'.{stable.name}.partial'), denied) + self.assertEqual(warning.getvalue().count('could not preserve file permissions'), len(denied)) + def test_a_build_counts_the_commits_of_its_day_in_los_angeles(self): # 07:34 UTC on the 29th is 00:34 in Los Angeles; 05:00 UTC on the 30th is 22:00 on the 29th. commits = [utc('2026-09-30T05:00:00'), utc('2026-09-29T19:23:00'), utc('2026-09-29T07:34:00'),