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
+2
View File
@@ -15,3 +15,5 @@ client-android/app/build/
client-android/local.properties
client-android/.idea/
*.apk
releases/
secrets/
+9
View File
@@ -4,6 +4,15 @@ Alle nennenswerten Änderungen an NetClip. Format nach [Keep a Changelog](https:
## [Unreleased]
### Hinzugefügt
- Release-Skript `scripts/release.py`: zählt die Version bei jedem Aufruf automatisch hoch
(patch/minor/major), schließt den CHANGELOG-Abschnitt ab, committet und taggt.
- Pakete werden komplett in Docker gebaut (`--build`): Windows-Installer und portable Version
(Wine), Linux-Paket, Android-APK und Server-Image, mit Release-Notes und SHA256SUMS.
- Versionsnummer wird im Desktop-Client (Fenstertitel, Tray, `--version`) und in der Android-App
(Einstellungen) angezeigt.
- Feste Android-Signatur (`secrets/`, wird beim ersten Build erzeugt), damit App-Updates möglich sind.
## [1.0.0] - 2026-10-16
### Hinzugefügt
+33 -2
View File
@@ -88,7 +88,7 @@ python build.py
| System | Ergebnis |
|---|---|
| Windows | `dist/NetClip/NetClip.exe`, danach `packaging/windows-installer.iss` mit Inno Setup kompilieren → `dist/NetClip-Setup-1.0.0.exe` |
| Windows | `dist/NetClip/NetClip.exe`, danach `packaging/windows-installer.iss` mit Inno Setup kompilieren → `dist/NetClip-Setup-<version>.exe` |
| macOS | `dist/NetClip.app` und `dist/NetClip-macos.zip` (Menüleisten-App ohne Dock-Symbol) |
| Linux | `dist/NetClip-linux-x86_64.tar.gz` mit `install-linux.sh` |
@@ -107,7 +107,7 @@ Vorgaben für alle Benutzer: eine `config.default.json` neben die Programmdatei
Start jedes Benutzers übernommen (Gerätename und Geräte-ID bleiben pro Gerät eindeutig).
- **Windows (GPO/Intune/PDQ):**
`NetClip-Setup-1.0.0.exe /VERYSILENT /SUPPRESSMSGBOXES /SERVER=https://clip.example.de /TOKEN=xyz [/SERVERKEY=k] [/INSECURE=1]`
`NetClip-<version>-windows-setup.exe /VERYSILENT /SUPPRESSMSGBOXES /SERVER=https://clip.example.de /TOKEN=xyz [/SERVERKEY=k] [/INSECURE=1]`
– schreibt `config.default.json` und richtet den Autostart für alle Benutzer ein.
- **Linux:** `sudo ./install-linux.sh --server https://clip.example.de --token xyz [--insecure]`
- **macOS:** `NetClip.app` nach `/Applications` kopieren, `config.default.json` nach
@@ -152,6 +152,37 @@ cd client-android
Das Release-APK ist zum Testen mit dem Debug-Schlüssel signiert; für die Verteilung in `app/build.gradle`
einen eigenen Keystore eintragen.
## Release erstellen
Die Version steht zentral in der Datei `VERSION`. Neue Änderungen kommen laufend in
`CHANGELOG.md` unter `## [Unreleased]`. Ein Release:
```bash
python3 scripts/release.py # 1.0.0 -> 1.0.1 (patch), committen + taggen
python3 scripts/release.py minor --build # 1.0.1 -> 1.1.0 und alle Pakete bauen
python3 scripts/release.py --build-only # aktuellen Stand nur bauen
git push --follow-tags
```
Das Skript schreibt die neue Version in Server, Desktop-Client, Windows-Installer und Android-App
(`versionName`, `versionCode` +1) und macht aus `[Unreleased]` den neuen Versionsabschnitt.
Mit `--build` wird ausschließlich in Docker gebaut (`docker-build/`), Ergebnis in `releases/v<version>/`:
| Datei | Inhalt |
|---|---|
| `NetClip-<v>-windows-setup.exe` | Windows-Installer (Inno Setup, unter Wine gebaut) |
| `NetClip-<v>-windows-portable.zip` | Windows ohne Installation |
| `NetClip-<v>-linux-x86_64.tar.gz` | Linux (Debian 12 / glibc ≥ 2.36 und neuer) |
| `NetClip-<v>.apk` | Android |
| `netclip-server-<v>.tar.gz` | Server-Image für `docker load -i …` (offline verteilbar) |
| `RELEASE-NOTES.md`, `SHA256SUMS` | Änderungen und Prüfsummen |
macOS kann nicht in Docker gebaut werden – dafür auf einem Mac in `client-desktop/` `python build.py`.
Mit `--targets windows,android` lassen sich einzelne Pakete bauen.
**Android-Signatur:** Beim ersten Android-Build wird `secrets/netclip-release.jks` erzeugt (nicht im
Git). Diesen Ordner sichern – ohne den Schlüssel lassen sich installierte Apps nicht mehr aktualisieren.
## Tests
```bash
+1
View File
@@ -0,0 +1 @@
1.0.0
+18 -2
View File
@@ -3,10 +3,27 @@ plugins {
id 'org.jetbrains.kotlin.android'
}
// Release-Signatur: secrets/android-keystore.properties (wird von scripts/release.py beim ersten
// Build erzeugt, nicht im Git). Fehlt die Datei, wird mit dem Debug-Schluessel signiert.
def keystoreProps = new Properties()
def keystoreFile = rootProject.file('../secrets/android-keystore.properties')
if (keystoreFile.exists()) keystoreFile.withInputStream { keystoreProps.load(it) }
android {
namespace 'de.netclip.app'
compileSdk 35
signingConfigs {
release {
if (keystoreFile.exists()) {
storeFile rootProject.file('../secrets/' + keystoreProps['storeFile'])
storePassword keystoreProps['storePassword']
keyAlias keystoreProps['keyAlias']
keyPassword keystoreProps['keyPassword']
}
}
}
defaultConfig {
applicationId "de.netclip.app"
minSdk 26
@@ -19,8 +36,7 @@ android {
release {
minifyEnabled true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
// Zum Testen mit dem Debug-Schluessel signieren; fuer echte Verteilung eigenen Keystore eintragen
signingConfig signingConfigs.debug
signingConfig keystoreFile.exists() ? signingConfigs.release : signingConfigs.debug
}
}
compileOptions {
@@ -153,7 +153,7 @@ class SettingsActivity : AppCompatActivity() {
super.onCreate(savedInstanceState)
b = ActivitySettingsBinding.inflate(layoutInflater)
setContentView(b.root)
title = "Einstellungen"
title = "Einstellungen · v" + packageManager.getPackageInfo(packageName, 0).versionName
load()
b.scan.setOnClickListener {
scan.launch(ScanOptions().setDesiredBarcodeFormats(ScanOptions.QR_CODE).setBeepEnabled(false)
+3 -2
View File
@@ -17,8 +17,9 @@ ROOT = Path(__file__).resolve().parent
def main():
subprocess.check_call([sys.executable, "-m", "pip", "install", "-r", "requirements.txt", "pyinstaller"], cwd=ROOT)
if platform.system() == "Windows":
if "--no-install" not in sys.argv:
subprocess.check_call([sys.executable, "-m", "pip", "install", "-r", "requirements.txt", "pyinstaller"], cwd=ROOT)
if platform.system() == "Windows" and not (ROOT / "packaging" / "netclip.ico").exists():
make_ico()
subprocess.check_call([sys.executable, "-m", "PyInstaller", "--noconfirm", "--clean", "netclip.spec"], cwd=ROOT)
dist = ROOT / "dist"
+1
View File
@@ -0,0 +1 @@
__version__ = "1.0.0"
+3 -1
View File
@@ -14,13 +14,14 @@ from PySide6.QtCore import QTimer
from PySide6.QtNetwork import QLocalServer, QLocalSocket
from PySide6.QtWidgets import QApplication, QMessageBox, QSystemTrayIcon
from . import autostart, config, connection
from . import __version__, autostart, config, connection
INSTANCE = "netclip-instance"
def parse_args():
p = argparse.ArgumentParser(prog="netclip", description="Netzwerk-Zwischenablage")
p.add_argument("--version", action="version", version=f"NetClip {__version__}")
p.add_argument("--minimized", action="store_true")
p.add_argument("--show", action="store_true")
p.add_argument("--import", dest="import_file")
@@ -68,6 +69,7 @@ def main():
app = QApplication(sys.argv)
app.setApplicationName("NetClip")
app.setApplicationVersion(__version__)
app.setQuitOnLastWindowClosed(False)
# Einzelinstanz: laeuft schon eine, dort ggf. Verlauf öffnen bzw. neu laden und beenden
+4 -4
View File
@@ -9,7 +9,7 @@ from PySide6.QtWidgets import (QApplication, QCheckBox, QDialog, QDialogButtonBo
QMessageBox, QProgressBar, QPushButton, QSpinBox, QStyle, QSystemTrayIcon,
QVBoxLayout, QWidget)
from . import autostart, config, connection
from . import __version__, autostart, config, connection
from .controller import Controller, Entry
KIND_ICON = {"text": QStyle.StandardPixmap.SP_FileDialogDetailedView,
@@ -46,7 +46,7 @@ def app_icon(status: str = "") -> QIcon:
class SettingsDialog(QDialog):
def __init__(self, cfg: config.Config, parent=None):
super().__init__(parent)
self.setWindowTitle("NetClip – Einstellungen")
self.setWindowTitle(f"NetClip {__version__} – Einstellungen")
self.setWindowIcon(app_icon())
self.cfg = dataclasses.replace(cfg)
form = QFormLayout()
@@ -229,7 +229,7 @@ class HistoryWindow(QWidget):
def __init__(self, ctl: Controller):
super().__init__()
self.ctl = ctl
self.setWindowTitle("NetClip – Verlauf")
self.setWindowTitle(f"NetClip {__version__} – Verlauf")
self.setWindowIcon(app_icon())
self.resize(560, 640)
@@ -382,7 +382,7 @@ class Tray(QSystemTrayIcon):
c = self.ctl
status = "pause" if c.paused else ("ok" if c.connected else "off")
self.setIcon(app_icon(status))
self.setToolTip("NetClip – " + {"ok": "verbunden", "off": "getrennt", "pause": "pausiert"}[status])
self.setToolTip(f"NetClip {__version__} – " + {"ok": "verbunden", "off": "getrennt", "pause": "pausiert"}[status])
m = self.menu
m.clear()
for e in c.history[:self.QUICK]:
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 842 B

+12
View File
@@ -0,0 +1,12 @@
# Build-Umgebung fuer die Android-App (JDK 17 + Android SDK)
FROM eclipse-temurin:17-jdk-jammy
ENV ANDROID_HOME=/opt/android-sdk ANDROID_SDK_ROOT=/opt/android-sdk
RUN apt-get update && apt-get install -y --no-install-recommends unzip wget && rm -rf /var/lib/apt/lists/* \
&& mkdir -p $ANDROID_HOME/cmdline-tools && cd /tmp \
&& wget -q https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip -O tools.zip \
&& unzip -q tools.zip -d $ANDROID_HOME/cmdline-tools && mv $ANDROID_HOME/cmdline-tools/cmdline-tools $ANDROID_HOME/cmdline-tools/latest \
&& rm tools.zip \
&& yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses > /dev/null \
&& $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager "platforms;android-35" "build-tools;35.0.0" > /dev/null
COPY docker-build/build-android.sh /usr/local/bin/build-android.sh
CMD ["sh", "/usr/local/bin/build-android.sh"]
+10
View File
@@ -0,0 +1,10 @@
#!/bin/sh
# Laeuft im Container: /src (Repo, nur lesend) -> /out
set -e
rm -rf /work && mkdir -p /work && cp -r /src/client-android /work/ && mkdir -p /work/secrets
[ -d /src/secrets ] && cp -r /src/secrets/. /work/secrets/
cd /work/client-android && rm -rf .gradle build app/build
rm -f local.properties
./gradlew --no-daemon -q assembleRelease
cp app/build/outputs/apk/release/app-release.apk "/out/NetClip-${VERSION}.apk"
chown -R "${HOST_UID:-0}:${HOST_GID:-0}" /out
+8
View File
@@ -0,0 +1,8 @@
#!/bin/sh
set -e
rm -rf /work && mkdir -p /work && cp -r /src/client-desktop /work/
cd /work/client-desktop && rm -rf build dist netclip/__pycache__
pip install -q -r requirements.txt
python build.py --no-install
cp dist/NetClip-linux-*.tar.gz "/out/NetClip-${VERSION}-linux-$(uname -m).tar.gz"
chown -R "${HOST_UID:-0}:${HOST_GID:-0}" /out
+12
View File
@@ -0,0 +1,12 @@
#!/bin/sh
set -e
rm -rf /work && mkdir -p /work && cp -r /src/client-desktop /work/
cd /work/client-desktop && rm -rf build dist netclip/__pycache__
wine python -m pip install -q -r requirements.txt -c Z:/usr/local/bin/windows-constraints.txt
wine python -m PyInstaller --noconfirm --clean netclip.spec
ISCC=$(find /opt/wineprefix/drive_c -iname ISCC.exe | head -1)
(cd packaging && xvfb-run -a wine "$ISCC" /Q windows-installer.iss)
cp dist/NetClip-Setup-*.exe "/out/NetClip-${VERSION}-windows-setup.exe"
(cd dist && zip -qr "/out/NetClip-${VERSION}-windows-portable.zip" NetClip)
wineserver -w || true
chown -R "${HOST_UID:-0}:${HOST_GID:-0}" /out
+10
View File
@@ -0,0 +1,10 @@
# Build-Umgebung fuer den Linux-Desktop-Client (PyInstaller). Debian 12 -> laeuft auf glibc >= 2.36.
FROM python:3.12-slim-bookworm
RUN apt-get update && apt-get install -y --no-install-recommends binutils libgl1 libegl1 libxkbcommon0 \
libxkbcommon-x11-0 libfontconfig1 libdbus-1-3 libglib2.0-0 libxcb-cursor0 libxcb-icccm4 libxcb-keysyms1 \
libxcb-shape0 libxcb-image0 libxcb-render-util0 libxcb-randr0 libxcb-xinerama0 \
&& rm -rf /var/lib/apt/lists/*
COPY client-desktop/requirements.txt /tmp/requirements.txt
RUN pip install --no-cache-dir -r /tmp/requirements.txt pyinstaller
COPY docker-build/build-linux.sh /usr/local/bin/build-linux.sh
CMD ["sh", "/usr/local/bin/build-linux.sh"]
+3
View File
@@ -0,0 +1,3 @@
# Neuere PySide6-Versionen laden unter Wine 9 nicht (QtCore-DLL), 6.8 ist Qt-LTS.
PySide6-Essentials==6.8.3
shiboken6==6.8.3
+12
View File
@@ -0,0 +1,12 @@
# Build-Umgebung fuer den Windows-Client: Windows-Python + PyInstaller + Inno Setup unter Wine
FROM tobix/pywine:3.12
ARG INNO_VERSION=6.7.3
RUN apt-get update && apt-get install -y --no-install-recommends xvfb xauth wget zip && rm -rf /var/lib/apt/lists/* \
&& wget -q https://github.com/jrsoftware/issrc/releases/download/is-$(echo ${INNO_VERSION} | tr . _)/innosetup-${INNO_VERSION}.exe -O /tmp/is.exe \
&& xvfb-run -a wine /tmp/is.exe /VERYSILENT /SUPPRESSMSGBOXES /NORESTART /SP- /ALLUSERS \
&& rm /tmp/is.exe && wineserver -w
COPY client-desktop/requirements.txt docker-build/windows-constraints.txt /tmp/
RUN wine python -m pip install --no-cache-dir -r Z:/tmp/requirements.txt -c Z:/tmp/windows-constraints.txt pyinstaller \
&& wineserver -w
COPY docker-build/build-windows.sh docker-build/windows-constraints.txt /usr/local/bin/
CMD ["sh", "/usr/local/bin/build-windows.sh"]
+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()