#!/usr/bin/env python3 """Attach the files of a release folder (release-files.py) to the Gitea release of the tag. TOKEN= python3 .gitea/scripts/release-attach.py /tmp/release Reads GITHUB_SERVER_URL, GITHUB_REPOSITORY and GITHUB_REF_NAME (the tag), as Gitea Actions sets them. Creates the release when the tag has none, with the tag's CHANGELOG.md section as its text. Uploads every file of the folder except README.md, and -.zip with the whole folder; an asset of the same name from an earlier run of the tag is replaced. Used by .gitea/workflows/release.yml. """ import io import json import os import re import sys import urllib.error import urllib.parse import urllib.request import uuid import zipfile from pathlib import Path ROOT = Path(__file__).resolve().parents[2] ZIP_TIME = (2020, 1, 1, 0, 0, 0) def changelog(tag): path = ROOT / "CHANGELOG.md" if not path.exists(): return "" m = re.search(rf"^## {re.escape(tag)}\b.*?$(.*?)(?=^## |\Z)", path.read_text(encoding="utf-8"), re.M | re.S) return m.group(1).strip() if m else "" def bundle(folder, top): """The whole folder as a zip under /, with fixed timestamps.""" buf = io.BytesIO() with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z: for p in sorted(folder.rglob("*")): if p.is_file(): info = zipfile.ZipInfo(f"{top}/{p.relative_to(folder).as_posix()}", date_time=ZIP_TIME) info.compress_type = zipfile.ZIP_DEFLATED z.writestr(info, p.read_bytes()) return buf.getvalue() class Gitea: def __init__(self, server, repo, token): self.api = f"{server.rstrip('/')}/api/v1/repos/{repo}" self.auth = {"Authorization": f"token {token}"} def call(self, method, path, body=None, ctype="application/json"): req = urllib.request.Request(self.api + path, data=body, method=method, headers={**self.auth, "Content-Type": ctype, "Accept": "application/json"}) with urllib.request.urlopen(req, timeout=120) as r: data = r.read() return json.loads(data) if data.strip() else None def release(self, tag): try: return self.call("GET", f"/releases/tags/{urllib.parse.quote(tag, safe='')}"), False except urllib.error.HTTPError as e: if e.code != 404: raise notes = changelog(tag) body = (f"{notes}\n\n" if notes else "") + "Gerbers, BOM, pick and place, PDFs and documentation: the files below." return self.call("POST", "/releases", json.dumps({"tag_name": tag, "name": tag, "body": body}).encode()), True def upload(self, rel, name, data): b = uuid.uuid4().hex head = (f"--{b}\r\nContent-Disposition: form-data; name=\"attachment\"; filename=\"{name}\"\r\n" "Content-Type: application/octet-stream\r\n\r\n").encode() body = head + data + f"\r\n--{b}--\r\n".encode() self.call("POST", f"/releases/{rel['id']}/assets?name={urllib.parse.quote(name)}", body, f"multipart/form-data; boundary={b}") def main(): if len(sys.argv) != 2: sys.exit(__doc__) folder = Path(sys.argv[1]) env = {k: os.environ.get(k, "") for k in ("GITHUB_SERVER_URL", "GITHUB_REPOSITORY", "GITHUB_REF_NAME", "TOKEN")} missing = [k for k, v in env.items() if not v] if missing: sys.exit(f"missing environment: {', '.join(missing)}") tag, repo = env["GITHUB_REF_NAME"], env["GITHUB_REPOSITORY"] files = [(p.name, p.read_bytes()) for p in sorted(folder.rglob("*")) if p.is_file() and p.name != "README.md"] top = f"{repo.split('/')[-1]}-{tag}" files.append((f"{top}.zip", bundle(folder, top))) names = [n for n, _ in files] if len(set(names)) != len(names): sys.exit(f"two files of the same name: {sorted(n for n in names if names.count(n) > 1)}") g = Gitea(env["GITHUB_SERVER_URL"], repo, env["TOKEN"]) rel, created = g.release(tag) print(("created" if created else "found") + f" release {tag} (id {rel['id']})") old = {a["name"]: a["id"] for a in rel.get("assets") or []} for name, data in files: if name in old: g.call("DELETE", f"/releases/{rel['id']}/assets/{old[name]}") g.upload(rel, name, data) print(f" {'replaced' if name in old else 'attached'} {name} ({len(data):,} bytes)") if __name__ == "__main__": main()