#!/usr/bin/env python3 """Publish immutable GovOPlaN runtime evidence as Gitea release assets.""" from __future__ import annotations import argparse import hashlib import json import mimetypes import os from pathlib import Path import re import secrets import sys from typing import Any from urllib.error import HTTPError from urllib.parse import quote, urlencode from urllib.request import Request, urlopen MAX_ASSET_BYTES = 256 * 1024 * 1024 COMMIT_SHA = re.compile(r"^[0-9a-f]{40}$") class PublishError(RuntimeError): """A release asset cannot be published immutably.""" def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--base-url", default="https://git.add-ideas.de") parser.add_argument("--owner", default="GovOPlaN") parser.add_argument("--repo", default="govoplan") parser.add_argument("--tag", required=True) parser.add_argument("--target-commit", required=True) parser.add_argument("--title", required=True) parser.add_argument("--body", default="Signed GovOPlaN runtime distribution.") parser.add_argument("--asset", type=Path, action="append", default=[], required=True) parser.add_argument("--token-env", default="GITEA_RELEASE_TOKEN") return parser class GiteaReleasePublisher: def __init__(self, *, base_url: str, owner: str, repo: str, token: str) -> None: if not base_url.startswith("https://"): raise PublishError("Gitea release publication requires HTTPS") if not token: raise PublishError("Gitea release token is empty") self.base_url = base_url.rstrip("/") self.owner = owner self.repo = repo self.token = token def release( self, *, tag: str, target_commit: str, title: str, body: str, ) -> dict[str, Any]: if COMMIT_SHA.fullmatch(target_commit) is None: raise PublishError("release target must be an exact lowercase commit SHA") resolved_target = self._resolve_commit(target_commit) if resolved_target != target_commit: raise PublishError("release target did not resolve to the requested commit") existing_tag = self._resolve_commit(tag, allow_missing=True) if existing_tag is not None and existing_tag != target_commit: raise PublishError( f"release tag {tag!r} already points to another commit" ) path = self._repo_path(f"/releases/tags/{quote(tag, safe='')}") try: release = self._json("GET", path) except HTTPError as exc: if exc.code != 404: raise release = self._json( "POST", self._repo_path("/releases"), payload={ "tag_name": tag, "target_commitish": target_commit, "name": title, "body": body, "draft": False, "prerelease": False, }, expected=201, ) if self._resolve_commit(tag) != target_commit: raise PublishError( f"release tag {tag!r} does not resolve to the requested commit" ) return release def upload_assets(self, release: dict[str, Any], assets: tuple[Path, ...]) -> None: release_id = release.get("id") if isinstance(release_id, bool) or not isinstance(release_id, int): raise PublishError("Gitea release response has no numeric id") existing = self._json( "GET", self._repo_path(f"/releases/{release_id}/assets"), ) if not isinstance(existing, list): raise PublishError("Gitea release assets response is invalid") existing_by_name = { str(item.get("name")): item for item in existing if isinstance(item, dict) } for asset in assets: path = asset.expanduser().resolve() if path.is_symlink() or not path.is_file(): raise PublishError(f"release asset is not a regular file: {path}") size = path.stat().st_size if size > MAX_ASSET_BYTES: raise PublishError(f"release asset exceeds size limit: {path.name}") prior = existing_by_name.get(path.name) if prior is not None: self._require_same_existing_asset(prior, path) continue self._upload(release_id, path) def _require_same_existing_asset(self, prior: dict[str, Any], path: Path) -> None: url = prior.get("browser_download_url") size = prior.get("size") if not isinstance(url, str) or not url.startswith("https://") or size != path.stat().st_size: raise PublishError(f"release asset already exists with another identity: {path.name}") request = Request(url, headers=self._headers()) digest = hashlib.sha256() total = 0 with urlopen(request, timeout=30) as response: # noqa: S310 while True: chunk = response.read(1024 * 1024) if not chunk: break total += len(chunk) if total > MAX_ASSET_BYTES: raise PublishError("existing release asset exceeds size limit") digest.update(chunk) if digest.hexdigest() != _sha256_file(path): raise PublishError(f"release asset already exists with another digest: {path.name}") def _upload(self, release_id: int, path: Path) -> None: boundary = "govoplan-" + secrets.token_hex(16) content_type = mimetypes.guess_type(path.name)[0] or "application/octet-stream" prefix = ( f"--{boundary}\r\n" f'Content-Disposition: form-data; name="attachment"; filename="{path.name}"\r\n' f"Content-Type: {content_type}\r\n\r\n" ).encode("utf-8") suffix = f"\r\n--{boundary}--\r\n".encode("ascii") data = prefix + path.read_bytes() + suffix query = urlencode({"name": path.name}) request = Request( self._repo_path(f"/releases/{release_id}/assets") + "?" + query, data=data, method="POST", headers={ **self._headers(), "Content-Type": f"multipart/form-data; boundary={boundary}", }, ) try: with urlopen(request, timeout=120) as response: # noqa: S310 if response.status != 201: raise PublishError( f"Gitea asset upload returned HTTP {response.status}" ) except HTTPError as exc: raise PublishError(f"Gitea asset upload failed with HTTP {exc.code}") from exc def _json( self, method: str, url: str, *, payload: dict[str, Any] | None = None, expected: int = 200, ) -> Any: data = None headers = self._headers() if payload is not None: data = json.dumps(payload).encode("utf-8") headers["Content-Type"] = "application/json" request = Request(url, data=data, method=method, headers=headers) with urlopen(request, timeout=30) as response: # noqa: S310 if response.status != expected: raise PublishError(f"Gitea API returned HTTP {response.status}") return json.load(response) def _headers(self) -> dict[str, str]: return {"Authorization": f"token {self.token}", "Accept": "application/json"} def _resolve_commit(self, ref: str, *, allow_missing: bool = False) -> str | None: path = self._repo_path(f"/git/commits/{quote(ref, safe='')}") try: commit = self._json("GET", path) except HTTPError as exc: if allow_missing and exc.code == 404: return None raise if not isinstance(commit, dict): raise PublishError(f"Gitea returned an invalid commit for {ref!r}") sha = commit.get("sha") if not isinstance(sha, str) or COMMIT_SHA.fullmatch(sha) is None: raise PublishError(f"Gitea returned an invalid commit SHA for {ref!r}") return sha def _repo_path(self, suffix: str) -> str: return ( f"{self.base_url}/api/v1/repos/{quote(self.owner, safe='')}/" f"{quote(self.repo, safe='')}{suffix}" ) def _sha256_file(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 main() -> int: args = build_parser().parse_args() try: publisher = GiteaReleasePublisher( base_url=args.base_url, owner=args.owner, repo=args.repo, token=os.environ.get(args.token_env, ""), ) release = publisher.release( tag=args.tag, target_commit=args.target_commit, title=args.title, body=args.body, ) publisher.upload_assets(release, tuple(args.asset)) except (HTTPError, OSError, PublishError, ValueError) as exc: print(f"error: {exc}", file=sys.stderr) return 1 print(f"Published {len(args.asset)} immutable asset(s) to {args.owner}/{args.repo} {args.tag}") return 0 if __name__ == "__main__": raise SystemExit(main())