Skip to content

Releases and Auto Updates

Pablo Martínez López edited this page Oct 27, 2026 · 7 revisions

Releases and Auto-Updates

English · Español

How a version is cut (pnpm release), what the release workflow builds, how installers are signed for the in-app updater, and how the installed app finds, downloads and installs a newer version. The updater lives in Rust (src-tauri/src/actualizar.rs, commands/actualizar.rs); the frontend only drives it and shows progress.

Overview

sequenceDiagram
    participant M as Maintainer
    participant S as scripts/release.mjs
    participant GH as GitHub (git, Actions, Releases)
    participant A as Installed app

    M->>S: pnpm release patch
    S->>GH: bump 4 files, commit "Version X", push main
    GH-->>S: ci.yml green for that commit
    S->>GH: dispatch release.yml
    GH-->>GH: build + sign installers, upload to DRAFT release vX with latest.json
    S->>GH: set notes on draft and in latest.json
    M->>S: confirm publish
    S->>GH: gh release edit --draft=false --latest (creates tag vX)
    A->>GH: GET releases/latest/download/latest.json
    A->>A: compare versions, download package, verify minisign signature
    A->>A: install and restart
Loading

Key decisions and why:

  • Releases are cut by scripts/release.mjs and the release workflow is manual-only. A tag trigger rebuilt everything when the draft was published (publishing creates the tag), so the workflow runs only via workflow_dispatch.
  • Drafts do not count for the updater. The app reads releases/latest/download/latest.json; GitHub's "latest" ignores drafts, so installed apps only see a version once the draft is published.
  • No OS code signing (SmartScreen and Gatekeeper will warn; see User Guide). The signature that matters here is the updater's minisign signature.

The release script

scripts/release.mjs (run as pnpm release <patch|minor|major|X.Y.Z> [options], needs git and an authenticated gh). Options (parsed by leerArgs in scripts/release-notas.mjs):

Option Effect
--dry Run the checks and print what would happen; problems become warnings instead of aborting
--publicar Publish the draft without asking
--notas "text" Release notes inline
--notas-fichero PATH Release notes from a Markdown file
--sin-notas Only the fixed text

Only one of the three notes options may be given. With none, the script writes a template to tmp/release-notas-v<version>.md (the template is a comment containing the commit list since the last tag) and opens $VISUAL, $EDITOR, nano/vi, or notepad on Windows; empty notes abort before anything is touched.

Steps, in order:

  1. Checks: gh auth status; the four version locations agree; the new version differs and is X.Y.Z; branch is main; working tree clean; no commits behind or ahead of origin/main; tag vX.Y.Z and a GitHub release of that tag do not exist.
  2. Notes (as above).
  3. Bump the version in four places: package.json, src-tauri/tauri.conf.json, src-tauri/Cargo.toml, src-tauri/Cargo.lock (the proxy-builder package entry). versionActual() refuses to start if they disagree.
  4. Commit Version X.Y.Z and push main.
  5. Wait for the CI run of that commit (ci.yml) and fail if it fails (gh run watch --exit-status, output streamed so the terminal does not look hung).
  6. Dispatch release.yml on main and wait (roughly ten minutes).
  7. Validate the draft: it must be a draft, its targetCommitish must equal the pushed commit, and it must contain a latest.json asset (otherwise the signing secret is probably missing).
  8. Put the notes on the draft (componer(notas) appends the fixed text) and rewrite latest.json's notes with the real notes (notasDelActualizador), then re-upload it with --clobber. The workflow's own latest.json carries the fixed text, which means nothing inside the app.
  9. Ask to publish (s/si/y/yes), or publish directly with --publicar. Publishing: gh release edit vX --draft=false --latest, which creates the tag. If you decline, the script prints the command to publish later.

Helpers in scripts/release-notas.mjs (unit-tested by scripts/__tests__/release-notas.test.mjs, which needs neither git nor gh): leerArgs, limpiarNotas (strips <!-- --> comments and collapses blank lines), plantilla, componer (notes plus TEXTO_FIJO, the text release.yml also sets), notasDelActualizador (throws if the JSON is not for the expected version or has no platforms).

