feat(desktop): add time-based staged rollout for stable updates

Linear ramp from 0% at publish to 100% at releaseDate + rolloutHours
(default 24h, configurable per release). Beta channel and rolloutHours=0
short-circuit to admit everyone. Per-machine bucket derives from a
persistent UUID at <userData>/.updaterId and feeds electron-updater's
isUserWithinRollout hook.

Adds desktop-rollout.yml workflow for in-place rolloutHours edits on
already-published releases (hotfix to 0 or extend the ramp), serialized
against finalize-rollout in desktop-release.yml via a shared concurrency
group keyed on the tag. Replaces the hand-rolled mac manifest merge
with a js-yaml round-trip that preserves unknown fields.
This commit is contained in:
Mohamed Boudra
2026-04-28 16:48:18 +07:00
parent 932a97e447
commit 25eb26db0f
12 changed files with 760 additions and 204 deletions
@@ -0,0 +1,121 @@
import { mkdtemp, readFile, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { UUID } from "builder-util-runtime";
import { describe, expect, it, vi } from "vitest";
vi.mock("electron", () => ({
app: {
getPath: vi.fn(),
},
}));
vi.mock("electron-updater", () => ({
autoUpdater: {},
}));
import { resolveStagingUserId, rolloutManifestSchema, shouldAdmitToRollout } from "./auto-updater";
describe("shouldAdmitToRollout", () => {
it("admits beta, missing rollout hours, zero-hour rollout, and missing release date", () => {
expect(
shouldAdmitToRollout({
channel: "beta",
rolloutHours: 24,
releaseDate: "2026-04-28T00:00:00.000Z",
now: Date.parse("2026-04-28T01:00:00.000Z"),
bucket: 0.99,
}),
).toBe(true);
expect(
shouldAdmitToRollout({
channel: "stable",
rolloutHours: undefined,
releaseDate: "2026-04-28T00:00:00.000Z",
now: Date.parse("2026-04-28T01:00:00.000Z"),
bucket: 0.99,
}),
).toBe(true);
expect(
shouldAdmitToRollout({
channel: "stable",
rolloutHours: 0,
releaseDate: "2026-04-28T00:00:00.000Z",
now: Date.parse("2026-04-28T01:00:00.000Z"),
bucket: 0.99,
}),
).toBe(true);
expect(
shouldAdmitToRollout({
channel: "stable",
rolloutHours: 24,
releaseDate: undefined,
now: Date.parse("2026-04-28T01:00:00.000Z"),
bucket: 0.99,
}),
).toBe(true);
});
it("blocks future releases and respects the linear threshold mid-rollout", () => {
expect(
shouldAdmitToRollout({
channel: "stable",
rolloutHours: 24,
releaseDate: "2026-04-28T02:00:00.000Z",
now: Date.parse("2026-04-28T01:00:00.000Z"),
bucket: 0,
}),
).toBe(false);
expect(
shouldAdmitToRollout({
channel: "stable",
rolloutHours: 24,
releaseDate: "2026-04-28T00:00:00.000Z",
now: Date.parse("2026-04-28T12:00:00.000Z"),
bucket: 0.49,
}),
).toBe(true);
expect(
shouldAdmitToRollout({
channel: "stable",
rolloutHours: 24,
releaseDate: "2026-04-28T00:00:00.000Z",
now: Date.parse("2026-04-28T12:00:00.000Z"),
bucket: 0.51,
}),
).toBe(false);
});
it("treats garbage manifest rollout fields as missing and admits", () => {
const parsed = rolloutManifestSchema.parse({
rolloutHours: "not a number",
releaseDate: 12345,
});
expect(
shouldAdmitToRollout({
channel: "stable",
rolloutHours: parsed.rolloutHours,
releaseDate: parsed.releaseDate,
now: Date.parse("2026-04-28T12:00:00.000Z"),
bucket: 0.99,
}),
).toBe(true);
});
it("creates and then reuses the on-disk staging user id", async () => {
const tempDir = await mkdtemp(path.join(os.tmpdir(), "paseo-updater-id-"));
const filePath = path.join(tempDir, ".updaterId");
try {
const first = await resolveStagingUserId(filePath);
const stored = (await readFile(filePath, "utf8")).trim();
const second = await resolveStagingUserId(filePath);
expect(UUID.check(stored)).toBeTruthy();
expect(second).toBe(first);
} finally {
await rm(tempDir, { force: true, recursive: true });
}
});
});