From 9f3bcf380eb898265953c342de8a74c4474b595c Mon Sep 17 00:00:00 2001 From: Adam Shiervani Date: Fri, 18 Sep 2026 22:46:05 +0200 Subject: [PATCH] fix(releases): serve staged releases before any release reaches 100% (#78) A prefix with no release at 100% made the default lookup throw a 500 before eligibility was checked, so no device got the staged build. That is every device for a new prefix until its first release is fully rolled out, and every JetKVM device the day no app or system row sits at 100%. The default lookup now returns null when nothing is at 100%. A device inside the rollout bucket gets the staged release as before; a device outside it gets a 404 saying no release is rolled out yet for its SKU, instead of a 500. Found by Bugbot on the release PR (#77). --- src/releases.ts | 18 +++++++++++++----- test/releases.test.ts | 37 +++++++++++++++++++++++++++++++------ 2 files changed, 44 insertions(+), 11 deletions(-) diff --git a/src/releases.ts b/src/releases.ts index d34ba83..fb0293f 100644 --- a/src/releases.ts +++ b/src/releases.ts @@ -471,16 +471,18 @@ function dbReleaseToMetadata(release: DbRelease, sku: string): ReleaseMetadata { }; } -async function getDefaultRelease(prefix: string, sku: string): Promise { +/** + * Newest fully rolled out release for the prefix, or null when no release + * has reached 100% yet (a prefix whose first release is still staged). + */ +async function getDefaultRelease(prefix: string, sku: string): Promise { const rolledOutReleases = await prisma.release.findMany({ where: { type: prefix, rolloutPercentage: 100 }, select: compatibleReleaseSelect(sku), }); if (rolledOutReleases.length === 0) { - throw new InternalServerError( - `No default release found for type ${prefix} and SKU "${sku}"`, - ); + return null; } // Only consider releases that ship a binary for this SKU. Without this, @@ -605,7 +607,13 @@ export async function Retrieve(req: Request, res: Response) { latest.artifacts.length > 0 && (await isDeviceEligibleForLatestRelease(latest.rolloutPercentage, deviceId)); - offered[kind] = dbReleaseToMetadata(useLatest ? latest : fallback, sku); + const chosen = useLatest ? latest : fallback; + if (!chosen) { + throw new NotFoundError( + `No ${prefix} release is rolled out yet for SKU "${sku}"`, + ); + } + offered[kind] = dbReleaseToMetadata(chosen, sku); }), ); diff --git a/test/releases.test.ts b/test/releases.test.ts index cc32725..9d672bc 100644 --- a/test/releases.test.ts +++ b/test/releases.test.ts @@ -587,15 +587,26 @@ describe("Retrieve handler", () => { expect(s3Mock.commandCalls(GetObjectCommand)).toHaveLength(0); }); - it("fails when no fully rolled out default exists for background checks", async () => { + it("serves a staged release to an in-bucket device even when nothing is at 100%", async () => { + // early-adopter hashes to rollout bucket 8, inside a 50% rollout. + await testPrisma.release.updateMany({ data: { rolloutPercentage: 50 } }); + + const res = createMockResponse(); + await Retrieve(createMockRequest({ deviceId: "early-adopter" }), res); + + expect(jsonBody(res)).toMatchObject({ appVersion: "1.2.0", systemVersion: "1.2.0" }); + }); + + it("answers 404, not 500, to an out-of-bucket device when nothing is at 100%", async () => { + // late-adopter hashes to rollout bucket 95, outside a 50% rollout. await testPrisma.release.updateMany({ data: { rolloutPercentage: 50 } }); await expect( - Retrieve( - createMockRequest({ deviceId: "no-default-device" }), - createMockResponse(), - ), - ).rejects.toThrow(InternalServerError); + Retrieve(createMockRequest({ deviceId: "late-adopter" }), createMockResponse()), + ).rejects.toThrow(NotFoundError); + await expect( + Retrieve(createMockRequest({ deviceId: "late-adopter" }), createMockResponse()), + ).rejects.toThrow(/No (app|system) release is rolled out yet for SKU "jetkvm-v2"/); }); }); @@ -789,6 +800,20 @@ describe("Retrieve handler", () => { ).rejects.toThrow('Version 1.0.0 has no artifact for SKU "jetkvm-mini-ethernet"'); }); + it("serves the first staged mini release to an in-bucket device and 404s the rest", async () => { + // No mini row at 100% yet: the first release is at 10%. + // early-adopter hashes to bucket 8, late-adopter to bucket 95. + await createDbRelease("mini", "1.0.0", 10, miniArtifacts("1.0.0")); + + const inBucket = createMockResponse(); + await Retrieve(createMockRequest({ deviceId: "early-adopter", sku: MINI_ETHERNET_SKU }), inBucket); + expect(jsonBody(inBucket)).toMatchObject({ systemVersion: "1.0.0" }); + + await expect( + Retrieve(createMockRequest({ deviceId: "late-adopter", sku: MINI_ETHERNET_SKU }), createMockResponse()), + ).rejects.toThrow('No mini release is rolled out yet for SKU "jetkvm-mini-ethernet"'); + }); + it("fails when no mini release exists for the SKU", async () => { const res = createMockResponse(); await expect(