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:
parent
c976c9c277
commit
c29bf39b5d
13 changed files with 577 additions and 3 deletions
|
|
@ -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.
|
||||
|
|
|
|||
2
tools/build-requirements.in
Normal file
2
tools/build-requirements.in
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# Build-only dependencies; runtime dependencies remain in uv.lock.
|
||||
hatchling>=1.27,<2
|
||||
26
tools/build-requirements.txt
Normal file
26
tools/build-requirements.txt
Normal 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
118
tools/verify_release.py
Normal 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
91
tools/wheel_smoke.py
Normal 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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue