- Engine: leere Antworten nachfassen, Aufgaben enden mit Ergebniszeile (EmptyResponse/Incomplete), gleiche Fehlschlaege erkennen (RepeatedFailure), Zeitzeile vor Chatnachrichten, Werkzeugergebnisse gerahmt, Vorpruefung vor dem Staging, Belegkosten wie RunUsage - Staging: gescheiterte Freigaben als Failed; AgentEditor-Aenderungen freigabepflichtig, Selbstbearbeitung gesperrt (allowSelfEdit) - Taskboard: Termine beim Anlegen pruefen, naechster/letzter Lauf, pausieren, Sofortlauf, Kostenprognose, Hinweis auf wachsenden Kontext; Jobs-Reiter und /api/jobs entfernt - Release: Instanzmanager samt Weboberflaeche im Paket, setup.json fragt manager.json ab, Token nicht mehr ins systemd-Journal Version 0.1.6. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
214 lines
7.8 KiB
Python
214 lines
7.8 KiB
Python
#!/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 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"
|
|
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"<Version>([^<]+)</Version>", text)
|
|
if not match:
|
|
sys.exit("Directory.Build.props enthaelt kein <Version>.")
|
|
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) -> None:
|
|
if out_dir.exists():
|
|
shutil.rmtree(out_dir)
|
|
out_dir.mkdir(parents=True)
|
|
|
|
run([
|
|
"dotnet", "publish", str(PROJECT),
|
|
"-c", "Release", "-r", rid, "--self-contained", "false",
|
|
"-o", str(out_dir), "--nologo", "-v", "q",
|
|
])
|
|
|
|
# #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",
|
|
])
|
|
merge_manager(manager_dir, out_dir)
|
|
|
|
|
|
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("--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)
|
|
|
|
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()
|