Files
Gotcha/.gitea/scripts/release.py
2026-08-15 14:27:21 +02:00

195 lines
7.2 KiB
Python

#!/usr/bin/env python3
"""Create and publish complete tagged Gotcha CLI/TUI releases."""
from __future__ import annotations
import json
import os
import re
import subprocess
import sys
import unittest
from urllib.error import HTTPError
from urllib.parse import quote
from urllib.request import Request, urlopen
PLATFORMS = (
"darwin-arm64",
"darwin-x64",
"linux-arm64",
"linux-x64",
"windows-x64",
)
def issue_numbers(message: str) -> list[int]:
return sorted({int(number) for number in re.findall(r"#(\d+)", message)})
def safe_version(tag: str) -> str:
return re.sub(r"[^A-Za-z0-9._-]", "-", tag)
def asset_names(tag: str) -> list[str]:
version = safe_version(tag)
return [f"gotcha-{version}-{platform}.tar.gz" for platform in PLATFORMS]
def markdown(text: str) -> str:
return text.replace("\\", "\\\\").replace("[", "\\[").replace("]", "\\]")
def release_body(issues: list[dict[str, object]], server: str, repository: str, sha: str) -> str:
groups = {"Fixes": [], "Improvements": [], "Other changes": []}
for issue in issues:
labels = {label["name"] for label in issue.get("labels", [])} # type: ignore[index]
group = "Fixes" if "bug" in labels else "Improvements" if "enhancement" in labels else "Other changes"
title = " ".join(str(issue["title"]).split())
groups[group].append(f"- [#{issue['number']} {markdown(title)}]({issue['html_url']})")
sections = [f"## {name}\n" + "\n".join(items) for name, items in groups.items() if items]
if not sections:
sections.append("No closed issues were linked from commits in this release.")
sections.append(f"Built from [{sha[:12]}]({server}/{repository}/commit/{sha}).")
return "\n\n".join(sections)
def validate_assets(assets: list[dict[str, object]], tag: str) -> None:
expected = asset_names(tag)
actual = sorted(str(asset["name"]) for asset in assets)
if actual != expected:
raise RuntimeError(f"release assets differ: expected {expected}, got {actual}")
empty = [str(asset["name"]) for asset in assets if int(asset.get("size", 0)) <= 0]
if empty:
raise RuntimeError(f"release assets are empty: {empty}")
class Gitea:
def __init__(self) -> None:
self.api_url = os.environ["GITEA_API_URL"].rstrip("/")
self.repository = os.environ["GITEA_REPOSITORY"]
self.token = os.environ["GITEA_TOKEN"]
def request(self, method: str, path: str, payload: object | None = None) -> object:
data = None if payload is None else json.dumps(payload).encode()
request = Request(
f"{self.api_url}{path}",
data=data,
method=method,
headers={"Authorization": f"token {self.token}", "Content-Type": "application/json"},
)
with urlopen(request) as response:
content = response.read()
return None if not content else json.loads(content)
def issue(self, number: int) -> dict[str, object]:
return self.request("GET", f"/repos/{self.repository}/issues/{number}") # type: ignore[return-value]
def commit_messages(sha: str) -> str:
try:
previous = subprocess.check_output(
["git", "describe", "--tags", "--abbrev=0", f"{sha}^"],
stderr=subprocess.DEVNULL,
text=True,
).strip()
except subprocess.CalledProcessError:
previous = ""
revision = f"{previous}..{sha}" if previous else sha
return subprocess.check_output(["git", "log", "--format=%s%n%b", revision], text=True)
def prepare() -> None:
gitea = Gitea()
tag = os.environ["GITEA_REF_NAME"]
sha = os.environ["GITEA_SHA"]
server = os.environ["GITEA_SERVER_URL"].rstrip("/")
issues = [gitea.issue(number) for number in issue_numbers(commit_messages(sha))]
closed = [issue for issue in issues if issue["state"] == "closed"]
payload = {
"tag_name": tag,
"target_commitish": sha,
"name": tag,
"body": release_body(closed, server, gitea.repository, sha),
"draft": True,
"prerelease": False,
}
try:
release = gitea.request("GET", f"/repos/{gitea.repository}/releases/tags/{quote(tag, safe='')}")
except HTTPError as error:
if error.code != 404:
raise
release = gitea.request("POST", f"/repos/{gitea.repository}/releases", payload)
else:
if not release["draft"]: # type: ignore[index]
raise RuntimeError(f"release {tag} is already published")
release = gitea.request("PATCH", f"/repos/{gitea.repository}/releases/{release['id']}", payload) # type: ignore[index]
for asset in release["assets"] or []: # type: ignore[index]
gitea.request(
"DELETE",
f"/repos/{gitea.repository}/releases/{release['id']}/assets/{asset['id']}", # type: ignore[index]
)
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output:
output.write(f"release_id={release['id']}\n") # type: ignore[index]
def publish() -> None:
gitea = Gitea()
release_id = int(os.environ["RELEASE_ID"])
release = gitea.request("GET", f"/repos/{gitea.repository}/releases/{release_id}")
validate_assets(release["assets"] or [], os.environ["GITEA_REF_NAME"]) # type: ignore[index]
gitea.request("PATCH", f"/repos/{gitea.repository}/releases/{release_id}", {"draft": False})
class ReleaseTests(unittest.TestCase):
def test_notes_are_unique_sorted_grouped_and_linked(self) -> None:
self.assertEqual(issue_numbers("Fix #12, refs #3 and #12"), [3, 12])
body = release_body(
[
{
"number": 12,
"title": "Fix [unlock]",
"html_url": "https://example.test/issues/12",
"labels": [{"name": "bug"}],
},
{
"number": 3,
"title": "Add search",
"html_url": "https://example.test/issues/3",
"labels": [{"name": "enhancement"}],
},
],
"https://example.test",
"owner/repo",
"1234567890abcdef",
)
self.assertIn("## Fixes\n- [#12 Fix \\[unlock\\]](https://example.test/issues/12)", body)
self.assertIn("## Improvements\n- [#3 Add search](https://example.test/issues/3)", body)
self.assertIn("[1234567890ab](https://example.test/owner/repo/commit/1234567890abcdef)", body)
def test_manifest_is_exact_and_non_empty(self) -> None:
names = asset_names("v1.0.0/rc1")
assets = [{"name": name, "size": 1} for name in names]
validate_assets(assets, "v1.0.0/rc1")
with self.assertRaises(RuntimeError):
validate_assets(assets[:-1], "v1.0.0/rc1")
assets[0]["size"] = 0
with self.assertRaises(RuntimeError):
validate_assets(assets, "v1.0.0/rc1")
if __name__ == "__main__":
command = sys.argv[1] if len(sys.argv) == 2 else ""
if command == "prepare":
prepare()
elif command == "publish":
publish()
elif command == "test":
unittest.main(argv=[sys.argv[0]])
else:
raise SystemExit("usage: release.py prepare|publish|test")