diff --git a/.gitignore b/.gitignore index 99045b4..1de45db 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,5 @@ client-android/app/build/ client-android/local.properties client-android/.idea/ *.apk +releases/ +secrets/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 27efd7f..5b8d904 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 146d819..12a2015 100644 --- a/README.md +++ b/README.md @@ -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-.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--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/`: + +| Datei | Inhalt | +|---|---| +| `NetClip--windows-setup.exe` | Windows-Installer (Inno Setup, unter Wine gebaut) | +| `NetClip--windows-portable.zip` | Windows ohne Installation | +| `NetClip--linux-x86_64.tar.gz` | Linux (Debian 12 / glibc ≥ 2.36 und neuer) | +| `NetClip-.apk` | Android | +| `netclip-server-.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 diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..3eefcb9 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +1.0.0 diff --git a/client-android/app/build.gradle b/client-android/app/build.gradle index 4e8cf1a..70508a3 100644 --- a/client-android/app/build.gradle +++ b/client-android/app/build.gradle @@ -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 { diff --git a/client-android/app/src/main/java/de/netclip/app/Activities.kt b/client-android/app/src/main/java/de/netclip/app/Activities.kt index d5e8d80..e764938 100644 --- a/client-android/app/src/main/java/de/netclip/app/Activities.kt +++ b/client-android/app/src/main/java/de/netclip/app/Activities.kt @@ -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) diff --git a/client-desktop/build.py b/client-desktop/build.py index af30f6a..c5900ae 100644 --- a/client-desktop/build.py +++ b/client-desktop/build.py @@ -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" diff --git a/client-desktop/netclip/__init__.py b/client-desktop/netclip/__init__.py index e69de29..5becc17 100644 --- a/client-desktop/netclip/__init__.py +++ b/client-desktop/netclip/__init__.py @@ -0,0 +1 @@ +__version__ = "1.0.0" diff --git a/client-desktop/netclip/__main__.py b/client-desktop/netclip/__main__.py index 8227f07..6c290d5 100644 --- a/client-desktop/netclip/__main__.py +++ b/client-desktop/netclip/__main__.py @@ -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 diff --git a/client-desktop/netclip/ui.py b/client-desktop/netclip/ui.py index 8217f73..e45a1d2 100644 --- a/client-desktop/netclip/ui.py +++ b/client-desktop/netclip/ui.py @@ -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]: diff --git a/client-desktop/packaging/netclip.ico b/client-desktop/packaging/netclip.ico new file mode 100644 index 0000000..5ec58fc Binary files /dev/null and b/client-desktop/packaging/netclip.ico differ diff --git a/client-desktop/packaging/netclip.png b/client-desktop/packaging/netclip.png new file mode 100644 index 0000000..5e6e843 Binary files /dev/null and b/client-desktop/packaging/netclip.png differ diff --git a/docker-build/android.Dockerfile b/docker-build/android.Dockerfile new file mode 100644 index 0000000..05ee132 --- /dev/null +++ b/docker-build/android.Dockerfile @@ -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"] diff --git a/docker-build/build-android.sh b/docker-build/build-android.sh new file mode 100644 index 0000000..f78aef2 --- /dev/null +++ b/docker-build/build-android.sh @@ -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 diff --git a/docker-build/build-linux.sh b/docker-build/build-linux.sh new file mode 100644 index 0000000..0d232ed --- /dev/null +++ b/docker-build/build-linux.sh @@ -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 diff --git a/docker-build/build-windows.sh b/docker-build/build-windows.sh new file mode 100644 index 0000000..73bc8cb --- /dev/null +++ b/docker-build/build-windows.sh @@ -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 diff --git a/docker-build/linux.Dockerfile b/docker-build/linux.Dockerfile new file mode 100644 index 0000000..447e825 --- /dev/null +++ b/docker-build/linux.Dockerfile @@ -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"] diff --git a/docker-build/windows-constraints.txt b/docker-build/windows-constraints.txt new file mode 100644 index 0000000..01881ed --- /dev/null +++ b/docker-build/windows-constraints.txt @@ -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 diff --git a/docker-build/windows.Dockerfile b/docker-build/windows.Dockerfile new file mode 100644 index 0000000..d34701f --- /dev/null +++ b/docker-build/windows.Dockerfile @@ -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"] diff --git a/scripts/release.py b/scripts/release.py new file mode 100755 index 0000000..5359491 --- /dev/null +++ b/scripts/release.py @@ -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/ + NetClip--windows-setup.exe, NetClip--windows-portable.zip, NetClip--linux-x86_64.tar.gz, + NetClip-.apk, netclip-server-.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()