#!/usr/bin/env python3 """Create a Forgejo release for the current tag and attach a binary asset. Invoked by .forgejo/workflows/release.yml after the single-file executable is built. Uses only the stdlib (urllib) so it runs in the bare CI Python with no extra dependencies. Environment: FORGEJO_TOKEN API token with contents:write (the workflow's GITHUB_TOKEN) FORGEJO_API base API URL, e.g. https://build.diganthrope.com/api/v1 REPO owner/name, e.g. pch/zubble TAG release tag, e.g. v0.1.0 GITHUB_WORKSPACE checkout root (for git log). Defaults to cwd. Usage: python scripts/make_release.py """ import json import os import subprocess import sys import urllib.error import urllib.request from pathlib import Path def _notes(repo_root: str, tag: str) -> str: """Release notes = commit subjects since the previous tag (or full log).""" prev = _previous_tag(repo_root, tag) if prev: rng = f"{prev}..{tag}" header = f"### Changes since {prev}\n\n" else: rng = tag header = "### Changes\n\n" try: out = subprocess.run( ["git", "log", "--pretty=format:- %s", rng], cwd=repo_root, capture_output=True, text=True, check=True, ).stdout.strip() except Exception: return header.strip() return header + out def _previous_tag(repo_root: str, tag: str) -> str | None: try: out = subprocess.run( ["git", "tag", "--sort=-v:refname", "--list", "v*"], cwd=repo_root, capture_output=True, text=True, check=True, ).stdout.split() except Exception: return None # tags strictly before this one, newest first if tag in out: out = out[out.index(tag) + 1:] return out[0] if out else None def _api(method, path, token, api_base, body=None, multipart=None): url = f"{api_base}/{path.lstrip('/')}" headers = {"Authorization": f"token {token}"} data = None if body is not None: data = json.dumps(body).encode() headers["Content-Type"] = "application/json" elif multipart is not None: data, boundary = multipart headers["Content-Type"] = f"multipart/form-data; boundary={boundary}" req = urllib.request.Request(url, data=data, method=method, headers=headers) try: with urllib.request.urlopen(req) as r: raw = r.read() return r.status, json.loads(raw) if raw else {} except urllib.error.HTTPError as e: return e.code, e.read().decode(errors="replace") def _upload_asset(release_id, asset_path, token, api_base, repo): """Upload one file as a release attachment (multipart form-data).""" boundary = "----zubble-release-boundary" path = Path(asset_path) fields = {"name": path.name} parts = [] for k, v in fields.items(): parts.append(f"--{boundary}\r\n" f'Content-Disposition: form-data; name="{k}"\r\n\r\n' f"{v}\r\n") body = "".join(parts).encode() body += (f"--{boundary}\r\n" 'Content-Disposition: form-data; name="attachment"; ' f'filename="{path.name}"\r\n' "Content-Type: application/octet-stream\r\n\r\n").encode() body += path.read_bytes() body += f"\r\n--{boundary}--\r\n".encode() status, resp = _api( "POST", f"repos/{repo}/releases/{release_id}/assets", token, api_base, multipart=(body, boundary), ) if status not in (200, 201): print(f"asset upload failed ({status}): {resp}", file=sys.stderr) sys.exit(1) print(f" attached {path.name} ({path.stat().st_size} bytes)") def main(): if len(sys.argv) != 2: print("usage: make_release.py ", file=sys.stderr) sys.exit(2) asset = sys.argv[1] token = os.environ["FORGEJO_TOKEN"] api = os.environ["FORGEJO_API"] repo = os.environ["REPO"] tag = os.environ["TAG"] root = os.environ.get("GITHUB_WORKSPACE", os.getcwd()) notes = _notes(root, tag) status, resp = _api( "POST", f"repos/{repo}/releases", token, api, body={"tag_name": tag, "name": tag, "body": notes, "draft": False, "prerelease": False}, ) if status == 409: # Release already exists for this tag — fetch it. status, resp = _api( "GET", f"repos/{repo}/releases/tags/{tag}", token, api, ) if status != 200: print(f"release exists but fetch failed ({status}): {resp}", file=sys.stderr) sys.exit(1) elif status not in (200, 201): print(f"release create failed ({status}): {resp}", file=sys.stderr) sys.exit(1) else: print(f"created release {tag}") release_id = resp["id"] print(f" release id: {release_id}") _upload_asset(release_id, asset, token, api, repo) print(f"release {tag} published with {Path(asset).name}") if __name__ == "__main__": main()