Resuming a cut release: Inferred: because every step after the push is a separate gh operation, an interrupted release can be finished by hand from the draft (gh release edit, uploading a corrected latest.json); the script has no resume flag. The lockfile must be in the same commit as the packageManager change, per the repository's release practice.

Release notes are written in English ("What's changed since X").

The release workflow

.github/workflows/release.yml; matrix over four legs (fail-fast: false):

Leg Produces
ubuntu-22.04 .deb, .rpm, AppImage (Tauri bundle targets all)
windows-latest NSIS and MSI installers
macos-latest with --target aarch64-apple-darwin Apple Silicon .app bundle (and DMG)
macos-latest with --target x86_64-apple-darwin Intel .app bundle (and DMG)

tauri-apps/tauri-action@v0 builds each leg and uploads to a draft release named Proxy Builder v__VERSION__ with tag v__VERSION__. Environment: GITHUB_TOKEN, TAURI_SIGNING_PRIVATE_KEY, TAURI_SIGNING_PRIVATE_KEY_PASSWORD (the key has no password; the secret may be empty). Without the private key secret tauri build fails, because bundle.createUpdaterArtifacts is true.

With createUpdaterArtifacts, each installer gets a minisign .sig file, and tauri-action merges one entry per installer into latest.json, with keys like linux-x86_64-deb, windows-x86_64-nsis, darwin-aarch64-app (named in the workflow comment). The updater picks the entry that matches the installer type the running binary came from.

Signing model

  • A private minisign key signs every bundle at build time. It lives only on the maintainer's machine and in the repository secret TAURI_SIGNING_PRIVATE_KEY.
  • The matching public key is embedded in src-tauri/tauri.conf.json at plugins.updater.pubkey (base64 of the minisign public key file).
  • Consequence: if the private key is lost, installed versions will reject every future update, because they only trust that public key. Treat it as irreplaceable and back it up.
  • requireSignedVersion: true makes the signature embed the version (written by tauri build). An attacker therefore cannot announce a newer version while serving an older signed package.
  • The Rust test la_configuracion_del_actualizador_es_valida (src-tauri/src/actualizar.rs) pins these settings, see Testing and CI.

Updater configuration

From src-tauri/tauri.conf.json:

Key Value
bundle.createUpdaterArtifacts true
plugins.updater.endpoints https://github.com/PoolFlamingo/proxy-builder/releases/latest/download/latest.json
plugins.updater.pubkey minisign public key (base64)
plugins.updater.requireSignedVersion true
plugins.updater.windows.installMode passive

The plugin is registered in src-tauri/src/lib.rs (tauri_plugin_updater::Builder::new().build()) and the Actualizador state is .managed there. It is not exposed to the webview: src-tauri/capabilities/default.json has no updater permission, so the page cannot call the plugin, only the four app commands. This follows the rule that all network work is done by Rust (Architecture).

Rust side

Module src-tauri/src/actualizar.rs

Item Role
Actualizador Managed state: Mutex<Option<Pendiente>>, where Pendiente { update: Update, paquete: Option<Vec<u8>> } holds the found update and, once downloaded, the package bytes
estado(app) → ActualizacionEstado { soportada, actual } soportada() is !cfg!(debug_assertions) && tauri::utils::platform::bundle_type().is_some(): false in dev builds and for binaries that do not come from an installer
buscar(app, estado) → Option<ActualizacionInfo> Builds the updater (with an on_before_exit hook that saves the window state), calls check(), stores the pending update. Returns ErrorCode::Unsupported if unsupported. ActualizacionInfo { version, actual, notas, fecha, pide_permiso }; pide_permiso is true when the bundle type is Deb or Rpm
bajar(estado, version, al_avanzar) Downloads the package for that exact version (Actualizador::update(version) returns NotFound if a later check replaced it) while reporting (hecho, total); the plugin verifies the signature during download
instalar(app, estado, version) Takes the package, runs update.install inside spawn_blocking (a .deb/.rpm install waits for the pkexec password), restores the pending update on failure so the user can retry (e.g. cancelled password), and on success calls app.request_restart() so exit events run and the window-state plugin saves the window
error(e) Maps plugin errors: network errors to Network; ReleaseNotFound/TargetNotFound/TargetsNotFound to NotFound (no latest.json on the latest release, or no package for this installer type); AuthenticationFailed to Cancelled; Io to Io; the rest to Internal

