- 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>
221 lines
10 KiB
Python
Executable File
221 lines
10 KiB
Python
Executable File
#!/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()
|