#!/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")