zubble/scripts/make_release.py
pch b005fb1ef6
Some checks are pending
release / release (push) Waiting to run
initial: zubble agentic harness — extracted from llm_benchmark
Package layout (zubble/), vendored benchlib.stream_chat, LocalEnv,
minimal  CLI, 38 pure-logic tests, and a tag-triggered
Forgejo Actions release pipeline that builds a single-file PyInstaller
executable.
2026-10-24 09:37:10 -04:00

146 lines
No EOL
4.9 KiB
Python

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