129 lines
5.7 KiB
Python
129 lines
5.7 KiB
Python
"""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"<!-- gitea-mail-templates:template-release:{version} -->"
|
|
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()
|