Files
tessera-ctl/docs/ci-cd-setup.md
T
schalli 7479cb485f
Tessera CI/CD / Lint & Type Check (push) Successful in 48s
Tessera CI/CD / Tests (push) Successful in 1m8s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 7m56s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m12s
docs: Desktop-App — Update in der App (Handbücher, CI-Secrets, CHANGELOG)
- Anwenderhandbuch: Menüeintrag "Update installieren", Ablauf per Klick
  unter Windows/Linux, Fehlerfall, https-Bedingung, einmaliger Wechsel für
  Clients bis 1.2.0
- Betriebshandbuch Kap. 10: Signierschlüssel (Secrets, Ablage, Sicherung,
  Verlust), Kontrollzeile /desktop/update, Manifest-Felder, zwei Fehlerbilder
- Entwicklungshandbuch: lokal `tauri build --no-sign`, Hinweise zu tauri dev
- ci-cd-setup.md: die zwei neuen Secrets, Bau-Schritte signieren,
  Fehlerbild "no private key"
- CHANGELOG: eine Zeile unter Unveröffentlicht → Neu

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 15:36:58 +02:00

20 KiB

Tessera CI/CD Setup

Runbook fuer die Einrichtung der Gitea-basierten CI/CD-Pipeline.

Voraussetzungen

  • Docker und Docker Compose installiert
  • Gitea-Instanz mit aktivierten Actions
  • Zugriff auf die Gitea-Administrationsoberflaeche

1. Gitea-Repository einrichten

  1. In Gitea ein neues Repository erstellen (z.B. tessera-ctl)
  2. Das Repository als Git-Remote hinzufuegen:
git remote add origin https://<gitea-url>/<user>/tessera-ctl.git
  1. In den Repository-Einstellungen unter Settings > Actions sicherstellen, dass Actions aktiviert ist.

2. act_runner registrieren

Der act_runner fuehrt Gitea Actions Workflow-Jobs in Docker-Containern aus.

Runner-Token generieren

  1. In Gitea navigieren zu: Repository > Settings > Actions > Runners (oder Admin-Panel > Actions > Runners fuer globale Runner)
  2. Create registration token klicken
  3. Token kopieren

Runner starten

Die Umgebungsvariablen setzen (z.B. in .env im Projektverzeichnis oder direkt in der Shell):

export GITEA_INSTANCE_URL=https://git.vicolab.de
export GITEA_RUNNER_REGISTRATION_TOKEN=<token-aus-schritt-oben>

Runner starten:

docker compose -f docker-compose.ci.yml up -d

Runner-Status pruefen:

docker compose -f docker-compose.ci.yml logs act_runner

Der Runner registriert sich automatisch bei Gitea und ist bereit, Jobs auszufuehren.

Bestehender Runner

Wenn bereits ein act_runner als separater Docker-Container laeuft (z.B. gitea-runner), kann docker-compose.ci.yml als Dokumentation und Vorlage fuer eine Neueinrichtung verwendet werden. Der bestehende Runner muss nicht ersetzt werden.

Bestehenden Runner pruefen:

docker ps --filter name=gitea-runner

3. Erforderliche Konfiguration

Umgebungsvariablen

Variable Beschreibung Wo setzen
GITEA_INSTANCE_URL URL der Gitea-Instanz .env oder Systemumgebung
GITEA_RUNNER_REGISTRATION_TOKEN Runner-Registrierungstoken .env oder Systemumgebung

Gitea Secrets (fuer die CI-Pipeline)

In Gitea unter Repository > Settings > Actions > Secrets werden die Secrets fuer die Pipeline konfiguriert. Benoetigt werden drei:

Secret Beschreibung
REGISTRY_TOKEN Gitea-Zugangstoken (Access Token) mit Schreibrecht auf Pakete (package: write) und zusaetzlich auf das Repository (repository: write, fuer Releases). Wird im Job publish fuer docker login localhost:3002 --password-stdin verwendet und im Release-Schritt ueber env als GITEA_TOKEN an .gitea/scripts/publish-release.sh gereicht -- nie als Argument.
TAURI_SIGNING_PRIVATE_KEY Inhalt der privaten Schluesseldatei des Tauri-Updaters (eine Base64-Zeile, erzeugt mit tauri signer generate). Wird ausschliesslich an den zwei tauri build-Schritten des Jobs desktop als env gesetzt; die Tauri-CLI signiert damit das AppImage und den Windows-Installer (.sig neben dem Bundle). Der oeffentliche Gegenpart steht in apps/desktop/src-tauri/tauri.conf.json (plugins.updater.pubkey).
TAURI_SIGNING_PRIVATE_KEY_PASSWORD Passwort zu diesem Schluessel; gleiche Stelle, gleicher Umfang.

