Files
tessera-ctl/docs/ci-cd-setup.md
T

521 lines
27 KiB
Markdown

# 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:
```bash
git remote add origin https://<gitea-url>/<user>/tessera-ctl.git
```
3. 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):
```bash
export GITEA_INSTANCE_URL=https://git.vicolab.de
export GITEA_RUNNER_REGISTRATION_TOKEN=<token-aus-schritt-oben>
```
Runner starten:
```bash
docker compose -f docker-compose.ci.yml up -d
```
Runner-Status pruefen:
```bash
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:
```bash
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 fuenf Jobs (vier bauen aufeinander auf, der fuenfte ist ein reiner Bericht):
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
5. **security** -- Sicherheitspruefung (nur Bericht): nach `publish`, nur auf `main`
und bei Tags `v*`; bricht nie ab, blockiert nie etwas, bekommt kein Secret
Ablauf: `quality` -> `test` -> `desktop` -> `publish` -> `security` (jeder Job nur
bei Erfolg des vorherigen; `security` ist nur ein Bericht und steht in keinem
`needs` eines anderen Jobs; `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:
```bash
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.
### Job `security`: Sicherheitspruefung (nur Bericht)
Der Job `security` (quick-261009-p0m) laeuft nach `publish`, damit er die Veroeffentlichung
nie verzoegert oder verhindert. Er laeuft nur auf `main` und bei Tags `v*`; die
Bedingung steht ausdruecklich am Job, weil ein uebersprungenes `needs` in Gitea nicht
blockiert (sonst liefe er auch nach einem Push auf `live`). Er hat `continue-on-error`,
ein Zeitlimit von 30 Minuten (ein haengender Scan haelt den einzigen Runner nicht fest)
und kein Secret. Das Skript haelt sich selbst an ein Zeitbudget von 25 Minuten (siehe
"Zeitbudget" unten). Alles steckt in einem Skript, das auch lokal laeuft:
```bash
GITHUB_REF=refs/heads/main sh .gitea/scripts/security-scan.sh --print-plan # ohne Netz
GITHUB_REF=refs/heads/main sh .gitea/scripts/security-scan.sh all
```
**Was geprueft wird:** gitleaks ueber die gesamte Git-Historie (Zugangsdaten), `pnpm audit
--prod` und osv-scanner ueber `pnpm-lock.yaml` und die Desktop-`Cargo.lock` (bekannte
Schwachstellen), Semgrep (statische Code-Analyse) und Trivy ueber den Quellstand sowie
ueber die frisch gebauten Abbilder `api` und `web` (`main` -> `:beta`, Tag `v*` -> `:live`).
Die Abbilder liegen nach `publish` im Docker-Daemon des Hosts (Socket-Mount, Abschnitt 5)
und werden direkt von dort gelesen.
**Quellstand:** Die Quellpruefungen lesen einen `git archive HEAD`-Export in einem
temporaeren Ordner, gitleaks nur die Git-Historie. Es wird also genau der eingecheckte
Stand geprueft; nicht eingecheckte Dateien eines Arbeitsordners erreichen nie ein
Werkzeug, einen Bericht oder ein Artefakt.
**Angepinnte Werkzeuge:** gitleaks 8.30.1, Trivy 0.75.0, osv-scanner 2.6.0, Semgrep 1.180.0,
pnpm 9.15.0. Die drei Programme werden von der offiziellen
GitHub-Veroeffentlichung geladen und vor jeder Benutzung gegen eine SHA256-Summe im Skript
geprueft (auch das zwischengespeicherte Archiv); stimmt die Summe nicht oder fehlt das
Netz, wird nur dieses Werkzeug uebersprungen. **Semgrep** laeuft als Container aus dem
offiziellen Abbild `semgrep/semgrep`, angepinnt per Digest (`SEMGREP_IMAGE` im Skript):
Docker prueft beim Laden, dass der Inhalt zum Digest passt. Es gibt bewusst keine
Installation ueber `pipx`/PyPI mehr -- deren frei aufgeloeste Abhaengigkeiten liefen sonst
in einem Job, der den Docker-Socket des Hosts sieht. Der Quellstand geht per `docker cp`
in den Container und der Bericht per `docker cp` zurueck (kein Ordner-Mount, denn der
Arbeitsordner liegt im Job-Container und nicht auf dem Docker-Rechner). Das Abbild belegt
rund 1,5 GB im Docker-Daemon des Runners und bleibt dort zwischengespeichert; ohne Docker
wird Semgrep mit `kein-docker` uebersprungen. Marktplatz-Aktionen fuer Scanner werden
bewusst nicht benutzt (veraenderliche Etiketten). **Version anheben:** im Kopf von
`security-scan.sh` die Version, die URL und die SHA256-Summe aus der offiziellen
Pruefsummendatei der Veroeffentlichung gemeinsam austauschen und einmal lokal
`sh .gitea/scripts/security-scan.sh all` laufen lassen. Bei Semgrep stattdessen
`SEMGREP_VERSION` und den Digest in `SEMGREP_IMAGE` tauschen (den Digest zeigt
`docker pull semgrep/semgrep:<Version>` in der Zeile `Digest:`). Werkzeuge und die Trivy-Datenbank
liegen im persistenten Zwischenspeicher des Runners (`/opt/hostedtoolcache/tessera-security`);
der Layer-Zwischenspeicher von Trivy wird nach jedem Lauf geloescht, damit die Platte nicht
vollaeuft.
**Ausnahmelisten:** `.gitleaks.toml` (geprueft harmlose Treffer: Testschluessel des
Zertifikatsmanagers, Schluessel-Ausschnitte in Tests und Notizen) und `.semgrepignore`
(Tests, Testdaten, Planungsnotizen). Ein neuer Treffer wird nie vorsorglich freigegeben,
sondern erst geprueft.
**Zeitbudget:** Der Job hat 30 Minuten (`timeout-minutes`). Das Skript rechnet mit einem
Gesamtbudget von 1500 Sekunden (25 Minuten) und jedes Werkzeug hat ein eigenes Limit
(Variablen `T_*` im Kopf des Skripts): Laden der drei Programme je 60 s, Semgrep-Abbild laden
150 s, pnpm 45 s, gitleaks 180 s, `pnpm audit` 90 s, osv-scanner 120 s, Semgrep 300 s, Trivy ueber
den Quellstand 180 s, je Abbild 120 s -- zusammen hoechstens 1485 Sekunden. Zusaetzlich kuerzt das
Skript jedes Limit auf die noch verbleibende Gesamtzeit; ist nichts mehr uebrig, werden die
restlichen Werkzeuge mit `skipped reason=gesamtbudget` gemeldet. Ein Werkzeug, das sein
Limit reisst, erscheint als `skipped reason=zeitgrenze`. Die fuenf Minuten Reserve bis zu den
30 Minuten decken Checkout und Artefakt-Schritt ab. **Jedes Werkzeug gibt seine Zeile aus,
sobald es fertig ist** -- bricht der Runner den Job trotzdem einmal ab, stehen die bis dahin
fertigen Ergebnisse im Protokoll.
**Wo man das Ergebnis sieht:** Im Protokoll des Jobs stehen je Werkzeug eine Zeile
`SECURITY-SUMMARY ...` (nur Zahlen), die Zeile `SECURITY-SUMMARY dauer=...` und am Ende
`SECURITY-SUMMARY fertig (nur Bericht, Exit 0)`; dieselben Zeilen stehen in `summary.txt`.
Prueft das Skript in einem flachen Klon (ohne volle Historie), steht bei gitleaks
`unvollstaendig (flacher Klon ...)` -- dann ist das Ergebnis nicht "sauber", sondern nur fuer die sichtbaren
Commits gueltig (die Pipeline holt mit `fetch-depth: 0` immer die volle Historie). Die Rohberichte (JSON) haengen,
soweit der Runner es zulaesst, als Artefakt `sicherheitsberichte` (30 Tage) am Lauf. Das
Artefakt ist nur "best effort" (Gitea lehnt `upload-artifact@v4` ab, es laeuft `@v3`);
verlassen Sie sich auf die Zeilen im Protokoll. Der Job braucht auf dem
Entwicklungsrechner mit warmem Zwischenspeicher etwa eine Minute; beim allerersten Lauf auf einem
frischen Runner kommen das Laden der Programme und des Semgrep-Abbilds (rund 1,5 GB) und die
Trivy-Datenbank dazu. Mehr als 25 Minuten laufen nie (siehe "Zeitbudget").
## 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`).
### Job `security` ist rot oder gelb
Der Job ist ein reiner Bericht und beeinflusst weder Abbilder noch Freigaben (er laeuft
erst nach `publish` und steht in keinem `needs`). Rot oder gelb heisst deshalb nie, dass
etwas nicht ausgeliefert wurde. Zur Ursache im Job-Protokoll die Zeilen
`SECURITY-SUMMARY <werkzeug> skipped reason=...` lesen: `download` oder `checksum` (Werkzeug
nicht ladbar bzw. Summe stimmt nicht -- Netz pruefen, nie die Summe ohne Pruefung
austauschen), `kein-docker` / `install` (Semgrep: Docker fehlt bzw. das Abbild liess sich nicht
laden), `kein-pnpm`, `abbild-fehlt` (das Abbild
liegt nicht im Docker-Daemon des Hosts), `zeitgrenze` (das Werkzeug hat sein eigenes Limit
gerissen), `gesamtbudget` (die 25 Minuten waren aufgebraucht), `fehler` (Werkzeug ist
abgestuerzt oder hat nichts geschrieben), `kein-bericht`. Ein haengender Lauf endet nach 30
Minuten von selbst. Fehlt nur das Artefakt, ist das erwartbar (best effort); die Zahlen
stehen trotzdem im Protokoll.