#!/usr/bin/env python3 """Create a deterministic complete source-only release without Git or live state.""" from __future__ import annotations import argparse import datetime as dt import hashlib import os from pathlib import Path import stat import zipfile ROOT = Path(__file__).resolve().parent MANAGED = [ 'src', 'tests', 'scripts', 'nagios', 'docker-agent', 'migrations', 'examples', 'docs', 'langs', 'deploy', 'debian', 'grafana', '.github', '.cargo', 'Cargo.toml', 'Cargo.lock', 'build.rs', 'rust-toolchain.toml', 'UPDATE_CHECK.toml', 'dev.sh', 'upgrade.sh', 'install.sh', 'install-debian13.sh', 'update.sh', 'README.md', 'ROADMAP.md', 'CHANGELOG.md', 'AGENTS.md', '.env.example', '.gitignore', '.dockerignore', 'Dockerfile', 'make_zip.py', ] EXCLUDED = {'__pycache__', '.DS_Store', 'node_modules', 'target', 'data', '.git', '.upgrade-backup'} EXECUTABLE_ROOT = {'dev.sh', 'upgrade.sh', 'install.sh', 'install-debian13.sh', 'update.sh', 'make_zip.py'} ZIP_MIN_EPOCH = 315532800 # 1980-01-01T00:00:00Z ZIP_MAX_EPOCH = 4354819198 # 2107-12-31T23:59:58Z DEFAULT_SOURCE_DATE_EPOCH = 946684800 # 2000-01-01T00:00:00Z def files() -> list[Path]: result: list[Path] = [] for name in MANAGED: path = ROOT / name if not path.exists(): continue if path.is_symlink(): raise ValueError(f'Refusing symlink in source package: {name}') paths = path.rglob('*') if path.is_dir() else [path] for file in paths: rel = file.relative_to(ROOT) if any(part in EXCLUDED for part in rel.parts) or file.suffix in ('.pyc', '.pyo', '.zip'): continue if rel.as_posix() == 'docker-agent/docker-agent': continue if file.is_symlink(): raise ValueError(f'Refusing symlink in source package: {rel}') if file.is_file(): result.append(file) return sorted(set(result), key=lambda path: path.relative_to(ROOT).as_posix()) def release_datetime() -> tuple[int, int, int, int, int, int]: raw = os.environ.get('SOURCE_DATE_EPOCH') try: epoch = int(raw) if raw is not None else DEFAULT_SOURCE_DATE_EPOCH except ValueError as error: raise ValueError('SOURCE_DATE_EPOCH must be an integer Unix timestamp') from error epoch = min(max(epoch, ZIP_MIN_EPOCH), ZIP_MAX_EPOCH) stamp = dt.datetime.fromtimestamp(epoch, tz=dt.timezone.utc).replace(microsecond=0) # The classic ZIP timestamp stores seconds in two-second increments. stamp = stamp.replace(second=stamp.second - stamp.second % 2) return stamp.year, stamp.month, stamp.day, stamp.hour, stamp.minute, stamp.second def deterministic_mode(rel: Path) -> int: executable = rel.as_posix() in EXECUTABLE_ROOT or ( rel.parts and rel.parts[0] == 'scripts' and rel.suffix in {'.py', '.sh'} ) or ( rel.parts and rel.parts[0] == 'debian' and (rel.suffix in {'.sh'} or rel.name in {'rules', 'rustproxy.postinst'}) ) or ( rel.parts and rel.parts[0] == 'nagios' and (rel.suffix in {'.py', '.sh'} or rel.name.startswith('check_')) ) or ( rel.as_posix() == 'docker-agent/install.sh' ) return 0o755 if executable else 0o644 def add_file(archive: zipfile.ZipFile, rel: Path, data: bytes, timestamp: tuple[int, int, int, int, int, int]) -> None: info = zipfile.ZipInfo(f'RustProxy/{rel.as_posix()}', date_time=timestamp) info.create_system = 3 info.external_attr = (stat.S_IFREG | deterministic_mode(rel)) << 16 info.compress_type = zipfile.ZIP_DEFLATED archive.writestr(info, data, compress_type=zipfile.ZIP_DEFLATED, compresslevel=9) def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('output', nargs='?', type=Path, default=ROOT.parent / 'RustProxy-0.24.9.zip') output = parser.parse_args().output.resolve() paths = files() checksum = ROOT / 'SHA256SUMS' if output in paths or output == checksum: raise SystemExit('Output must not replace a source file.') sums = ''.join( f'{hashlib.sha256(path.read_bytes()).hexdigest()} {path.relative_to(ROOT).as_posix()}\n' for path in paths ) #checksum.write_text(sums, encoding='utf-8', newline='\n') checksum.write_text(sums, encoding='utf-8') timestamp = release_datetime() output.parent.mkdir(parents=True, exist_ok=True) with zipfile.ZipFile(output, 'w') as archive: for path in paths: add_file(archive, path.relative_to(ROOT), path.read_bytes(), timestamp) add_file(archive, Path('SHA256SUMS'), checksum.read_bytes(), timestamp) print(f'Created {output}; {len(paths) + 1} source files; deterministic SHA256SUMS refreshed.') if __name__ == '__main__': main()