#!/usr/bin/env python3 """Upload a verified draft; promote only with explicit --promote owner approval.""" import argparse import base64 import datetime import hashlib import json import os import re import subprocess import urllib.error import urllib.parse import urllib.request import uuid from collections.abc import Iterable from pathlib import Path from typing import Any SERVER = "https://git.justinvisser.org" REPOSITORY = "justinzeus/wolfskin-tv-updates" REQUIRED = { "Wolfskin-armeabi-v7a.apk", "Wolfskin-arm64-v8a.apk", "Wolfskin-x86_64.apk", "Wolfskin.apk", "wolfskin-update.json", "RELEASE-NOTES.md", "SHA256SUMS", } class ForgeHttpError(RuntimeError): """Retain HTTP status without exposing response bodies or authorization.""" def __init__(self, method: str, status: int) -> None: super().__init__(f"Forgejo {method} failed: HTTP {status}") self.status = status class NoRedirect(urllib.request.HTTPRedirectHandler): """Validate every redirect before forwarding distribution-tool credentials.""" def redirect_request( self, req: Any, fp: Any, code: int, msg: str, headers: Any, newurl: str ) -> None: return None class Forge: """Credential-scoped release API; credentials exist only in process memory.""" def __init__( self, server: str, authorization: str, test_server: bool = False ) -> None: self.server = server.rstrip("/") self.origin = urllib.parse.urlsplit(self.server) self.authorization = authorization self.test_server = test_server self.opener = urllib.request.build_opener(NoRedirect) self.api = self.server + "/api/v1/repos/" + REPOSITORY def request( self, method: str, url: str, data: Any = None, content_type: str | None = None, length: int | None = None, authenticated: bool = True, ) -> Any: """Reject cross-origin credential forwarding and non-HTTPS production URLs.""" for _ in range(6): parsed = urllib.parse.urlsplit(url) local = self.test_server and parsed.hostname in ( "localhost", "127.0.0.1", "::1", ) if ( parsed.username or parsed.password or (parsed.scheme != "https" and not local) ): raise ValueError( "Distribution requests require credential-free HTTPS URLs" ) if authenticated and (parsed.scheme, parsed.netloc) != ( self.origin.scheme, self.origin.netloc, ): raise ValueError( "Refusing to send release credentials to another origin" ) headers = {"Accept": "application/json"} if authenticated: headers["Authorization"] = self.authorization if content_type: headers["Content-Type"] = content_type if length is not None: headers["Content-Length"] = str(length) request = urllib.request.Request( url, data=data, method=method, headers=headers ) try: return self.opener.open(request, timeout=60) except urllib.error.HTTPError as error: if method != "GET" or error.code not in (301, 302, 303, 307, 308): status = error.code error.close() raise ForgeHttpError(method, status) from None location = error.headers.get("Location") error.close() if not location: raise ValueError("Forgejo redirect has no location") url = urllib.parse.urljoin(url, location) raise ValueError("Too many Forgejo redirects") def json( self, method: str, path: str, value: dict[str, Any] | None = None, authenticated: bool = True, ) -> Any: """Read or mutate release metadata without exposing HTTP authorization.""" data = None if value is None else json.dumps(value).encode() with self.request( method, self.api + path, data, "application/json", authenticated=authenticated, ) as response: return json.load(response) def upload(self, release_id: int, file: Path) -> dict[str, Any]: """Stream multipart APK bytes instead of copying a universal APK into memory.""" boundary = uuid.uuid4().hex prefix = ( f'--{boundary}\r\nContent-Disposition: form-data; name="attachment"; filename="{file.name}"\r\n' "Content-Type: application/octet-stream\r\n\r\n" ).encode() suffix = f"\r\n--{boundary}--\r\n".encode() def chunks() -> Iterable[bytes]: yield prefix with file.open("rb") as stream: while chunk := stream.read(1024 * 1024): yield chunk yield suffix url = f"{self.api}/releases/{release_id}/assets?name={urllib.parse.quote(file.name)}" with self.request( "POST", url, chunks(), f"multipart/form-data; boundary={boundary}", len(prefix) + file.stat().st_size + len(suffix), ) as response: return json.load(response) def verify(self, url: str, expected: str, authenticated: bool) -> None: """Verify bytes fetched from Forgejo, not only its declared asset size.""" with self.request("GET", url, authenticated=authenticated) as response: actual = hashlib.file_digest(response, "sha256").hexdigest() if actual != expected: raise ValueError("Uploaded artifact checksum mismatch") def require_newer_code(self, code: int) -> None: """Read the public channel anonymously and fail closed on an uncertain floor.""" try: release = self.json("GET", "/releases/latest", authenticated=False) except ForgeHttpError as error: if error.status != 404: raise releases = self.json("GET", "/releases?limit=1", authenticated=False) if releases == []: return raise ValueError("Latest is missing but the channel is not empty") from None assets = [ asset for asset in release["assets"] if asset["name"] == "wolfskin-update.json" ] if len(assets) != 1: raise ValueError("Latest release must have exactly one update manifest") with self.request( "GET", assets[0]["browser_download_url"], authenticated=False ) as response: manifest = json.load(response) latest_code = manifest["versionCode"] if ( manifest["schemaVersion"] != 1 or manifest["packageId"] != "org.justinvisser.wolfskin.tv.opt" or type(latest_code) is not int or not 1 <= latest_code <= 2100000000 ): raise ValueError("Invalid live household version identity") if code <= latest_code: raise ValueError( f"Bundle versionCode must exceed live channel code {latest_code}" ) def credential(server: str) -> str: """Read git's existing credential helper without secrets in argv, stdout or disk.""" parsed = urllib.parse.urlsplit(server) environment = { name: value for name, value in os.environ.items() if not name.startswith("GIT_TRACE") and name not in {"GIT_CURL_VERBOSE", "SHELLOPTS", "BASHOPTS", "BASH_ENV", "ENV"} } environment["GIT_TERMINAL_PROMPT"] = "0" result = subprocess.run( ["git", "credential", "fill"], input=f"protocol={parsed.scheme}\nhost={parsed.netloc}\n\n", text=True, capture_output=True, check=False, env=environment, ) if result.returncode: raise RuntimeError("Git credential helper failed; no release created") fields = dict( line.split("=", 1) for line in result.stdout.splitlines() if "=" in line ) if not fields.get("password"): raise ValueError("Git credential helper returned no release credential") encoded = base64.b64encode( f"{fields.get('username', '')}:{fields['password']}".encode() ).decode() return "Basic " + encoded def validate_bundle(directory: Path) -> tuple[dict[str, Any], dict[str, str]]: """Require every public asset and verify the local final-byte checksum set.""" for name in REQUIRED: if not (directory / name).is_file(): raise ValueError(f"Required release asset missing: {name}") hashes: dict[str, str] = {} for line in (directory / "SHA256SUMS").read_text().splitlines(): match = re.fullmatch(r"([0-9a-f]{64}) ([A-Za-z0-9_.-]+)", line) if match is None or match[2] in hashes: raise ValueError("Invalid or duplicate SHA256SUMS entry") hashes[match[2]] = match[1] if set(hashes) != REQUIRED - {"SHA256SUMS"}: raise ValueError("Checksum set must cover exactly the public artifacts") for name, expected in hashes.items(): with (directory / name).open("rb") as stream: if hashlib.file_digest(stream, "sha256").hexdigest() != expected: raise ValueError(f"Local checksum mismatch: {name}") hashes["SHA256SUMS"] = hashlib.sha256( (directory / "SHA256SUMS").read_bytes() ).hexdigest() manifest = json.loads((directory / "wolfskin-update.json").read_text()) if ( manifest["schemaVersion"] != 1 or manifest["packageId"] != "org.justinvisser.wolfskin.tv.opt" ): raise ValueError("Wrong household manifest identity") if ( manifest["signerSha256"] != "182bac72e292b9ea4d3d4e6383f92eb2161fa28ba5afcff0337cb7d5a9d5341d" ): raise ValueError("Wrong household signing authority") version = manifest["versionName"] code = manifest["versionCode"] if ( not re.fullmatch(r"\d+\.\d+\.\d+", version) or type(code) is not int or not 1 <= code <= 2100000000 ): raise ValueError("Invalid household version identity") if ( not re.fullmatch(r"[0-9a-f]{40}", manifest["sourceCommit"]) or manifest["sourceTag"] != f"pwolfskin-opt-v{version}" ): raise ValueError("Invalid immutable source identity") published = datetime.datetime.fromisoformat( manifest["publishedAt"].replace("Z", "+00:00") ) if published.utcoffset() is None: raise ValueError("Publication time must include a timezone") expected_abis = {"armeabi-v7a", "arm64-v8a", "x86_64", "universal"} if ( len(manifest["assets"]) != 4 or {asset["abi"] for asset in manifest["assets"]} != expected_abis ): raise ValueError("Manifest must describe all four standalone APKs") for asset in manifest["assets"]: expected_name = ( "Wolfskin.apk" if asset["abi"] == "universal" else f"Wolfskin-{asset['abi']}.apk" ) if asset["name"] != expected_name or asset["minSdk"] < 23: raise ValueError("Invalid APK asset identity or minimum SDK") path = directory / asset["name"] if ( hashes.get(path.name) != asset["sha256"] or path.stat().st_size != asset["bytes"] ): raise ValueError("Manifest does not match final APK bytes") return manifest, hashes def publish( directory: Path, server: str, promote: bool, dry_run: bool, test_server: bool = False, draft_id: int | None = None, ) -> None: """Keep incomplete uploads draft; withdraw a failed promoted release from latest.""" manifest, hashes = validate_bundle(directory) version = manifest["versionName"] tag = f"wolfskin-opt-v{version}" if dry_run: print( f"Dry run: draft {REPOSITORY} {tag}; upload and verify {len(hashes)} assets; promote={promote}; no requests or credentials" ) return Forge(server, "", test_server).require_newer_code(manifest["versionCode"]) forge = Forge(server, credential(server), test_server) if draft_id is None: release = forge.json( "POST", "/releases", { "name": f"v{version}", "tag_name": tag, "body": (directory / "RELEASE-NOTES.md").read_text(), "draft": True, "prerelease": False, }, ) else: release = forge.json("GET", f"/releases/{draft_id}") if not release["draft"] or release["tag_name"] != tag: raise ValueError("Resume target must be the matching draft release") release_id = release["id"] existing = {asset["name"]: asset for asset in release.get("assets", [])} print( f"Verifying draft release {release_id}; it stays draft on any verification failure" ) uploaded: dict[str, str] = {} for name, expected in hashes.items(): asset = existing.get(name) or forge.upload(release_id, directory / name) url = asset["browser_download_url"] forge.verify(url, expected, authenticated=True) uploaded[name] = url if not promote: print( "Draft upload verified. Publication requires owner approval and explicit --promote." ) return forge.require_newer_code(manifest["versionCode"]) try: forge.json("PATCH", f"/releases/{release_id}", {"draft": False}) latest = forge.json("GET", "/releases/latest", authenticated=False) if latest["id"] != release_id or latest["tag_name"] != tag: raise ValueError("Anonymous latest does not identify the promoted release") for name, url in uploaded.items(): forge.verify(url, hashes[name], authenticated=False) except (ValueError, RuntimeError, OSError, KeyError, TypeError): try: forge.json("PATCH", f"/releases/{release_id}", {"draft": True}) except (ValueError, RuntimeError, OSError): raise RuntimeError( "Post-publication verification AND withdrawal failed; release may remain public" ) from None raise RuntimeError( "Post-publication verification failed; release withdrawn to draft" ) from None print(f"Published and anonymously verified {tag}") def main() -> None: """Expose a deliberately separate publication command.""" parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--directory", required=True, type=Path) parser.add_argument("--promote", action="store_true") parser.add_argument("--dry-run", action="store_true") parser.add_argument( "--draft-id", type=int, help="Resume and reverify an existing matching draft" ) parser.add_argument( "--test-server", help="Local fake Forgejo only; HTTP loopback permitted" ) args = parser.parse_args() if args.test_server: parsed = urllib.parse.urlsplit(args.test_server) if parsed.scheme != "http" or parsed.hostname not in ( "localhost", "127.0.0.1", "::1", ): parser.error("--test-server is restricted to an HTTP loopback fixture") publish( args.directory.resolve(), args.test_server or SERVER, args.promote, args.dry_run, bool(args.test_server), args.draft_id, ) if __name__ == "__main__": main()