#!/usr/bin/env python3 """Validate and attach one tested Signage release bundle to an existing Gitea release.""" from __future__ import annotations import hashlib import json import os import re import sys import tarfile import urllib.error import urllib.parse import urllib.request import uuid from pathlib import Path ARCHIVE = re.compile(r"^signage-(\d+\.\d+\.\d+)-linux-x64-glibc\.tar\.gz$") class Refused(Exception): """The release does not meet a condition that must fail closed.""" def sha256(path: Path) -> str: digest = hashlib.sha256() with path.open("rb") as handle: for chunk in iter(lambda: handle.read(1024 * 1024), b""): digest.update(chunk) return digest.hexdigest() def package_metadata(archive: Path) -> dict: try: with tarfile.open(archive, "r:gz") as bundle: member = next( entry for entry in bundle.getmembers() if entry.name == "signage/ekklesia.package.json" and entry.isfile() ) extracted = bundle.extractfile(member) if extracted is None: raise KeyError(member.name) return json.loads(extracted.read()) except (KeyError, StopIteration, tarfile.TarError, json.JSONDecodeError) as error: raise Refused(f"cannot read signage/ekklesia.package.json from {archive.name}") from error def package_pair(directory: Path, tag: str, revision: str) -> list[Path]: archives = [path for path in directory.iterdir() if path.is_file() and ARCHIVE.fullmatch(path.name)] if len(archives) != 1: raise Refused(f"expected one Signage Module Package, found {len(archives)}") package = archives[0] match = ARCHIVE.fullmatch(package.name) assert match is not None version = match.group(1) if tag != f"v{version}": raise Refused(f"tag {tag} does not match package version {version}") if not re.fullmatch(r"[0-9a-f]{40}", revision): raise Refused("release revision is not a full Git object ID") checksum = directory / f"{package.name}.sha256" expected = [package, checksum] missing = [path.name for path in expected if not path.is_file()] if missing: raise Refused(f"Module Package pair is incomplete; missing={missing}") package_digest = sha256(package) checksum_line = checksum.read_text(encoding="utf-8") wanted_line = f"{package_digest} {package.name}\n" if checksum_line != wanted_line: raise Refused(f"{checksum.name} does not match {package.name}") built_digest = os.environ.get("EXPECTED_PACKAGE_SHA256") if built_digest is not None and built_digest != package_digest: raise Refused("selected Module Package differs from the package job's bytes") metadata = package_metadata(package) if metadata.get("module") != {"id": "signage", "version": version}: raise Refused("package metadata does not identify the expected Signage version") if metadata.get("provenance", {}).get("revision") != revision: raise Refused("package provenance revision does not match the tag commit") return expected def expected_paths(directory: Path, tag: str, revision: str) -> list[Path]: package, checksum = package_pair(directory, tag, revision) package_digest = sha256(package) match = ARCHIVE.fullmatch(package.name) assert match is not None version = match.group(1) source = directory / f"signage-{version}-linux-x64-glibc-corresponding-source.tar.gz" source_checksum = directory / f"{source.name}.sha256" redistribution = directory / f"signage-{version}-linux-x64-glibc.redistribution.json" notice = directory / f"signage-{version}-linux-x64-glibc.source-notice.txt" expected = [package, checksum, source, source_checksum, redistribution, notice] missing = [path.name for path in expected if not path.is_file()] extras = sorted(path.name for path in directory.iterdir() if path.is_file() and path not in expected) if missing or extras: raise Refused(f"release bundle differs from the six required files; missing={missing}, extra={extras}") source_line = source_checksum.read_text(encoding="utf-8") wanted_source_line = f"{sha256(source)} {source.name}\n" if source_line != wanted_source_line: raise Refused(f"{source_checksum.name} does not match {source.name}") binding = json.loads(redistribution.read_text(encoding="utf-8")) package_binding = binding.get("package", {}) if package_binding.get("file") != package.name or package_binding.get("sha256") != package_digest: raise Refused("redistribution metadata does not bind the selected Module Package") corresponding = binding.get("correspondingSource", {}) if corresponding.get("file") != source.name or corresponding.get("sha256") != sha256(source): raise Refused("redistribution metadata does not bind the selected corresponding source") return expected class Gitea: def __init__(self) -> None: self.server = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/") self.repository = os.environ.get("GITHUB_REPOSITORY", "") self.token = os.environ.get("GITHUB_TOKEN", "") if not self.server or not self.repository or not self.token: raise Refused("GITHUB_SERVER_URL, GITHUB_REPOSITORY, and GITHUB_TOKEN are required") def request(self, method: str, path: str, body: bytes | None = None, headers: dict[str, str] | None = None, *, authenticated: bool = True) -> bytes: target = path if path.startswith("http") else f"{self.server}/api/v1/repos/{self.repository}{path}" request_headers = dict(headers or {}) if authenticated: request_headers["Authorization"] = f"token {self.token}" request = urllib.request.Request(target, data=body, method=method, headers=request_headers) try: with urllib.request.urlopen(request, timeout=120) as response: return response.read() except urllib.error.HTTPError as error: detail = error.read()[:300].decode("utf-8", "replace") raise Refused(f"{method} {path} answered {error.code}: {detail}") from error def json(self, method: str, path: str) -> object: data = self.request(method, path) return json.loads(data) if data else None def public_download(self, url: str) -> bytes: return self.request("GET", url, authenticated=False) def upload(directory: Path, tag: str, revision: str) -> None: files = expected_paths(directory, tag, revision) gitea = Gitea() quoted_tag = urllib.parse.quote(tag, safe="") release = gitea.json("GET", f"/releases/tags/{quoted_tag}") tagged = gitea.json("GET", f"/tags/{quoted_tag}") if not isinstance(release, dict): raise Refused(f"release {tag} does not exist") if not isinstance(tagged, dict) or tagged.get("commit", {}).get("sha") != revision: raise Refused(f"tag {tag} does not name {revision}") release_id = release.get("id") listed = gitea.json("GET", f"/releases/{release_id}/assets") present = {asset["name"]: asset for asset in listed or []} unexpected = sorted(set(present) - {path.name for path in files}) if unexpected: raise Refused(f"release already has unexpected assets: {unexpected}") # Preflight every published name before changing the release. This prevents a later conflict # from leaving newly uploaded earlier files beside immutable, different bytes. verified: dict[str, str] = {} for path in files: asset = present.get(path.name) if asset is None: continue public_digest = hashlib.sha256(gitea.public_download(asset["browser_download_url"])).hexdigest() expected_digest = sha256(path) if public_digest != expected_digest: raise Refused(f"public {path.name} has sha256 {public_digest}, expected {expected_digest}") verified[path.name] = public_digest for path in files: digest = sha256(path) asset = present.get(path.name) if asset is None: boundary = uuid.uuid4().hex body = ( f"--{boundary}\r\n" f'Content-Disposition: form-data; name="attachment"; filename="{path.name}"\r\n' "Content-Type: application/octet-stream\r\n\r\n" ).encode() + path.read_bytes() + f"\r\n--{boundary}--\r\n".encode() gitea.request( "POST", f"/releases/{release_id}/assets?name={urllib.parse.quote(path.name)}", body, {"Content-Type": f"multipart/form-data; boundary={boundary}"}, ) listed = gitea.json("GET", f"/releases/{release_id}/assets") asset = next((item for item in listed or [] if item.get("name") == path.name), None) if asset is None: raise Refused(f"Gitea did not list {path.name} after upload") public_digest = verified.get(path.name) if public_digest is None: public_digest = hashlib.sha256(gitea.public_download(asset["browser_download_url"])).hexdigest() if public_digest != digest: raise Refused(f"public {path.name} has sha256 {public_digest}, expected {digest}") print(f"release-assets: public {path.name}, sha256 {digest}", flush=True) def main(argv: list[str]) -> int: try: if len(argv) != 4 or argv[0] not in {"verify-package", "verify", "upload"}: raise Refused("usage: release-assets.py (verify-package|verify|upload) DIRECTORY TAG REVISION") command, directory, tag, revision = argv selected = (package_pair(Path(directory), tag, revision) if command == "verify-package" else expected_paths(Path(directory), tag, revision)) if command in {"verify-package", "verify"}: for path in selected: print(path) else: upload(Path(directory), tag, revision) return 0 except (OSError, json.JSONDecodeError, Refused) as error: print(f"release-assets: REFUSED: {error}", file=sys.stderr) return 1 if __name__ == "__main__": raise SystemExit(main(sys.argv[1:]))