Compare commits

...

1 Commits

Author SHA1 Message Date
neriousy 94dc0d8ba0 fix(update): prevent rollout fallback downgrades 2026-09-19 12:13:43 +00:00
3 changed files with 61 additions and 7 deletions
+6
View File
@@ -43,6 +43,12 @@ rollouts. Earlier inactive releases can be fallbacks, including manually deactiv
releases; releases newer than the active release cannot. If no eligible artifact
exists, it is omitted from listings and individual artifact requests return 404.
An identified caller already running a release newer than its eligible fallback keeps
that retained release. This prevents a client that received a newer build through the
desktop, another network, or an earlier rollout check from being downgraded merely
because its current IP maps to an earlier rollout cohort. Activating an older release
still performs an intentional rollback once that active release is eligible.
Minimum releases bypass rollout for clients that need them, and identified clients
are not sent a fallback below their configured minimum. Rollout applies to all JSON
endpoints and desktop manifests. Responses, including unavailable artifacts, are not cached.
+22
View File
@@ -0,0 +1,22 @@
import { describe, expect, test } from "bun:test"
import { selectRolloutFallback } from "./index"
describe("rollout fallback selection", () => {
test("keeps a newer installed release instead of downgrading to an inactive fallback", () => {
const current = { version: "2.0.8" }
const fallback = { version: "2.0.6" }
expect(selectRolloutFallback("2.0.8", fallback, current)).toBe(current)
})
test("offers a newer eligible fallback to an older client", () => {
const current = { version: "2.0.1" }
const fallback = { version: "2.0.6" }
expect(selectRolloutFallback("2.0.1", fallback, current)).toBe(fallback)
})
test("returns no fallback when the newer installed release is not retained", () => {
expect(selectRolloutFallback("2.0.8", { version: "2.0.6" }, null)).toBeNull()
})
})
+33 -7
View File
@@ -109,8 +109,8 @@ export default {
async function channel(db: D1Database, channel: string, current: string | undefined, cutoff: number) {
const result = await db
.prepare(`${select} WHERE channel = ? AND (active = 1 OR minimum = 1) ORDER BY name, distribution`)
.bind(channel)
.prepare(`${select} WHERE channel = ? AND (active = 1 OR minimum = 1 OR version = ?) ORDER BY name, distribution`)
.bind(channel, currentArtifactVersion(current))
.all<ArtifactRow>()
const artifacts = await selectArtifacts(db, result.results, current, cutoff)
if (!artifacts.length) return json({ error: "Channel not found" }, 404)
@@ -125,8 +125,10 @@ async function artifactName(
cutoff: number,
) {
const result = await db
.prepare(`${select} WHERE channel = ? AND name = ? AND (active = 1 OR minimum = 1) ORDER BY distribution`)
.bind(channel, name)
.prepare(
`${select} WHERE channel = ? AND name = ? AND (active = 1 OR minimum = 1 OR version = ?) ORDER BY distribution`,
)
.bind(channel, name, currentArtifactVersion(current))
.all<ArtifactRow>()
const artifacts = await selectArtifacts(db, result.results, current, cutoff)
if (!artifacts.length) return json({ error: "Artifact not found" }, 404)
@@ -143,8 +145,10 @@ async function artifactDistribution(
manifest?: string,
) {
const result = await db
.prepare(`${select} WHERE channel = ? AND name = ? AND distribution = ? AND (active = 1 OR minimum = 1)`)
.bind(channel, name, distribution)
.prepare(
`${select} WHERE channel = ? AND name = ? AND distribution = ? AND (active = 1 OR minimum = 1 OR version = ?)`,
)
.bind(channel, name, distribution, currentArtifactVersion(current))
.all<ArtifactRow>()
const artifact = (await selectArtifacts(db, result.results, current, cutoff))[0]
if (!artifact) return json({ error: "Artifact not found" }, 404)
@@ -185,12 +189,30 @@ async function selectArtifacts(db: D1Database, rows: ArtifactRow[], current: str
const version = previous && releaseVersion(previous.version)
if (!version || !floor || semver.lt(version, floor)) return minimum
}
return previous
const retained = rows.find(
(row) =>
row.name === active.name &&
row.distribution === active.distribution &&
releaseVersion(row.version) === caller,
)
return selectRolloutFallback(current, previous, retained ?? null)
}),
)
return artifacts.filter((artifact) => artifact !== null)
}
export function selectRolloutFallback<T extends { readonly version: string }>(
current: string | undefined,
fallback: T | null,
retained: T | null,
) {
if (!current || !fallback) return fallback
const caller = releaseVersion(current)
const previous = releaseVersion(fallback.version)
if (!caller || !previous || !semver.gt(caller, previous)) return fallback
return retained
}
async function configureRollout(request: Request, env: Env, prefix: string) {
const invalid = validMutation(request)
if (invalid) return invalid
@@ -226,6 +248,10 @@ function releaseVersion(input: string) {
)
}
function currentArtifactVersion(current: string | undefined) {
return current?.replace(/^v/, "") ?? ""
}
async function admin(request: Request, env: Env, prefix: string) {
const url = new URL(request.url)
const rollouts = await env.DB.prepare(