railiance-clock/tools/verify_release.py
tegwick c29bf39b5d Verify reproducible release artifacts and map acceptance evidence
Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a0e6f0-a6f6-7b93-9124-55ef584930e2
2026-09-28 10:22:52 +02:00

118 lines
6 KiB
Python

"""Build twice, smoke-test an isolated wheel, and emit release evidence and SBOM."""
import argparse
import hashlib
import json
import os
from pathlib import Path
import platform
import shutil
import subprocess
import sys
import tempfile
import tomllib
import zipfile
ROOT = Path(__file__).resolve().parents[1]
EPOCH = '315532800' # Fixed ZIP-compatible build timestamp, not trusted time.
def run(*args, cwd=ROOT, env=None):
try:
return subprocess.run([str(a) for a in args], cwd=cwd, env=env,
check=True, capture_output=True, text=True).stdout
except subprocess.CalledProcessError as exc:
print(exc.stderr, file=sys.stderr)
raise
def sha(path):
return hashlib.sha256(path.read_bytes()).hexdigest()
def write(path, value):
path.write_text(json.dumps(value, indent=2, sort_keys=True) + '\n')
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--output', type=Path, required=True, help='New evidence directory')
args = parser.parse_args()
output = args.output.resolve()
output.mkdir(parents=True, exist_ok=False)
env = dict(os.environ, SOURCE_DATE_EPOCH=EPOCH, PYTHONHASHSEED='0')
env.pop('PYTHONPATH', None)
inputs = [ROOT / 'pyproject.toml', ROOT / 'README.md', ROOT / '.gitignore', *sorted((ROOT / 'src').rglob('*.py')),
*sorted((ROOT / 'src').rglob('*.json'))]
manifest = {str(p.relative_to(ROOT)): sha(p) for p in inputs}
# Verify the separately reviewed spec and packaged schema copies agree.
for p in (ROOT / 'src/railiance_clock/schemas').glob('*.json'):
assert p.read_bytes() == (ROOT / 'specs' / p.name).read_bytes(), p.name
with tempfile.TemporaryDirectory(prefix='rclk-release-') as tmp:
temp = Path(tmp)
build_env = temp / 'build-env'
run('uv', 'venv', '--python', sys.executable, build_env)
build_python = build_env / 'bin/python'
run('uv', 'pip', 'install', '--python', build_python, '--require-hashes',
'-r', ROOT / 'tools/build-requirements.txt')
artifacts = []
for number in (1, 2):
source = temp / f'source-{number}'
source.mkdir()
for original in inputs:
target = source / original.relative_to(ROOT)
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(original, target)
# Deliberately vary source mtimes between the independent roots.
os.utime(target, (int(EPOCH) + number * 100, int(EPOCH) + number * 100))
dest = temp / f'dist-{number}'
run('uv', 'build', source, '--python', build_python, '--no-build-isolation',
'--out-dir', dest, env=env)
artifacts.append({p.name: sha(p) for p in dest.iterdir()
if p.name.endswith(('.whl', '.tar.gz'))})
assert artifacts[0] == artifacts[1], 'build outputs differ'
checkout_dist = temp / 'checkout-dist'
run('uv', 'build', ROOT, '--python', build_python, '--no-build-isolation',
'--out-dir', checkout_dist, env=env)
checkout_artifacts = {p.name: sha(p) for p in checkout_dist.iterdir()
if p.name.endswith(('.whl', '.tar.gz'))}
assert artifacts[0] == checkout_artifacts, 'checkout build differs from copied inputs'
wheel = next((temp / 'dist-1').glob('*.whl'))
with zipfile.ZipFile(wheel) as archive:
members = archive.namelist()
assert len([m for m in members if m.endswith('.schema.json')]) == 3
requirements = temp / 'runtime.txt'
run('uv', 'export', '--locked', '--no-dev', '--no-emit-project', '-o', requirements)
install_env = temp / 'installed-env'
run('uv', 'venv', '--python', sys.executable, install_env)
python = install_env / 'bin/python'
run('uv', 'pip', 'install', '--python', python, '--require-hashes', '-r', requirements)
run('uv', 'pip', 'install', '--python', python, '--no-deps', wheel)
run('uv', 'pip', 'check', '--python', python)
smoke = json.loads(run(python, '-I', ROOT / 'tools/wheel_smoke.py', cwd=temp, env=env))
locked = {p['name']: p['version'] for p in tomllib.loads((ROOT / 'uv.lock').read_text())['package']}
for name, version in smoke['distributions']:
assert locked[name.lower().replace('_', '-')] == version, (name, version)
components = [{'type': 'library', 'name': name, 'version': version,
'purl': f'pkg:pypi/{name.lower().replace("_", "-")}@{version}'}
for name, version in smoke['distributions']]
write(output / 'runtime.cdx.json', {'bomFormat': 'CycloneDX', 'specVersion': '1.5',
'version': 1, 'components': components})
build_tools = json.loads(run(build_python, '-I', '-c',
'import importlib.metadata as m,json; print(json.dumps(sorted((d.metadata["Name"],d.version) for d in m.distributions())))'))
write(output / 'verification.json', {
'source_base_commit': run('git', 'rev-parse', 'HEAD').strip(),
'source_inputs_sha256': manifest, 'runtime_lock_sha256': sha(ROOT / 'uv.lock'),
'build_requirements_sha256': sha(ROOT / 'tools/build-requirements.txt'),
'verifier_sha256': sha(Path(__file__)), 'smoke_script_sha256': sha(ROOT / 'tools/wheel_smoke.py'),
'python': platform.python_version(), 'platform': platform.system() + '/' + platform.machine(),
'uv': run('uv', '--version').strip(), 'build_tools': build_tools,
'source_date_epoch': EPOCH, 'independent_builds': artifacts,
'checkout_build': checkout_artifacts,
'byte_identical': True, 'smoke': smoke,
'sbom_sha256': sha(output / 'runtime.cdx.json'),
'limits': 'Local Linux build/install evidence; no native UTC, security admission, vulnerability audit or cross-platform reproducibility claim.'})
print(f'Verified two identical builds and isolated wheel smoke test: {output}')
if __name__ == '__main__':
main()