feat(update): unify the updater with Sabiá's Gitea-based mechanism
Replaces the signed tauri-plugin-updater (latest.json manifest, minisign key pair) with the same simple mechanism Sabiá already uses: query the Gitea Releases API directly, download the matching platform archive, verify its SHA-256 checksum, and swap the binary in place. The CI job that built the old latest.json manifest is removed in the next commit, alongside an unrelated runner-routing change to the same workflow file. - New guara-core::update module (check/apply), ported from sabia-core::update and adapted to Guará's own release asset naming (guara-<tag>-<platform>.tar.gz, matching scripts/package-cli.sh). - New Engine::update_check / Engine::update_apply, wired into the RPC service for the browser UI. - New CLI subcommand: "guara update" (check) and "guara update --apply" (download, verify, replace), mirroring "sabia update". - GUI: replaced the Tauri plugin-updater flow (download_and_install, update-progress events, in-app restart) with a call to the new update_check/update_apply commands. The desktop app can no longer self-install in one click — it now points the user to the Releases page, same as Sabiá's desktop app already does. - Removed tauri-plugin-updater, createUpdaterArtifacts and the updater.pubkey config, the signing-key loading in build-gui.sh, the CI step testing the old latest.json generator, and the now-unused make-latest-json.py/test_make_latest_json.py scripts. - Documented the break for existing installs in CHANGELOG.md / CHANGELOG.pt-BR.md: versions up to 1.3.2 won't find this release automatically and need one manual download. Trade-off accepted knowingly: SHA-256 verifies integrity against Gitea's own API but, unlike the retired signed mechanism, does not protect against a compromised Gitea server independently of it. This is Lucas's own self-hosted instance, not a third-party mirror. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VAyr8d7HuAdUGB6aA5xmDD
This commit is contained in:
1 parent
6328220c60
commit
76e4466652
29 files changed
+373
-913
No files matched your search
@@ -7,11 +7,6 @@
|
||||
# scripts/build-gui.sh windows # NSIS installer, cross-built from Linux via cargo-xwin
|
||||
# scripts/build-gui.sh windows-docker # Same as above inside an isolated container
|
||||
#
|
||||
# The Tauri updater signs each bundle (createUpdaterArtifacts: true), so
|
||||
# TAURI_SIGNING_PRIVATE_KEY and TAURI_SIGNING_PRIVATE_KEY_PASSWORD must be set
|
||||
# in the environment. If unset locally, this script loads ~/.tauri/guara-updater.key
|
||||
# when available.
|
||||
#
|
||||
# Windows builds use cargo-xwin and NSIS (requires: nsis, lld, llvm, clang, and
|
||||
# `cargo install cargo-xwin`).
|
||||
set -euo pipefail
|
||||
@@ -20,19 +15,12 @@ cd "$(dirname "$0")/.."
|
||||
platform=${1:?usage: build-gui.sh <linux|linux-x86_64|linux-aarch64|windows|windows-docker>}
|
||||
tauri() { npx --yes @tauri-apps/cli@2 "$@"; }
|
||||
|
||||
if [ -z "${TAURI_SIGNING_PRIVATE_KEY:-}" ] && [ -f "$HOME/.tauri/guara-updater.key" ]; then
|
||||
export TAURI_SIGNING_PRIVATE_KEY="$(cat "$HOME/.tauri/guara-updater.key")"
|
||||
[ -f "$HOME/.tauri/guara-updater.pass" ] && export TAURI_SIGNING_PRIVATE_KEY_PASSWORD="$(cat "$HOME/.tauri/guara-updater.pass")"
|
||||
fi
|
||||
: "${TAURI_SIGNING_PRIVATE_KEY:?set TAURI_SIGNING_PRIVATE_KEY (or generate a local key with 'tauri signer generate')}"
|
||||
|
||||
if [ "$platform" = windows-docker ]; then
|
||||
docker volume create guara-cargo >/dev/null
|
||||
docker volume create guara-rustup >/dev/null
|
||||
mkdir -p dist-gui
|
||||
exec docker run --rm -v "$PWD":/src -v guara-cargo:/root/.cargo -v guara-rustup:/root/.rustup \
|
||||
-w /src -e HOST_UID="$(id -u)" -e HOST_GID="$(id -g)" \
|
||||
-e TAURI_SIGNING_PRIVATE_KEY -e TAURI_SIGNING_PRIVATE_KEY_PASSWORD \
|
||||
ubuntu:24.04 bash -c '
|
||||
set -e
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
@@ -81,7 +69,6 @@ case "$platform" in
|
||||
rm -rf "$bundle/appimage" "$bundle/deb"
|
||||
tauri build --bundles appimage,deb
|
||||
cp "$bundle"/appimage/*.AppImage "$bundle"/deb/*.deb ../../../dist-gui/
|
||||
cp "$bundle"/appimage/*.AppImage.sig ../../../dist-gui/ 2>/dev/null || true
|
||||
;;
|
||||
linux-x86_64)
|
||||
if [ "$(uname -m)" = x86_64 ]; then
|
||||
@@ -169,7 +156,6 @@ EOF
|
||||
tauri build --target x86_64-unknown-linux-gnu --bundles appimage,deb
|
||||
fi
|
||||
cp "$bundle"/appimage/*.AppImage "$bundle"/deb/*.deb ../../../dist-gui/
|
||||
cp "$bundle"/appimage/*.AppImage.sig ../../../dist-gui/ 2>/dev/null || true
|
||||
;;
|
||||
windows)
|
||||
rustup target add x86_64-pc-windows-msvc >/dev/null
|
||||
@@ -177,7 +163,6 @@ EOF
|
||||
rm -rf "$bundle"
|
||||
tauri build --runner cargo-xwin --target x86_64-pc-windows-msvc --bundles nsis
|
||||
cp "$bundle"/*-setup.exe ../../../dist-gui/
|
||||
cp "$bundle"/*-setup.exe.sig ../../../dist-gui/ 2>/dev/null || true
|
||||
;;
|
||||
*) echo "unknown platform: $platform" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
@@ -21,7 +21,6 @@ step "formatting (cargo fmt)" cargo fmt --all -- --check
|
||||
# Same flags as CI: warnings are treated as errors across all targets, including tests
|
||||
step "clippy (workspace, all targets)" env RUSTFLAGS="-D warnings" cargo clippy --workspace --all-targets
|
||||
step "UI translation coverage" node scripts/check-ui-i18n.mjs
|
||||
step "latest.json unit tests" python3 -B scripts/test_make_latest_json.py
|
||||
step "bash script syntax" bash -c 'for f in scripts/*.sh .githooks/*; do bash -n "$f" || exit 1; done'
|
||||
step "workflows and templates (valid YAML)" python3 -c "
|
||||
import glob, yaml
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Builds the latest.json manifest read by the Tauri updater from the release
|
||||
bundles and `.sig` signatures already uploaded to the Gitea release.
|
||||
|
||||
Runs in the final publish job after all matrix packaging jobs complete. Building
|
||||
the manifest in a single step avoids race conditions from parallel matrix jobs.
|
||||
|
||||
Usage: make-latest-json.py <tag> <notes.md> <out.json>
|
||||
Requires API (Gitea repository API URL) and TOKEN in the environment.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from datetime import datetime, timezone
|
||||
|
||||
# (platform keys, package filename patterns). {v} = version without leading "v".
|
||||
# No macOS: no Apple hardware/signing runner is available.
|
||||
PLATFORMS = [
|
||||
(["linux-x86_64", "linux-x86_64-appimage"], ("Guará_{v}_amd64.AppImage", "Guara_{v}_amd64.AppImage")),
|
||||
(["linux-aarch64", "linux-aarch64-appimage"], ("Guará_{v}_aarch64.AppImage", "Guara_{v}_aarch64.AppImage")),
|
||||
(["windows-x86_64", "windows-x86_64-nsis"], ("Guará_{v}_x64-setup.exe", "Guara_{v}_x64-setup.exe")),
|
||||
]
|
||||
|
||||
|
||||
def api_get(path):
|
||||
req = urllib.request.Request(f"{os.environ['API']}{path}", headers={"Authorization": f"token {os.environ['TOKEN']}"})
|
||||
return json.load(urllib.request.urlopen(req))
|
||||
|
||||
|
||||
def api_get_text(url):
|
||||
safe_url = urllib.parse.quote(url, safe=":/?&=%")
|
||||
req = urllib.request.Request(safe_url, headers={"Authorization": f"token {os.environ['TOKEN']}"})
|
||||
return urllib.request.urlopen(req).read().decode()
|
||||
|
||||
|
||||
def build(version, notes, assets, pub_date):
|
||||
"""`assets` = {package_name: (url, signature)}. Returns the latest.json dict.
|
||||
|
||||
Platforms missing a package or signature are omitted; the caller decides whether that is fatal."""
|
||||
platforms = {}
|
||||
for keys, patterns in PLATFORMS:
|
||||
name = next((n for p in patterns if (n := p.format(v=version)) in assets), None)
|
||||
if name is None:
|
||||
continue
|
||||
url, sig = assets[name]
|
||||
for k in keys:
|
||||
platforms.setdefault(k, {"signature": sig.strip(), "url": url})
|
||||
return {"version": version, "notes": notes.strip(), "pub_date": pub_date, "platforms": platforms}
|
||||
|
||||
|
||||
def main():
|
||||
tag, notes_path, out = sys.argv[1:4]
|
||||
version = tag.lstrip("v")
|
||||
rel = api_get(f"/releases/tags/{tag}")
|
||||
by_name = {a["name"]: a for a in rel["assets"]}
|
||||
assets = {}
|
||||
for _, patterns in PLATFORMS:
|
||||
name = next((n for p in patterns if (n := p.format(v=version)) in by_name), None)
|
||||
if name is None:
|
||||
continue
|
||||
sig_name = name + ".sig"
|
||||
if sig_name in by_name:
|
||||
sig = api_get_text(by_name[sig_name]["browser_download_url"])
|
||||
assets[name] = (by_name[name]["browser_download_url"], sig)
|
||||
data = build(version, open(notes_path, encoding="utf-8").read(), assets,
|
||||
datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"))
|
||||
# Require signed packages for Linux (x86_64 and aarch64) and Windows.
|
||||
platforms = data["platforms"]
|
||||
for must in ("linux-x86_64", "linux-aarch64", "windows-x86_64"):
|
||||
if must not in platforms:
|
||||
sys.exit(f"missing package or signature for {must} (found {sorted(platforms)}); "
|
||||
"verify that TAURI_SIGNING_PRIVATE_KEY is set in repository secrets")
|
||||
with open(out, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
f.write("\n")
|
||||
print(f"{out}: {sorted(data['platforms'])}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,55 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Unit tests for scripts/make-latest-json.py: python3 scripts/test_make_latest_json.py"""
|
||||
import importlib.util
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
sys.dont_write_bytecode = True
|
||||
|
||||
spec = importlib.util.spec_from_file_location("m", os.path.join(os.path.dirname(__file__), "make-latest-json.py"))
|
||||
m = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(m)
|
||||
|
||||
BASE = "https://git.lucasrguerra.dev.br/lucasrguerra/guara/releases/download/v1.3.2/"
|
||||
|
||||
|
||||
def assets(version="1.3.2"):
|
||||
names = [patterns[0].format(v=version) for _, patterns in m.PLATFORMS]
|
||||
return {n: (BASE + n, f"sig-of-{n}\n") for n in names}
|
||||
|
||||
|
||||
class LatestJson(unittest.TestCase):
|
||||
def test_all_platforms_and_variants(self):
|
||||
d = m.build("1.3.2", "### What's New\n", assets(), "2026-10-30T12:00:00Z")
|
||||
self.assertEqual(d["version"], "1.3.2")
|
||||
self.assertEqual(d["notes"], "### What's New")
|
||||
p = d["platforms"]
|
||||
for k in ("linux-x86_64", "linux-x86_64-appimage", "linux-aarch64", "windows-x86_64", "windows-x86_64-nsis"):
|
||||
self.assertIn(k, p)
|
||||
self.assertTrue(p["windows-x86_64"]["url"].endswith("x64-setup.exe"))
|
||||
self.assertEqual(p["linux-x86_64"]["signature"], "sig-of-Guará_1.3.2_amd64.AppImage")
|
||||
|
||||
def test_missing_package_is_left_out(self):
|
||||
a = assets()
|
||||
del a["Guará_1.3.2_x64-setup.exe"]
|
||||
d = m.build("1.3.2", "", a, "2026-10-30T12:00:00Z")
|
||||
self.assertNotIn("windows-x86_64", d["platforms"])
|
||||
self.assertIn("linux-x86_64", d["platforms"])
|
||||
|
||||
def test_other_versions_do_not_leak(self):
|
||||
d = m.build("1.3.2", "", assets("1.2.0"), "2026-10-30T12:00:00Z")
|
||||
self.assertEqual(d["platforms"], {})
|
||||
|
||||
def test_accepts_legacy_unaccented_name(self):
|
||||
a = assets()
|
||||
old = "Guara_1.3.2_amd64.AppImage"
|
||||
_, sig = a.pop("Guará_1.3.2_amd64.AppImage")
|
||||
a[old] = (BASE + old, sig)
|
||||
d = m.build("1.3.2", "", a, "2026-10-30T12:00:00Z")
|
||||
self.assertIn("linux-x86_64", d["platforms"])
|
||||
self.assertTrue(d["platforms"]["linux-x86_64"]["url"].endswith(old))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user