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 promote reaches the page as a delta with no reload, useUpdateImpulse asks the platform's update channel to check exactly once per new version, and voltro release policy --min-supported blocks 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 storage

The 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.