Das Token erscheint nie im Log: es wird per --password-stdin uebergeben und Gitea maskiert Secret-Werte in der Job-Ausgabe. Das Veroeffentlichungs-Skript .gitea/scripts/publish-images.sh kennt das Token nicht; der Login bleibt im Workflow.

Der Signierschluessel wird ebenfalls nie ausgegeben: die Skripte kennen ihn nicht (desktop-collect.sh prueft nur, OB die Variable gesetzt ist, um die Signatur zur Pflicht zu machen), nur die beiden Bau-Schritte sehen ihn -- weder pnpm install, apt-get, cargo install cargo-xwin noch die Cache-Schritte. Die Sicherung des Schluessels ausserhalb der Pipeline (~/.tessera/desktop-updater/ auf dem Entwicklungsrechner) beschreibt das Betriebshandbuch, Kapitel 10.

Der Push geht ueber localhost:3002 (Gitea laeuft auf demselben Rechner wie der Runner), weil der Nginx Proxy Manager vor git.vicolab.de grosse Image-Blobs blockt. Das Pullen auf den Servern laeuft ueber git.vicolab.de (docker-compose.prod.yml).

Weitere Secrets bei Bedarf:

  1. In Gitea Settings > Actions > Secrets den Secret anlegen
  2. In .gitea/workflows/ci.yml ueber ${{ secrets.SECRET_NAME }} referenzieren

Wichtig: Secrets niemals in Logs ausgeben oder in Workflow-Dateien hardcoden.

4. Pipeline-Ueberblick

