#!/usr/bin/env python3 """Baut ClawdDotNet fuer eine Plattform und veroeffentlicht es im Deploymentcenter. python deploy/publish.py --rid win-x64 --channel dev python deploy/publish.py --rid linux-x64 --channel dev --dry-run Drei Schritte, die einzeln zu leicht vergessen werden: 1. dotnet publish in ein frisches Verzeichnis. Frisch, weil der Packager alles einpackt, was er vorfindet - Reste eines aelteren Laufs landeten sonst mit im Paket. 2. update-agent dazulegen. Ohne ihn findet ResolveAgentPath() nichts, und die Anwendung kann sich nicht selbst aktualisieren. Die Erstinstallation legt ihn NICHT ins Zielverzeichnis - sie laeuft von dort, wo der Benutzer sie hingelegt hat. 3. pack-and-deploy aufrufen. Die Version kommt aus Directory.Build.props. Sie hier noch einmal anzugeben waere eine zweite Pflegestelle - und der Packager bricht bei einer Abweichung zur Assembly ohnehin ab. """ import argparse import hashlib import json import os import re import shutil import subprocess import sys import tempfile import urllib.request from datetime import datetime from pathlib import Path REPO = Path(__file__).resolve().parent.parent PROJECT = REPO / "src" / "ClawdDotNet.Desktop" / "ClawdDotNet.Desktop.csproj" MANAGER_PROJECT = REPO / "src" / "ClawdDotNet.ManagerApp" / "ClawdDotNet.ManagerApp.csproj" SHELL_PROJECT = REPO / "src" / "ClawdDotNet.Shell" / "ClawdDotNet.Shell.csproj" CONFIG = REPO / "deploy" / "packager.config.json" PACKAGER = Path( "J:/Softwareprojekte/Deploymentcenter/client-dotnet/Deploymentcenter.Packager" "/bin/Release/net10.0/win-x64/pack-and-deploy.exe" ) INSTALLER_BASE = "https://dc.mhdf.de/installer" def read_version() -> str: text = (REPO / "Directory.Build.props").read_text(encoding="utf-8") match = re.search(r"([^<]+)", text) if not match: sys.exit("Directory.Build.props enthaelt kein .") return match.group(1).strip() def run(cmd: list[str], **kw) -> None: print(" $", " ".join(str(c) for c in cmd)) result = subprocess.run(cmd, **kw) if result.returncode != 0: sys.exit(f"Abgebrochen (Rueckgabewert {result.returncode}): {cmd[0]}") def publish(rid: str, out_dir: Path, include_shell: bool = True) -> None: if out_dir.exists(): shutil.rmtree(out_dir) out_dir.mkdir(parents=True) # Eine Zeitmarke fuer beide Publish-Laeufe: Directory.Build.targets stempelt sonst # jede Uebersetzung mit der aktuellen Uhrzeit, und der zweite Lauf baut Core und # Licensing mit anderer Marke neu - dieselbe Fassung, aber nicht mehr bytegleich. stamp = f"-p:BuildTimestamp={datetime.now():%Y-%m-%d %H:%M:%S}" run([ "dotnet", "publish", str(PROJECT), "-c", "Release", "-r", rid, "--self-contained", "false", "-o", str(out_dir), "--nologo", "-v", "q", stamp, ]) # #588: Der Instanzmanager samt Weboberflaeche gehoert ins selbe Paket. Vorher kam # ueber den Installer nur die Desktop-Anwendung an - keine Oberflaeche im Browser, und # der Manager, der laut Konzept neben der Instanz liegt, fehlte ganz. manager_dir = out_dir.parent / f"{out_dir.name}-manager" if manager_dir.exists(): shutil.rmtree(manager_dir) manager_dir.mkdir(parents=True) run([ "dotnet", "publish", str(MANAGER_PROJECT), "-c", "Release", "-r", rid, "--self-contained", "false", "-o", str(manager_dir), "--nologo", "-v", "q", stamp, ]) merge_manager(manager_dir, out_dir) if include_shell: publish_shell(rid, out_dir, stamp) # #795: Die systemd-Vorlagen gehoeren ins Linux-Paket - sonst steht die Anleitung in # einem Repository, das auf der Zielmaschine niemand hat. if rid.startswith("linux"): units = out_dir / "systemd" units.mkdir(exist_ok=True) for unit in sorted((REPO / "deploy" / "systemd").glob("*.service")): shutil.copy2(unit, units / unit.name) print(f" systemd-Vorlagen: {len(list(units.glob('*.service')))} nach systemd/") def publish_shell(rid: str, out_dir: Path, stamp: str) -> None: """#764: Die Huelle - Fenster mit eingebettetem Chromium - nach shell/. Eigener Ordner, weil sie Avalonia 11.3 mitbringt (CefGlue ist dagegen gebaut) und der Instanzprozess weder Avalonia noch Chromium laden soll. Kein merge_manager: Die Bibliotheken der Huelle sollen gerade NICHT neben denen der Instanz liegen. build statt publish: Das Chromium-Laufzeitpaket kopiert seine Dateien nur im AfterBuild-Ziel mit gesetzter Plattform; ein publish liess sie aus (23 MB statt ~370 MB, und kein libcef). Unter Linux sucht Chromium icudtl.dat und die .pak-Dateien neben libcef.so, das Paket legt sie nach Resources/ - sie werden hochgezogen. """ shell_dir = out_dir / "shell" if shell_dir.exists(): shutil.rmtree(shell_dir) run([ "dotnet", "build", str(SHELL_PROJECT), "-c", "Release", "-r", rid, "--self-contained", "false", "-o", str(shell_dir), "--nologo", "-v", "q", stamp, ]) resources = shell_dir / "Resources" if rid.startswith("linux") and resources.is_dir(): for item in resources.iterdir(): target = shell_dir / item.name if target.exists(): shutil.rmtree(target) if target.is_dir() else target.unlink() shutil.move(str(item), str(target)) resources.rmdir() for pdb in shell_dir.rglob("*.pdb"): pdb.unlink() size = sum(f.stat().st_size for f in shell_dir.rglob("*") if f.is_file()) print(f" Huelle: {size / 1_048_576:.1f} MB nach shell/") def merge_manager(src: Path, dst: Path) -> None: """Legt den Manager neben die Instanz. Beide Programme teilen viele Bibliotheken. Liegt eine schon da, muss sie bytegleich sein - sonst ersetzte der zweite Publish die Fassung des ersten still, und eines der beiden Programme liefe mit einer Bibliothek, gegen die es nicht gebaut wurde. Das faellt nicht beim Bauen auf, sondern beim Kunden. """ conflicts = [] copied = 0 for f in sorted(src.rglob("*")): if not f.is_file(): continue rel = f.relative_to(src) target = dst / rel if target.exists(): if target.read_bytes() != f.read_bytes(): conflicts.append(str(rel)) continue target.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(f, target) copied += 1 if conflicts: sys.exit("Instanz und Manager liefern unterschiedliche Fassungen derselben Datei:\n " + "\n ".join(conflicts[:30])) shutil.rmtree(src) print(f" Instanzmanager: {copied} Datei(en) neben die Instanz gelegt") def fetch_agent(rid: str, out_dir: Path, agent_file: Path | None = None) -> None: """Holt den update-agent und prueft die Pruefsumme. Bewusst das ausgelieferte Binary statt eines selbst gebauten: Es ist dasselbe, das die Erstinstallation verwendet, und wird zentral gepflegt. Ohne Pruefsummenvergleich waere das ein Download, der spaeter fremden Code ausfuehrt - genau der Pfad, den eine Signatur schuetzen soll. agent_file ersetzt nur den Download, nicht die Pruefung: Die Ablage liefert zeitweise um 100 KB/s, und urllib blieb dabei am 2026-09-14 ohne Meldung stehen. Eine vorhandene Kopie muss dieselbe Pruefsumme tragen wie das Manifest - sonst ist sie nicht das ausgelieferte Binary. """ with urllib.request.urlopen(f"{INSTALLER_BASE}/installer.json", timeout=30) as r: manifest = json.load(r) entry = next((b for b in manifest.get("binaries", []) if b.get("platform") == rid), None) if entry is None: sys.exit(f"Kein update-agent fuer {rid} im Installer-Manifest.") print(f" update-agent {manifest.get('version')} fuer {rid} ({entry['sizeBytes']} Bytes)") if agent_file is not None: print(f" aus Datei: {agent_file}") payload = agent_file.read_bytes() else: with urllib.request.urlopen(f"{INSTALLER_BASE}/{entry['file']}", timeout=180) as r: payload = r.read() actual = hashlib.sha256(payload).hexdigest() if actual != entry["sha256"].lower(): sys.exit(f"Pruefsumme weicht ab!\n erwartet: {entry['sha256']}\n erhalten: {actual}") target = out_dir / ("update-agent.exe" if rid.startswith("win") else "update-agent") target.write_bytes(payload) if not rid.startswith("win"): target.chmod(0o755) print(f" Pruefsumme in Ordnung -> {target.name}") def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--rid", default="win-x64", help="win-x64, linux-x64, ...") parser.add_argument("--channel", default="dev", choices=["dev", "beta", "prod"]) parser.add_argument("--changelog", default="") parser.add_argument("--no-agent", action="store_true", help="update-agent nicht mitliefern") parser.add_argument("--agent-file", type=Path, help="vorhandene Kopie des update-agent statt Download; " "die Pruefsumme wird trotzdem gegen installer.json geprueft") parser.add_argument("--no-shell", action="store_true", help="die Huelle (Fenster mit Chromium, ~370 MB) nicht mitliefern") parser.add_argument("--dry-run", action="store_true", help="nur bauen und pruefen, nicht hochladen") args = parser.parse_args() version = read_version() print(f"ClawdDotNet {version} | {args.rid} | Kanal {args.channel}") out_dir = Path(tempfile.gettempdir()) / f"clawddotnet-publish-{args.rid}" print("\n[1/3] dotnet publish") publish(args.rid, out_dir, include_shell=not args.no_shell) print("\n[2/3] update-agent") if args.no_agent: print(" uebersprungen (--no-agent)") else: fetch_agent(args.rid, out_dir, args.agent_file) total = sum(f.stat().st_size for f in out_dir.rglob("*") if f.is_file()) print(f"\n {len(list(out_dir.rglob('*')))} Eintraege, {total / 1_048_576:.1f} MB") if args.dry_run: print(f"\n[3/3] uebersprungen (--dry-run). Ergebnis liegt in {out_dir}") return if not CONFIG.exists(): sys.exit(f"{CONFIG} fehlt. Vorlage: deploy/packager.config.example.json") print("\n[3/3] pack-and-deploy") cmd = [ str(PACKAGER), "--config", str(CONFIG), "--project", "clawddotnet", "--version", version, "--channel", args.channel, "--platform", args.rid, "--publish-dir", str(out_dir), ] if args.changelog: cmd += ["--changelog", args.changelog] run(cmd) if __name__ == "__main__": main()