Template
A stage tag (v*) now builds, with kicad-cli, pandoc and pdflatex in the CI image (.gitea/scripts/release-files.py): - per board: Gerbers + Excellon drill zip (JLCPCB-compatible settings), BOM (grouped, DNP left out), pick and place, schematic and PCB PDFs, 3D renders; - docs/*.md and docs/tex/*.tex as PDF, tag and commit in the footer; - release/README.md with tag, commit, ERC/DRC result, files, SHA-256 and the stage's CHANGELOG section. Every file name carries the tag. release-attach.py attaches the files plus a zip of the whole folder to the tag's release (replacing assets of an earlier run); for the newest stage tag (not older stages, not pre-releases) the job commits them to release/ on main with [skip ci]. .gitignore: !/release/ after Release/ (Windows matches it case-insensitively); .gitattributes: release/** -text so the SHA-256 match on every checkout. TEMPLATE.md: "Release files" section, rules and CI updated. Tested in git.b4l.co.th/b4l/kicad10_auto_node with harNES's board and docs: v0.1 committed, v0.0 and v0.2-rc.1 left release/ alone, v0.2 tagged on an older commit landed on top of a moved main; uploads byte-exact against a stub of the Gitea release API.
108 lines
4.4 KiB
Python
108 lines
4.4 KiB
Python
#!/usr/bin/env python3
|
|
"""Attach the files of a release folder (release-files.py) to the Gitea release of the tag.
|
|
|
|
TOKEN=<job 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 <repository>-<tag>.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 <top>/, 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()
|