Release-Skript mit automatischer Versionierung und Docker-Builds

- scripts/release.py: Version (patch/minor/major) automatisch hochzaehlen, CHANGELOG
  abschliessen, committen, taggen; --build baut alle Pakete in Docker
- docker-build/: Build-Container fuer Windows (Wine + PyInstaller + Inno Setup),
  Linux und Android; Server-Image als docker-save-Archiv
- Versionsanzeige im Desktop-Client und in der Android-App
- Feste Android-Release-Signatur aus secrets/ (nicht im Git)
- Windows-Icon im Repo, damit der Wine-Build ohne Qt-Fenster auskommt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
duffyduck
2026-10-17 12:45:17 +02:00
co-authored by Claude Opus 5.5
parent 5cbe6376f0
commit af51ef5701
20 changed files with 362 additions and 12 deletions
+220
View File
@@ -0,0 +1,220 @@
#!/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<version>/
NetClip-<v>-windows-setup.exe, NetClip-<v>-windows-portable.zip, NetClip-<v>-linux-x86_64.tar.gz,
NetClip-<v>.apk, netclip-server-<v>.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()