ci: Desktop-Pakete mit dem Updater-Schluessel signieren, Signatur und updateVersion ins Manifest

- ci.yml: Secrets TAURI_SIGNING_PRIVATE_KEY/_PASSWORD als env nur an den
  beiden tauri-build-Schritten des Jobs desktop (kein Job-env, kein
  --no-sign); alles andere strukturgleich
- desktop-collect.sh: liest <bundle>.sig (genau eine Base64-Zeile) als
  files.<p>.signature ins Manifest, schreibt updateVersion (X.Y.Z bzw.
  X.Y.Z-beta.g<sha7>); fehlende .sig bricht auf main/Tag oder bei
  gesetztem Schluessel ab, warnt sonst (dev, lokal --no-sign)
- desktop-stamp.sh check: kein Cache-Stand ohne updateVersion und
  Signaturen beider Plattformen
- .gitignore: *.key

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-17 15:34:45 +02:00
parent de81c74e53
commit 7004b5b020
4 changed files with 97 additions and 5 deletions
+69 -5
View File
@@ -20,6 +20,21 @@
# Die Version kommt aus tauri.conf.json (von desktop-version.sh geschrieben # Die Version kommt aus tauri.conf.json (von desktop-version.sh geschrieben
# oder als eingecheckte Basislinie vorhanden) -- dieses Skript liest sie nur, # oder als eingecheckte Basislinie vorhanden) -- dieses Skript liest sie nur,
# es schreibt sie nicht. Dieses Skript kennt kein Secret. # es schreibt sie nicht. Dieses Skript kennt kein Secret.
#
# Signatur (quick-260917-kgc): Die Tauri-CLI legt beim Bau mit
# `createUpdaterArtifacts` neben jedem Bundle eine `<bundle>.sig` ab
# (minisign, eine Base64-Zeile). Dieses Skript traegt deren INHALT als
# `files.<plattform>.signature` ins Manifest -- die `.sig`-Datei selbst wird
# nicht kopiert (die API liefert JSON). Pflichtregel: fehlt die `.sig`,
# bricht das Skript ab, wenn TAURI_SIGNING_PRIVATE_KEY gesetzt ist ODER der
# Kanal nicht `dev` ist (main/Tag -- dort sind Signaturen Pflicht,
# `--no-sign` gibt es nur lokal); sonst Warnung und das Feld entfaellt
# (lokaler Bau mit `tauri build --no-sign` bleibt moeglich, die API antwortet
# auf /desktop/update dann 204). Neues Feld `updateVersion` = die SemVer-Form
# fuer den Updater: X.Y.Z bei live/dev, X.Y.Z-beta.g<sha7> bei beta (das
# `g` ist Pflicht, ein rein numerischer SHA mit fuehrender Null waere kein
# gueltiger SemVer-Identifier). Der Schluessel wird nur auf Gesetztsein
# geprueft, nie gelesen oder ausgegeben.
set -eu set -eu
REQUIRE="" REQUIRE=""
@@ -79,6 +94,43 @@ esac
COMMIT="$(git rev-parse --short=7 HEAD)" COMMIT="$(git rev-parse --short=7 HEAD)"
BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)" BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
# SemVer-Form fuer den Updater (siehe Kopfkommentar): erst NACH der
# Versionspruefung bilden, SUFFIX/SHA_SHORT sind aus der Kanalentscheidung
# bekannt.
UPDATE_VERSION="$VERSION"
if [ "$CHANNEL" = beta ]; then
UPDATE_VERSION="$VERSION-beta.g$SHA_SHORT"
fi
SIGN_REQUIRED=0
if [ -n "${TAURI_SIGNING_PRIVATE_KEY:-}" ] || [ "$CHANNEL" != dev ]; then
SIGN_REQUIRED=1
fi
# read_signature <bundle-pfad>: schreibt den Inhalt von <bundle-pfad>.sig
# nach stdout (genau eine Base64-Zeile). Fehlt die Datei: bei
# SIGN_REQUIRED=1 Fehler und exit 1, sonst Warnung und leere Ausgabe.
read_signature() {
SIG_FILE="$1.sig"
if [ -f "$SIG_FILE" ]; then
SIG_CONTENT="$(cat "$SIG_FILE")"
# grep prueft zeilenweise -- die Zeilenzahl deshalb getrennt erzwingen.
SIG_LINES="$(printf '%s\n' "$SIG_CONTENT" | wc -l | tr -d ' ')"
if [ "$SIG_LINES" -ne 1 ] || ! printf '%s' "$SIG_CONTENT" | grep -qE '^[A-Za-z0-9+/=]+$'; then
echo "Signaturdatei $SIG_FILE hat nicht die erwartete Form (genau eine Base64-Zeile)." >&2
exit 1
fi
printf '%s' "$SIG_CONTENT"
return 0
fi
if [ "$SIGN_REQUIRED" = 1 ]; then
echo "Signaturdatei $SIG_FILE fehlt. Im CI muessen TAURI_SIGNING_PRIVATE_KEY und TAURI_SIGNING_PRIVATE_KEY_PASSWORD an den tauri-build-Schritten gesetzt sein; --no-sign ist nur lokal erlaubt." >&2
exit 1
fi
echo "Warnung: Signaturdatei $SIG_FILE fehlt (Bau ohne Schluessel, Kanal dev) -- Feld signature entfaellt, kein Update in der App." >&2
return 0
}
mkdir -p "$DESKTOP_DIST" mkdir -p "$DESKTOP_DIST"
# Alte Pakete/Manifest entfernen, Platzhalter (.gitkeep) bleibt erhalten. # Alte Pakete/Manifest entfernen, Platzhalter (.gitkeep) bleibt erhalten.
rm -f "$DESKTOP_DIST"/*.AppImage "$DESKTOP_DIST"/*.exe "$DESKTOP_DIST/manifest.json" rm -f "$DESKTOP_DIST"/*.AppImage "$DESKTOP_DIST"/*.exe "$DESKTOP_DIST/manifest.json"
@@ -90,9 +142,11 @@ trap 'rm -f "$TMP_MANIFEST"' EXIT INT TERM
LINUX_NAME="" LINUX_NAME=""
LINUX_SIZE="" LINUX_SIZE=""
LINUX_SHA="" LINUX_SHA=""
LINUX_SIG=""
WINDOWS_NAME="" WINDOWS_NAME=""
WINDOWS_SIZE="" WINDOWS_SIZE=""
WINDOWS_SHA="" WINDOWS_SHA=""
WINDOWS_SIG=""
case ",$REQUIRE," in case ",$REQUIRE," in
*,linux,*) *,linux,*)
@@ -107,7 +161,10 @@ case ",$REQUIRE," in
cp "$APPIMAGE_SRC" "$DESKTOP_DIST/$LINUX_NAME" cp "$APPIMAGE_SRC" "$DESKTOP_DIST/$LINUX_NAME"
LINUX_SIZE="$(stat -c %s "$DESKTOP_DIST/$LINUX_NAME")" LINUX_SIZE="$(stat -c %s "$DESKTOP_DIST/$LINUX_NAME")"
LINUX_SHA="$(sha256sum "$DESKTOP_DIST/$LINUX_NAME" | cut -d' ' -f1)" LINUX_SHA="$(sha256sum "$DESKTOP_DIST/$LINUX_NAME" | cut -d' ' -f1)"
echo "linux: $LINUX_NAME (${LINUX_SIZE} Bytes, sha256 $LINUX_SHA)" LINUX_SIG="$(read_signature "$APPIMAGE_SRC")"
[ -n "$LINUX_SIG" ] || [ "$SIGN_REQUIRED" = 0 ] || exit 1
if [ -n "$LINUX_SIG" ]; then LINUX_SIGNED="signiert"; else LINUX_SIGNED="ohne Signatur"; fi
echo "linux: $LINUX_NAME (${LINUX_SIZE} Bytes, sha256 $LINUX_SHA, $LINUX_SIGNED)"
;; ;;
esac esac
@@ -124,7 +181,10 @@ case ",$REQUIRE," in
cp "$NSIS_SRC" "$DESKTOP_DIST/$WINDOWS_NAME" cp "$NSIS_SRC" "$DESKTOP_DIST/$WINDOWS_NAME"
WINDOWS_SIZE="$(stat -c %s "$DESKTOP_DIST/$WINDOWS_NAME")" WINDOWS_SIZE="$(stat -c %s "$DESKTOP_DIST/$WINDOWS_NAME")"
WINDOWS_SHA="$(sha256sum "$DESKTOP_DIST/$WINDOWS_NAME" | cut -d' ' -f1)" WINDOWS_SHA="$(sha256sum "$DESKTOP_DIST/$WINDOWS_NAME" | cut -d' ' -f1)"
echo "windows: $WINDOWS_NAME (${WINDOWS_SIZE} Bytes, sha256 $WINDOWS_SHA)" WINDOWS_SIG="$(read_signature "$NSIS_SRC")"
[ -n "$WINDOWS_SIG" ] || [ "$SIGN_REQUIRED" = 0 ] || exit 1
if [ -n "$WINDOWS_SIG" ]; then WINDOWS_SIGNED="signiert"; else WINDOWS_SIGNED="ohne Signatur"; fi
echo "windows: $WINDOWS_NAME (${WINDOWS_SIZE} Bytes, sha256 $WINDOWS_SHA, $WINDOWS_SIGNED)"
;; ;;
esac esac
@@ -132,25 +192,29 @@ esac
# manuelles String-Zusammenbauen von JSON). # manuelles String-Zusammenbauen von JSON).
jq -n \ jq -n \
--arg version "$VERSION" \ --arg version "$VERSION" \
--arg updateVersion "$UPDATE_VERSION" \
--arg channel "$CHANNEL" \ --arg channel "$CHANNEL" \
--arg commit "$COMMIT" \ --arg commit "$COMMIT" \
--arg buildTime "$BUILD_TIME" \ --arg buildTime "$BUILD_TIME" \
--arg linuxName "$LINUX_NAME" \ --arg linuxName "$LINUX_NAME" \
--argjson linuxSize "${LINUX_SIZE:-null}" \ --argjson linuxSize "${LINUX_SIZE:-null}" \
--arg linuxSha "$LINUX_SHA" \ --arg linuxSha "$LINUX_SHA" \
--arg linuxSig "$LINUX_SIG" \
--arg windowsName "$WINDOWS_NAME" \ --arg windowsName "$WINDOWS_NAME" \
--argjson windowsSize "${WINDOWS_SIZE:-null}" \ --argjson windowsSize "${WINDOWS_SIZE:-null}" \
--arg windowsSha "$WINDOWS_SHA" \ --arg windowsSha "$WINDOWS_SHA" \
--arg windowsSig "$WINDOWS_SIG" \
'{ '{
version: $version, version: $version,
updateVersion: $updateVersion,
channel: $channel, channel: $channel,
commit: $commit, commit: $commit,
buildTime: $buildTime, buildTime: $buildTime,
files: ( files: (
{} {}
+ (if $linuxName != "" then { linux: { name: $linuxName, size: $linuxSize, sha256: $linuxSha } } else {} end) + (if $linuxName != "" then { linux: ({ name: $linuxName, size: $linuxSize, sha256: $linuxSha } + (if $linuxSig != "" then { signature: $linuxSig } else {} end)) } else {} end)
+ (if $windowsName != "" then { windows: { name: $windowsName, size: $windowsSize, sha256: $windowsSha } } else {} end) + (if $windowsName != "" then { windows: ({ name: $windowsName, size: $windowsSize, sha256: $windowsSha } + (if $windowsSig != "" then { signature: $windowsSig } else {} end)) } else {} end)
) )
}' > "$DESKTOP_DIST/manifest.json" }' > "$DESKTOP_DIST/manifest.json"
echo "Manifest geschrieben: $DESKTOP_DIST/manifest.json (Version $VERSION, Kanal $CHANNEL)" echo "Manifest geschrieben: $DESKTOP_DIST/manifest.json (Version $VERSION, updateVersion $UPDATE_VERSION, Kanal $CHANNEL)"
+16
View File
@@ -43,6 +43,13 @@
# liest ausserhalb von apps/desktop keine Werkstatt-Datei -- frontendDist ist # liest ausserhalb von apps/desktop keine Werkstatt-Datei -- frontendDist ist
# ../src innerhalb von apps/desktop, keine Abhaengigkeit auf packages/*. # ../src innerhalb von apps/desktop, keine Abhaengigkeit auf packages/*.
# #
# quick-260917-kgc: `check` verlangt zusaetzlich `updateVersion` und je
# Plattform `files.<p>.signature` im gecachten Manifest -- ein Cache-Stand
# aus der Zeit vor der Update-Funktion (oder aus einem Bau mit --no-sign)
# wird nie uebernommen, sonst lieferte der Update-Endpunkt dauerhaft 204.
# Ein Schluesselwechsel (`pubkey` in tauri.conf.json unter apps/desktop)
# aendert den Stempel automatisch, weil apps/desktop in DESKTOP_PATHS liegt.
#
# Dieses Skript kennt kein Secret. # Dieses Skript kennt kein Secret.
set -eu set -eu
@@ -112,6 +119,10 @@ cmd_check() {
if [ "$SHA" != "$EXP_SHA" ]; then if [ "$SHA" != "$EXP_SHA" ]; then
no_reuse "Pruefsumme von $2 weicht ab" no_reuse "Pruefsumme von $2 weicht ab"
fi fi
SIG="$(jq -r ".files.$1.signature // empty" "$MANIFEST")"
if [ -z "$SIG" ]; then
no_reuse "Signatur fuer $2 fehlt im Manifest"
fi
} }
if [ "$CACHE_HIT" != "true" ]; then if [ "$CACHE_HIT" != "true" ]; then
@@ -132,6 +143,11 @@ cmd_check() {
no_reuse "Version $MVERSION statt $STAMP_VERSION" no_reuse "Version $MVERSION statt $STAMP_VERSION"
fi fi
UPDATE_VERSION="$(jq -r '.updateVersion // empty' "$MANIFEST")"
if [ -z "$UPDATE_VERSION" ]; then
no_reuse "updateVersion fehlt im Manifest (Stand vor der Update-Funktion)"
fi
LINUX_NAME="$(jq -r '.files.linux.name // empty' "$MANIFEST")" LINUX_NAME="$(jq -r '.files.linux.name // empty' "$MANIFEST")"
WINDOWS_NAME="$(jq -r '.files.windows.name // empty' "$MANIFEST")" WINDOWS_NAME="$(jq -r '.files.windows.name // empty' "$MANIFEST")"
if [ -z "$LINUX_NAME" ] || [ -z "$WINDOWS_NAME" ]; then if [ -z "$LINUX_NAME" ] || [ -z "$WINDOWS_NAME" ]; then
+9
View File
@@ -14,6 +14,9 @@
# Desktop-Pfaden, siehe .gitea/scripts/desktop-stamp.sh) bereits fertige # Desktop-Pfaden, siehe .gitea/scripts/desktop-stamp.sh) bereits fertige
# Pakete im Zwischenspeicher des Runners liegen; Tags v* bauen immer neu; # Pakete im Zwischenspeicher des Runners liegen; Tags v* bauen immer neu;
# `publish` bleibt unveraendert. # `publish` bleibt unveraendert.
# quick-260917-kgc: Die beiden tauri-build-Schritte signieren die Pakete mit dem
# Updater-Schluessel (Secrets TAURI_SIGNING_PRIVATE_KEY/_PASSWORD, nur an diesen
# zwei Schritten); desktop-collect.sh traegt die .sig-Inhalte ins Manifest.
name: Tessera CI/CD name: Tessera CI/CD
on: on:
@@ -178,10 +181,16 @@ jobs:
- name: Linux-AppImage bauen - name: Linux-AppImage bauen
if: steps.reuse.outputs.reuse != 'true' if: steps.reuse.outputs.reuse != 'true'
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: pnpm --filter @tessera/desktop exec tauri build --bundles appimage run: pnpm --filter @tessera/desktop exec tauri build --bundles appimage
- name: Windows-Installer bauen (Cross-Bau) - name: Windows-Installer bauen (Cross-Bau)
if: steps.reuse.outputs.reuse != 'true' if: steps.reuse.outputs.reuse != 'true'
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: pnpm --filter @tessera/desktop exec tauri build --runner cargo-xwin --target x86_64-pc-windows-msvc --bundles nsis run: pnpm --filter @tessera/desktop exec tauri build --runner cargo-xwin --target x86_64-pc-windows-msvc --bundles nsis
- name: Pakete einsammeln - name: Pakete einsammeln
+3
View File
@@ -43,3 +43,6 @@ user-files/
# Desktop-Pakete aus dem Bau (Phase 18) # Desktop-Pakete aus dem Bau (Phase 18)
desktop-dist/* desktop-dist/*
!desktop-dist/.gitkeep !desktop-dist/.gitkeep
# Privater Updater-Signierschluessel liegt ausserhalb des Repos (~/.tessera/desktop-updater/) -- nie einchecken
*.key