release automation: gitea actions build every push and publish tagged releases (version from the tag, packages, sha256sums, changelog section as text), simple 5-step install guide in the readme

This commit is contained in:
MineTec committed 2026-11-07 16:47:42 +01:00
1 parent f5fd411592
commit a64df88c77
7 files changed
+262 -9

No files matched your search

+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env python3
"""Prints the CHANGELOG.md section of one version (used as the release text).
changelog_section.py 0.2.1 [CHANGELOG.md]
A section starts at a "## <version>" heading (anything may follow the version, e.g. " – changes
since 0.1.0") and ends before the next "## " heading. Exit code 1 if the version has no section.
"""
import re
import sys
def section(text, version):
out, inside = [], False
for line in text.splitlines():
if line.startswith("## "):
if inside:
break
inside = re.match(r"## v?" + re.escape(version) + r"(\s|$)", line) is not None
continue
if inside:
out.append(line)
return "\n".join(out).strip() if inside or out else None
def main():
if len(sys.argv) < 2:
sys.exit(__doc__)
path = sys.argv[2] if len(sys.argv) > 2 else "CHANGELOG.md"
with open(path, encoding="utf-8") as f:
s = section(f.read(), sys.argv[1])
if not s:
sys.exit("no section for version %s in %s" % (sys.argv[1], path))
print(s)
if __name__ == "__main__":
main()
+87
View File
@@ -0,0 +1,87 @@
#!/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()