diff --git a/scripts/check_release.py b/scripts/check_release.py new file mode 100644 index 00000000..8890e1a9 --- /dev/null +++ b/scripts/check_release.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python3 +"""Compare a dated release with release.json and the checksum list. + +Run on the draft before publishing it, and on the published release at any +time. Every pack asset release.json names must be on the release at the sha256 +SHA256SUMS.txt lists for it, the parts of a pack must add up to the download +size the README prints, and nothing else may be attached. An upload that +failed in the loop, or a part re-uploaded from an older build, otherwise +publishes a release that `sha256sum --check` rejects for every user. + +Usage: + python scripts/check_release.py # the tag release.json records + python scripts/check_release.py --tag v2026.10.10 --sums dist/SHA256SUMS.txt + python scripts/check_release.py --json +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path + +sys.path.insert(0, os.path.dirname(__file__)) +from check_release_assets import digests_of +from largefiles import LARGE_FILES_REPO +from release_record import RECORD, load_record + +SUMS = "SHA256SUMS.txt" +SIGNATURE = SUMS + ".sig" + + +@dataclass(frozen=True) +class Asset: + size: int + sha256: str | None + + +@dataclass(frozen=True) +class Finding: + kind: str + name: str + detail: str + + +def parse_sums(text: str) -> dict[str, str]: + """`sha256sum` output: digest, then a space and a mode character.""" + sums: dict[str, str] = {} + for line in text.splitlines(): + line = line.strip() + if not line: + continue + digest, _, name = line.partition(" ") + if len(digest) != 64 or not name: + raise ValueError(f"not a sha256sum line: {line!r}") + sums[name.lstrip(" *")] = digest.lower() + return sums + + +def compare_release( + record: dict, + sums: dict[str, str], + assets: dict[str, Asset], + tag: str, + local_files: dict[str, str] | None = None, +) -> list[Finding]: + """Every way the release can differ from what was built. + + *local_files* maps a file kept beside the packs (the checksum list, its + signature) to its local sha256: the copy on the release must be that one. + """ + findings: list[Finding] = [] + if record.get("tag") != tag: + findings.append(Finding("tag", tag, f"release.json records {record.get('tag')}")) + + recorded = { + name: pack_name + for pack_name, pack in record.get("packs", {}).items() + for name in pack.get("assets", []) + } + expected = set(recorded) | set(sums) | {SUMS} | set(local_files or ()) + allowed = expected | {SIGNATURE} + + for name in sorted(expected - set(assets)): + findings.append(Finding("missing", name, "not attached to the release")) + for name in sorted(set(assets) - allowed): + findings.append(Finding("extra", name, "attached but neither recorded nor listed")) + for name in sorted(set(recorded) - set(sums)): + findings.append(Finding("unlisted", name, f"absent from {SUMS}")) + for name in sorted(set(sums) - set(recorded)): + findings.append(Finding("unrecorded", name, f"listed in {SUMS}, absent from release.json")) + + wanted = {**sums, **(local_files or {})} + for name, digest in sorted(wanted.items()): + asset = assets.get(name) + if asset is None: + continue + if asset.sha256 is None: + findings.append(Finding("unverified", name, "the release gives no sha256")) + elif asset.sha256 != digest: + findings.append(Finding( + "content", name, f"release sha256 {asset.sha256[:12]} != {digest[:12]}" + )) + + for pack_name, pack in sorted(record.get("packs", {}).items()): + parts = [assets[name].size for name in pack.get("assets", []) if name in assets] + if len(parts) == len(pack.get("assets", [])) and sum(parts) != pack.get("download_size"): + findings.append(Finding( + "size", pack_name, + f"assets total {sum(parts)} bytes, release.json says {pack.get('download_size')}", + )) + return findings + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with open(path, "rb") as handle: + for chunk in iter(lambda: handle.read(1 << 20), b""): + digest.update(chunk) + return digest.hexdigest() + + +def fetch_assets(tag: str) -> tuple[dict[str, Asset], bool]: + """Name, size and sha256 of every asset of *tag*, and whether it is a draft.""" + result = subprocess.run( + ["gh", "release", "view", tag, "-R", LARGE_FILES_REPO, "--json", "assets,isDraft"], + capture_output=True, text=True, check=True, + ) + data = json.loads(result.stdout) + digests = digests_of(data["assets"]) + return ( + {a["name"]: Asset(a["size"], digests.get(a["name"])) for a in data["assets"]}, + bool(data.get("isDraft")), + ) + + +def fetch_sums(tag: str) -> str: + """The checksum list as attached to *tag*.""" + result = subprocess.run( + ["gh", "release", "download", tag, "-R", LARGE_FILES_REPO, + "--pattern", SUMS, "--output", "-"], + capture_output=True, text=True, check=True, + ) + return result.stdout + + +def main() -> int: + parser = argparse.ArgumentParser(allow_abbrev=False, description=__doc__.split("\n")[0]) + parser.add_argument("--record", default=str(RECORD), help="release record (release.json)") + parser.add_argument("--tag", help="release to check (default: the tag the record names)") + parser.add_argument( + "--sums", metavar="FILE", + help=f"local {SUMS} the packs were listed in; its copy and signature on " + f"the release must be these files (default: the list attached to the release)", + ) + parser.add_argument("--json", action="store_true") + args = parser.parse_args() + + record = load_record(args.record) + tag = args.tag or record.get("tag") + if not tag: + print(f"ERROR: {args.record} names no tag; pass --tag", file=sys.stderr) + return 2 + try: + assets, draft = fetch_assets(tag) + if args.sums: + sums_path = Path(args.sums) + sums_text = sums_path.read_text(encoding="utf-8") + local_files = {SUMS: _sha256(sums_path)} + signature = sums_path.with_name(SIGNATURE) + if signature.is_file(): + local_files[SIGNATURE] = _sha256(signature) + else: + sums_text = fetch_sums(tag) + local_files = {} + except subprocess.CalledProcessError as exc: + print(f"ERROR: cannot read release {tag}: {exc.stderr.strip() or exc}", file=sys.stderr) + return 2 + except (FileNotFoundError, OSError) as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 2 + try: + sums = parse_sums(sums_text) + except ValueError as exc: + print(f"ERROR: {SUMS}: {exc}", file=sys.stderr) + return 2 + + findings = compare_release(record, sums, assets, tag, local_files) + if args.json: + print(json.dumps( + {"tag": tag, "draft": draft, "assets": len(assets), + "findings": [vars(f) for f in findings]}, + indent=2, + )) + else: + state = "draft" if draft else "published" + print(f"{tag} ({state}): {len(assets)} assets, {len(sums)} listed in {SUMS}") + for finding in findings: + print(f" {finding.kind.upper():<10} {finding.name}: {finding.detail}") + if not findings: + print(" every asset is the recorded file at its listed sha256") + return 1 if findings else 0 + + +if __name__ == "__main__": + from common import run_cli + + run_cli(main) diff --git a/tests/test_check_release.py b/tests/test_check_release.py new file mode 100644 index 00000000..c4fca2a3 --- /dev/null +++ b/tests/test_check_release.py @@ -0,0 +1,132 @@ +"""A dated release is checked against what was built before it is published. + +The upload loop has no `set -e`: a part that failed to upload, or a part +re-uploaded from an older build, left a release whose count looked right and +whose checksums every user's `sha256sum --check` rejected. +""" + +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts")) + +from check_release import SIGNATURE, SUMS, Asset, compare_release, parse_sums + +A = "a" * 64 +B = "b" * 64 +C = "c" * 64 +S = "d" * 64 + +RECORD = { + "tag": "v2026.10.10", + "packs": { + "Big_BIOS_Pack.zip": { + "download_size": 300, + "assets": ["Big_BIOS_Pack.part1of2.zip", "Big_BIOS_Pack.part2of2.zip"], + }, + "Small_BIOS_Pack.zip": {"download_size": 50, "assets": ["Small_BIOS_Pack.zip"]}, + }, +} +SUMS_LIST = { + "Big_BIOS_Pack.part1of2.zip": A, + "Big_BIOS_Pack.part2of2.zip": B, + "Small_BIOS_Pack.zip": C, +} + + +def _assets() -> dict[str, Asset]: + return { + "Big_BIOS_Pack.part1of2.zip": Asset(200, A), + "Big_BIOS_Pack.part2of2.zip": Asset(100, B), + "Small_BIOS_Pack.zip": Asset(50, C), + SUMS: Asset(300, S), + SIGNATURE: Asset(10, "e" * 64), + } + + +def _kinds(findings) -> set[tuple[str, str]]: + return {(f.kind, f.name) for f in findings} + + +class ReleaseMatchesWhatWasBuilt(unittest.TestCase): + def test_a_complete_release_passes(self): + self.assertEqual( + compare_release(RECORD, SUMS_LIST, _assets(), "v2026.10.10", {SUMS: S}), [] + ) + + def test_a_part_that_failed_to_upload_is_missing(self): + assets = _assets() + del assets["Big_BIOS_Pack.part2of2.zip"] + self.assertIn( + ("missing", "Big_BIOS_Pack.part2of2.zip"), + _kinds(compare_release(RECORD, SUMS_LIST, assets, "v2026.10.10")), + ) + + def test_a_part_from_an_older_build_is_caught_at_the_same_count(self): + assets = _assets() + assets["Big_BIOS_Pack.part1of2.zip"] = Asset(200, "f" * 64) + self.assertEqual( + _kinds(compare_release(RECORD, SUMS_LIST, assets, "v2026.10.10")), + {("content", "Big_BIOS_Pack.part1of2.zip")}, + ) + + def test_a_checksum_list_other_than_the_local_one_is_caught(self): + self.assertEqual( + _kinds(compare_release(RECORD, SUMS_LIST, _assets(), "v2026.10.10", {SUMS: "0" * 64})), + {("content", SUMS)}, + ) + + def test_an_asset_nobody_recorded_is_extra(self): + assets = _assets() + assets["Big_BIOS_Pack.zip.001"] = Asset(1, "1" * 64) + self.assertEqual( + _kinds(compare_release(RECORD, SUMS_LIST, assets, "v2026.10.10")), + {("extra", "Big_BIOS_Pack.zip.001")}, + ) + + def test_record_and_list_must_name_the_same_files(self): + sums = {k: v for k, v in SUMS_LIST.items() if k != "Small_BIOS_Pack.zip"} + sums["Other_BIOS_Pack.zip"] = C + kinds = _kinds(compare_release(RECORD, sums, _assets(), "v2026.10.10")) + self.assertIn(("unlisted", "Small_BIOS_Pack.zip"), kinds) + self.assertIn(("unrecorded", "Other_BIOS_Pack.zip"), kinds) + self.assertIn(("missing", "Other_BIOS_Pack.zip"), kinds) + + def test_parts_must_add_up_to_the_printed_download_size(self): + assets = _assets() + assets["Big_BIOS_Pack.part2of2.zip"] = Asset(99, B) + self.assertEqual( + _kinds(compare_release(RECORD, SUMS_LIST, assets, "v2026.10.10")), + {("size", "Big_BIOS_Pack.zip")}, + ) + + def test_an_asset_without_a_digest_is_not_taken_on_trust(self): + assets = _assets() + assets["Small_BIOS_Pack.zip"] = Asset(50, None) + self.assertEqual( + _kinds(compare_release(RECORD, SUMS_LIST, assets, "v2026.10.10")), + {("unverified", "Small_BIOS_Pack.zip")}, + ) + + def test_another_tag_than_the_record_is_named(self): + self.assertEqual( + _kinds(compare_release(RECORD, SUMS_LIST, _assets(), "v2026.10.11")), + {("tag", "v2026.10.11")}, + ) + + +class SumsParsing(unittest.TestCase): + def test_text_and_binary_mode_lines(self): + text = f"{A} Small_BIOS_Pack.zip\n{B} *Big BIOS.zip\n\n" + self.assertEqual(parse_sums(text), {"Small_BIOS_Pack.zip": A, "Big BIOS.zip": B}) + + def test_a_line_that_is_not_a_checksum_stops(self): + with self.assertRaises(ValueError): + parse_sums("Not Found\n") + + +if __name__ == "__main__": + unittest.main() diff --git a/wiki/release-process.md b/wiki/release-process.md index 304fcf4a..b59b72de 100644 --- a/wiki/release-process.md +++ b/wiki/release-process.md @@ -216,8 +216,13 @@ gh release create "v${DATE}" --draft --title "BIOS Pack v${DATE}" --notes-file n for f in dist/SHA256SUMS.txt dist/SHA256SUMS.txt.sig dist/*.zip; do [ -f "$f" ] && gh release upload "v${DATE}" "$f#$(basename "$f")" --clobber done -gh release view "v${DATE}" --json assets --jq '.assets | length' # expect every file -gh release edit "v${DATE}" --draft=false --latest +# A count does not see a failed upload beside a stray file, nor a part +# re-uploaded from an older build. The check holds every asset against +# release.json and the local checksum list, by the sha256 GitHub computes, +# and the parts of each pack against the download size the README prints. +# Publish only on a clean exit; rerun the upload for what it names. +python scripts/check_release.py --tag "v${DATE}" --sums dist/SHA256SUMS.txt \ + && gh release edit "v${DATE}" --draft=false --latest # The record and the pages it feeds go to main with the release, so the # table never describes a pack other than the one the link serves.