Files
KenanZhu f4d96de79e
Release / Validate Templates (push) Successful in 3m1s
Release / Package & Release (push) Skipped
Release / Update Latest Release Documentation (push) Skipped
chore: refresh docs and adapt workflows for Gitea
2026-10-09 22:02:33 +08:00

153 lines
6.9 KiB
Python

"""Gitea API operations for the tracker and release workflows (Python stdlib only)."""
import argparse
import json
import os
from pathlib import Path
import re
import subprocess
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 git(*args):
return subprocess.check_output(["git", *args], text=True).strip()
def create_pull_request(api, version):
if not re.fullmatch(r"\d+\.\d+\.\d+", version):
raise ValueError("Expected a stable X.Y.Z upstream version")
branch = "track/gitea-" + version
title = f"Track Gitea {version} compatibility"
changed = git("diff", "--name-only", "HEAD", "--").splitlines()
if any(not name.endswith(".md") for name in changed):
raise ValueError("Tracker may commit only Markdown changes")
if not changed:
print("[PASS] No tracked documentation changes")
return
# Repeated scheduled runs can find a branch from an earlier pending PR.
# Reuse identical content, but never force-push over a changed branch.
remote = git("ls-remote", "--heads", "origin", "refs/heads/" + branch)
if remote:
git("fetch", "origin", "refs/heads/" + branch)
if git("diff", "--name-only", "FETCH_HEAD", "--"):
raise ValueError(f"Existing {branch} differs; review it before updating the tracking PR")
else:
git("switch", "-c", branch)
git("add", "--", *changed)
git("-c", "user.name=release-bot", "-c", "user.email=release-bot@users.noreply.local",
"commit", "-m", f"docs: track Gitea {version} pending verification")
git("push", "origin", "HEAD:refs/heads/" + branch)
page = 1
while True:
pulls = api.request("GET", f"/pulls?state=open&base_branch=main&limit=50&page={page}")
for pull in pulls:
if (pull["head"]["ref"] == branch and pull["base"]["ref"] == "main"
and pull["head"]["repo"]["full_name"] == pull["base"]["repo"]["full_name"]):
print(f"[PASS] Tracking PR already exists: {pull['html_url']}")
return
if not pulls:
break
page += 1
body = f"""Gitea **{version}** is recorded as [PENDING]. Verified/tested versions remain unchanged.
- [ ] Review upstream mail templates, mailer data, functions and translation keys.
- [ ] Run `go test ./...` and `go run . preview all` from `tools/`.
- [ ] Record results in `COMPATIBILITY.md`; update verified status only after testing.
- [ ] Release template changes separately with reviewed notes and assets.
"""
pull = api.request("POST", "/pulls", {"title": title, "head": branch, "base": "main", "body": body})
print(f"[PASS] Created tracking PR: {pull['html_url']}")
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-pull-request", "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-pull-request":
create_pull_request(api, args.version)
else:
publish_release(api, args.version)
except (ValueError, KeyError, OSError, RuntimeError, subprocess.CalledProcessError) as error:
parser.exit(1, f"[FAIL] {error}\n")
if __name__ == "__main__":
main()