Install method per installer type: the module documents that it downloads the package of the installer the app was installed with (.deb, .rpm, AppImage, NSIS, MSI or .app) and verifies its signature. .deb/.rpm are installed through pkexec (system password prompt; pide_permiso), and on Windows the installer closes the app itself and relaunches it (hence the on_before_exit window-state save). Inferred: AppImage and macOS .app are replaced in place by tauri-plugin-updater; the exact mechanics live inside that plugin, not in this repository.

Commands src-tauri/src/commands/actualizar.rs

Command Behaviour
update_status Returns ActualizacionEstado
update_check Returns Option<ActualizacionInfo>
update_download(version) Emits update://progress with ActualizacionProgressEvent { hecho, total } (total is 0 when the server does not say)
update_install(version) Busy guards, both before doing anything. First the game: if AppState::hay_partida() (a session in AppState.mesa, even if the user is on another tab) it returns Busy with params.motivo = "partida", which src/lib/errores.ts (MOTIVOS.Busy.partida) shows as «Hay una partida abierta: termínala o sal de la mesa para actualizar.». Then AppState::ocupado(): true while any TrabajoEnMarcha guard from AppState::trabajo() is alive (export, resolving a list, OCR batch, design batch, downloads, game and room preparation; see commands/export.rs, cards.rs, identify.rs, diseno.rs, fuentes.rs, mesa.rs, red.rs, salas.rs) or a game is open; that Busy has no motivo and shows the general «Hay un trabajo en marcha; espera a que termine.». Test con_partida_la_app_esta_ocupada. Why: restarting in the middle of a PDF export would lose it, and in the middle of a game would close it under the player (it is saved, but the session ends)