Die CI/CD-Pipeline (.gitea/workflows/ci.yml) laeuft bei jedem Push auf die Zweige main und live sowie bei jedem Tag v* (z. B. v1.0.0) und besteht aus vier aufeinander aufbauenden Jobs:

  1. quality -- Lint und TypeScript Type-Check (Lint ist derzeit ein Leerlauf, siehe WINDOWS #35; der Type-Check ist echt)
  2. test -- Vitest Unit- und Integrationstests
  3. desktop -- Desktop-Pakete fuer Windows und Linux bauen (nur auf main und bei Tags v*, siehe unten)
  4. publish -- Docker Images mit Versionsstempel bauen und in die Gitea-Registry veroeffentlichen

Ablauf: quality -> test -> desktop -> publish (jeder Job nur bei Erfolg des vorherigen; desktop selbst laeuft nur, wenn die if-Bedingung zutrifft -- auf einem Push nach live ohne Tag entfaellt der Job, publish startet in diesem Fall trotzdem, weil needs: desktop bei einem uebersprungenen Job nicht blockiert). Der Job publish besteht aus sechs Schritten: actions/checkout@v4 mit fetch-depth: 0 (volle Historie samt Tags, sonst liefert git describe nichts), der Wiederherstellung der Desktop-Pakete aus dem Zwischenspeicher, einer harten Pruefung des Manifests, Login in die Registry (siehe Abschnitt 3), der Aufruf von .gitea/scripts/publish-images.sh und der Aufruf von .gitea/scripts/publish-release.sh (legt bei Tags v* den Gitea-Release aus dem CHANGELOG-Abschnitt an und haengt die Desktop-Pakete als Dateien an; auf main endet er mit "nichts zu tun").

Job desktop: Windows- und Linux-Pakete auf dem Linux-Runner

Der Job desktop baut auf demselben ubuntu-latest-Runner nacheinander (ein Cache, ein Runner, siehe 18-CONTEXT.md Specific Ideas) sowohl das Linux-AppImage als auch -- per Cross-Bau -- den Windows-Installer:

Ueberspringen bei unveraendertem Desktop (quick-260917-jdh): Direkt nach dem Checkout berechnet desktop-stamp.sh stamp den Stempel <Version>-<SHA> (Version aus desktop-version.sh --print; SHA = letzter Commit an apps/desktop, desktop-version.sh, desktop-collect.sh, desktop-stamp.sh, ci.yml -- Konstante DESKTOP_PATHS; pnpm-lock.yaml bewusst nicht, weil die Tauri-CLI-Version an apps/desktop/package.json haengt und der Bau keine Datei ausserhalb von apps/desktop liest) und gibt skip_allowed=true nur fuer refs/heads/main aus. Danach actions/cache/restore@v4 mit dem Schluessel desktop-dist-stamp-<Stempel> -- ohne restore-keys, weil act_runner auch den Hauptschluessel per Praefix sucht und ein aelterer Stand nie als Treffer gelten darf. Anschliessend desktop-stamp.sh check: cache-hit, Manifest, Kanal beta, Version, beide Dateien mit Groesse und sha256 laut Manifest -> reuse=true; sonst raeumt es desktop-dist/ auf und gibt reuse=false. Alle Bau-Schritte (setup-node bis Pakete einsammeln) tragen if: steps.reuse.outputs.reuse != 'true'. Nach einem echten Bau legt actions/cache/save@v4 die Pakete zusaetzlich unter dem Stempel-Schluessel ab (nur auf main). Die Uebergabe an publish (desktop-dist-<sha>) laeuft in beiden Faellen; publish ist unveraendert. Bei Tags v* wird weder gesucht noch abgelegt. Lokale Probe: DESKTOP_TAG=v1.2.0 GITHUB_REF=refs/heads/main sh .gitea/scripts/desktop-stamp.sh stamp.

  1. Systemabhaengigkeiten (apt-get install): WebKit/Tauri-Pakete (libwebkit2gtk-4.1-dev usw.) fuer den Linux-Bau, dazu lld llvm clang nsis fuer den Windows-Cross-Bau (der NSIS-Bundler ruft makensis aus genau diesem Paket auf).
  2. Rust-Toolchain per rustup (kein Rust im Runner-Abbild), inklusive rustup component add clippy -- --profile minimal installiert clippy sonst nicht mit (siehe Fehlerbehebung unten).
  3. Cargo-Zwischenspeicher (actions/cache@v4, Schluessel ueber den Hash von Cargo.lock): ~/.cargo/registry, ~/.cargo/git, ~/.cargo/bin/cargo-xwin, ~/.cache/cargo-xwin (die von cargo-xwin heruntergeladene Windows-SDK-Ablage -- soll nur einmal geladen werden), ~/.local/share/tauri (NSIS-Plugins) und apps/desktop/src-tauri/target.
  4. Windows-Werkzeuge -- bewusst NACH dem Cache-Wiederherstellungsschritt: rustup target add x86_64-pc-windows-msvc und command -v cargo-xwin || cargo install --locked cargo-xwin. Stuende dieser Schritt vor der Cache-Wiederherstellung, wuerde cargo-xwin bei jedem Lauf neu gebaut, selbst wenn der Cache es bereits enthaelt.
  5. Version setzen (desktop-version.sh), Rust pruefen (cargo check/cargo clippy, D-16), Bau des Linux-AppImage (tauri build --bundles appimage), dann des Windows-Installers per Cross-Bau (tauri build --runner cargo-xwin --target x86_64-pc-windows-msvc --bundles nsis). Beide Bau-Schritte tragen die Secrets TAURI_SIGNING_PRIVATE_KEY/_PASSWORD als env und signieren die Bundles (quick-260917-kgc): die Tauri-CLI legt .sig-Dateien neben -setup.exe und .AppImage ab -- host-unabhaengig, also auch im Cross-Bau. Kein --no-sign im CI.
  6. Pakete einsammeln (desktop-collect.sh --require linux,windows) -- schreibt manifest.json (mit updateVersion und je Plattform der signature aus der .sig-Datei) und schlaegt fehl, wenn eine der beiden Dateien fehlt oder auf main/Tags eine .sig fehlt.
  7. Uebergabe an publish per actions/cache/save@v4 mit dem Schluessel desktop-dist-${{ gitea.sha }} (ein neuer Schluessel je Commit, damit publish garantiert die Pakete des gerade gebauten Standes bekommt, nicht einen aelteren Cache-Treffer). Dieser Schritt laeuft auch dann, wenn der Bau uebersprungen wurde -- er sichert in diesem Fall den aus dem Stempel-Cache restaurierten Stand unter dem neuen SHA.

Warum actions/cache und nicht upload-artifact: Auf dieser Gitea-Instanz ist actions/upload-artifact/download-artifact unzuverlaessig (Erfahrungswert aus 18-02) -- die Uebergabe zwischen desktop und publish laeuft deshalb bewusst ueber actions/cache/save und actions/cache/restore mit fail-on-cache-miss: true, nicht ueber Artefakt-Uploads.

Das Release-Skript spricht die Gitea-API NIE ueber die oeffentliche Adresse (GITHUB_API_URL/GITHUB_SERVER_URL = https://git.vicolab.de hinter dem Proxy, der grosse Uploads abbricht -- so blieb Release 1.2.0 am 2026-09-17 zunaechst ohne Anhaenge). Im Job-Container ist localhost:3002 nicht der Host; das Skript ermittelt deshalb das Host-Gateway aus /proc/net/route (z. B. 172.17.0.1) und ruft http://<gateway>:3002/api/v1 auf -- derselbe Weg wie der Registry-Push. Lokal nimmt es http://localhost:3002/api/v1; GITEA_API bleibt als expliziter Override. Mit --dry-run --tag vX.Y.Z laesst sich die gewaehlte Adresse ohne Netzaufruf und ohne Token pruefen.

Zwei Kanaele: Etiketten je Anlass

Das Skript .gitea/scripts/publish-images.sh entscheidet allein anhand GITHUB_REF, ob und unter welchen Etiketten veroeffentlicht wird:

Anlass Kanal (APP_CHANNEL) Etiketten in der Registry
Push auf main beta beta und latest (latest ist nur ein Alias fuer beta und entfaellt spaeter)
Tag vX.Y.Z live live und vX.Y.Z + Gitea-Release Tessera X.Y.Z mit dem CHANGELOG-Abschnitt
Push auf live ohne Tag -- keine; der Lauf prueft nur (quality, test), das Skript endet mit "nichts zu tun"

Das Kanalmodell fuer den Betrieb (welcher Server welches Etikett zieht, Freigabe, Hotfix) steht in docs/anleitung-betrieb.md, Kapitel 9.

Versionsstempel

Das Skript berechnet vier Werte und gibt sie als --build-arg an beide Dockerfiles (apps/web/Dockerfile, apps/api/Dockerfile):

Build-Arg Quelle
APP_VERSION git describe --tags --always (ohne Tag: kurzer Commit-SHA)
APP_CHANNEL beta oder live, siehe Tabelle oben
APP_COMMIT git rev-parse --short HEAD
APP_BUILD_TIME date -u, ISO-Format

Die API liest die Werte zur Laufzeit (GET /health/version, Startzeile im Log). Das Web-Image bettet NEXT_PUBLIC_APP_* beim Build in das Browser-Bundle ein -- deshalb laeuft die builder-Stufe des Web-Images jetzt bei jedem Pipeline-Lauf neu, die Laufzeit liegt eher bei 4-6 statt 2 Minuten.

Lokale Probe ohne Docker-Aufruf:

GITHUB_REF=refs/tags/v1.2.3 sh .gitea/scripts/publish-images.sh --print-plan

Hinweis: Die fruehere Entscheidung D-13 (Images lokal bauen, keine Registry) ist ueberholt -- seit der Einfuehrung der Gitea-Registry werden Images gepusht und von den Servern per docker compose pull geholt.

5. Sicherheitshinweise

Docker Socket

Der act_runner mountet den Host-Docker-Socket (/var/run/docker.sock), um Jobs in Docker-Containern ausfuehren zu koennen. Das bedeutet:

  • Der Runner hat Zugriff auf den Docker-Daemon des Hosts
  • Er kann Container starten, stoppen und inspizieren

Risikobewertung: Akzeptabel fuer interne CI, da:

  • Nur Claude pusht manuell bei Meilensteinen (D-11)
  • Kein externer Code wird ausgefuehrt
  • Ephemeral Mode ist aktiviert (siehe unten)

Ephemeral Runner

GITEA_RUNNER_EPHEMERAL=1 sorgt dafuer, dass der Runner seine Credentials nach jedem Job widerruft und sich neu registriert. Das verhindert:

  • Persistente Zugriffstokens im Runner-Container
  • Kompromittierung ueber alte Job-Artefakte
  • Seitliche Bewegung zwischen Jobs

Workflow-Dateien

Workflow-Dateien in .gitea/workflows/ werden direkt aus dem Repository geladen. Da nur vertrauenswuerdiger Code gepusht wird (D-11, Claude als einziger Committer), ist das Risiko einer manipulierten Pipeline minimal.

Ein Tag-Push v* ist der Freigabe-Hebel fuer Live: wer ihn setzen darf, kann das live-Etikett neu belegen. Heute hat nur das Konto schalli Schreibrecht (0 Kollaborateure, keine Branch-Regeln). Kommen weitere Konten dazu, in Gitea unter Repository > Settings > Branches / Tags eine Tag-Schutzregel fuer v* und einen Branch-Schutz fuer live anlegen (T-KU1-04).

6. Fehlerbehebung

Runner registriert sich nicht

  1. Pruefen ob GITEA_INSTANCE_URL korrekt und erreichbar ist
  2. Token ggf. neu generieren (Token sind einmalig verwendbar bei Ephemeral Mode)
  3. Runner-Logs pruefen: docker compose -f docker-compose.ci.yml logs act_runner

Pipeline startet nicht

  1. In Gitea pruefen ob Actions fuer das Repository aktiviert ist
  2. Pruefen ob der Runner als "online" angezeigt wird (Gitea > Actions > Runners)
  3. Sicherstellen dass die Workflow-Datei unter .gitea/workflows/ liegt
  4. YAML-Syntax validieren

Docker-Build schlaegt fehl

  1. Sicherstellen dass docker-compose.yml im Arbeitsverzeichnis des Runners erreichbar ist
  2. Docker Daemon Status pruefen: docker info
  3. Disk Space pruefen: df -h

Stempel zeigt dev oder nur eine Kurzkennung statt des Tags

  1. Im Job publish pruefen, dass actions/checkout@v4 mit fetch-depth: 0 auscheckt -- ohne Tags liefert git describe --tags --always nur den SHA
  2. Pruefen, ob der Tag wirklich gepusht wurde: git ls-remote --tags origin
  3. dev bedeutet: das Image wurde ohne Build-Args gebaut (lokal statt ueber das Skript) -- das ist fuer lokale Builds normal

Job desktop schlaegt fehl

  1. cargo clippy meldet 'cargo-clippy' is not installed for the toolchain -- der Schritt "Rust-Toolchain" installiert mit --profile minimal, das clippy nicht mitbringt. Behoben durch rustup component add clippy direkt nach der Toolchain-Installation (siehe oben); bei einem aehnlichen Fehlerbild in Zukunft pruefen, ob diese Zeile noch vorhanden ist.
  2. apt-Paketname unbekannt / pkg-config findet eine Bibliothek nicht -- Paketnamen aendern sich gelegentlich zwischen Ubuntu-Versionen des Runner-Abbilds; den fehlenden .pc/.so-Namen aus der Fehlermeldung in apt-cache search nachschlagen und die Paketliste im Schritt "Systemabhaengigkeiten" ergaenzen.
  3. openssl-sys scheitert beim Windows-Cross-Ziel -- OpenSSL laesst sich fuer x86_64-pc-windows-msvc von Linux aus nicht ohne Weiteres cross-kompilieren; falls eine neue Abhaengigkeit das ueber openssl-sys statt rustls-tls einzieht, das Feature/die Abhaengigkeit auf rustls-tls umstellen (in diesem Job bislang nicht aufgetreten, reqwest ist bereits auf rustls-tls konfiguriert).
  4. NSIS-Plugin-Download schlaegt fehl -- Tauris NSIS-Bundler laedt beim ersten Bau zusaetzliche Plugins nach ~/.local/share/tauri; ein Netzwerkfehler dort bricht den Bauschritt "Windows-Installer bauen (Cross-Bau)" ab. Lauf erneut anstossen; bleibt der Cache warm, entfaellt der Download beim naechsten Mal.
  5. Runner-Speicher/-Zeit reicht nicht -- der Rust-Bau laeuft auf einem begrenzten Runner (8 Kerne/15 GB, siehe 18-CONTEXT.md); bei Speicherdruck CARGO_BUILD_JOBS (z. B. auf 4) als Umgebungsvariable im Job setzen, um die parallele Uebersetzung zu drosseln.

Job desktop: "A public key has been found, but no private key"

Die Tauri-CLI bricht den Bau ab, weil plugins.updater.pubkey in tauri.conf.json gesetzt ist, aber TAURI_SIGNING_PRIVATE_KEY in der Umgebung fehlt. Ursache sind fast immer fehlende oder umbenannte Secrets: in Gitea unter Repository > Settings > Actions > Secrets pruefen, ob TAURI_SIGNING_PRIVATE_KEY und TAURI_SIGNING_PRIVATE_KEY_PASSWORD unter genau diesen Namen existieren, und ob beide Bau-Schritte in ci.yml den env-Block tragen. Niemals --no-sign in ci.yml eintragen -- damit entstuenden unsignierte Pakete, die kein Client als Update annimmt (desktop-collect.sh bricht auf main/Tags ohne .sig ohnehin ab).

desktop baut, obwohl nichts geaendert wurde -- oder uebernimmt trotz Aenderung

Baut trotzdem:

  1. Erster Lauf nach einer Aenderung unter den Desktop-Pfaden -- der Stempel ist neu, das ist erwartet.
  2. Ein neuer Freigabe-Tag ist gesetzt -- die Version im Stempel hat sich geaendert, die Beta-Pakete muessen einmal neu entstehen.
  3. Der Eintrag ist vom Runner-Cache weggeraeumt worden -- act_runner raeumt ungenutzte Eintraege nach einigen Tagen, aeltere nach etwa einem Monat weg.
  4. ci.yml oder eines der Desktop-Skripte wurde geaendert -- beides gehoert selbst zur Pfadliste DESKTOP_PATHS.
  5. desktop-stamp.sh check hat den gefundenen Eintrag verworfen -- der Grund steht im Log des Schritts "Gefundene Pakete pruefen".

Uebernimmt trotz Aenderung: Die Aenderung liegt ausserhalb der Pfadliste (zum Beispiel nur pnpm-lock.yaml). Entweder zusaetzlich etwas unter apps/desktop/ aendern, oder DESKTOP_PATHS in .gitea/scripts/desktop-stamp.sh um den betroffenen Pfad erweitern -- diese Erweiterung loest selbst einen Neubau aus, weil desktop-stamp.sh Teil der eigenen Pfadliste ist.

publish: cache miss

actions/cache/restore@v4 mit fail-on-cache-miss: true bricht den Job publish hart ab, wenn kein Eintrag unter dem Schluessel desktop-dist-${{ gitea.sha }} existiert. Wahrscheinlichste Ursachen:

  1. Der Job desktop ist fehlgeschlagen oder uebersprungen worden (siehe if-Bedingung oben) -- im Gitea-Actions-Lauf pruefen, ob desktop tatsaechlich gruen war.
  2. Der Zwischenspeicher-Server des act_runner ist nicht erreichbar oder nicht aktiviert -- Runner-Konfiguration pruefen (Abschnitt 2, [cache] enabled muss gesetzt sein).
  3. Der Commit-SHA im Schluessel weicht zwischen den Jobs ab (sollte bei gitea.sha innerhalb desselben Laufs nicht vorkommen) -- bei Verdacht die Job-Logs beider Schritte (Uebergabe an publish in desktop, Desktop-Pakete aus dem Zwischenspeicher holen in publish) auf den verwendeten Schluessel vergleichen.

Release-Upload 413

Bricht der Datei-Upload in publish-release.sh mit HTTP 413 oder curl: (92) HTTP/2 ... PROTOCOL_ERROR ab, laeuft er ueber den Proxy vor git.vicolab.de -- genau das ist am 2026-09-17 bei Release 1.2.0 passiert (AppImage, 82 MB). Seitdem geht das Skript von selbst ueber das Host-Gateway (siehe Abschnitt 4); der Fehler kann nur noch auftreten, wenn GITEA_API ausdruecklich auf die oeffentliche Adresse gesetzt wird. Fehlende Anhaenge lassen sich jederzeit vom Host nachtragen: GITEA_TOKEN=... DESKTOP_DIST=<Ordner mit manifest.json> sh .gitea/scripts/publish-release.sh --tag vX.Y.Z (die Pakete liegen im API-Abbild unter /app/desktop-dist).