from __future__ import annotations
import argparse
import hashlib
import json
import os
import re
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Callable
ARCHIVE_SUFFIXES = (".tar.gz", ".zip")
SIDECAR_SUFFIX = ".sha256"
_HEX64 = re.compile(r"^[0-9a-f]{64}$")
class ReleaseAssetError(Exception):
def is_archive(name: str) -> bool:
return name.endswith(ARCHIVE_SUFFIXES)
def sidecar_name(archive: str) -> str:
return archive + SIDECAR_SUFFIX
def parse_sidecar(text: str, source: str) -> str:
fields = text.split()
if not fields:
raise ReleaseAssetError(f"{source}: empty checksum file")
digest = fields[0].lower()
if not _HEX64.match(digest):
raise ReleaseAssetError(f"{source}: first field is not a sha256 hex digest: {fields[0]!r}")
return digest
def normalize_digest(digest: str | None) -> str | None:
if not digest:
return None
algo, _, value = digest.partition(":")
if algo != "sha256" or not _HEX64.match(value):
raise ReleaseAssetError(f"unexpected asset digest format: {digest!r}")
return value
@dataclass(frozen=True)
class Plan:
upload: list[str]
kept: dict[str, str | None]
live_only: list[str]
@property
def new_archives(self) -> bool:
return bool(self.upload)
def make_plan(local_archives: list[str], live: dict[str, str | None]) -> Plan:
upload: list[str] = []
kept: dict[str, str | None] = {}
half: list[str] = []
for archive in sorted(local_archives):
has_archive = archive in live
has_sidecar = sidecar_name(archive) in live
if has_archive and has_sidecar:
kept[archive] = live[archive]
elif not has_archive and not has_sidecar:
upload.append(archive)
else:
present = archive if has_archive else sidecar_name(archive)
half.append(present)
if half:
raise ReleaseAssetError(
"these assets are live without their archive/checksum partner: "
+ ", ".join(half)
+ ". Uploading the missing half from this run would pair a rebuilt file with the "
"original, so the checksum would not match the archive. Delete the orphan by hand "
"(nobody can have installed from an incomplete pair) and re-run."
)
local = set(local_archives)
live_only = sorted(name for name in live if is_archive(name) and name not in local)
return Plan(upload=upload, kept=kept, live_only=live_only)
def verify_release(
local_archives: list[str],
live: dict[str, str | None],
snapshot: dict[str, str | None],
read_sidecar: Callable[[str], str],
hash_asset: Callable[[str], str],
) -> list[str]:
problems: list[str] = []
for archive in sorted(local_archives):
if archive not in live:
problems.append(f"{archive}: not on the release after the upload")
if sidecar_name(archive) not in live:
problems.append(f"{sidecar_name(archive)}: not on the release after the upload")
for archive in sorted(name for name in live if is_archive(name)):
if sidecar_name(archive) not in live:
continue actual = live[archive] or hash_asset(archive)
recorded = parse_sidecar(read_sidecar(sidecar_name(archive)), sidecar_name(archive))
if actual != recorded:
problems.append(
f"{archive}: live digest {actual} does not match its checksum file ({recorded})"
)
for archive, before in sorted(snapshot.items()):
after = live.get(archive)
if archive not in live:
problems.append(f"{archive}: was live before the upload and is gone now")
elif before is not None and after != before:
problems.append(
f"{archive}: replaced during this run (was {before}, now {after}); "
"published archives must never change"
)
return problems
def local_archives(artifacts: Path) -> dict[str, Path]:
found: dict[str, Path] = {}
for path in sorted(artifacts.glob("release-*/rumdl-*")):
if not is_archive(path.name):
continue
if path.name in found:
raise ReleaseAssetError(f"{path.name}: built by more than one job")
sidecar = path.with_name(sidecar_name(path.name))
if not sidecar.is_file():
raise ReleaseAssetError(f"{path}: built without its {SIDECAR_SUFFIX} file")
recorded = parse_sidecar(sidecar.read_text(), str(sidecar))
actual = hashlib.sha256(path.read_bytes()).hexdigest()
if recorded != actual:
raise ReleaseAssetError(f"{sidecar}: records {recorded}, but the archive hashes to {actual}")
found[path.name] = path
if not found:
raise ReleaseAssetError(f"no release archives under {artifacts}/release-*/")
return found
def _gh(*args: str) -> subprocess.CompletedProcess[str]:
return subprocess.run(["gh", *args], capture_output=True, text=True)
def fetch_live(tag: str) -> dict[str, str | None]:
result = _gh("release", "view", tag, "--json", "assets")
if result.returncode != 0:
if result.stderr.strip() == "release not found":
return {}
raise ReleaseAssetError(f"gh release view {tag} failed: {result.stderr.strip()}")
assets = json.loads(result.stdout)["assets"]
return {a["name"]: normalize_digest(a.get("digest")) for a in assets}
def _download(tag: str, name: str) -> bytes:
result = subprocess.run(
["gh", "release", "download", tag, "--pattern", name, "--output", "-"],
capture_output=True,
)
if result.returncode != 0:
raise ReleaseAssetError(f"downloading {name} failed: {result.stderr.decode().strip()}")
return result.stdout
def _write_output(key: str, value: str) -> None:
path = os.environ.get("GITHUB_OUTPUT")
if path:
with open(path, "a", encoding="utf-8") as fh:
fh.write(f"{key}={value}\n")
def cmd_plan(args: argparse.Namespace) -> int:
archives = local_archives(Path(args.artifacts))
live = fetch_live(args.tag)
plan = make_plan(list(archives), live)
print(f"GitHub release {args.tag}: {len(live)} live assets")
for name in plan.upload:
print(f" upload {name}")
for name, digest in plan.kept.items():
print(f" keep {name} (sha256:{digest or 'unrecorded'}); this run's build is discarded")
for name in plan.live_only:
print(f" note {name} is live but this run did not build it; left as is")
Path(args.snapshot).write_text(json.dumps(plan.kept, indent=2, sort_keys=True) + "\n")
_write_output("new_archives", "true" if plan.new_archives else "false")
print(f"new_archives={'true' if plan.new_archives else 'false'}")
return 0
def cmd_verify(args: argparse.Namespace) -> int:
archives = local_archives(Path(args.artifacts))
live = fetch_live(args.tag)
snapshot = json.loads(Path(args.snapshot).read_text())
problems = verify_release(
list(archives),
live,
snapshot,
read_sidecar=lambda name: _download(args.tag, name).decode("utf-8"),
hash_asset=lambda name: hashlib.sha256(_download(args.tag, name)).hexdigest(),
)
if problems:
for problem in problems:
print(f"::error::{problem}")
return 1
archive_count = sum(1 for name in live if is_archive(name))
print(
f"GitHub release {args.tag}: {archive_count} archives, each matching its checksum file; "
f"{len(snapshot)} previously published archive(s) unchanged"
)
return 0
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
sub = parser.add_subparsers(dest="command", required=True)
for name, func in (("plan", cmd_plan), ("verify", cmd_verify)):
p = sub.add_parser(name)
p.add_argument("--tag", required=True)
p.add_argument("--artifacts", required=True, help="directory holding release-*/ artifacts")
p.add_argument("--snapshot", required=True, help="JSON file recording the digests kept by `plan`")
p.set_defaults(func=func)
args = parser.parse_args(argv)
try:
return args.func(args)
except ReleaseAssetError as exc:
print(f"::error::{exc}")
return 1
if __name__ == "__main__":
sys.exit(main())