The four commands are registered in src-tauri/src/lib.rs and typed in src/lib/ipc.ts (update_status, update_check, update_download, update_install; event update://progress). ActualizacionInfo, ActualizacionEstado and ActualizacionProgressEvent are exported to src/bindings/ by ts-rs.

Frontend side

stateDiagram-v2
    [*] --> inactiva
    inactiva --> buscando: buscar()
    buscando --> alDia: no update
    buscando --> disponible: found
    buscando --> inactiva: error (no info)
    disponible --> bajando: bajar()
    bajando --> lista: package downloaded
    bajando --> disponible: error
    lista --> instalando: instalar()
    instalando --> lista: error (retry)
    instalando --> [*]: app restarts
Loading

(alDia is the step "al-dia".)

src/stores/actualizacion-store.ts (useActualizacion)

State: soportada: boolean | null, actual, paso ("inactiva" | "buscando" | "al-dia" | "disponible" | "bajando" | "lista" | "instalando"), info, progreso (0-100 or null), error (a raw AppError, translated by mensaje).

Action Details
iniciar() Asks update_status once and caches; any failure means unsupported
buscar() No-op while busy (buscando, bajando, instalando); if already lista, returns the info without searching again (a downloaded package is kept until restart); otherwise calls update_check
bajar() Only from disponible. Subscribes to update://progress before calling update_download, converts to a percentage, and unsubscribes in finally
instalar() Only from lista. Calls guardarAhora() (flush the deck autosave) then update_install. With the table open it only sets alSalirDeLaMesa and returns (see "Updating with a game open" below). If it returns after update_install, it failed: the step returns to lista and the error is thrown. On success the app restarts and this never returns

src/features/shell/actualizacion-automatica.tsx

Export Behaviour
useActualizacionAutomatica() Mounted once in src/App.tsx. If the actualizarSola preference is on (default true, in ui-store), waits 8 s after start (PRIMERA_MS, so the app is fully up), then runs a round (iniciar, buscar, bajar) and repeats every 6 h (CADA_MS). No network or no release means silence
useAvisosActualizacion() Mounted once in App. Subscribes to the store and shows a toast with id actualizacion: a loading toast with the percentage while downloading, a persistent success toast with a "Reiniciar" action when ready (text differs when a password will be asked), and an error toast if the download fails. Works for automatic and manual downloads
BotonActualizar() Top-bar button visible only when a version is lista or instalando
PuntoActualizacion() Dot on the Settings button: amber and pulsing while downloading, accent colour when ready
useAvisoAjustes() Tooltip text for the Settings button

Interaction with closing the window

Closing the window while an update is bajando or instalando asks for confirmation (ConfirmarCierre, reason "An update is being downloaded or installed."; an update that is only available or already lista does not count). In the other direction, the close guard does not block updating: "Restart and update" saves the deck itself and restarts from Rust with request_restart, which does not go through the window close request. Inferred: from Tauri's behaviour; not exercised end to end. See Settings and Shell.

Updating with a game open

The install does not refuse and give up: it waits for the player to leave the table and then runs by itself.

  • instalar() does not call update_install while the table is open (useMesa.activa): it sets alSalirDeLaMesa and returns, with no error and no restart. useAvisosActualizacion toasts «Se instalará al salir de la mesa.» and BotonActualizar stays disabled, with that same text as its tooltip. The same happens when the screen did not know about the game and Rust refuses with Busy motivo: partida: the store takes that refusal as «wait» (esperaALaPartida), not as a failure.
  • When the table closes (useMesa.activa goes from true to false: salir() in features/mesa/conexion.ts after mesa_salir, both «Dejarla para luego» and «Terminarla», or the host closing the game for a guest), the same hook calls instalar() again: it saves the deck, installs and restarts. If that fails it is toasted like any other install error.
  • Checking and downloading are not blocked: with auto-update on, the new version is downloaded during the game and only the install waits.
  • The Rust guard stays as a safety net (update_install still returns Busy with motivo: partida, test con_partida_la_app_esta_ocupada): the app never restarts under a game, even if the screen asked at the wrong time.
  • A replay opened from the history also lives in AppState.mesa, so it also holds the install back. Watching an online game as a spectator lives in AppState.mirada, which hay_partida() does not look at, so it does not.
  • Inferred: a game that has just ended but is still on the table keeps the install waiting, because the table only closes when the player leaves it.

src/features/settings/actualizaciones.tsx

Actualizaciones (card in the Settings dialog): "Buscar ahora" (manual check; if actualizarSola is on it also downloads), the auto-update switch, a status line, "Descargar la X" when disponible, "Reiniciar y actualizar" when lista, a progress bar, the translated error, and the release notes in a ScrollArea. If soportada === false it explains that updates exist only in the installed app. ReiniciarParaActualizar repeats the restart button in the dialog header. Tested by src/stores/__tests__/actualizacion-store.test.ts.

Operational checklist and gotchas

Situation What to know
First updater-enabled version Versions before 2.4.0 have no updater; users install 2.4.0 by hand once
latest.json missing from the draft Secrets TAURI_SIGNING_PRIVATE_KEY absent or the build failed; release.mjs aborts at step 7
Draft not published Installed apps see nothing (GitHub "latest" ignores drafts)
Update on Linux packages .deb/.rpm need pkexec and show the password prompt on restart
Updating during an export or with a game open update_install returns Busy (with a game, motivo: partida and its own message); with the table open the screen does not even ask: the install waits and runs when the player leaves the table (see "Updating with a game open" above, Game Table)
Dev builds soportada is false; Settings says updates are available only in the installed app
Changing the key or endpoint Breaks all installed copies; the guard test fails first
Four version files Keep them in sync; only release.mjs should change them
Lockfile and packageManager Commit the lockfile with the packageManager it was generated by, or the release CI install (--frozen-lockfile) fails
Re-running failed jobs Transient network failures in matrix legs can be re-run in GitHub Actions; the draft collects whatever legs have uploaded

See also

Documentation · Documentación

Language: English · Español

Use and build · Uso y desarrollo

Architecture · Arquitectura

Data · Datos

Getting cards · Conseguir cartas

Layout, images, PDF · Maquetación, imagen y PDF

Card designer · Diseñador de cartas

Game table · Mesa de juego

Frontend · Frontend

Reference · Referencia

Clone this wiki locally