"""Gitea API operations for the release workflow (Python stdlib only).""" import argparse import json import os from pathlib import Path import re from urllib.error import HTTPError from urllib.parse import quote, urlencode, urlsplit from urllib.request import HTTPRedirectHandler, Request, build_opener class NoRedirects(HTTPRedirectHandler): def redirect_request(self, req, fp, code, msg, headers, newurl): # Never forward an instance token to a redirect destination. return None class GiteaAPI: def __init__(self, server, repository, token): parsed = urlsplit(server) if parsed.scheme != "https" or not parsed.netloc or parsed.username or parsed.password or parsed.query or parsed.fragment: raise ValueError("GITEA_SERVER_URL must be an HTTPS instance URL") if not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", repository): raise ValueError("GITEA_REPOSITORY must be owner/repository") if not token: raise ValueError("GITEA_TOKEN is required") self.base = server.rstrip("/") + "/api/v1/repos/" + repository self.token = token self.opener = build_opener(NoRedirects()) def request(self, method, path, data=None, content_type="application/json", allow_missing=False): if data is not None and not isinstance(data, bytes): data = json.dumps(data).encode("utf-8") req = Request(self.base + path, data=data, method=method, headers={ "Authorization": "token " + self.token, "Accept": "application/json", "Content-Type": content_type, "User-Agent": "GiteaMailTemplates-actions", }) try: with self.opener.open(req, timeout=120) as response: return json.load(response) except HTTPError as error: status = error.code error.close() if allow_missing and status == 404: return None raise RuntimeError(f"Gitea API {method} {path}: HTTP {status}") from None def create_issue(api, version): if not re.fullmatch(r"v\d+\.\d+\.\d+", version): raise ValueError("Expected a stable vX.Y.Z release tag") marker = f"" title = f"Update documentation for template release {version}" page = 1 while True: issues = api.request("GET", f"/issues?state=all&type=issues&limit=50&page={page}") for issue in issues: if issue.get("pull_request") is None and (marker in (issue.get("body") or "") or issue.get("title") == title): print(f"[PASS] Reminder issue already exists: {issue['html_url']}") return issue if not issues: break page += 1 body = f"""{marker} Template release **{version}** has been published. Documentation is maintained manually. - [ ] Update the release version in the English and Chinese READMEs. - [ ] Review compatibility records and mark versions verified only according to test results. - [ ] Check release links, release notes and remaining version references. This reminder does not change repository files, branches or existing release assets. """ issue = api.request("POST", "/issues", {"title": title, "body": body}) print(f"[PASS] Created reminder issue: {issue['html_url']}") return issue def publish_release(api, version, root=Path(".")): if not re.fullmatch(r"v\d+\.\d+\.\d+", version): raise ValueError("Expected a stable vX.Y.Z release tag") root = Path(root) lock = json.loads((root / "gitea.lock.json").read_text(encoding="utf-8")) if lock["tag"] != version: raise ValueError("Release tag must match gitea.lock.json") notes = (root / ".github" / "release-notes" / (version + ".md")).read_text(encoding="utf-8") if not notes.strip(): raise ValueError("Release notes are empty") assets = [root / "dist" / ("gitea-mail-templates-" + version + ext) for ext in (".zip", ".tar.gz")] for asset in assets: if asset.is_symlink() or not asset.is_file() or not asset.stat().st_size: raise ValueError(f"Missing or invalid release archive: {asset}") existing = api.request("GET", "/releases/tags/" + quote(version, safe=""), allow_missing=True) if existing is not None: raise ValueError("Release already exists; refusing to replace its notes or assets") release = api.request("POST", "/releases", { "tag_name": version, "name": version, "body": notes, "draft": True, "prerelease": False, }) release_id = int(release["id"]) # Gitea accepts raw attachment data with the filename in the query string. # Publish only after both uploads succeed; failures leave a draft for review. for asset in assets: api.request("POST", f"/releases/{release_id}/assets?" + urlencode({"name": asset.name}), asset.read_bytes(), content_type="application/octet-stream") release = api.request("PATCH", f"/releases/{release_id}", {"draft": False}) print(f"[PASS] Published {release['html_url']}") def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("operation", choices=("create-issue", "publish-release")) parser.add_argument("--version", required=True) args = parser.parse_args() try: api = GiteaAPI(os.environ.get("GITEA_SERVER_URL", ""), os.environ.get("GITEA_REPOSITORY", ""), os.environ.get("GITEA_TOKEN", "")) if args.operation == "create-issue": create_issue(api, args.version) else: publish_release(api, args.version) except (ValueError, KeyError, OSError, RuntimeError) as error: parser.exit(1, f"[FAIL] {error}\n") if __name__ == "__main__": main()