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
@@ -15,3 +15,5 @@ client-android/app/build/
|
||||
client-android/local.properties
|
||||
client-android/.idea/
|
||||
*.apk
|
||||
releases/
|
||||
secrets/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
__version__ = "1.0.0"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
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