mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-10-18 09:23:26 -05:00
feat: check a release against its record
A count of assets misses a failed upload beside a stray file and a part re-uploaded from an older build. check_release.py holds each asset's GitHub sha256 against SHA256SUMS.txt and release.json, and the release process publishes the draft only on its clean exit.
This commit is contained in:
1 parent
694d1defc0
commit
572a2d20de
3 files changed
+351
-2
No files matched your search
@@ -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)
|
||||
@@ -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("<html>Not Found</html>\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user