#!/usr/bin/env python3 """Attach a release's checked files to its GitHub release. GH_TOKEN= python3 deploy/github-release.py v .github/workflows/release.yml runs this after deploy/release-assets.sh has filled with files it checked against the signed manifest. The release for the tag is made if it does not exist yet, named `bastion `, with that version's section of as its notes; an existing release keeps its name and notes. Each file in is uploaded, replacing an asset of the same name, so a re-run leaves the same set. Standard library only; the token is read from the environment and sent only to the GitHub API. """ import json import os import re import sys import urllib.error import urllib.parse import urllib.request API = os.environ.get("GITHUB_API_URL", "https://api.github.com") UPLOADS = os.environ.get("GITHUB_UPLOADS_URL", "https://uploads.github.com") def call(method, url, token, body=None, ctype="application/json"): data = None if body is not None: data = body if isinstance(body, bytes) else json.dumps(body).encode() req = urllib.request.Request(url, data=data, method=method, headers={ "Authorization": "Bearer " + token, "Accept": "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28", "Content-Type": ctype, "User-Agent": "bastion-release-assets"}) try: with urllib.request.urlopen(req, timeout=300) as r: raw = r.read() return r.status, (json.loads(raw) if raw else None) except urllib.error.HTTPError as e: raw = e.read() try: return e.code, json.loads(raw) except ValueError: return e.code, {"message": raw.decode(errors="replace")[:300]} def notes(changelog, ver): """The CHANGELOG section `## `, up to the next `## ` heading.""" text = open(changelog, encoding="utf-8").read() m = re.search(r"^## " + re.escape(ver) + r"[ \t]*$", text, re.M) if m is None: raise SystemExit("CHANGELOG has no section for %s" % ver) nxt = re.search(r"^## ", text[m.end():], re.M) body = text[m.end():m.end() + nxt.start() if nxt else len(text)].strip() return (body + "\n\n---\nThe binaries attached here were rebuilt from this tag by this repository's " "release workflow, and their digests are the ones in the signed manifest `version.json` " "attached beside them. To check them yourself, see " "[Verify a release](https://github.com/Privatae/bastion#verify-a-release).") def main(argv): if len(argv) != 5: print(__doc__.strip().splitlines()[2].strip(), file=sys.stderr) return 2 repo, tag, assets, changelog = argv[1:] if not re.fullmatch(r"v[0-9]+\.[0-9]+\.[0-9]+", tag): raise SystemExit("not a release tag: " + tag) token = os.environ.get("GH_TOKEN", "") if not token: raise SystemExit("GH_TOKEN is not set") ver = tag[1:] files = sorted(os.listdir(assets)) if not files: raise SystemExit("no assets in " + assets) st, rel = call("GET", "%s/repos/%s/releases/tags/%s" % (API, repo, tag), token) if st == 404: st, rel = call("POST", "%s/repos/%s/releases" % (API, repo), token, { "tag_name": tag, "name": "bastion " + ver, "body": notes(changelog, ver), "draft": False, "prerelease": False}) if st != 201: raise SystemExit("could not make the release for %s: HTTP %d %s" % (tag, st, rel)) print("made the release for %s" % tag) elif st != 200: raise SystemExit("could not read the release for %s: HTTP %d %s" % (tag, st, rel)) have = {a["name"]: a["id"] for a in rel.get("assets", [])} for name in files: if name in have: st, _ = call("DELETE", "%s/repos/%s/releases/assets/%d" % (API, repo, have[name]), token) if st != 204: raise SystemExit("could not replace %s: HTTP %d" % (name, st)) with open(os.path.join(assets, name), "rb") as fh: data = fh.read() url = "%s/repos/%s/releases/%d/assets?name=%s" % (UPLOADS, repo, rel["id"], urllib.parse.quote(name)) st, up = call("POST", url, token, data, "application/octet-stream") if st != 201 or up.get("size") != len(data): raise SystemExit("upload of %s failed: HTTP %d %s" % (name, st, up)) print("attached %s (%d bytes)" % (name, len(data))) return 0 if __name__ == "__main__": sys.exit(main(sys.argv))