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:
co-authored by
Claude Opus 5.5
parent
5cbe6376f0
commit
af51ef5701
Executable
+220
@@ -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()
|
||||
Reference in New Issue
Block a user