Files
tessera-ctl/.planning/phases/18-desktop-client-fertigstellen/18-02-PLAN.md
T

291 lines
18 KiB
Markdown

---
phase: 18-desktop-client-fertigstellen
plan: 02
type: execute
wave: 2
depends_on: ["18-01"]
files_modified:
- .gitea/workflows/ci.yml
- .gitea/scripts/publish-images.sh
- .gitea/scripts/publish-release.sh
autonomous: true
requirements: [DESK-01, DESK-04]
user_setup: []
estimate:
tokens: 45000
raw_tokens: 45000
tasks: 2
confidence: low
must_haves:
truths:
- "Der CI-Job desktop laeuft nach test auf main und bei Tags v*, baut das Linux-AppImage mit der Tag-Version und uebergibt desktop-dist/ per actions/cache an publish (D-06, D-07)."
- "publish bricht hart ab, wenn das Manifest aus dem Zwischenspeicher fehlt — nie ein Abbild ohne Pakete (D-08, Pitfall 1)."
- "publish-release.sh haengt bei Tags jede Datei aus dem Manifest idempotent als Release-Datei an den Gitea-Release; das Token verlaesst nie die Header-Datei (D-01, D-08)."
artifacts:
- path: ".gitea/workflows/ci.yml"
provides: "Job desktop (Linux-AppImage) und Uebergabe an publish per actions/cache"
contains: "desktop-dist-${{ gitea.sha }}"
- path: ".gitea/scripts/publish-images.sh"
provides: "Harte Pruefung auf desktop-dist/manifest.json vor dem Docker-Bau"
contains: "manifest.json"
- path: ".gitea/scripts/publish-release.sh"
provides: "Idempotenter Upload der Release-Dateien (GET assets, DELETE, POST multipart)"
contains: "upload_asset"
key_links:
- from: ".gitea/workflows/ci.yml (desktop)"
to: ".gitea/workflows/ci.yml (publish)"
via: "actions/cache/save + actions/cache/restore mit Schluessel desktop-dist-${{ gitea.sha }}, fail-on-cache-miss: true"
pattern: "fail-on-cache-miss"
- from: ".gitea/workflows/ci.yml (desktop)"
to: ".gitea/scripts/desktop-version.sh + desktop-collect.sh"
via: "Schritte 'Version setzen' und 'Pakete einsammeln'"
pattern: "desktop-collect.sh --require linux"
- from: ".gitea/scripts/publish-release.sh"
to: "desktop-dist/manifest.json"
via: "jq -r '.files[].name' — nur Dateien aus dem Manifest werden hochgeladen"
pattern: "files\\[\\]"
---
<objective>
Die in 18-01 lokal bewiesene Strecke wird in die Pipeline gehoben: ein neuer
Job `desktop` baut auf `main` und bei Tags `v*` das Linux-AppImage mit der
Tag-Version, sammelt es mit Manifest ein und uebergibt `desktop-dist/` per
`actions/cache` an `publish`, das ohne Manifest hart abbricht und die Pakete
ins API-Abbild kopiert. Bei Tags haengt `publish-release.sh` jede Datei aus
dem Manifest an den Gitea-Release. Der Windows-Cross-Bau kommt in 18-05 in
denselben Job; der echte Pipeline-Lauf wird dort mit beiden Dateien bewiesen.
Purpose: D-06, D-08 (Pipeline-Seite) und D-01 (Release-Dateien) aus
18-CONTEXT.md; Erfolgskriterium 1 (Linux-Haelfte und Release-Anhang).
Output: Job `desktop`, angepasster Job `publish`, Manifest-Pruefung in
`publish-images.sh`, Funktion `upload_asset` in `publish-release.sh`.
**Externe Schnittstellen (Gitea REST):** siehe `18-COVERAGE.md` — neu sind
`GET …/releases/{id}/assets`, `DELETE …/releases/{id}/assets/{asset_id}` und
`POST …/releases/{id}/assets?name=` (multipart-Feld `attachment`); Gitea
1.26.2 laesst Release-Anhaenge standardmaessig ohne Typ-Beschraenkung bis
2048 MB zu.
</objective>
## Artifacts this phase produces
Dieser Plan: `.gitea/workflows/ci.yml` (Job `desktop`: Schritte
"Systemabhaengigkeiten", "Rust-Toolchain", "Cargo-Zwischenspeicher", "Version
setzen", "Rust pruefen", "Alte Bundles entfernen", "Linux-AppImage bauen",
"Pakete einsammeln", "Uebergabe an publish"; Job `publish`: "Desktop-Pakete
aus dem Zwischenspeicher holen", "Pakete pruefen"),
`.gitea/scripts/publish-images.sh` (Manifest-Pruefung),
`.gitea/scripts/publish-release.sh` (`HDR_AUTH`, `upload_asset`,
`DESKTOP_DIST`). Gesamtliste der Phase: siehe 18-01-PLAN.md.
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/18-desktop-client-fertigstellen/18-CONTEXT.md
@.planning/phases/18-desktop-client-fertigstellen/18-RESEARCH.md
@.planning/phases/18-desktop-client-fertigstellen/18-COVERAGE.md
@.planning/phases/18-desktop-client-fertigstellen/18-01-SUMMARY.md
@.gitea/workflows/ci.yml
@.gitea/scripts/publish-images.sh
@.gitea/scripts/publish-release.sh
@.gitea/scripts/desktop-collect.sh
@.gitea/scripts/desktop-version.sh
</context>
<tasks>
<task type="auto">
<name>Task 1: Job desktop (Linux-AppImage) und Uebergabe an publish per actions/cache</name>
<reversibility rating="reversible">Job-Aufbau und Cache-Schluessel lassen sich jederzeit aendern; kein Zustand ausserhalb des Runners.</reversibility>
<files>
.gitea/workflows/ci.yml,
.gitea/scripts/publish-images.sh
</files>
<read_first>
.gitea/workflows/ci.yml,
.gitea/scripts/publish-images.sh,
.gitea/scripts/desktop-collect.sh (Optionen und Ausgabe, aus 18-01),
.planning/phases/18-desktop-client-fertigstellen/18-RESEARCH.md (Abschnitte "Code Examples 2", "Common Pitfalls 1 und 5", "Standard Stack: Installation"),
docs/ci-cd-setup.md (Abschnitt 4 "Pipeline-Ueberblick")
</read_first>
<action>
**Job `desktop` in `.gitea/workflows/ci.yml`** zwischen `test` und `publish`
einfuegen, Kopfkommentar der Datei um einen Satz zu Phase 18 ergaenzen.
`name: Desktop-Pakete bauen`, `runs-on: ubuntu-latest`, `needs: test`,
`if: gitea.ref == 'refs/heads/main' || startsWith(gitea.ref, 'refs/tags/v')`
(D-06). Schritte in dieser Reihenfolge, deutsche Schrittnamen wie im Rest der
Datei: `actions/checkout@v4` mit `fetch-depth: 0` (Tags fuer `git describe`);
`actions/setup-node@v4` (Node 24); corepack/pnpm wie in `test`;
`pnpm install --frozen-lockfile`; "Systemabhaengigkeiten":
`sudo apt-get update` und `sudo apt-get install -y --no-install-recommends`
mit **vollstaendiger** Liste `libwebkit2gtk-4.1-dev libjavascriptcoregtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev libgtk-3-dev libssl-dev patchelf file xdg-utils`
(Pitfall 5 — das Runner-Abbild hat davon nur `librsvg2-dev` und `file`;
alle Paketnamen wurden am 2026-09-16 per `apt-cache policy` im Abbild
`gitea/runner-images:ubuntu-latest` bestaetigt, ebenso `sudo`, `jq`, `curl`
und `git`); "Rust-Toolchain": `curl -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable`
und danach `echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"` (kein Rust im
Runner-Abbild; bewusst kein Fremd-Action, gleiche Zurueckhaltung wie beim
Verzicht auf die Artefakt-Aktionen); "Cargo-Zwischenspeicher": `actions/cache@v4`
mit `path` `~/.cargo/registry`, `~/.cargo/git`, `~/.cache/tauri`,
`apps/desktop/src-tauri/target`, `key: desktop-cargo-${{ hashFiles('apps/desktop/src-tauri/Cargo.lock') }}`,
`restore-keys: desktop-cargo-` (der Cache-Server des Runners ist laut
RESEARCH aktiv: `172.18.0.1:42641`); "Version setzen":
`sh .gitea/scripts/desktop-version.sh`; "Rust pruefen":
`cargo check` und `cargo clippy` mit `working-directory: apps/desktop/src-tauri`
(D-16; Clippy ohne `-D warnings`, Fehler brechen ab, Warnungen nicht);
"Alte Bundles entfernen": `rm -rf apps/desktop/src-tauri/target/release/bundle`
(ein aus dem Cache wiederhergestelltes altes AppImage wuerde sonst neben dem
neuen liegen und das Sammel-Skript zu Recht abbrechen); "Linux-AppImage
bauen": `pnpm --filter @tessera/desktop exec tauri build --bundles appimage`;
"Pakete einsammeln": `sh .gitea/scripts/desktop-collect.sh --require linux`
(18-05 erweitert auf `linux,windows`); "Uebergabe an publish":
`actions/cache/save@v4` mit `path: desktop-dist` und
`key: desktop-dist-${{ gitea.sha }}` (Pitfall 1: bewusst **nicht** die
Artefakt-Aktionen von GitHub — auf dieser Gitea-Instanz dokumentiert
unzuverlaessig; im Workflow-Kommentar ebenfalls nur so umschreiben, damit
das Negativ-Tor in `<verify>` nicht am Kommentartext scheitert).
**Job `publish` anpassen:** `needs: desktop` statt `needs: test`. Nach dem
Checkout und vor dem Registry-Login zwei Schritte: "Desktop-Pakete aus dem
Zwischenspeicher holen" mit `actions/cache/restore@v4`, `path: desktop-dist`,
`key: desktop-dist-${{ gitea.sha }}`, `fail-on-cache-miss: true`; "Pakete
pruefen": `test -f desktop-dist/manifest.json` und `jq . desktop-dist/manifest.json`
(harter Abbruch, nie stillschweigend ein Abbild ohne Pakete). Der Schritt mit
`publish-release.sh` bleibt; die Pakete liegen fuer ihn unter `desktop-dist/`.
**`publish-images.sh`:** Im echten Bau-Pfad (nicht bei `--print-plan`) vor
der Schleife pruefen, dass `desktop-dist/manifest.json` existiert, sonst
Exit 1 mit Meldung — zweites Netz gegen Pitfall 1. Kopfkommentar um einen
Absatz ergaenzen (Phase 18: die Pakete kommen aus dem Job `desktop`, das
Dockerfile der API kopiert `desktop-dist/`). Weiterhin kein Secret.
</action>
<acceptance_criteria>
- `grep -c '^ desktop:$' .gitea/workflows/ci.yml` ergibt 1; `grep -c 'needs: desktop' .gitea/workflows/ci.yml` ergibt 1; `grep -c 'fail-on-cache-miss: true' .gitea/workflows/ci.yml` ergibt 1; `grep -c 'desktop-dist-${{ gitea.sha }}' .gitea/workflows/ci.yml` ergibt 2 (save und restore).
- `grep -c 'upload-artifact' .gitea/workflows/ci.yml` ergibt 0.
- `grep -c 'libwebkit2gtk-4.1-dev' .gitea/workflows/ci.yml` ergibt mindestens 1; `grep -c 'desktop-collect.sh --require linux' .gitea/workflows/ci.yml` ergibt 1; `grep -c 'desktop-version.sh' .gitea/workflows/ci.yml` ergibt 1.
- `sh -n .gitea/scripts/publish-images.sh` endet mit 0; `GITHUB_REF=refs/tags/v1.1.0 sh .gitea/scripts/publish-images.sh --print-plan` gibt weiterhin die vier `push`-Zeilen aus (Probelauf braucht kein Manifest).
- `grep -c 'manifest.json' .gitea/scripts/publish-images.sh` ergibt mindestens 1.
</acceptance_criteria>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl && test "$(grep -c 'desktop-dist-${{ gitea.sha }}' .gitea/workflows/ci.yml)" = "2" && grep -q 'fail-on-cache-miss: true' .gitea/workflows/ci.yml && grep -q 'needs: desktop' .gitea/workflows/ci.yml && test "$(grep -c 'upload-artifact' .gitea/workflows/ci.yml)" = "0" && grep -q 'desktop-collect.sh --require linux' .gitea/workflows/ci.yml && grep -q 'desktop-version.sh' .gitea/workflows/ci.yml && node -e "const y=require('fs').readFileSync('.gitea/workflows/ci.yml','utf8');if(!/^ desktop:\n/m.test(y)||!/^ publish:\n/m.test(y))process.exit(1)" && echo CI-OK</automated>
<fails_when>Cache-Schluessel nicht genau zweimal, Restore ohne harten Abbruch, publish haengt nicht an desktop, ein upload-artifact-Schritt ist vorhanden, Skript-Schritte fehlen, oder die Job-Schluessel fehlen — `CI-OK` fehlt.</fails_when>
<automated>cd /home/vicolab/projects/tessera-ctl && sh -n .gitea/scripts/publish-images.sh && GITHUB_REF=refs/tags/v1.1.0 sh .gitea/scripts/publish-images.sh --print-plan | grep -c '^push ' | grep -qx 4 && grep -q 'manifest.json' .gitea/scripts/publish-images.sh && echo IMAGES-OK</automated>
<fails_when>Syntaxfehler, weniger als vier push-Zeilen im Probelauf, oder die Manifest-Pruefung fehlt im Skript — `IMAGES-OK` fehlt.</fails_when>
</verify>
<done>
Der Workflow enthaelt den Job `desktop` (Linux-AppImage mit Tag-Version,
Cache, Uebergabe per `actions/cache`), `publish` haengt daran und bricht
ohne Manifest ab; `publish-images.sh` prueft das Manifest ebenfalls.
</done>
</task>
<task type="auto">
<name>Task 2: Release-Dateien idempotent an den Gitea-Release haengen</name>
<precondition>Das Gitea-Secret `REGISTRY_TOKEN` traegt `repository: write` (damit wurde am 2026-09-16 der Release v1.1.0 aus der Pipeline angelegt); es wird unveraendert weiterverwendet. Lokal liegt `desktop-dist/manifest.json` aus 18-01 vor (fuer den Probelauf).</precondition>
<files>
.gitea/scripts/publish-release.sh
</files>
<read_first>
.gitea/scripts/publish-release.sh (gesamt — Idempotenz-Muster GET -> case -> PATCH/POST, Header-Datei-Mechanik ab Zeile 117),
.planning/phases/18-desktop-client-fertigstellen/18-RESEARCH.md (Abschnitte "Code Examples 6", "Don't Hand-Roll", "Security Domain"),
.planning/phases/18-desktop-client-fertigstellen/18-COVERAGE.md,
desktop-dist/manifest.json (Form der `files`-Eintraege)
</read_first>
<action>
Kopfkommentar um Umgebung `DESKTOP_DIST` (Vorgabe `desktop-dist`) und die
drei neuen Endpunkte ergaenzen. Neben `$HDR` (mit JSON-Content-Type) eine
zweite Header-Datei `$HDR_AUTH` anlegen, die **nur** die
`Authorization`-Zeile traegt — beim multipart-Upload darf kein
`Content-Type: application/json` mitgehen; gleiche `umask 077`/`mktemp`/
`trap`-Mechanik, Token nie als Argument (T-18-03). Funktion
`upload_asset FILE NAME RELEASE_ID` nach dem Muster GET -> Entscheidung per
HTTP-Code -> Aktion: `GET $RELEASES_URL/$ID/assets` (200 erwartet), per
`jq -r --arg n "$NAME" '.[] | select(.name == $n) | .id'` vorhandene Datei
gleichen Namens ermitteln und mit `DELETE $RELEASES_URL/$ID/assets/$ASSET_ID`
entfernen (204 erwartet), dann
`curl -sS --header @"$HDR_AUTH" -X POST -F "attachment=@$FILE;filename=$NAME" -o "$RESP" -w '%{http_code}' "$RELEASES_URL/$ID/assets?name=$NAME"`
(201 erwartet; jeder andere Code: Meldung mit Code und Antwort nach stderr,
Exit 1). Aufruf nach dem bestehenden `case`-Block (Release angelegt oder
aktualisiert; `ID` aus beiden Zweigen verfuegbar machen): Manifest
`$DESKTOP_DIST/manifest.json` muss existieren, sonst Exit 1 (Release-Text ist
dann schon da, der Job wird sichtbar rot); fuer jeden Namen aus
`jq -r '.files[].name'` `upload_asset "$DESKTOP_DIST/$NAME" "$NAME" "$ID"`,
danach je Datei `Release-Datei $NAME hochgeladen`. `--dry-run` listet
zusaetzlich die geplanten Uploads (`POST $RELEASES_URL/{id}/assets?name=…`)
aus dem Manifest, falls es vorhanden ist.
Bekannter Fallstrick fuer 18-05: Der Job-Container erreicht Gitea ueber
`https://git.vicolab.de` hinter dem Nginx Proxy Manager; das AppImage ist
rund 106 MB — falls der Proxy den Upload abweist (413), kann `GITEA_API` im
Workflow-Schritt auf die Host-Adresse `http://172.18.0.1:3002/api/v1` gesetzt
werden (gleiche Route, ueber die der Runner seinen Cache-Server erreicht).
Das wird erst im CI-Lauf entschieden, nicht hier. Der Upload-Pfad selbst
laeuft erst beim naechsten Freigabe-Tag (ein Test-Tag wuerde den Live-Kanal
ausloesen) — deshalb ist der Probelauf mit `--dry-run` hier das Tor.
</action>
<acceptance_criteria>
- `sh -n .gitea/scripts/publish-release.sh` endet mit 0.
- `sh .gitea/scripts/publish-release.sh --dry-run --tag v1.1.0` gibt eine Zeile mit `assets?name=Tessera-1.1.0.AppImage` aus (Manifest aus 18-01 vorhanden) und endet mit 0; ohne Token, ohne Netzaufruf.
- `grep -c 'HDR_AUTH' .gitea/scripts/publish-release.sh` ergibt mindestens 3 (Anlegen, Schreiben, Verwendung); `grep -c '^upload_asset()' .gitea/scripts/publish-release.sh` ergibt 1.
- `grep -c "files\[\].name" .gitea/scripts/publish-release.sh` ergibt mindestens 1.
- Das Token wird nirgends als Argument uebergeben: `grep -c 'token %s' .gitea/scripts/publish-release.sh` ergibt genau 1 (die bestehende printf-Zeile in die Header-Datei) oder 2 (zweite Header-Datei), nie in einer `curl`-Zeile.
</acceptance_criteria>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl && sh -n .gitea/scripts/publish-release.sh && sh .gitea/scripts/publish-release.sh --dry-run --tag v1.1.0 | grep -q 'assets?name=Tessera-1.1.0.AppImage' && test "$(grep -c 'HDR_AUTH' .gitea/scripts/publish-release.sh)" -ge 3 && grep -q '^upload_asset()' .gitea/scripts/publish-release.sh && grep -q 'files\[\].name' .gitea/scripts/publish-release.sh && test "$(grep -c 'curl.*GITEA_TOKEN' .gitea/scripts/publish-release.sh)" = "0" && echo RELEASE-OK</automated>
<fails_when>Syntaxfehler, der Probelauf nennt den AppImage-Upload nicht, die zweite Header-Datei oder die Funktion fehlt, die Dateinamen kommen nicht aus dem Manifest, oder das Token steht in einer curl-Zeile — `RELEASE-OK` fehlt.</fails_when>
</verify>
<done>
Das Release-Skript laedt alle Manifest-Dateien idempotent hoch (vorhandene
Datei gleichen Namens wird ersetzt), das Token bleibt in Header-Dateien, der
Probelauf nennt die geplanten Uploads. Der echte Pipeline-Beweis folgt in
18-05 (gemeinsam mit Windows), der Release-Anhang beim naechsten Freigabe-Tag.
</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| CI-Runner -> Gitea-API (Release-Dateien) | Ausgehender Aufruf mit dem Zugriffstoken `REGISTRY_TOKEN`. |
| Runner -> Cache-Server (`actions/cache`) | Uebergabe der Pakete zwischen zwei Jobs desselben Laufs. |
| Runner -> Internet (rustup, crates.io, Tauri-Werkzeuge) | Der Job laedt Werkzeuge aus dem Netz. |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-18-03 | Information Disclosure | `publish-release.sh` (Token) | high | mitigate | Token nur aus der Umgebung, nie als Argument, nur ueber Header-Dateien mit `umask 077`; keine Ausgabe des Tokens; zweite Header-Datei ohne JSON-Content-Type fuer multipart. Gate: keine `curl`-Zeile enthaelt `GITEA_TOKEN`. |
| T-18-06 | Tampering | `publish` ohne Pakete (Cache-Fehlschlag) | medium | mitigate | `fail-on-cache-miss: true` plus expliziter `test -f desktop-dist/manifest.json` im Workflow und in `publish-images.sh`. |
| T-18-21 | Tampering | Cache-Uebergabe zwischen Jobs (`desktop-dist-{sha}`) | low | accept | Cache-Server nur lokal fuer diesen Runner (`172.18.0.1`), Schluessel exakt am Commit-SHA, keine `restore-keys`-Fallbacks fuer die Uebergabe. |
| T-18-SC | Tampering | Paketinstallationen (`actions/cache@v4`, `actions/checkout@v4`, `actions/setup-node@v4`; Rust-Toolchain per rustup) | low | mitigate | Nur GitHub-eigene Actions in der bereits genutzten Major-Version; rustup-Installer von der offiziellen Adresse; keine neuen npm/pip/cargo-Pakete in diesem Plan. |
</threat_model>
<verification>
1. `ci.yml` enthaelt Job `desktop`, `publish` mit `needs: desktop`, Cache-Restore mit hartem Abbruch, kein upload-artifact.
2. `publish-images.sh` und `publish-release.sh` bestehen `sh -n`; Probelaeufe zeigen die erwarteten Zeilen (`push` x4, `assets?name=Tessera-1.1.0.AppImage`).
3. Kein `curl`-Aufruf traegt das Token als Argument.
4. Der echte Lauf wird in 18-05 bewiesen; der Release-Anhang beim naechsten Tag (18-06, human-check Punkt b).
</verification>
<success_criteria>
- Job `desktop` baut das AppImage mit Tag-Version und uebergibt es per Cache.
- `publish` kann kein Abbild ohne Pakete mehr bauen.
- Release-Dateien werden bei Tags idempotent aus dem Manifest hochgeladen.
</success_criteria>
<output>
Create `.planning/phases/18-desktop-client-fertigstellen/18-02-SUMMARY.md` when done.
</output>