App updates and version blocking
updatesTell users an update is ready, refuse old versions on the server as well as in the app, tell open tabs about a new build, and roll releases out by percentage and market.
updatesFree in your app, Apache-2.0Status Status is where the work is: Planned (in a milestone, not started), Building (tickets in progress), Preview (usable, may change), then the stability it ships at.
Stability The promise an interface ships with.
- Stable Follows semantic versioning; breaking changes need a new major, 12 months of overlap and a codemod.
- Beta Usable in production; may change before it becomes stable.
- Experimental May change or go away; needs an explicit opt-in in main.yaml.
updates tells users an update is ready and refuses old versions on the server as well as in the app. Use it after a security fix, a breaking API change or a staged release.
At a glance
updates:
driver: local
min-version: 2.4.0
build: 2026.10.27-1
exempt: [/api/me/export]
channels:
stable: { version: 2.6.0, rollout: 50, markets: { IN: 100, EU: 10 } }
beta: { version: 2.7.0-beta.1, rollout: 100 }
api:
deprecated:
- { path: /api/v1, since: 2026-10-01, sunset: 2027-06-30, successor: /api/v2 }import { app } from "mainsdk";
const updates = app().updates;
const status = updates.check({ version: "2.3.9", user: user.id });
if (status.required) showUpdateScreen(status.minVersion);app/updates.test.ts
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { createTestApp, fakeUpdates } from "@mainsdk/testing";
import { applyUpdate } from "mainsdk/updates/client";
describe("updates", () => {
it("updates AC2 asks for the full-screen flow when the version is too old", async () => {
const app = createTestApp({ main: "./main.yaml" });
const status = app.updates.check({ version: "1.0.0" });
const driver = fakeUpdates();
await applyUpdate(status, driver);
assert.deepEqual(driver.calls, ["apply:immediate"]);
});
});Calls
updates.minVersion()updates.check({ version, channel?, user?, market? })updates.channel({ user?, market?, channel? })updates.build()updates.announce(build)createUpdates(deps)parseUpdates(raw)updatesIssues(raw)versionHeaders(version, platform?)refusal(res)applyUpdate(status, driver)watchBuild({ build, onUpdate, banner? })expoUpdates(module)googlePlay(module)appStore({ url, open })tauri(module)
Guarantees
Whatever the driver.
- Blocking enforced by the server as well as the app
- The minimum version can only rise through a push, and every change is audited
- Blocked users can still sign out and export their data
- Open web tabs hear about a new build over server-sent events
- The same user always gets the same answer in a staged rollout
- API clients get Deprecation and Sunset headers before a route goes away
- Reads memory only, so a config or store outage never changes an answer
Drivers
5 drivers. Select one to see its line in main.yaml.
updates:
driver: localChange this one line to swap providers; your calls stay the same.Built-in and local drivers integration
Codes
| Code | Where it appears | Meaning |
|---|---|---|
MAIN-UPDATE-0003 | Checkmainsdk check, before deploy | The updates section is invalid: a version that isn't dotted numbers, a rollout outside 0 to 100, a channel other than stable or beta, or a deprecation without a path and a sunset date |
MAIN-UPDATE-0004 | Checkmainsdk check, before deploy | updates.min-version is above the stable channel's version, so every current client would be blocked |
MAIN-UPDATE-0001 | RuntimeError responses and logs | See the spec |
MAIN-UPDATE-0002 | RuntimeError responses and logs | See the spec |
Features
Nothing here has shipped yet. Each feature lands in its phase; the spec ticket builds it.
| Feature | Phase | Milestone | Spec ticket |
|---|---|---|---|
| updates@1 contract and the updates main.yaml keys | Phase 2 | 0.7 | updates#contract |
| Minimum version that only tightens | Phase 2 | 0.7 | updates#min-version |
| Server gate with 426 MAIN-UPDATE-0001 | Phase 2 | 0.7 | updates#server-gate |
| Web version skew banner over SSE | Phase 2 | 0.7 | updates#web-skew |
| Stable and beta channels with a staged rollout by percentage and market | Phase 2 | 0.7 | updates#channels |
| Deprecation and Sunset headers for API clients | Phase 2 | 0.7 | updates#api-sunset |
| Update checks | Phase 2 | 0.7 | updates#checks |
| Size budgets and no network in the request path | Phase 2 | 0.7 | updates#budgets |
| Store drivers for Expo Updates | Phase 3 | 1.1 | updates#drivers |
| Update metadata signed and delivered through TUF | Phase 2 | 0.7 | Not specified yet |
| Automatic halt of a staged rollout on a crash or error spike | Phase 2 | 0.7 | Not specified yet |
MainSDK Cloud
Everything above is free in your app. Cloud adds these on top, by plan:
| Plan | What it adds |
|---|---|
| Teams | Release dashboards |
| Fleet | Fleet-wide version rules |
Quick start
schema: 1
updates:
min-version: 2.4.0
channels:
stable: { version: 2.6.0 }import { createTestApp } from "@mainsdk/testing";
const app = createTestApp({
yaml: "schema: 1\nupdates:\n min-version: 2.4.0\n channels:\n stable: { version: 2.6.0 }\n",
});
const status = app.updates.check({ version: "2.3.9", user: "usr_1" });
console.log(status.required, status.minVersion); // true "2.4.0"updates.check() answers { required, available, minVersion, latest, channel, build }. required means the client is below the minimum: show your own update screen. In a running app you reach the same object with app().updates from mainsdk.
The adapters enforce the minimum for you. A request that carries MainSDK-App-Version: 2.3.9 is refused with 426 MAIN-UPDATE-0001 before any route runs. Add the header from the client with versionHeaders from mainsdk/updates/client.
Configuration
updates:
driver: local # expo-updates | google-play | app-store | tauri (1.1)
min-version: 2.4.0 # the lowest client the server serves; a push may only raise it
build: 2026.10.27-1 # this release's build id, published to open tabs
stream: 2m # how long one build stream stays open before the browser reconnects
strict: false # true also refuses a request that names no version
exempt: [/api/me/export] # paths a blocked client may still reach, next to sign-out
channels:
stable: { version: 2.6.0, rollout: 50, markets: { IN: 100, EU: 10 } }
beta: { version: 2.7.0-beta.1, rollout: 100 }
api:
deprecated:
- { path: /api/v1, since: 2026-10-01, sunset: 2027-06-30, successor: /api/v2 }| Key | Type | Default | What it does |
|---|---|---|---|
driver | string | local | The store the app's client talks to on the device; the server needs none |
min-version | version | none | The lowest client version the server serves. Dotted numbers with an optional -pre suffix; a pre-release sorts below its release |
build | string | none | The build id of this release; blank means none is published |
stream | duration | 2m | How long a build stream stays open; the browser then reconnects to whichever release is live |
strict | boolean | false | Also refuse a request with no version header. A browser sends none, so use it only for native-only APIs |
exempt | list of paths | [] | Paths a blocked client may still reach, such as the data export. /_mainsdk/auth/signout and /_mainsdk/updates are always open |
channels.<name> | map | none | stable or beta: the version offered, the rollout percent of users (default 100) and markets that replace it per market |
api.deprecated | list | [] | Path prefixes on their way out: path, sunset (a date) and optionally since and successor |
Building it by hand
The SDK builds updates from main.yaml. Call these only to wire it yourself, for example in a test or a custom runtime.
| Name | What it is |
|---|---|
parseUpdates(raw) | Reads the updates section into a config: the minimum, build, channels with their markets in capitals, exempt paths and deprecations. An invalid key fails with MAIN-CONFIG-0003. |
updatesIssues(raw) | Every problem in a raw section as { key, code, message }, the list mainsdk check reports as MAIN-UPDATE-0003 and MAIN-UPDATE-0004. |
createUpdates(deps) | Builds the interface from a config and a reader of live values (value), with now and timers. Optional: market. |
What you get
The minimum version
updates.minVersion() returns the higher of the main.yaml value and a pushed one, or null. A push goes through config as main.updates.min-version. It only rises: a lower value is refused with MAIN-CONFIG-0021, the last good value stays and the refusal is audited with hashes, never the value. Every applied change is audited the same way. The check reads memory only, so a config store outage leaves the last good minimum in force.
Version skew on the web
With build set, updates.build() returns the id, GET /_mainsdk/updates/build answers { build } and GET /_mainsdk/updates/stream?build=<id> opens a server-sent event stream. Its first event is build with { build, stale }, where stale is true when the tab was loaded from another build. updates.announce(build) sends a new one to the streams open on this instance. In the browser:
import { watchBuild } from "mainsdk/updates/client";
const stop = watchBuild({ build: "2026.10.27-1", banner: true });watchBuild calls onUpdate once for a stale tab and, with banner: true, shows a status banner with a reload button. It never reloads on its own, because a reload can lose unsaved work.
Release channels
updates.channel({ user, market?, channel? }) offers the release of stable (the default) or beta. A user is eligible when the stable hash of the channel, the version and the user id falls under the rollout, the same hash flags uses. A market under markets replaces the channel's percent. Raising the rollout never removes a user who already had the release, and a user with no id is eligible only at 100. An ineligible user is offered nothing (version: null).
API clients
A path under updates.api.deprecated[].path answers with Deprecation, Sunset and, when successor is set, Link: <…>; rel="successor-version", built by the same function app-api uses for a deprecated route. After the sunset date it answers 410 MAIN-API-0006.
Examples
Block a version after a security fix
Raise min-version in main.yaml through a reviewed change, or push main.updates.min-version through config to tighten a running fleet in seconds.
import { createTestApp } from "@mainsdk/testing";
const app = createTestApp({ yaml: "schema: 1\nupdates:\n min-version: 2.4.0\n" });
console.log(app.updates.minVersion()); // "2.4.0"Roll a release out to one market first
import { createTestApp } from "@mainsdk/testing";
const app = createTestApp({
yaml: "schema: 1\nupdates:\n channels:\n stable: { version: 2.6.0, rollout: 10, markets: { IN: 100 } }\n",
});
const offer = app.updates.channel({ user: "usr_1", market: "IN" });
console.log(offer.eligible, offer.version); // true "2.6.0"Show the update screen
declare function showUpdateScreen(minVersion: string | null): void; // your own screen
import { check, refusal, versionHeaders } from "mainsdk/updates/client";
const status = await check("https://app.example.com", { version: "2.3.9", user: "usr_1" });
if (status.required) showUpdateScreen(status.minVersion);
const res = await fetch("https://app.example.com/api/orders", { headers: versionHeaders("2.3.9", "ios") });
const blocked = await refusal(res); // { minVersion } for a 426 MAIN-UPDATE-0001showUpdateScreen() is your own screen. Pass the platform driver to applyUpdate(status, driver) to open the right store flow.
Errors and checks
| Code | Where | Meaning and fix |
|---|---|---|
MAIN-UPDATE-0001 | runtime | The client is below updates.min-version, or named none under strict (426, with min_version). Update the app |
MAIN-UPDATE-0002 | runtime | A version, platform or channel is not valid (400). Send a version such as 2.4.0 and stable or beta |
MAIN-UPDATE-0003 | check | The updates section is invalid. Correct the key the finding names |
MAIN-UPDATE-0004 | check | min-version is above the stable release, so every client is blocked. Lower it or raise the release first |
MAIN-API-0006 | runtime | A deprecated API path past its sunset (410). Call the successor route |
See check codes and errors.
Swapping drivers
updates:
driver: google-play # was expo-updatesThe server answers the same whatever the driver. The driver is the app's client side: expoUpdates(module), googlePlay(module), appStore({ url, open }) and tauri(module) from mainsdk/updates/drivers each wrap the platform's own module, which you pass in. Most apps use a different driver per target.
Testing
createTestApp runs updates with no network, and fakeUpdates from @mainsdk/testing is the local driver: it answers from a script and records every call, so your update screen runs with no store.
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { createTestApp, fakeUpdates } from "@mainsdk/testing";
import { applyUpdate } from "mainsdk/updates/client";
describe("updates", () => {
it("updates AC2 asks for the full-screen flow when the version is too old", async () => {
const app = createTestApp({ main: "./main.yaml" });
const status = app.updates.check({ version: "1.0.0" });
const driver = fakeUpdates();
await applyUpdate(status, driver);
assert.deepEqual(driver.calls, ["apply:immediate"]);
});
});Security and privacy
- The server enforces the minimum, so a modified client cannot skip it, except by leaving the header out;
strictcloses that for native-only APIs. - Blocking reads memory only and makes no call (ADR-0003); a store or config outage never changes an answer.
- The stream and the build route carry no person or tenant, and the check route stores nothing.
- Every pushed change to the minimum is audited with hashes only.
What Cloud adds
updates is free in your app. Teams adds release dashboards. Fleet adds fleet-wide version rules, such as "no app below a version with a known bug". See Free in-app and Cloud.
FAQ
Can iOS apps update in place? iOS has no in-app update API, so the appStore driver opens the store page and never claims an install. JavaScript-only changes can go over the air with Expo Updates where Apple's rules allow.
Can a rollout be changed while the app runs? Only by a reviewed change to main.yaml, since a push may only tighten (ADR-0017). To stop a bad release, raise min-version.
Does the banner reload my page? No. It offers a reload and waits.
Next
Was this page helpful?