#!/usr/bin/env python3 """Release erstellen: Version hochzaehlen, CHANGELOG abschliessen, committen, taggen, optional bauen. python3 scripts/release.py # patch: 1.0.0 -> 1.0.1 python3 scripts/release.py minor # 1.0.1 -> 1.1.0 python3 scripts/release.py major # 1.1.0 -> 2.0.0 python3 scripts/release.py --build # zusaetzlich alle Pakete in Docker bauen python3 scripts/release.py --build-only # aktuelle Version (HEAD) nur bauen, nichts hochzaehlen python3 scripts/release.py --build --targets android,windows Gebaut wird ausschliesslich in Docker-Containern (docker-build/*.Dockerfile) aus einem sauberen `git archive` des Release-Commits. Ergebnis: releases/v/ NetClip--windows-setup.exe, NetClip--windows-portable.zip, NetClip--linux-x86_64.tar.gz, NetClip-.apk, netclip-server-.tar.gz (docker load -i ...), RELEASE-NOTES.md, SHA256SUMS macOS laesst sich nicht in Docker bauen -> auf einem Mac `python build.py` in client-desktop/ ausfuehren. Einzige Versionsquelle ist die Datei VERSION; das Skript schreibt sie in alle Stellen unten. """ import argparse import datetime import hashlib import os import re import secrets import shutil import subprocess import sys import tempfile from pathlib import Path ROOT = Path(__file__).resolve().parent.parent TARGETS = ("server", "linux", "windows", "android") # Datei -> (Regex, Ersatz); {v} = neue Version. Jede Regel muss genau einmal treffen. VERSION_SPOTS = { "VERSION": (r"^\d+\.\d+\.\d+$", "{v}"), "server/netclip_server.py": (r'^VERSION = "[^"]+"', 'VERSION = "{v}"'), "client-desktop/netclip/__init__.py": (r'^__version__ = "[^"]+"', '__version__ = "{v}"'), "client-desktop/netclip.spec": (r'"CFBundleShortVersionString": "[^"]+"', '"CFBundleShortVersionString": "{v}"'), "client-desktop/packaging/windows-installer.iss": (r'^#define AppVersion "[^"]+"', '#define AppVersion "{v}"'), "client-android/app/build.gradle": (r'versionName "[^"]+"', 'versionName "{v}"'), } def run(*cmd, cwd=ROOT, capture=False, **kw): print("+", " ".join(str(c) for c in cmd)) r = subprocess.run([str(c) for c in cmd], cwd=cwd, check=True, text=True, stdout=subprocess.PIPE if capture else None, **kw) return r.stdout if capture else None def die(msg): print(f"FEHLER: {msg}", file=sys.stderr) sys.exit(1) def bump(version: str, part: str) -> str: major, minor, patch = map(int, version.split(".")) if part == "major": return f"{major + 1}.0.0" if part == "minor": return f"{major}.{minor + 1}.0" return f"{major}.{minor}.{patch + 1}" def unreleased_notes(changelog: str) -> str: m = re.search(r"^## \[Unreleased\]\s*\n(.*?)(?=^## \[|\Z)", changelog, re.S | re.M) if not m: die("CHANGELOG.md enthaelt keinen Abschnitt '## [Unreleased]'") return m.group(1).strip() def version_notes(changelog: str, version: str) -> str: m = re.search(rf"^## \[{re.escape(version)}\][^\n]*\n(.*?)(?=^## \[|\Z)", changelog, re.S | re.M) return m.group(1).strip() if m else "" def write_versions(new: str): for rel, (pattern, repl) in VERSION_SPOTS.items(): path = ROOT / rel text = path.read_text(encoding="utf-8") text, n = re.subn(pattern, repl.format(v=new), text, count=1, flags=re.M) if n != 1: die(f"Version in {rel} nicht gefunden (Muster {pattern})") path.write_text(text, encoding="utf-8") # Android versionCode: bei jedem Release +1 (muss fuer App-Updates steigen) gradle = ROOT / "client-android/app/build.gradle" text = gradle.read_text(encoding="utf-8") text, n = re.subn(r"versionCode (\d+)", lambda m: f"versionCode {int(m.group(1)) + 1}", text, count=1) if n != 1: die("versionCode in build.gradle nicht gefunden") gradle.write_text(text, encoding="utf-8") def close_changelog(new: str): path = ROOT / "CHANGELOG.md" text = path.read_text(encoding="utf-8") today = datetime.date.today().isoformat() text = re.sub(r"^## \[Unreleased\][ \t]*\n", f"## [Unreleased]\n\n## [{new}] - {today}\n", text, count=1, flags=re.M) text = re.sub(r"\n{3,}", "\n\n", text) path.write_text(text, encoding="utf-8") # ---------------------------------------------------------------------------- Bauen def docker_image(name: str, src: Path): run("docker", "build", "-q", "-f", src / f"docker-build/{name}.Dockerfile", "-t", f"netclip-build-{name}", src) def ensure_android_keystore(): """Erzeugt einmalig einen Release-Schluessel. OHNE diesen Schluessel sind keine App-Updates moeglich -> sichern!""" sec = ROOT / "secrets" props = sec / "android-keystore.properties" if props.exists(): return sec.mkdir(exist_ok=True) os.chmod(sec, 0o700) password = secrets.token_urlsafe(24) run("docker", "run", "--rm", "-v", f"{sec}:/secrets", "netclip-build-android", "keytool", "-genkeypair", "-keystore", "/secrets/netclip-release.jks", "-alias", "netclip", "-keyalg", "RSA", "-keysize", "4096", "-validity", "36500", "-storepass", password, "-keypass", password, "-dname", "CN=NetClip, O=NetClip, C=DE") run("docker", "run", "--rm", "-v", f"{sec}:/secrets", "netclip-build-android", "chown", f"{os.getuid()}:{os.getgid()}", "/secrets/netclip-release.jks") props.write_text(f"storeFile=netclip-release.jks\nstorePassword={password}\nkeyAlias=netclip\nkeyPassword={password}\n") os.chmod(props, 0o600) os.chmod(sec / "netclip-release.jks", 0o600) print("\n*** Neuer Android-Signaturschluessel in secrets/ erzeugt – unbedingt sichern! " "Ohne ihn lassen sich installierte Apps nicht mehr aktualisieren. ***\n") def build(version: str, ref: str, targets: list[str]): out = ROOT / "releases" / f"v{version}" out.mkdir(parents=True, exist_ok=True) with tempfile.TemporaryDirectory(prefix="netclip-release-") as tmp: src = Path(tmp) / "src" src.mkdir() archive = subprocess.run(["git", "archive", "--format=tar", ref], cwd=ROOT, check=True, capture_output=True).stdout subprocess.run(["tar", "-x", "-C", str(src)], input=archive, check=True) (src / "secrets").mkdir() mounts = ["-v", f"{src}:/src:ro", "-v", f"{out}:/out"] env = ["-e", f"VERSION={version}", "-e", f"HOST_UID={os.getuid()}", "-e", f"HOST_GID={os.getgid()}"] for target in targets: print(f"\n=== {target} ===") if target == "server": tag = f"netclip-server:{version}" run("docker", "build", "-q", "-t", tag, "-t", "netclip-server:latest", src / "server") with open(out / f"netclip-server-{version}.tar.gz", "wb") as f: save = subprocess.Popen(["docker", "save", tag], stdout=subprocess.PIPE) subprocess.run(["gzip", "-9"], stdin=save.stdout, stdout=f, check=True) save.wait() continue docker_image(target, src) extra = [] if target == "android": ensure_android_keystore() extra = ["-v", f"{ROOT / 'secrets'}:/src/secrets:ro", "-v", "netclip-gradle-cache:/root/.gradle"] run("docker", "run", "--rm", *mounts, *extra, *env, f"netclip-build-{target}") notes = version_notes((ROOT / "CHANGELOG.md").read_text(encoding="utf-8"), version) (out / "RELEASE-NOTES.md").write_text(f"# NetClip {version}\n\n{notes}\n", encoding="utf-8") sums = [] for f in sorted(out.iterdir()): if f.is_file() and f.name != "SHA256SUMS": sums.append(f"{hashlib.sha256(f.read_bytes()).hexdigest()} {f.name}") (out / "SHA256SUMS").write_text("\n".join(sums) + "\n") print(f"\nPakete in {out}:") for f in sorted(out.iterdir()): print(f" {f.name:45} {f.stat().st_size / 1024 / 1024:8.1f} MB") # ---------------------------------------------------------------------------- Ablauf def main(): p = argparse.ArgumentParser(description="NetClip-Release erstellen") p.add_argument("part", nargs="?", default="patch", choices=("patch", "minor", "major")) p.add_argument("--build", action="store_true", help="nach dem Release alle Pakete in Docker bauen") p.add_argument("--build-only", action="store_true", help="nur die aktuelle Version (HEAD) bauen") p.add_argument("--targets", default=",".join(TARGETS), help=f"Auswahl aus {','.join(TARGETS)}") p.add_argument("--allow-empty", action="store_true", help="auch ohne Eintraege unter [Unreleased]") args = p.parse_args() targets = [t.strip() for t in args.targets.split(",") if t.strip()] if unknown := set(targets) - set(TARGETS): die(f"Unbekannte Ziele: {', '.join(unknown)}") if (args.build or args.build_only) and not shutil.which("docker"): die("docker nicht gefunden") current = (ROOT / "VERSION").read_text().strip() if args.build_only: if run("git", "status", "--porcelain", capture=True).strip(): print("Hinweis: Arbeitsverzeichnis hat uncommittete Aenderungen – gebaut wird der Stand von HEAD.") build(current, "HEAD", targets) return if run("git", "status", "--porcelain", capture=True).strip(): die("Arbeitsverzeichnis nicht sauber – erst committen oder stashen.") notes = unreleased_notes((ROOT / "CHANGELOG.md").read_text(encoding="utf-8")) if not notes and not args.allow_empty: die("Keine Eintraege unter '## [Unreleased]' im CHANGELOG (oder --allow-empty).") new = bump(current, args.part) tag = f"v{new}" if run("git", "tag", "--list", tag, capture=True).strip(): die(f"Tag {tag} existiert bereits") print(f"Release {current} -> {new}") write_versions(new) close_changelog(new) run("git", "add", "VERSION", "CHANGELOG.md", "client-android/app/build.gradle", *VERSION_SPOTS.keys()) run("git", "commit", "-q", "-m", f"Release {tag}\n\n{notes}".strip()) run("git", "tag", "-a", tag, "-m", f"NetClip {new}\n\n{notes}".strip()) if args.build: build(new, tag, targets) print(f"\nFertig: {tag}. Veroeffentlichen mit:\n git push --follow-tags") if __name__ == "__main__": main()