Skip to content

Releases and updates

An app release is a set of packages plus a signed update manifest, sabine-update.json. Installed copies of your app read that manifest, check its signature against the public key they were built with, and update from the same kind of package they were installed from. App releases are separate from Sabine’s own: your app updates without shipping Chromium again, and Sabine updates its shared runtime without you publishing anything.

  1. Give the app its identity and version in Sabine.toml. The version is [app] version, or the Cargo package’s version when that is absent.

    [app]
    id = "com.example.tern"
    name = "Tern"
    version = "1.0.0"
  2. From the project folder, create the update signing key, point the app at its repository and write the release workflow:

    Terminal window
    sabine release-init --repository example/tern
  3. Commit, then push a tag named after the version:

    Terminal window
    git tag v1.0.0
    git push origin v1.0.0

The workflow builds and signs every package, writes the signed manifest, and publishes them all as the release for that tag. The tag has to match the version exactly; a v1.0.1 tag on a project at 1.0.0 fails before anything is published.

sabine release-init sets up signed releases for a GitHub repository. It uses the GitHub CLI, which has to be signed in with permission to change the repository’s secrets and settings.

  • It takes the repository from --repository, then GITHUB_REPOSITORY, then gh repo view in the current folder.
  • It generates a new Ed25519 signing key and stores it as the repository’s Actions secret SABINE_UPDATE_SIGNING_KEY. The private key never touches the disk.
  • It turns on immutable releases for the repository, so a published release can’t be changed.
  • It sets provider = "github", the repository and the new public_key in the [updates] table of the folder’s Sabine.toml, creating the file or the table when there is none. Everything else in the file, such as the policy, stays as it was.
  • It writes the release workflow to .github/workflows/release.yml. When that file already calls Sabine’s workflow, it moves it onto the CLI’s Sabine release instead, and it leaves a workflow of your own alone.

It has to run from the app’s project folder, and prints the public key when it’s done.

Sabine ships a reusable GitHub Actions workflow that does the whole release. sabine new and sabine release-init write a workflow that calls it on version tags:

.github/workflows/release.yml
name: Release
on:
push:
tags:
- "v*"
permissions:
contents: write
id-token: write
attestations: write
jobs:
release:
uses: Lantharos/Sabine/.github/workflows/release-app.yml@v0.68
with:
sabine_version: v0.68
# Building the MSI package accepts the WiX 7 license: https://docs.firegiant.com/wix/osmf/
accept_wix_eula: true
secrets: inherit

Keep sabine_version and the workflow’s tag on the Sabine release your app builds against; sabine release-init moves both onto the CLI’s release. The written workflow accepts the WiX license that MSI builds need, so read its terms before you publish; without accept_wix_eula, the Windows builds stop.

Input Default Meaning
sabine_version required The Sabine tag to install the CLI from, such as v0.68
rust_toolchain stable The Rust toolchain that builds the app; Sabine needs 1.98 or newer
accept_wix_eula false Accepts the WiX 7 EULA for MSI builds. MSI builds stop until you set it to true after reviewing the terms
upgrade_from_sabine_version empty An older Sabine tag to install before the Windows launch test, to check that your app works on a machine upgrading from it

The workflow needs the SABINE_UPDATE_SIGNING_KEY secret, and takes the optional signing secrets for macOS and Windows. secrets: inherit passes them all on.

Each platform builds on a runner of its own, installs the matching Sabine CLI with cargo install, installs your web dependencies with bun install --frozen-lockfile at the repository root, and runs sabine bundle for each package:

Platform Packages
Linux x86_64 and ARM64 (Ubuntu 24.04) AppImage, deb, rpm, and a portable tarball
Windows x86_64 and ARM64 MSI, setup .exe, and a windows tarball
macOS Apple Silicon DMG, and a macos tarball

The tarballs serve installations that update from archives, such as sabine install --bundle and unpacked portable bundles.

On Windows x86_64 the workflow then installs the MSI silently on a clean machine, launches the app, waits for Sabine’s first-run setup to finish with a running service, and checks that the page renders, before uninstalling it again. It also checks that the executable needs no C runtime redistributable, runs as a GUI program, and carries its icon.

Once every platform succeeds, the workflow writes and signs sabine-update.json with sabine release-manifest, records build provenance attestations for every file, and publishes them as the GitHub release for the tag with generated release notes.

[updates] in Sabine.toml says where installed copies look for releases:

[updates]
provider = "github"
repository = "example/tern"
channel = "stable"
policy = "automatic"
public_key = "4Zc2Y0v2pZ8m0nq3w6m2b1V8x0o0Yk1o8f6lV0Gq1yE="
Key Default Meaning
provider required "github" for GitHub Releases, or "http" for a manifest you host
repository owner/name of the GitHub repository; required with provider = "github"
url The HTTPS URL of the release manifest; required with provider = "http"
channel "stable" The channel releases must name. GitHub sources have only stable
policy "automatic" "automatic", "notify" or "disabled"; see below
public_key The base64 Ed25519 public key releases are signed with. Filled in at bundle time from the signing key when empty

