release/: the newest stage's manufacturing files and documentation

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.
This commit is contained in:
grabowski committed 2026-10-16 18:50:01 +07:00
1 parent 0e002be80c
commit 52a40c1c3e
11 files changed
+654 -53

No files matched your search

+107
View File
@@ -0,0 +1,107 @@
#!/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()