Files
zwischenablage-server/scripts/release.py
T
duffyduckandClaude Opus 5.5 298c142c14 Bauen und Releases ohne lokale Abhaengigkeiten ueber ./build.sh
- build.sh (release, build, test) startet einen temporaeren Runner-Container
  (Python, Git, Docker-CLI), der die Build-Container ueber den Docker-Socket startet
- release.py: Quellexport unter releases/ statt /tmp (Host-Pfade fuer Bind-Mounts),
  Test-Befehl in Docker, Git-Identitaet vorab pruefen, bei Commit-Fehler Versionen zuruecknehmen
- Android-Build-Image enthaelt Platform-Tools fest

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 15:41:45 +02:00

266 lines
12 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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<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)
# 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()