A GitHub source reads https://github.com/<repository>/releases/latest/download/sabine-update.json, so installed copies follow the repository’s latest release, and GitHub never counts prereleases as that. A GitHub source therefore takes no channel other than stable, and sabine refuses one when it reads Sabine.toml. For beta or nightly channels, publish them from an HTTP source, which reads its url, must use HTTPS and serves a manifest in the same format. A manifest names its channel, so each channel needs a url of its own.

Policy What installed copies do
automatic Sabine’s service checks for releases in the background and installs them. Archive installations update without asking; packages download and are offered the next time the app opens
notify The service checks and downloads in the background like automatic, but every installation asks first: the next time the app opens, it offers the downloaded release
disabled The app never updates itself, even when [updates] names a source

sabine update <app id> checks the app’s source and installs a release right away under both automatic and notify.

A bundle built in GitHub Actions updates from the repository’s own releases even without an [updates] table: it uses GITHUB_REPOSITORY with the stable channel and the automatic policy. A bundle built anywhere else has no update source unless [updates] names one.

An app that never updates itself, such as one people install through your own setup program or an app store, says so with the policy:

[updates]
policy = "disabled"

It needs no provider and no signing key, and keeps the other keys from doing anything, so you can switch updates off without removing the source. Bundles installed for every user with sabine install --bundle --system-wide never carry an update source; whoever installed them updates them.

Update manifests are signed with Ed25519. The private key is a base64 32-byte seed in the SABINE_UPDATE_SIGNING_KEY environment variable; the app only ever ships the public key.

When SABINE_UPDATE_SIGNING_KEY is set during sabine bundle, Sabine derives the public key from it and writes that into the bundle. If [updates] also names a public_key, the two must match. Inside GitHub Actions, bundling an app with enabled updates fails when it ends up without a public key, so a release can’t ship copies that would never accept an update.

To manage a key without sabine release-init, for example for an HTTP source:

Terminal window
sabine release-keygen --public-output update-key.pub > update-key.private
export SABINE_UPDATE_SIGNING_KEY="$(cat update-key.private)"
sabine release-public-key

release-keygen prints the new private key and writes the public key to the file you name. release-public-key prints the public key of SABINE_UPDATE_SIGNING_KEY. Keep the private key out of your repository; each app should have its own.

sabine release-manifest writes the signed manifest for the packages of one release. It reads the app’s id, version and [updates] from Sabine.toml, so it needs an update source that isn’t disabled, and SABINE_UPDATE_SIGNING_KEY:

Terminal window
sabine release-manifest . \
--artifact linux-x86_64=dist/artifacts/com.example.tern-1.0.0-portable.tar.gz \
--executable linux-x86_64=com.example.tern/root/usr/lib/sabine/com.example.tern/tern \
--artifact linux-x86_64-deb=dist/artifacts/com.example.tern-1.0.0-deb.deb

Each --artifact maps an update target to a package file, and --executable gives the program’s path inside an archive, as sabine bundle --json reports it. The manifest goes to dist/sabine-update.json unless you pass --output, and --channel must match [updates]. Inside GitHub Actions the version must match the tag being built.

Each package’s download URL is its file name in the release’s downloads on GitHub. For an HTTP source, upload the packages and the manifest yourself and pass the HTTPS folder the packages are in:

Terminal window
sabine release-manifest . --base-url https://downloads.example.com/tern/1.0.0 \
--artifact linux-x86_64=dist/artifacts/com.example.tern-1.0.0-portable.tar.gz \
--executable linux-x86_64=com.example.tern/root/usr/lib/sabine/com.example.tern/tern

Then upload dist/sabine-update.json to the url your [updates] names, last, so installed copies never see a manifest whose packages aren’t there yet.

{
"schema": 1,
"app_id": "com.example.tern",
"version": "1.0.0",
"channel": "stable",
"published_at": "1793491200",
"requires_sabine": { "major": 0, "build": 65 },
"artifacts": {
"linux-x86_64": {
"url": "https://github.com/example/tern/releases/download/v1.0.0/com.example.tern-1.0.0-portable-linux-x86_64.tar.gz",
"sha256": "9f2c…",
"kind": "archive",
"executable": "com.example.tern/root/usr/lib/sabine/com.example.tern/tern"
}
},
"signature": "base64 Ed25519 signature"
}

published_at is in Unix seconds, requires_sabine is the Sabine build the app was made with, and kind is inferred from the file name: archive for .tar.gz and .zip, or deb, rpm, msi, exe, dmg or app-image. The signature covers the whole manifest.

