115 lines
5.0 KiB
Python
Executable File
115 lines
5.0 KiB
Python
Executable File
#!/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
|
|
# Never ship local environment files from recursively managed trees.
|
|
# Explicit templates such as .env.example remain safe because they are
|
|
# named in MANAGED and do not have the exact basename `.env`.
|
|
if rel.name == '.env':
|
|
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.26.0.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()
|