#!/usr/bin/env python3 """Release erstellen: Version hochzaehlen, CHANGELOG abschliessen, committen, taggen, optional bauen. Normalerweise ueber ./build.sh aufrufen (laeuft komplett in Docker, nichts lokal zu installieren): ./build.sh release [patch|minor|major] [--build] ./build.sh build ./build.sh test Direkt (benoetigt python3, git, docker auf dem Rechner): 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) # Unterhalb des Repos statt /tmp: laeuft das Skript selbst im Runner-Container (build.sh), # muessen die Pfade fuer die Bind-Mounts der Build-Container auch auf dem Host gelten. (ROOT / "releases").mkdir(exist_ok=True) with tempfile.TemporaryDirectory(prefix=".src-", dir=ROOT / "releases") 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") def test(): """Krypto-Kompatibilitaet Python <-> Kotlin, komplett in den Build-Containern.""" (ROOT / "releases").mkdir(exist_ok=True) with tempfile.TemporaryDirectory(prefix=".test-", dir=ROOT / "releases") as tmp: src = Path(tmp) / "src" data = Path(tmp) / "interop" src.mkdir() data.mkdir() archive = subprocess.run(["git", "archive", "--format=tar", "HEAD"], cwd=ROOT, check=True, capture_output=True).stdout subprocess.run(["tar", "-x", "-C", str(src)], input=archive, check=True) for name in ("linux", "android"): docker_image(name, src) user = ["--user", f"{os.getuid()}:{os.getgid()}", "-e", "HOME=/tmp"] py = ["docker", "run", "--rm", *user, "-v", f"{src}:/src:ro", "-v", f"{data}:/data", "netclip-build-linux", "python", "/src/tools/interop.py"] run(*py, "gen", "/data") run("docker", "run", "--rm", "-v", f"{src}:/src:ro", "-v", f"{data}:/data", "-v", "netclip-gradle-cache:/root/.gradle", "netclip-build-android", "sh", "-c", "cp -r /src/client-android /work && cd /work && rm -f local.properties && " "./gradlew --no-daemon -q testDebugUnitTest -Pinterop=/data; r=$?; " f"chown -R {os.getuid()}:{os.getgid()} /data; exit $r") run(*py, "check", "/data") # ---------------------------------------------------------------------------- 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]") p.add_argument("--test", action="store_true", help="nur Kompatibilitaetstest Python <-> Kotlin") args = p.parse_args() if args.test: test() return 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 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).") if subprocess.run(["git", "var", "GIT_COMMITTER_IDENT"], cwd=ROOT, capture_output=True).returncode: die("Git kennt keinen Namen/E-Mail fuer Commits: git config --global user.name/user.email setzen") 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}") files = ["CHANGELOG.md", *VERSION_SPOTS.keys()] write_versions(new) close_changelog(new) try: run("git", "add", *files) run("git", "commit", "-q", "-m", f"Release {tag}\n\n{notes}".strip()) except subprocess.CalledProcessError: run("git", "reset", "-q", "--", *files) run("git", "checkout", "--", *files) die("Commit fehlgeschlagen – Versionsaenderungen wurden zurueckgenommen.") 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()