Update targets name the platform, followed by the package kind for installations from a package:

Target Installed from
linux-x86_64, linux-aarch64 Archives: sabine install --bundle, portable and linux tarballs
linux-x86_64-deb, -rpm, -appimage (and the aarch64 ones) deb, rpm or AppImage packages
windows-x86_64, windows-aarch64 Archives: sabine install --bundle, portable and windows bundles
windows-x86_64-msi, -exe (and the aarch64 ones) MSI or setup program
macos-aarch64 Archives: sabine install --bundle, portable and macos bundles
macos-aarch64-dmg The disk image

Sabine’s service checks for updates when it starts and every six hours after, for every installed app with an update source. For each one it:

  1. fetches the release manifest over HTTPS,
  2. checks its signature against the app’s public key, and that it names this app, this channel and manifest schema 1,
  3. continues only when the release is strictly newer than the installed version, by SemVer,
  4. waits for the release to soak (see below),
  5. makes sure the installed Sabine runs the release, installing a newer Sabine first when the release needs one,
  6. picks the artifact for the installation’s own target and downloads it over HTTPS, checking its SHA-256.

What happens next depends on how the app was installed:

Installed from How the update is applied
Archive (sabine install --bundle, a portable or linux/windows/macos bundle) Extracted in the background next to the installed version and checked before use. The next launch of the original program forwards to the new one; nothing is shown. With policy = "notify", downloaded, then offered at the next launch, and extracted once accepted
deb Downloaded, then offered at the next launch. Installs with apt-get through pkexec
rpm Downloaded, then offered at the next launch. Installs with dnf, zypper or rpm, whichever the system has, through pkexec
AppImage Downloaded, then offered at the next launch. Replaces the AppImage file in place
MSI Downloaded, then offered at the next launch. Runs msiexec with its progress window
Setup .exe Downloaded, then offered at the next launch. Runs the setup program silently
DMG Downloaded, then offered at the next launch. Replaces the .app, asking for an administrator password only when you can’t replace it yourself

For deb and rpm updates, the elevated step copies the package somewhere only the administrator can write and checks its SHA-256 there, so the package manager installs exactly the file that matched the release. A downloaded package is checked against the installed version again before it’s offered, and deleted once it’s installed. Apps installed through an app store are left to the store.

When a downloaded package, or an archive release of an app with policy = "notify", is waiting, the app shows a prompt before it opens:

Update Tern

Version 1.1.0 is ready to install. The app will close while the update is installed. You can choose Later to keep using this version.

Install update closes the app, runs the installer or unpacks the archive, and opens the app again once it succeeds. Later opens the app as it is and doesn’t ask again for a day. Development runs never show the prompt.

A release becomes eligible 24 hours after it was published, plus a rollout offset between zero and six hours that each installation picks once at random and keeps. This spreads downloads out and leaves you a day to withdraw a bad release before most people get it.

sabine update <app id> --force installs a release before it has soaked; signatures, checksums and version checks still apply. An app that the installed Sabine can no longer run skips the soak, so it gets a working version as soon as there is one.

Sabine’s CLI, service, daemon and CEF host are released together as one MAJOR.BUILD release, such as v0.68. The daemon looks for a newer Sabine on every maintenance run and installs it after the same soak and per-installation rollout offset, then hands over to the new daemon. The previous version is kept for rollback, and a release that fails to start is rolled back and retried later. sabine update updates the CLI and shared components right away, and with --force skips the soak.

The CEF runtime updates on its own schedule: Sabine downloads newer compatible runtimes, checks that Chromium starts and renders on them before using them, and keeps the newest two. See Shared runtime.

Every app records the Sabine build it was made with, requires_sabine in its release manifest and sabine in its bundled Sabine.toml. Every Sabine release records its own build and the oldest app build it still runs. An app runs on a Sabine with the same major version whose build is at least that oldest build and no newer than its own.

  • An app needs a newer Sabine. Installing or updating it fetches that exact Sabine release and installs it first, without waiting for the soak.
  • A Sabine release would stop an installed app. Sabine holds that release and installs the newest earlier release that runs every installed app instead, so fixes keep arriving. The held release installs on the next check after those apps update or are removed. sabine update names the apps it is waiting for. Apps run from sabine dev never hold Sabine back.
  • An app is older than the oldest build Sabine runs. It isn’t registered again. Sabine fetches its newest release right away, and offers a downloaded update before explaining the problem.

The oldest accepted build moves up only when Sabine’s host protocol changes, so most Sabine releases run every app built since the last such change.

When an app can’t run, it shows a notice instead of its window:

The developer of Tern has not updated it to work with this version of Sabine. The app was not added to Sabine.

When the app needs a newer Sabine than this computer could update to, the notice names both versions instead.