#!/usr/bin/env python3 """Creates (or reuses) a Gitea release for a tag and uploads files to it. publish_gitea.py --tag v0.2.1 --title "LlamaMod 0.2.1" --notes notes.md FILE... Environment: GITEA_URL (e.g. https://git.example.com), GITEA_REPO (owner/name), GITEA_TOKEN (a token with write access to the repository). In Gitea Actions these come from the workflow. Files that already exist on the release with the same name are replaced, so a re-run is safe. Only the standard library is used. """ import argparse import json import mimetypes import os import sys import urllib.error import urllib.parse import urllib.request import uuid def api(method, url, token, data=None, headers=None): h = {"Authorization": "token " + token, "Accept": "application/json"} h.update(headers or {}) req = urllib.request.Request(url, data=data, method=method, headers=h) try: with urllib.request.urlopen(req) as r: body = r.read() return r.status, json.loads(body) if body else None except urllib.error.HTTPError as e: return e.code, e.read().decode("utf-8", "replace") def multipart(path): boundary = uuid.uuid4().hex name = os.path.basename(path) ctype = mimetypes.guess_type(name)[0] or "application/octet-stream" with open(path, "rb") as f: content = f.read() head = ('--%s\r\nContent-Disposition: form-data; name="attachment"; filename="%s"\r\n' "Content-Type: %s\r\n\r\n" % (boundary, name, ctype)).encode() tail = ("\r\n--%s--\r\n" % boundary).encode() return head + content + tail, "multipart/form-data; boundary=" + boundary def main(): ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("--tag", required=True) ap.add_argument("--title", required=True) ap.add_argument("--notes", help="file with the release text (Markdown)") ap.add_argument("--prerelease", action="store_true") ap.add_argument("files", nargs="+") a = ap.parse_args() base, repo, token = (os.environ.get(k, "") for k in ("GITEA_URL", "GITEA_REPO", "GITEA_TOKEN")) if not (base and repo and token): sys.exit("set GITEA_URL, GITEA_REPO and GITEA_TOKEN") root = "%s/api/v1/repos/%s" % (base.rstrip("/"), repo) body = open(a.notes, encoding="utf-8").read() if a.notes else "" st, rel = api("GET", "%s/releases/tags/%s" % (root, urllib.parse.quote(a.tag)), token) if st == 404: payload = json.dumps({"tag_name": a.tag, "name": a.title, "body": body, "draft": False, "prerelease": a.prerelease}).encode() st, rel = api("POST", root + "/releases", token, payload, {"Content-Type": "application/json"}) if st not in (200, 201): sys.exit("creating the release failed (%s): %s" % (st, rel)) print("created release", rel["id"], a.tag) elif st == 200: print("reusing release", rel["id"], a.tag) else: sys.exit("reading the release failed (%s): %s" % (st, rel)) existing = {x["name"]: x["id"] for x in rel.get("assets") or []} for path in a.files: name = os.path.basename(path) if name in existing: api("DELETE", "%s/releases/%s/assets/%s" % (root, rel["id"], existing[name]), token) data, ctype = multipart(path) st, res = api("POST", "%s/releases/%s/assets?name=%s" % (root, rel["id"], urllib.parse.quote(name)), token, data, {"Content-Type": ctype}) if st not in (200, 201): sys.exit("uploading %s failed (%s): %s" % (name, st, res)) print("uploaded", name, "(%.1f MB)" % (len(data) / 1e6)) if __name__ == "__main__": main()