Fix desktop updater latest.json generation for releases (#6540)

This commit is contained in:
Anthony Stirling
2026-06-05 15:41:41 +01:00
committed by GitHub
parent e79f4a044f
commit 9866d6e12d
4 changed files with 228 additions and 49 deletions
@@ -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 <dir-to-scan> [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)
+138 -48
View File
@@ -588,33 +588,34 @@ jobs:
mkdir -p "$DIST" mkdir -p "$DIST"
cd ./frontend/editor/src-tauri/target 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 <bundle> ships with a sibling <bundle>.sig consumed by latest.json.
if [ "${{ matrix.platform }}" = "windows-latest" ]; then 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" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.msi" \;
find . -name "*.msi.zip" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.msi.zip" \; find . -name "*.msi.sig" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.msi.sig" \;
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" \;
elif [ "${{ matrix.platform }}" = "macos-15" ]; then 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 "*.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" -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" \; find . -name "*.app.tar.gz.sig" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.app.tar.gz.sig" \;
else 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" -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" -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" -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.sig" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.AppImage.sig" \;
find . -name "*.AppImage.tar.gz.sig" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.AppImage.tar.gz.sig" \;
fi 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 - name: Upload build artifacts
if: always() && steps.digicert-setup.conclusion != 'failure' if: always() && steps.digicert-setup.conclusion != 'failure'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
@@ -634,6 +635,16 @@ jobs:
with: with:
egress-policy: audit 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 - name: Download all Tauri artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with: with:
@@ -661,41 +672,106 @@ jobs:
- name: Display structure of downloaded files - name: Display structure of downloaded files
run: ls -R ./artifacts 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: | run: |
python3 - << 'PYEOF' 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)) VERSION = os.environ['VERSION']
if not files: TAG = os.environ['TAG']
print("No latest-*.json files found — skipping latest.json generation") 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 <artifact-name>/.
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) sys.exit(0)
merged = None manifest = {
for f in files: 'version': VERSION,
print(f"Merging: {f}") 'notes': f"See https://github.com/{REPO}/releases/tag/{TAG}",
with open(f) as fh: 'pub_date': datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'),
data = json.load(fh) 'platforms': platforms,
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
if merged and merged.get('platforms'): out = Path('./artifacts/latest.json')
with open('./artifacts/latest.json', 'w') as fh: out.write_text(json.dumps(manifest, indent=2) + '\n', encoding='utf-8')
json.dump(merged, fh, indent=2) print(f"Generated {out} with platforms: {sorted(platforms.keys())}")
print(f"Generated latest.json with platforms: {list(merged['platforms'].keys())}")
PYEOF PYEOF
- name: Upload merged artifacts for review - name: Upload merged artifacts for review
@@ -705,23 +781,37 @@ jobs:
path: ./artifacts/ path: ./artifacts/
retention-days: 7 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 - 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 uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
with: with:
tag_name: v${{ needs.determine-matrix.outputs.version }} tag_name: v${{ needs.determine-matrix.outputs.version }}
generate_release_notes: true 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: | files: |
./artifacts/**/*.jar ./artifacts/**/*.jar
./artifacts/**/*.msi ./artifacts/**/*.msi
./artifacts/**/*.msi.zip
./artifacts/**/*.nsis.zip
./artifacts/**/*.dmg ./artifacts/**/*.dmg
./artifacts/**/*.app.tar.gz ./artifacts/**/*.app.tar.gz
./artifacts/**/*.deb ./artifacts/**/*.deb
./artifacts/**/*.rpm ./artifacts/**/*.rpm
./artifacts/**/*.AppImage ./artifacts/**/*.AppImage
./artifacts/**/*.AppImage.tar.gz
./artifacts/latest.json ./artifacts/latest.json
draft: false draft: false
prerelease: false prerelease: false
+2 -1
View File
@@ -23,8 +23,9 @@
}, },
"bundle": { "bundle": {
"active": true, "active": true,
"createUpdaterArtifacts": true,
"publisher": "Stirling PDF Inc.", "publisher": "Stirling PDF Inc.",
"targets": ["deb", "rpm", "appimage", "dmg", "msi"], "targets": ["deb", "rpm", "appimage", "dmg", "app", "msi"],
"icon": [ "icon": [
"icons/icon.png", "icons/icon.png",
"icons/icon.icns", "icons/icon.icns",
@@ -151,6 +151,11 @@ if [ "$SKIP_BUILD" = false ] || [ -z "$BUNDLE_FILE" ]; then
PRIVATE_KEY="$(cat "$KEYS_DIR/dev-update-key")" PRIVATE_KEY="$(cat "$KEYS_DIR/dev-update-key")"
cd "$FRONTEND_DIR/editor" 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). # 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. # The WiX fragment references these binaries, so light.exe fails to bind without them.
node scripts/build-provisioner.mjs node scripts/build-provisioner.mjs