From 2235e014d54c39bc3b91589ce329ec455fb3d05c Mon Sep 17 00:00:00 2001 From: Adam Shiervani Date: Mon, 21 Sep 2026 09:35:20 +0200 Subject: [PATCH 1/2] feat(releases): register new releases at 0% rollout A newly registered release went to 10% of devices at once. It now starts at 0%: registered, but served to no device until an operator raises it. Existing rows keep their percentage. --- src/release-sync.ts | 3 ++- test/sync-releases.test.ts | 10 +++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/release-sync.ts b/src/release-sync.ts index b176040..8fdd32d 100644 --- a/src/release-sync.ts +++ b/src/release-sync.ts @@ -36,7 +36,8 @@ export interface ReleaseArtifactInput { compatibleSkus: string[]; } -export const DEFAULT_ROLLOUT_PERCENTAGE = 10; +/** A new release is registered but served to no device until an operator raises it. */ +export const DEFAULT_ROLLOUT_PERCENTAGE = 0; export type ReleaseOutcome = | "created" diff --git a/test/sync-releases.test.ts b/test/sync-releases.test.ts index 3d340d4..87e081b 100644 --- a/test/sync-releases.test.ts +++ b/test/sync-releases.test.ts @@ -201,7 +201,7 @@ describe("syncReleases", () => { expect(artifacts).toEqual([]); }); - it("creates new releases at 10% with their S3 artifacts and skips already-synced versions", async () => { + it("creates new releases at 0% with their S3 artifacts and skips already-synced versions", async () => { const version = "9.9.4"; // Pre-existing system row simulates a release the migration (or a prior @@ -249,8 +249,8 @@ describe("syncReleases", () => { where: { version_type: { version: "10.0.0-beta.1", type: "app" } }, }); - // App release is new — created at 10% rollout with a single legacy-compatible artifact. - expect(appRelease.rolloutPercentage).toBe(10); + // App release is new — created at 0% rollout with a single legacy-compatible artifact. + expect(appRelease.rolloutPercentage).toBe(0); expect(appRelease.artifacts).toEqual([ expect.objectContaining({ url: `https://cdn.test.com/app/${version}/jetkvm_app`, @@ -480,7 +480,7 @@ describe("scheduleReleaseSync", () => { const release = await testPrisma.release.findUnique({ where: { version_type: { version, type: "app" } }, }); - expect(release?.rolloutPercentage).toBe(10); + expect(release?.rolloutPercentage).toBe(0); }); }); @@ -551,7 +551,7 @@ describe("Sync handler", () => { await testPrisma.release.findUnique({ where: { version_type: { version: FRESH_VERSION, type: "app" } }, }), - ).toMatchObject({ rolloutPercentage: 10 }); + ).toMatchObject({ rolloutPercentage: 0 }); expect( await testPrisma.release.findUnique({ where: { version_type: { version: OTHER_FRESH_VERSION, type: "app" } }, From 2c64c11f7dbb02698c0c9ff4769bfb0791950da6 Mon Sep 17 00:00:00 2001 From: Adam Shiervani Date: Mon, 21 Sep 2026 15:29:54 +0200 Subject: [PATCH 2/2] fix(releases): a 0% release does not displace a rollout in progress Selection took the single newest release as the rollout candidate, so registering the next version at 0% pushed every device off an older release still mid-rollout and onto the last 100% one. The latest lookup now ignores rows at 0%. Version-constrained requests, which skip rollout, still see them. --- src/releases.ts | 10 ++++++++-- test/releases.test.ts | 15 +++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/releases.ts b/src/releases.ts index 30310fd..c73d1af 100644 --- a/src/releases.ts +++ b/src/releases.ts @@ -1,4 +1,5 @@ import { Request, Response } from "express"; +import type { Prisma } from "@prisma/client"; import { prisma } from "./db"; import { BadRequestError, ConflictError, InternalServerError, NotFoundError } from "./errors"; import type { ReleaseSyncRunner } from "./release-sync"; @@ -468,17 +469,22 @@ async function getDefaultRelease(prefix: string, sku: string): Promise { - return getReleaseByRange(prefix, sku, "*"); + return getReleaseByRange(prefix, sku, "*", { rolloutPercentage: { gt: 0 } }); } async function getReleaseByRange( prefix: string, sku: string, range: string, + where: Prisma.ReleaseWhereInput = {}, ): Promise { const releases = await prisma.release.findMany({ - where: { type: prefix }, + where: { type: prefix, ...where }, select: compatibleReleaseSelect(sku), }); diff --git a/test/releases.test.ts b/test/releases.test.ts index 9d672bc..5796885 100644 --- a/test/releases.test.ts +++ b/test/releases.test.ts @@ -392,6 +392,21 @@ describe("Retrieve handler", () => { }); }); + it("keeps an in-progress rollout when a newer release is registered at 0%", async () => { + // early-adopter hashes to bucket 8, late-adopter to bucket 95. + await createDbReleasePair("2.0.0", 100); + await createDbReleasePair("2.1.0", 50); + await createDbReleasePair("2.2.0", 0); + + const inBucket = createMockResponse(); + await Retrieve(createMockRequest({ deviceId: "early-adopter" }), inBucket); + expect(jsonBody(inBucket)).toMatchObject({ appVersion: "2.1.0", systemVersion: "2.1.0" }); + + const outOfBucket = createMockResponse(); + await Retrieve(createMockRequest({ deviceId: "late-adopter" }), outOfBucket); + expect(jsonBody(outOfBucket)).toMatchObject({ appVersion: "2.0.0", systemVersion: "2.0.0" }); + }); + it("applies app and system rollout independently", async () => { await createDbReleasePair("2.4.0", 100); await createDbRelease("app", "2.5.0", 100);