From 9866d6e12dd33245bb8df9455f3a332d29174e5c Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Fri, 5 Jun 2026 15:41:41 +0100 Subject: [PATCH] Fix desktop updater latest.json generation for releases (#6540) --- .github/scripts/verify-updater-signatures.py | 83 ++++++++ .github/workflows/multiOSReleases.yml | 186 +++++++++++++----- frontend/editor/src-tauri/tauri.conf.json | 3 +- .../dev-update-test/test-update-e2e.sh | 5 + 4 files changed, 228 insertions(+), 49 deletions(-) create mode 100644 .github/scripts/verify-updater-signatures.py diff --git a/.github/scripts/verify-updater-signatures.py b/.github/scripts/verify-updater-signatures.py new file mode 100644 index 000000000..a1624448e --- /dev/null +++ b/.github/scripts/verify-updater-signatures.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +"""Verify Tauri updater .sig files against plugins.updater.pubkey in tauri.conf.json. + +Usage: verify-updater-signatures.py [tauri.conf.json] +""" + +import binascii +import sys +import json +import base64 +import hashlib +from pathlib import Path +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey +from cryptography.exceptions import InvalidSignature + +ART_ROOT = Path(sys.argv[1]) +CONF = Path( + sys.argv[2] if len(sys.argv) > 2 else "frontend/editor/src-tauri/tauri.conf.json" +) + + +def load_pubkey(): + # tauri pubkey = base64 of a minisign .pub file; last line is base64 of + # [2 algo][8 key-id][32 ed25519 public key]. + raw = json.loads(CONF.read_text())["plugins"]["updater"]["pubkey"] + blob = base64.b64decode(base64.b64decode(raw).decode().splitlines()[-1]) + return blob[2:10], Ed25519PublicKey.from_public_bytes(blob[10:]) + + +def hash_file(path: Path) -> bytes: + h = hashlib.blake2b(digest_size=64) + with path.open("rb") as f: + for chunk in iter(lambda: f.read(1 << 16), b""): + h.update(chunk) + return h.digest() + + +def verify(artifact: Path, sig_file: Path, keyid_pub, pub) -> str: + # tauri .sig = base64 of a minisign signature file (4 lines). + try: + lines = base64.b64decode(sig_file.read_text()).decode().splitlines() + sig_blob = base64.b64decode(lines[1]) + except (binascii.Error, IndexError, UnicodeDecodeError) as e: + return f"FAIL malformed sig ({type(e).__name__})" + algo, keyid, sig = sig_blob[:2], sig_blob[2:10], sig_blob[10:74] + if keyid != keyid_pub: + return f"FAIL key-id mismatch (sig {keyid.hex()} vs pub {keyid_pub.hex()})" + # 'ED' = prehashed (BLAKE2b-512), 'Ed' = legacy (raw message). + msg = hash_file(artifact) if algo == b"ED" else artifact.read_bytes() + try: + pub.verify(sig, msg) + except InvalidSignature: + return f"FAIL signature invalid (algo={algo.decode()})" + # Global signature covers sig + trusted_comment. + gc = "global-sig FAIL" + try: + tc = lines[2].split("trusted comment: ", 1)[1] + pub.verify(base64.b64decode(lines[3]), sig + tc.encode()) + gc = "global-sig OK" + except (InvalidSignature, IndexError, binascii.Error): + pass + return f"VALID (algo={algo.decode()}, keyid={keyid.hex()}, {gc})" + + +keyid_pub, pub = load_pubkey() +print(f"updater pubkey keyid={keyid_pub.hex()}\n") +sigs = sorted(ART_ROOT.rglob("*.sig")) +if not sigs: + print(f"WARN: no .sig files under {ART_ROOT} - nothing to verify") + sys.exit(0) +bad = 0 +for sig_file in sigs: + artifact = sig_file.with_suffix("") + if not artifact.exists(): + print(f" ? {sig_file.name}: artifact missing") + bad += 1 + continue + res = verify(artifact, sig_file, keyid_pub, pub) + print(f" {artifact.name}: {res}") + if not res.startswith("VALID") or "global-sig FAIL" in res: + bad += 1 +print(f"\n{'ALL SIGNATURES VALID' if bad == 0 else f'{bad} SIGNATURE(S) FAILED'}") +sys.exit(1 if bad else 0) diff --git a/.github/workflows/multiOSReleases.yml b/.github/workflows/multiOSReleases.yml index 438126f90..32c97022a 100644 --- a/.github/workflows/multiOSReleases.yml +++ b/.github/workflows/multiOSReleases.yml @@ -588,33 +588,34 @@ jobs: mkdir -p "$DIST" cd ./frontend/editor/src-tauri/target - # Find and rename artifacts based on platform + echo "=== tauri bundle artifacts ===" + find . -path "*/bundle/*" \( -name "*.msi" -o -name "*.deb" \ + -o -name "*.rpm" -o -name "*.AppImage" -o -name "*.dmg" \ + -o -name "*.app.tar.gz" -o -name "*.sig" \) 2>/dev/null | sort || true + echo "==============================" + + # createUpdaterArtifacts:true signs the native installers in place; + # each ships with a sibling .sig consumed by latest.json. if [ "${{ matrix.platform }}" = "windows-latest" ]; then - # Only ship the MSI installer on Windows. The loose exe and WiX toolset exes - # are not the user-facing installer - the MSI contains the signed inner exe. find . -name "*.msi" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.msi" \; - find . -name "*.msi.zip" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.msi.zip" \; - find . -name "*.msi.zip.sig" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.msi.zip.sig" \; - find . -name "*.nsis.zip" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.nsis.zip" \; - find . -name "*.nsis.zip.sig" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.nsis.zip.sig" \; + find . -name "*.msi.sig" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.msi.sig" \; elif [ "${{ matrix.platform }}" = "macos-15" ]; then + # DMG = manual install; .app.tar.gz (+ .sig) = updater payload. + # Raw .app is intentionally not shipped (hundreds of MB of uncompressed input). find . -name "*.dmg" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.dmg" \; - find . -name "*.app" -exec cp -r {} "$DIST/Stirling-PDF-${{ matrix.name }}.app" \; find . -name "*.app.tar.gz" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.app.tar.gz" \; find . -name "*.app.tar.gz.sig" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.app.tar.gz.sig" \; else + # The raw .AppImage IS its updater payload (signed -> .AppImage.sig), + # not a .tar.gz wrapper - that's only produced under v1Compatible. find . -name "*.deb" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.deb" \; + find . -name "*.deb.sig" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.deb.sig" \; find . -name "*.rpm" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.rpm" \; + find . -name "*.rpm.sig" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.rpm.sig" \; find . -name "*.AppImage" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.AppImage" \; - find . -name "*.AppImage.tar.gz" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.AppImage.tar.gz" \; - find . -name "*.AppImage.tar.gz.sig" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.AppImage.tar.gz.sig" \; + find . -name "*.AppImage.sig" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.AppImage.sig" \; fi - # Copy updater latest.json if generated by tauri-action (rename to be platform-specific). - # Scope strictly to the tauri target tree - a broader find from repo root - # could pick up stray latest.json files and corrupt the release manifest. - find . -name "latest.json" -not -path "*/deps/*" 2>/dev/null | head -1 | xargs -I{} cp {} "$DIST/latest-${{ matrix.name }}.json" 2>/dev/null || true - - name: Upload build artifacts if: always() && steps.digicert-setup.conclusion != 'failure' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -634,6 +635,16 @@ jobs: with: egress-policy: audit + # Sparse-check out the verifier + pubkey before the artifact downloads + # so the checkout cannot clobber ./artifacts. + - name: Checkout updater verifier + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + sparse-checkout: | + .github/scripts/verify-updater-signatures.py + frontend/editor/src-tauri/tauri.conf.json + sparse-checkout-cone-mode: false + - name: Download all Tauri artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: @@ -661,41 +672,106 @@ jobs: - name: Display structure of downloaded files run: ls -R ./artifacts - - name: Generate merged updater latest.json + # tauri-action only emits latest.json when it also publishes the release + # (tagName/releaseId set). We publish separately via action-gh-release, + # so build latest.json here from the per-platform .sig files. + - name: Generate updater latest.json + env: + VERSION: ${{ needs.determine-matrix.outputs.version }} + TAG: v${{ needs.determine-matrix.outputs.version }} + REPO: ${{ github.repository }} run: | python3 - << 'PYEOF' - import json, glob, os, sys + import json, os, sys + from pathlib import Path + from datetime import datetime, timezone - files = sorted(glob.glob('./artifacts/tauri/**/latest-*.json', recursive=True)) - if not files: - print("No latest-*.json files found — skipping latest.json generation") + VERSION = os.environ['VERSION'] + TAG = os.environ['TAG'] + REPO = os.environ['REPO'] + + ART = Path('./artifacts/tauri') + + # Tauri updater looks up {os}-{arch}-{installer} (e.g. linux-x86_64-deb) + # before bare {os}-{arch}, so per-format Linux keys let deb/rpm/appimage + # each self-update from their matching file. macOS universal serves both + # arches from the one .app.tar.gz. + PLATFORM_MAP = [ + { + 'bundles': ['Stirling-PDF-linux-x86_64.deb'], + 'targets': ['linux-x86_64-deb'], + }, + { + 'bundles': ['Stirling-PDF-linux-x86_64.rpm'], + 'targets': ['linux-x86_64-rpm'], + }, + { + 'bundles': ['Stirling-PDF-linux-x86_64.AppImage'], + 'targets': ['linux-x86_64-appimage'], + }, + { + 'bundles': ['Stirling-PDF-windows-x86_64.msi'], + 'targets': ['windows-x86_64-msi', 'windows-x86_64'], + }, + { + 'bundles': ['Stirling-PDF-macos-universal.app.tar.gz'], + 'targets': ['darwin-x86_64', 'darwin-aarch64'], + }, + ] + + # rglob() because download-artifact varies layout: one artifact -> flat, + # many -> nested under /. + def find_signed(name): + for bundle_path in sorted(ART.rglob(name)): + sig_path = bundle_path.with_name(bundle_path.name + '.sig') + if sig_path.exists(): + return bundle_path, sig_path + return None + + platforms = {} + skipped = [] + for entry in PLATFORM_MAP: + picked = None + for name in entry['bundles']: + picked = find_signed(name) + if picked: + break + if not picked: + skipped.append( + f"{entry['targets']} (no signed bundle among " + f"{entry['bundles']} - TAURI_SIGNING_PRIVATE_KEY unset " + f"or createUpdaterArtifacts disabled?)" + ) + continue + bundle_path, sig_path = picked + signature = sig_path.read_text(encoding='utf-8').strip() + url = f"https://github.com/{REPO}/releases/download/{TAG}/{bundle_path.name}" + for target in entry['targets']: + platforms[target] = {'signature': signature, 'url': url} + print(f"Added {entry['targets']} from {bundle_path.name}") + + if skipped: + print("Skipped platforms:") + for s in skipped: + print(f" - {s}") + + if not platforms: + print( + "WARN: no signed updater bundles found - " + "skipping latest.json generation" + ) sys.exit(0) - merged = None - for f in files: - print(f"Merging: {f}") - with open(f) as fh: - data = json.load(fh) - if merged is None: - merged = dict(data) - merged['platforms'] = {} - else: - # Guard against stale artifacts from a rerun bleeding into a new - # release's latest.json. All per-platform files must agree on version. - if merged.get('version') != data.get('version'): - sys.exit( - f"Version mismatch: {merged.get('version')} vs " - f"{data.get('version')} in {f}" - ) - for platform, payload in data.get('platforms', {}).items(): - if platform in merged['platforms']: - sys.exit(f"Duplicate platform entry for {platform} in {f}") - merged['platforms'][platform] = payload + manifest = { + 'version': VERSION, + 'notes': f"See https://github.com/{REPO}/releases/tag/{TAG}", + 'pub_date': datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'), + 'platforms': platforms, + } - if merged and merged.get('platforms'): - with open('./artifacts/latest.json', 'w') as fh: - json.dump(merged, fh, indent=2) - print(f"Generated latest.json with platforms: {list(merged['platforms'].keys())}") + out = Path('./artifacts/latest.json') + out.write_text(json.dumps(manifest, indent=2) + '\n', encoding='utf-8') + print(f"Generated {out} with platforms: {sorted(platforms.keys())}") PYEOF - name: Upload merged artifacts for review @@ -705,23 +781,37 @@ jobs: path: ./artifacts/ retention-days: 7 + # Gate publish on valid updater sigs. Runs after the review upload (so + # artifacts survive for debugging) and before action-gh-release. + - name: Verify updater signatures + run: | + python3 -m pip install --quiet 'cryptography==44.0.0' + python3 .github/scripts/verify-updater-signatures.py \ + ./artifacts/tauri frontend/editor/src-tauri/tauri.conf.json + + # workflow_dispatch path requires platform=='all' so a single-platform + # dispatch can't overwrite an existing release's full latest.json with a + # partial one (action-gh-release defaults overwrite_files:true). + # release / V2-master always build the full matrix so no extra guard needed. + # fail_on_unmatched_files makes a missing latest.json or installer fail loudly + # instead of silently shipping a broken auto-update. - name: Upload binaries to Release - if: (github.event_name == 'workflow_dispatch' && github.event.inputs.test_mode != 'true') || github.event_name == 'release' || github.ref == 'refs/heads/V2-master' + if: (github.event_name == 'workflow_dispatch' && github.event.inputs.test_mode != 'true' && github.event.inputs.platform == 'all') || github.event_name == 'release' || github.ref == 'refs/heads/V2-master' uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0 with: tag_name: v${{ needs.determine-matrix.outputs.version }} generate_release_notes: true + fail_on_unmatched_files: true + # Installers + updater payloads + manifest. .sig contents are embedded + # in latest.json so the .sig files themselves are not uploaded. files: | ./artifacts/**/*.jar ./artifacts/**/*.msi - ./artifacts/**/*.msi.zip - ./artifacts/**/*.nsis.zip ./artifacts/**/*.dmg ./artifacts/**/*.app.tar.gz ./artifacts/**/*.deb ./artifacts/**/*.rpm ./artifacts/**/*.AppImage - ./artifacts/**/*.AppImage.tar.gz ./artifacts/latest.json draft: false prerelease: false diff --git a/frontend/editor/src-tauri/tauri.conf.json b/frontend/editor/src-tauri/tauri.conf.json index 6c6dcb376..5c2168f72 100644 --- a/frontend/editor/src-tauri/tauri.conf.json +++ b/frontend/editor/src-tauri/tauri.conf.json @@ -23,8 +23,9 @@ }, "bundle": { "active": true, + "createUpdaterArtifacts": true, "publisher": "Stirling PDF Inc.", - "targets": ["deb", "rpm", "appimage", "dmg", "msi"], + "targets": ["deb", "rpm", "appimage", "dmg", "app", "msi"], "icon": [ "icons/icon.png", "icons/icon.icns", diff --git a/frontend/scripts/dev-update-test/test-update-e2e.sh b/frontend/scripts/dev-update-test/test-update-e2e.sh index 92af32618..4ad884fe8 100644 --- a/frontend/scripts/dev-update-test/test-update-e2e.sh +++ b/frontend/scripts/dev-update-test/test-update-e2e.sh @@ -151,6 +151,11 @@ if [ "$SKIP_BUILD" = false ] || [ -z "$BUNDLE_FILE" ]; then PRIVATE_KEY="$(cat "$KEYS_DIR/dev-update-key")" cd "$FRONTEND_DIR/editor" + # Mirror tauri:dev-with-update's prebuild: setup-env writes .env.local + + # .env.desktop.local, generate-icons emits src/assets/material-symbols-icons.json + # which LocalIcon.tsx imports. Without these, vite build fails to resolve the icons file. + npx tsx scripts/setup-env.mts --desktop + node scripts/generate-icons.js # Build the Windows installer provisioner + thumbnail-handler (no-op on macOS/Linux). # The WiX fragment references these binaries, so light.exe fails to bind without them. node scripts/build-provisioner.mjs