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:
1 parent
f5fd411592
commit
a64df88c77
7 files changed
+262
-9
No files matched your search
@@ -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()
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user