#!/usr/bin/env python3 """Publish a static release as immutable files in Gitea's generic registry.""" from __future__ import annotations import argparse import base64 import json import os from pathlib import Path import re import sys import urllib.error import urllib.parse import urllib.request try: from .static_release import ReleaseError, inspect_archive, validate_revision except ImportError: # Direct script execution adds scripts/ to sys.path. from static_release import ReleaseError, inspect_archive, validate_revision COMPONENT_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._+-]*$") ARCHIVE_NAME = "site.tar.gz" CHECKSUM_NAME = "site.tar.gz.sha256" COORDINATE_NAME = "release-coordinate.json" MAX_RESPONSE_BYTES = 200 * 1024 * 1024 class PackageError(RuntimeError): pass def validate_component(value: str, label: str) -> None: if not COMPONENT_RE.fullmatch(value): raise PackageError(f"{label} contains unsupported characters") def validate_registry(registry: str, *, allow_http: bool = False) -> str: parsed = urllib.parse.urlsplit(registry) allowed_schemes = {"https"} | ({"http"} if allow_http else set()) if ( parsed.scheme not in allowed_schemes or not parsed.netloc or parsed.query or parsed.fragment or parsed.username or parsed.password ): raise PackageError("registry must be an HTTPS origin without credentials, query or fragment") return registry.rstrip("/") def package_file_url( registry: str, owner: str, package: str, version: str, filename: str, *, allow_http: bool = False, ) -> str: registry = validate_registry(registry, allow_http=allow_http) for value, label in ((owner, "owner"), (package, "package"), (filename, "filename")): validate_component(value, label) validate_revision(version) components = [owner, package, version, filename] owner_part, package_part, version_part, filename_part = ( urllib.parse.quote(value, safe="") for value in components ) return ( f"{registry}/api/packages/{owner_part}/generic/" f"{package_part}/{version_part}/{filename_part}" ) def authorization(username: str | None, token: str | None) -> str | None: if bool(username) != bool(token): raise PackageError("package username and token must be supplied together") if not username: return None encoded = base64.b64encode(f"{username}:{token}".encode()).decode("ascii") return f"Basic {encoded}" def request( method: str, url: str, *, username: str | None, token: str | None, data: bytes | None = None, timeout: float = 300.0, ) -> tuple[int, bytes]: headers = {"User-Agent": "donatella-static-publisher/1"} auth = authorization(username, token) if auth: headers["Authorization"] = auth if data is not None: headers["Content-Type"] = "application/octet-stream" outgoing = urllib.request.Request(url, data=data, headers=headers, method=method) try: with urllib.request.urlopen(outgoing, timeout=timeout) as response: content = response.read(MAX_RESPONSE_BYTES + 1) if len(content) > MAX_RESPONSE_BYTES: raise PackageError("package registry response exceeds the size limit") return response.status, content except urllib.error.HTTPError as exc: try: content = exc.read(MAX_RESPONSE_BYTES + 1) if len(content) > MAX_RESPONSE_BYTES: raise PackageError("package registry error response exceeds the size limit") return exc.code, content finally: exc.close() except (OSError, urllib.error.URLError) as exc: raise PackageError(f"package registry request failed: {exc}") from exc def put_immutable( url: str, content: bytes, *, username: str, token: str, timeout: float = 300.0, ) -> None: status, existing = request("GET", url, username=username, token=token, timeout=timeout) if status == 200: if existing != content: raise PackageError(f"immutable package file already exists with different content: {url}") return if status != 404: raise PackageError(f"package preflight returned HTTP {status}: {url}") status, _ = request( "PUT", url, username=username, token=token, data=content, timeout=timeout, ) if status not in {201, 409}: raise PackageError(f"package upload returned HTTP {status}: {url}") status, published = request("GET", url, username=username, token=token, timeout=timeout) if status != 200 or published != content: raise PackageError(f"published package file did not verify byte-for-byte: {url}") def coordinate_bytes(revision: str, artifact_url: str, digest: str) -> bytes: return ( json.dumps( { "revision": revision, "artifact_url": artifact_url, "artifact_digest": digest, }, separators=(",", ":"), ) + "\n" ).encode("utf-8") def publish_release( *, registry: str, owner: str, package: str, revision: str, archive: Path, checksum: Path, coordinate: Path, username: str, token: str, allow_http: bool = False, ) -> dict[str, str]: if not username or not token: raise PackageError("package username and token are required") if archive.name != ARCHIVE_NAME or checksum.name != CHECKSUM_NAME: raise PackageError(f"release files must be named {ARCHIVE_NAME} and {CHECKSUM_NAME}") try: _, _, digest = inspect_archive(archive, checksum, revision) except ReleaseError as exc: raise PackageError(str(exc)) from exc artifact_url = package_file_url( registry, owner, package, revision, ARCHIVE_NAME, allow_http=allow_http, ) checksum_url = package_file_url( registry, owner, package, revision, CHECKSUM_NAME, allow_http=allow_http, ) coordinate_url = package_file_url( registry, owner, package, revision, COORDINATE_NAME, allow_http=allow_http, ) payload = coordinate_bytes(revision, artifact_url, digest) if coordinate.resolve() in {archive.resolve(), checksum.resolve()}: raise PackageError("coordinate output must be separate from the archive and checksum") coordinate.parent.mkdir(parents=True, exist_ok=True) coordinate.write_bytes(payload) for url, content in ( (artifact_url, archive.read_bytes()), (checksum_url, checksum.read_bytes()), (coordinate_url, payload), ): put_immutable(url, content, username=username, token=token) return { "revision": revision, "artifact_url": artifact_url, "artifact_digest": digest, } def parser() -> argparse.ArgumentParser: command = argparse.ArgumentParser(description=__doc__) command.add_argument("--registry", required=True) command.add_argument("--owner", required=True) command.add_argument("--package", required=True) command.add_argument("--revision", required=True) command.add_argument("--archive", type=Path, required=True) command.add_argument("--checksum", type=Path, required=True) command.add_argument("--coordinate", type=Path, required=True) return command def main() -> int: args = parser().parse_args() try: result = publish_release( registry=args.registry, owner=args.owner, package=args.package, revision=args.revision, archive=args.archive, checksum=args.checksum, coordinate=args.coordinate, username=os.environ.get("STATIC_PACKAGE_USER", ""), token=os.environ.get("STATIC_PACKAGE_TOKEN", ""), ) except PackageError as exc: print(f"error: {exc}", file=sys.stderr) return 1 print(json.dumps(result, separators=(",", ":"))) return 0 if __name__ == "__main__": raise SystemExit(main())