Releases
@voltro/plugin-releases — release channels, artefacts and rollouts: the self-hosted update server behind the Tauri updater, expo-updates, electron-updater and the PWA update impulse, with a reactive release policy (minimum version, kill switches) every client subscribes to.
@voltro/plugin-releases makes your api the update server of the apps it
serves. One release model — channels, releases, artefacts, rollouts — rendered
into the formats each update mechanism expects, plus a release policy your
clients subscribe to over the same reactive wire they use for data: change the
minimum supported version and every running app learns it in the time a
ChangeEvent takes, not at its next launch.
// app.config.ts (api)
import { releasesPlugin } from '@voltro/plugin-releases'
export default {
type: 'api' as const,
plugins: [
releasesPlugin({
apps: [
{ app: 'desktop', platform: 'macos', shell: 'tauri', updaterPublicKey: process.env.TAURI_UPDATER_PUBKEY },
{ app: 'mobile', platform: 'ios', shell: 'expo', runtimeVersion: '3' },
],
}),
],
}Activation creates each target with stable (public), beta and internal
(accepts unsigned artefacts) channels. Channels are pointers: promoting a
release moves the pointer, nothing is copied, and a rollback moves it back.
The management routes (releases:manage)
| Route | Does |
|---|---|
releases.registerApp |
an app target at runtime — { app, platform, shell, channels? }, with its channels; idempotent. What a feed that hosts apps it cannot know in apps: needs. Inspect: POST /apps |
releases.create |
a draft release of a version on a channel |
releases.upload |
attach an artefact: kind, target, URL, sha256, size, signature |
releases.promote |
point a channel at a release and start its rollout (default 100 %) |
releases.rollout |
change the current release's rollout percentage |
releases.rollback |
point the channel at the previous release; the current one is marked rolled back |
releases.setPolicy |
minimum supported version and kill switches per channel |
releases.list |
every release of a target, newest first |
stable and beta refuse a release with unsigned artefacts. The rollout is a
deterministic bucket per install (FNV-1a(salt:installId) mod 100, salt fixed
per release), so raising the percentage keeps every install that was already
in; a release's cohorts (tenant:acme) are always in.
The feeds
| Mechanism | URL |
|---|---|
| Tauri updater | GET /releases/:app/tauri/:channel/:target/:currentVersion → updater JSON or 204 |
| expo-updates | GET /releases/:app/expo/manifest with the expo-platform / expo-runtime-version / expo-channel-name / expo-current-update-id headers |
| electron-updater | GET /releases/:app/electron/:channel/latest.yml (latest-mac.yml, latest-linux.yml) |
| direct download | GET /releases/:app/download/:channel?os=macos&arch=aarch64 → 302 to the artefact (public channels only; OS read from the user agent when omitted) |
| policy (any client) | GET /releases/:app/policy/:channel?version=1.4.0 → the policy and this client's verdict |
A feed for a target with no signed artefact answers "no update" rather than an unsigned entry. Artefact bytes live wherever you put them (a storage ref URL, a CDN); the plugin hands out the URL you uploaded.
The policy on the client
Verified in a real browser (2026-09-26). The framework's browser check boots an api with this plugin and a web client, and watches the open page in Chromium: the channel policy arrives as a subscription,
voltro release promotereaches the page as a delta with no reload,useUpdateImpulseasks the platform's update channel to check exactly once per new version, andvoltro release policy --min-supportedblocks the running version live.
import { ForcedUpdateGate, useReleasePolicy, useKillSwitch, useInstallId } from '@voltro/plugin-releases/web'
<ForcedUpdateGate app="desktop" channel="stable" fallback={(v) => <UpdateRequired latest={v.latestVersion} />}>
<App />
</ForcedUpdateGate>
const { verdict } = useReleasePolicy({ app: 'desktop', channel: 'stable' })
verdict?.updateAvailable // a newer version this install is inside the rollout of
verdict?.killed // feature keys switched off for the running version
const pdfOff = useKillSwitch({ app: 'desktop', channel: 'stable', key: 'export-pdf' })
const installId = useInstallId() // the rollout bucket's key: minted once, kept in the platform's storageThe running version comes from the platform adapter
(appVersion), the install id from the platform's storage. verdict is
computed with the same function the /policy feed uses.
Retention
Channels are pointers and artefacts hang on releases, so retention is a rule about releases per channel: after every create, the plugin keeps the newest keep releases of that channel by version (default 10, floor 1) and deletes the rest with their artefact rows — never a release any channel currently points to, so a rollback target survives however old it is. Each run that removed something writes one prune event naming the versions.
releasesPlugin({
apps: [{ app: 'desktop', platform: 'macos', shell: 'tauri' }],
retention: { keep: 10, perChannel: { internal: 3 } },
})The bytes behind an artefact's URL are your storage's own retention; the plugin holds the reference, not the file. The event log has a time bound instead of a count: VOLTRO_RELEASE_EVENTS_TTL_HOURS (default one year), swept by the framework's retention sweep like every other framework table.
Inspect
Inspect write endpoints validate JSON inputs against the same schemas as their RPC descriptors before executing an action. Malformed field types receive HTTP 400 without changing release state or appending a release event.
Release management mutations use one store transaction for release state, channel
pointers, retention changes and their audit event, through both RPC and Inspect.
If persistence fails, the mutation is rolled back. Inspect reports internal
persistence failures as HTTP 500, distinct from malformed input and typed refusals.
RPC mutations reuse the framework's request transaction through
PluginRouteContext.mutationStore; Inspect actions open their own transaction.
Direct plugin invocations may omit this context field.
Dashboard confirmations retain the exact target ID and revision from the overview. Every management mutation advances that target revision in the same transaction as its state and audit event. The server rejects a stale confirmation with HTTP 409, including changes the dashboard has not received yet. A refreshed revision also disables the open confirmation. Cancel and reopen it to review current state.
The management RPCs and Inspect actions for create, upload, promote, rollout,
rollback and policy accept expectedTarget: { id, revision } from the overview.
Automation may omit it for a single target; writes still advance the revision.
When multiple platform targets share an app name, select an exact target with
this expectation. Uploads also resolve their target through the release ID.
The declarative schema adds revision with default zero to existing targets;
apply the schema before running the updated plugin.
For CLI management, read voltro release status --format json and copy the
chosen target's id and revision. Pass them together as --target-id ID --expected-revision N on create, upload, promote, rollout, rollback or policy.
--target continues to select the API URL; --target-id selects a release target.
A rollout or rollback remains a dry run without --commit. A stale expectation
fails instead of refreshing and retrying automatically; review a fresh status
before submitting again. Rollout percentages must be whole numbers from 0 to 100.
GET /_voltro/inspect/plugins/releases/overview (targets, channels, current
versions, rollout) and /events (the last hundred promote / rollback / rollout
/ policy events). The Releases panel in the local DevTools and in Voltro
Cloud renders them: channels with current version and rollout per target,
rollout / rollback / policy as confirmed actions (behind the manage-releases
capability), and the recent events.
The dashboard requires an explicit whole rollout percentage between 0 and 100. Empty input is not treated as zero. Invalid values show an inline message and cannot be submitted. While an action is pending, its inputs are locked and repeated clicks do not send another request. A failed request preserves the draft for review and an explicit retry.
The releases.promote and releases.rollout input schemas reject fractional percentages before storage. Inspect returns HTTP 400 for these invalid inputs. Integer values retain the existing server-side clamping to 0–100.
Release events show their app, platform/shell and channel. If target or channel metadata is no longer available, the stored ID remains visible. Events without a channel apply to all channels. Custom event providers must preserve the nullable channelId and channelName fields.