Verify reproducible release artifacts and map acceptance evidence

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a0e6f0-a6f6-7b93-9124-55ef584930e2
This commit is contained in:
tegwick 2026-09-28 10:22:52 +02:00
parent c976c9c277
commit c29bf39b5d
13 changed files with 577 additions and 3 deletions

View file

@ -22,3 +22,11 @@ later workplan tasks.
The 2026-09-14 live JSON receipt accompanies the human-readable host inventory.
Six unit tests cover transport failure, host argument validation, fixed command
transport and unknown-versus-synchronized semantics.
# Release verification
`python3 tools/verify_release.py --output /tmp/rclk-release-UNIQUE` builds and
compares source archives and wheels, installs the wheel into a fresh environment,
and runs `wheel_smoke.py` with isolated imports. It retains artifact/input hashes
and a runtime component SBOM. See [the procedure](../docs/release-verification.md)
for build pins, evidence and platform limits.

View file

@ -0,0 +1,2 @@
# Build-only dependencies; runtime dependencies remain in uv.lock.
hatchling>=1.27,<2

View file

@ -0,0 +1,26 @@
# This file was autogenerated by uv via the following command:
# uv pip compile tools/build-requirements.in --generate-hashes -o tools/build-requirements.txt
hatchling==1.32.4 \
--hash=sha256:08ecf7548fb48205e7f213d70c71e67b8271b7242093dc3f1da578b42c734a2c \
--hash=sha256:c4468f73144c054d2aab4ef0f0378c43b9878bf07f8ffd6b79690e970d375f07
# via -r tools/build-requirements.in
packaging==26.3 \
--hash=sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79 \
--hash=sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c
# via hatchling
pathspec==1.1.1 \
--hash=sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a \
--hash=sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189
# via hatchling
pluggy==1.6.0 \
--hash=sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3 \
--hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746
# via hatchling
tomlkit==0.15.1 \
--hash=sha256:177a05aece5a8ca5266fd3c448abb47b8d352f09d477d3ca8332db4d89b24304 \
--hash=sha256:e25bbf38843005246210a12982776f27f99cb9be67160e14434d0c0d21ee1e97
# via hatchling
trove-classifiers==2026.9.21.13 \
--hash=sha256:0a9ebc8d4e2f3e8a22848c5258033035bec17a3012ac3fea16dbaa764489eb71 \
--hash=sha256:8b1ff4f9c191b1040b71c37f1e445ab99732911e3cd91de52838453a854d7a17
# via hatchling

118
tools/verify_release.py Normal file
View file

@ -0,0 +1,118 @@
"""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()

91
tools/wheel_smoke.py Normal file
View file

@ -0,0 +1,91 @@
"""Run with an isolated interpreter containing only the installed wheel and deps."""
import importlib.metadata
import json
import os
from pathlib import Path
import subprocess
import sys
import tempfile
import threading
import time
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import ec
import railiance_clock
from railiance_clock.admission import admit
from railiance_clock.authority import Authority, Server
from railiance_clock.client import Clock, FileTrust
from railiance_clock.core import ClockError
def main():
installed = Path(railiance_clock.__file__).resolve()
assert installed.is_relative_to(Path(sys.prefix).resolve()), installed
schemas = installed.parent / 'schemas'
for name in ('time-request', 'time-sample', 'protected-header'):
assert json.loads((schemas / (name + '-v0.1.schema.json')).read_text())['type'] == 'object'
cli = Path(sys.executable).parent / 'railiance-clock'
help_result = subprocess.run([str(cli), '--help'], capture_output=True, text=True, check=True)
assert all(command in help_result.stdout for command in ('read', 'serve', 'health-export'))
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
key = ec.generate_private_key(ec.SECP256R1())
private = root / 'key.pem'
private.write_bytes(key.private_bytes(serialization.Encoding.PEM,
serialization.PrivateFormat.PKCS8, serialization.NoEncryption()))
private.chmod(0o600)
health = root / 'health.json'
health.write_text(json.dumps({'quality': 'bounded',
'boot_id': Path('/proc/sys/kernel/random/boot_id').read_text().strip(),
'policy_id': 'wheel-test', 'observed_monotonic_ns': time.monotonic_ns(), 'error_ns': 1000}))
config = {'private_key_file': str(private), 'health_file': str(health),
'health_owner_uid': os.getuid(), 'policy_id': 'wheel-test',
'authority_id': 'disposable', 'environment': 'test', 'kid': 'test-key',
'max_health_age_ns': 30_000_000_000, 'host_drift_ppm': 1000,
'max_error_ns': 100_000_000, 'max_continuity_error_ns': 50_000_000,
'max_sample_age_ns': 5_000_000_000}
authority = Authority(config)
server = Server(('127.0.0.1', 0), authority)
thread = threading.Thread(target=server.serve_forever)
thread.start()
try:
trust = admit(directory=root, authority_id='disposable', environment='test',
kid='test-key', epoch=authority.epoch, policy_id='wheel-test',
public_key_pem=key.public_key().public_bytes(serialization.Encoding.PEM,
serialization.PublicFormat.SubjectPublicKeyInfo).decode(),
endpoint=f'http://127.0.0.1:{server.server_port}/v1/time-samples',
admission_ref='disposable-wheel-test-only', transport='admitted-loopback',
limits={'max_age_ns': 5_000_000_000, 'max_rtt_ns': 2_000_000_000,
'max_width_ns': 3_000_000_000, 'max_server_error_ns': 100_000_000,
'timer_ppm': 1000, 'timer_resolution_ns': 5_000_000,
'suspend_tolerance_ns': 5_000_000, 'trust_session_ns': 60_000_000_000})
result = subprocess.run([str(cli), 'read', '--trust', str(trust)],
capture_output=True, text=True, check=True)
window = json.loads(result.stdout)
assert window['authority_id'] == 'disposable'
assert window['epoch'] == authority.epoch
assert window['lower_ns'] <= window['upper_ns']
bad = json.loads(trust.read_text()); bad['kid'] = 'wrong-key'
trust.write_text(json.dumps(bad))
try:
Clock(FileTrust(trust)).read()
except ClockError:
pass
else:
raise AssertionError('wrong key accepted')
bad['kid'] = 'test-key'; trust.write_text(json.dumps(bad))
health.write_text('{"quality":"unusable"}')
refused = subprocess.run([str(cli), 'read', '--trust', str(trust)],
capture_output=True, text=True)
assert refused.returncode == 1 and not refused.stdout
assert refused.stderr == 'Railiance time unavailable or untrusted.\n'
finally:
server.shutdown(); server.server_close(); thread.join()
distributions = sorted((d.metadata['Name'], d.version) for d in importlib.metadata.distributions())
print(json.dumps({'checks': ['installed_import', 'three_packaged_schemas', 'cli_help',
'signed_http_cli_read', 'wrong_key_refusal', 'unhealthy_cli_refusal'],
'distributions': distributions}, sort_keys=True))
if __name__ == '__main__':
main()