Files
pristontale-eu-linux/scripts/generate_history.py

264 lines
7.9 KiB
Python
Executable File

#!/usr/bin/env python3
"""Generate signed-history source data from Gitea release API results."""
from __future__ import annotations
import argparse
import json
import os
from pathlib import Path
import sys
from typing import Mapping
from urllib.parse import urlsplit
if __package__:
from .repository_paths import PROJECT_ROOT, ensure_project_imports
else:
from repository_paths import PROJECT_ROOT, ensure_project_imports
ensure_project_imports()
from scripts.release_lib import ( # noqa: E402
ReleaseToolError,
_parse_timestamp,
load_config,
)
from ptlinux.update_resolver import (
SUPPORTED_RELEASE_STATUSES,
ReleaseModelError,
SemanticVersion,
)
def release_history(
releases: object,
generated_at: str,
statuses: Mapping[str, object] | None = None,
) -> dict[str, object]:
_parse_timestamp(generated_at)
if not isinstance(releases, list):
raise ReleaseToolError("Gitea releases response must be an array")
records: list[tuple[SemanticVersion, dict[str, object]]] = []
seen_versions: set[SemanticVersion] = set()
included_tags: set[str] = set()
for item in releases:
if not isinstance(item, dict) or item.get("draft") is True:
continue
tag = item.get("tag_name")
if not isinstance(tag, str):
continue
try:
version = SemanticVersion.from_tag(tag)
except ReleaseModelError:
continue
if version in seen_versions:
raise ReleaseToolError(f"Gitea returned duplicate release tag {tag}")
seen_versions.add(version)
if bool(item.get("prerelease")) != version.is_prerelease:
continue
assets = item.get("assets")
if not isinstance(assets, list):
continue
asset_urls: dict[str, str] = {}
for asset in assets:
if not isinstance(asset, dict):
continue
name = asset.get("name")
url = asset.get("browser_download_url")
if isinstance(name, str) and isinstance(url, str):
asset_urls[name] = url
manifest_url = asset_urls.get("manifest.json")
signature_url = asset_urls.get("manifest.json.sig")
release_date = item.get("published_at") or item.get("created_at")
release_notes_url = item.get("html_url")
if (
manifest_url is None
or signature_url is None
or not isinstance(release_date, str)
or not isinstance(release_notes_url, str)
or not all(
_https_url(url)
for url in (manifest_url, signature_url, release_notes_url)
)
):
continue
try:
_parse_timestamp(release_date)
except ReleaseToolError:
continue
release_id = item.get("id")
override = (statuses or {}).get(tag, "active")
if isinstance(override, dict):
status = override.get("status", "active")
config_schema = _positive_integer(
override.get("config_schema", 1),
f"{tag} config_schema",
)
minimum_config_schema = _positive_integer(
override.get("minimum_config_schema", 1),
f"{tag} minimum_config_schema",
)
else:
status = override
config_schema = 1
minimum_config_schema = 1
if status not in SUPPORTED_RELEASE_STATUSES:
raise ReleaseToolError(f"invalid status for {tag}: {status!r}")
if minimum_config_schema > config_schema:
raise ReleaseToolError(
f"minimum_config_schema exceeds config_schema for {tag}"
)
title = item.get("name")
if not isinstance(title, str) or not title:
title = f"PristonTale EU Launcher {version}"
records.append(
(
version,
{
"channel": version.channel,
"compatibility": {
"config_schema": config_schema,
"minimum_config_schema": minimum_config_schema,
},
"manifest_signature_url": signature_url,
"manifest_url": manifest_url,
"release_date": release_date,
"release_id": (
release_id
if isinstance(release_id, int)
and not isinstance(release_id, bool)
and release_id > 0
else None
),
"release_notes_url": release_notes_url,
"status": status,
"tag": tag,
"title": title,
"version": str(version),
},
)
)
included_tags.add(tag)
unused_statuses = set(statuses or {}).difference(included_tags)
if unused_statuses:
raise ReleaseToolError(
"release status overrides do not match published, manifest-backed tags: "
+ ", ".join(sorted(unused_statuses))
)
records.sort(key=lambda entry: entry[0], reverse=True)
config = load_config()
return {
"application": config["application"],
"generated_at": generated_at,
"releases": [record for _version, record in records],
"schema": 1,
}
def load_release_statuses(path: Path) -> dict[str, object]:
try:
value = json.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
raise ReleaseToolError(f"invalid release status file {path}: {exc}") from exc
if not isinstance(value, dict) or value.get("schema") != 1:
raise ReleaseToolError("release status file must use schema 1")
releases = value.get("releases")
if not isinstance(releases, dict):
raise ReleaseToolError("release status file releases must be an object")
statuses: dict[str, object] = {}
for tag, status_value in releases.items():
if not isinstance(tag, str):
raise ReleaseToolError("release status tags must be strings")
try:
SemanticVersion.from_tag(tag)
except ReleaseModelError as exc:
raise ReleaseToolError(f"invalid release status tag {tag!r}") from exc
status = status_value.get("status", "active") if isinstance(status_value, dict) else status_value
if not isinstance(status, str) or status not in SUPPORTED_RELEASE_STATUSES:
raise ReleaseToolError(f"invalid status for {tag}: {status!r}")
if isinstance(status_value, dict):
unknown = set(status_value).difference(
{"status", "config_schema", "minimum_config_schema"}
)
if unknown:
raise ReleaseToolError(
f"unknown release override fields for {tag}: {', '.join(sorted(unknown))}"
)
config_schema = _positive_integer(
status_value.get("config_schema", 1),
f"{tag} config_schema",
)
minimum_config_schema = _positive_integer(
status_value.get("minimum_config_schema", 1),
f"{tag} minimum_config_schema",
)
if minimum_config_schema > config_schema:
raise ReleaseToolError(
f"minimum_config_schema exceeds config_schema for {tag}"
)
statuses[tag] = {
"status": status,
"config_schema": config_schema,
"minimum_config_schema": minimum_config_schema,
}
else:
statuses[tag] = status
return statuses
def _positive_integer(value: object, label: str) -> int:
if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
raise ReleaseToolError(f"{label} must be a positive integer")
return value
def _https_url(value: str) -> bool:
parts = urlsplit(value)
return (
parts.scheme.casefold() == "https"
and bool(parts.netloc)
and parts.username is None
and parts.password is None
)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("input", type=Path)
parser.add_argument("output", type=Path)
parser.add_argument("--generated-at", required=True)
parser.add_argument("--status-file", type=Path)
arguments = parser.parse_args()
try:
releases = json.loads(arguments.input.read_text(encoding="utf-8"))
config = load_config()
status_file = arguments.status_file or (
PROJECT_ROOT / str(config["release_status_file"])
)
history = release_history(
releases,
arguments.generated_at,
load_release_statuses(status_file),
)
arguments.output.parent.mkdir(parents=True, exist_ok=True)
temporary = arguments.output.with_name(
f".{arguments.output.name}.{os.getpid()}.tmp"
)
try:
with temporary.open("x", encoding="utf-8", newline="\n") as handle:
json.dump(history, handle, indent=2, sort_keys=True)
handle.write("\n")
handle.flush()
os.fsync(handle.fileno())
temporary.chmod(0o644)
os.replace(temporary, arguments.output)
finally:
temporary.unlink(missing_ok=True)
except (OSError, UnicodeError, json.JSONDecodeError, ReleaseToolError) as exc:
print(f"Release history generation failed: {exc}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())