From bac15ab34a2b3d4cfce145eb2854776ceb6c99ef Mon Sep 17 00:00:00 2001 From: Georg Bauer Date: Sat, 15 Aug 2026 14:27:21 +0200 Subject: [PATCH] Add tagged CLI and TUI releases (#67) --- .gitea/scripts/release.py | 194 +++++++++++++++++++++++++++++++++++ .gitea/workflows/release.yml | 179 ++++++++++++++++++++++++++++++++ 2 files changed, 373 insertions(+) create mode 100644 .gitea/scripts/release.py create mode 100644 .gitea/workflows/release.yml diff --git a/.gitea/scripts/release.py b/.gitea/scripts/release.py new file mode 100644 index 0000000..fadcd3a --- /dev/null +++ b/.gitea/scripts/release.py @@ -0,0 +1,194 @@ +#!/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") diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..9459e16 --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,179 @@ +name: Tagged release + +on: + push: + tags: + - "*" + +env: + CARGO_TERM_COLOR: always + +jobs: + prepare-release: + runs-on: linux-arm64 + outputs: + release_id: ${{ steps.release.outputs.release_id }} + steps: + - name: Check out the tag + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Test release handling + run: python3 .gitea/scripts/release.py test + + - name: Create draft release + id: release + env: + GITEA_API_URL: ${{ gitea.server_url }}/api/v1 + GITEA_REPOSITORY: ${{ gitea.repository }} + GITEA_REF_NAME: ${{ gitea.ref_name }} + GITEA_SERVER_URL: ${{ gitea.server_url }} + GITEA_SHA: ${{ gitea.sha }} + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + run: python3 .gitea/scripts/release.py prepare + + build-unix: + needs: prepare-release + runs-on: linux-arm64 + container: ghcr.io/rust-cross/cargo-zigbuild@sha256:82af75c41958c2af2787e8bedd912da7678a9438937e223e9d83d006d747b38b + steps: + - name: Check out the exact tag + env: + GITEA_REF_NAME: ${{ gitea.ref_name }} + GITEA_REPOSITORY: ${{ gitea.repository }} + GITEA_SERVER_URL: ${{ gitea.server_url }} + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + run: | + set -eu + git init + git -c http.extraHeader="Authorization: token $GITEA_TOKEN" fetch --depth=1 \ + "$GITEA_SERVER_URL/$GITEA_REPOSITORY.git" "refs/tags/$GITEA_REF_NAME" + git checkout --detach FETCH_HEAD + + - name: Build, verify, package, and upload Darwin and Linux binaries + env: + GITEA_API_URL: ${{ gitea.server_url }}/api/v1 + GITEA_REF_NAME: ${{ gitea.ref_name }} + GITEA_REPOSITORY: ${{ gitea.repository }} + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + RELEASE_ID: ${{ needs.prepare-release.outputs.release_id }} + run: | + set -eu + version=$(printf '%s' "$GITEA_REF_NAME" | sed 's/[^A-Za-z0-9._-]/-/g') + for build in \ + 'aarch64-apple-darwin|aarch64-apple-darwin|darwin-arm64|cffaedfe' \ + 'x86_64-apple-darwin|x86_64-apple-darwin|darwin-x64|cffaedfe' \ + 'aarch64-unknown-linux-gnu|aarch64-unknown-linux-gnu.2.28|linux-arm64|7f454c46' \ + 'x86_64-unknown-linux-gnu|x86_64-unknown-linux-gnu.2.28|linux-x64|7f454c46' + do + target=${build%%|*} + rest=${build#*|} + build_target=${rest%%|*} + rest=${rest#*|} + platform=${rest%%|*} + magic=${rest#*|} + cargo zigbuild --release --locked --target "$build_target" \ + --package gotcha-cli --package gotcha-tui + + release="target/$target/release" + for binary in gotcha gotcha-tui; do + test -s "$release/$binary" + actual=$(od -An -tx1 -N4 "$release/$binary" | tr -d ' \n') + test "$actual" = "$magic" + done + + package="gotcha-${version}-${platform}" + mkdir -p "dist/$package" + cp "$release/gotcha" "$release/gotcha-tui" "dist/$package/" + tar -C dist -czf "dist/${package}.tar.gz" "$package" + test "$(tar -tzf "dist/${package}.tar.gz" | wc -l)" -eq 3 + rm -r "dist/$package" + + asset="dist/${package}.tar.gz" + name=$(basename "$asset") + curl --fail --silent --show-error --retry 3 \ + -H "Authorization: token $GITEA_TOKEN" \ + -H "Content-Type: application/octet-stream" \ + --data-binary "@$asset" \ + "$GITEA_API_URL/repos/$GITEA_REPOSITORY/releases/$RELEASE_ID/assets?name=$name" + done + + build-windows: + needs: + - prepare-release + - build-unix + runs-on: linux-arm64 + container: messense/cargo-xwin@sha256:4696dd4e79edf8569fa99c4b06bd99273e0501c7adc983aa61d57945f795bef0 + steps: + - name: Check out the exact tag + env: + GITEA_REF_NAME: ${{ gitea.ref_name }} + GITEA_REPOSITORY: ${{ gitea.repository }} + GITEA_SERVER_URL: ${{ gitea.server_url }} + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + run: | + set -eu + git init + git -c http.extraHeader="Authorization: token $GITEA_TOKEN" fetch --depth=1 \ + "$GITEA_SERVER_URL/$GITEA_REPOSITORY.git" "refs/tags/$GITEA_REF_NAME" + git checkout --detach FETCH_HEAD + + - name: Install the repository Rust toolchain + run: rustup toolchain install 1.92.0 --profile minimal --target x86_64-pc-windows-msvc + + - name: Cross-build release binaries + run: >- + cargo +1.92.0 xwin build --release --locked + --target x86_64-pc-windows-msvc + --package gotcha-cli --package gotcha-tui + + - name: Verify, package, and upload Windows binaries + env: + GITEA_API_URL: ${{ gitea.server_url }}/api/v1 + GITEA_REF_NAME: ${{ gitea.ref_name }} + GITEA_REPOSITORY: ${{ gitea.repository }} + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + RELEASE_ID: ${{ needs.prepare-release.outputs.release_id }} + run: | + set -eu + version=$(printf '%s' "$GITEA_REF_NAME" | sed 's/[^A-Za-z0-9._-]/-/g') + release=target/x86_64-pc-windows-msvc/release + for binary in gotcha.exe gotcha-tui.exe; do + test -s "$release/$binary" + test "$(od -An -tx1 -N2 "$release/$binary" | tr -d ' \n')" = "4d5a" + done + + package="gotcha-${version}-windows-x64" + mkdir -p "dist/$package" + cp "$release/gotcha.exe" "$release/gotcha-tui.exe" "dist/$package/" + tar -C dist -czf "dist/${package}.tar.gz" "$package" + test "$(tar -tzf "dist/${package}.tar.gz" | wc -l)" -eq 3 + rm -r "dist/$package" + + asset="dist/${package}.tar.gz" + name=$(basename "$asset") + curl --fail --silent --show-error --retry 3 \ + -H "Authorization: token $GITEA_TOKEN" \ + -H "Content-Type: application/octet-stream" \ + --data-binary "@$asset" \ + "$GITEA_API_URL/repos/$GITEA_REPOSITORY/releases/$RELEASE_ID/assets?name=$name" + + publish-release: + needs: + - prepare-release + - build-unix + - build-windows + runs-on: linux-arm64 + steps: + - name: Check out the tag + uses: actions/checkout@v4 + + - name: Verify the asset manifest and publish the release + env: + GITEA_API_URL: ${{ gitea.server_url }}/api/v1 + GITEA_REF_NAME: ${{ gitea.ref_name }} + GITEA_REPOSITORY: ${{ gitea.repository }} + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + RELEASE_ID: ${{ needs.prepare-release.outputs.release_id }} + run: python3 .gitea/scripts/release.py publish