146 lines
4.9 KiB
Python
146 lines
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()
|