#!/usr/bin/env python3
"""Assemble release-manifest.json from a publication candidate.
The release workflow emits per-artifact `*.metadata.json` fragments and a
SHA256SUMS file, but not the combined manifest that gets published beside the
tarballs. This assembles it, so the file that is the authoritative record of a
release is produced by something reviewable rather than by hand.
`source_tree_sha256` is sha256 over "<file-sha256> <relative-path>\\n" lines
SORTED BY LINE — that is, by hash, since the hash starts the line. It is the
`sha256sum <files> | sort | sha256sum` idiom. Verified bit-for-bit against the
published 0.1.4 manifest. (0.1.3's value does not reproduce, and its own file
count is wrong by 11 against its published tarball, so 0.1.4 is the reference.)
Usage:
assemble-manifest.py CANDIDATE_DIR VERSION FOSSIL_CHECKIN [--date YYYY-MM-DD]
"""
import argparse
import hashlib
import json
import pathlib
import re
import sys
import tarfile
import tempfile
FOSSIL_VERSION = "2.28"
ZIG_VERSION = "0.17.0"
OPTIMIZATION = "ReleaseFast"
SCHEMA_VERSION = 1
# Per-target qualification, following 0.1.4's wording. The last entry differs
# per target because it names the *independent* execution that proves the
# packaged binary runs below the CPU that built it.
QUALIFICATION = {
"linux-aarch64": [
"format", "portable ReleaseFast build", "unit", "e2e",
"dst seed 1 x1000", "32 fuzz targets seed 1 x100", "Fossil S1-S19 x10",
"packaged binary run on the qualifying native Linux ARM64 host (hetzner-vps)",
],
"linux-x86_64": [
"format", "portable ReleaseFast build", "unit", "e2e",
"dst seed 1 x1000", "32 fuzz targets seed 1 x100", "Fossil S1-S19 x10",
"packaged binary run on the qualifying native Linux x86_64 host (hetzner-big-eu); no QEMU low-CPU check",
],
"macos-aarch64": [
"format", "portable ReleaseFast build", "unit", "e2e",
"dst seed 1 x1000", "32 fuzz targets seed 1 x100", "Fossil S1-S19 x3",
"packaged binary run on the qualifying macOS ARM64 host (mac-mini)",
],
}
ARTIFACT_KEYS = ("binary_sha256", "bytes", "name", "qualification", "sha256", "target")
def sha256_file(path: pathlib.Path) -> str:
h = hashlib.sha256()
with path.open("rb") as fh:
for chunk in iter(lambda: fh.read(1 << 20), b""):
h.update(chunk)
return h.hexdigest()
def source_tree_digest(tarball: pathlib.Path) -> tuple[str, int]:
"""Return (source_tree_sha256, file_count) for a source tarball."""
with tempfile.TemporaryDirectory() as tmp:
with tarfile.open(tarball) as tf:
tf.extractall(tmp)
root = pathlib.Path(tmp)
top = next(root.iterdir())
files = [p for p in top.rglob("*") if p.is_file()]
lines = sorted(
f"{sha256_file(p)} {p.relative_to(top)}\n" for p in files
)
return hashlib.sha256("".join(lines).encode()).hexdigest(), len(files)
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("candidate")
ap.add_argument("version")
ap.add_argument("checkin")
ap.add_argument("--date", required=True)
args = ap.parse_args()
cand = pathlib.Path(args.candidate)
version, checkin, tag = args.version, args.checkin, f"version-{args.version}"
artifacts = []
for frag in sorted(cand.glob("*.metadata.json")):
data = json.loads(frag.read_text())
artifacts.append({k: data[k] for k in ARTIFACT_KEYS})
artifacts.sort(key=lambda a: a["name"])
if not artifacts:
print("no *.metadata.json fragments found", file=sys.stderr)
return 1
src = cand / f"zit-{version}-source.tar.gz"
if not src.is_file():
print(f"missing source tarball: {src}", file=sys.stderr)
return 1
tree_sha, file_count = source_tree_digest(src)
src_sha, src_bytes = sha256_file(src), src.stat().st_size
# Every published archive must actually verify against its own recorded
# digest before it is described as qualified.
for a in artifacts:
p = cand / a["name"]
if not p.is_file():
print(f"missing archive named in a fragment: {p}", file=sys.stderr)
return 1
actual = sha256_file(p)
if actual != a["sha256"]:
print(f"digest mismatch for {a['name']}: {actual} != {a['sha256']}", file=sys.stderr)
return 1
manifest = {
"artifacts": artifacts,
"omitted_targets": [],
"qualification": QUALIFICATION,
"qualification_source": {
"bytes": src_bytes,
"files": file_count,
"name": src.name,
"sha256": src_sha,
"source_tree_sha256": tree_sha,
},
"release": {
"date": args.date,
"fossil_check_in": checkin,
"fossil_tag": tag,
"optimization": OPTIMIZATION,
"version": version,
"zig_version": ZIG_VERSION,
},
"schema_version": SCHEMA_VERSION,
"source": {
"bytes": src_bytes,
"files": file_count,
"fossil_check_in": checkin,
"fossil_tag": tag,
"matches_qualified": True,
"name": src.name,
"qualification": (
f"generated by Fossil {FOSSIL_VERSION} from the exact release "
"check-in; every target's build and qualification battery ran "
"against this exact archive"
),
"sha256": src_sha,
"source_tree_sha256": tree_sha,
"target": "source",
},
}
print(json.dumps(manifest, indent=2, sort_keys=True))
return 0
if __name__ == "__main__":
raise SystemExit(main())