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:
@@ -27,7 +27,8 @@
|
||||
"@getpaseo/server": "*",
|
||||
"electron-log": "^5.4.3",
|
||||
"electron-updater": "^6.6.2",
|
||||
"ws": "^8.14.2"
|
||||
"ws": "^8.14.2",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "24.6.0",
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,10 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { app } from "electron";
|
||||
import { UUID } from "builder-util-runtime";
|
||||
import { autoUpdater, type UpdateInfo } from "electron-updater";
|
||||
import { z } from "zod";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -22,6 +27,15 @@ export interface AppUpdateInstallResult {
|
||||
|
||||
export type AppReleaseChannel = "stable" | "beta";
|
||||
|
||||
export const rolloutManifestSchema = z.object({
|
||||
rolloutHours: z
|
||||
.union([z.number(), z.string().transform(Number)])
|
||||
.pipe(z.number().finite().nonnegative())
|
||||
.optional()
|
||||
.catch(undefined),
|
||||
releaseDate: z.string().optional().catch(undefined),
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -31,6 +45,66 @@ let downloadedUpdateVersion: string | null = null;
|
||||
let downloading = false;
|
||||
let autoUpdaterConfigured = false;
|
||||
let configuredReleaseChannel: AppReleaseChannel | null = null;
|
||||
let cachedStagingUserIdPromise: Promise<string> | null = null;
|
||||
|
||||
export function shouldAdmitToRollout(args: {
|
||||
channel: AppReleaseChannel;
|
||||
rolloutHours: number | undefined;
|
||||
releaseDate: string | undefined;
|
||||
now: number;
|
||||
bucket: number;
|
||||
}): boolean {
|
||||
if (args.channel !== "stable") return true;
|
||||
if (args.rolloutHours == null) return true;
|
||||
if (args.rolloutHours === 0) return true;
|
||||
if (!args.releaseDate) return true;
|
||||
|
||||
const releaseTime = new Date(args.releaseDate).getTime();
|
||||
if (Number.isNaN(releaseTime)) return true;
|
||||
|
||||
const ageHours = (args.now - releaseTime) / 3_600_000;
|
||||
if (ageHours < 0) return false;
|
||||
|
||||
const pct = Math.min(100, (ageHours / args.rolloutHours) * 100);
|
||||
return args.bucket * 100 < pct;
|
||||
}
|
||||
|
||||
function bucketFromStagingUserId(stagingUserId: string): number {
|
||||
return UUID.parse(stagingUserId).readUInt32BE(12) / 0xffffffff;
|
||||
}
|
||||
|
||||
export async function resolveStagingUserId(filePath: string): Promise<string> {
|
||||
try {
|
||||
const id = (await readFile(filePath, "utf8")).trim();
|
||||
if (UUID.check(id)) {
|
||||
return id;
|
||||
}
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||
console.warn(`[auto-updater] Couldn't read staging user ID, creating a blank one: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
const id = UUID.v5(randomBytes(4096), UUID.OID);
|
||||
|
||||
try {
|
||||
await mkdir(path.dirname(filePath), { recursive: true });
|
||||
await writeFile(filePath, id);
|
||||
} catch (error) {
|
||||
console.warn(`[auto-updater] Couldn't write out staging user ID: ${error}`);
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
export function getStagingUserId(): Promise<string> {
|
||||
if (cachedStagingUserIdPromise == null) {
|
||||
cachedStagingUserIdPromise = resolveStagingUserId(
|
||||
path.join(app.getPath("userData"), ".updaterId"),
|
||||
);
|
||||
}
|
||||
return cachedStagingUserIdPromise;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Configuration
|
||||
@@ -46,6 +120,22 @@ function configureAutoUpdater(releaseChannel: AppReleaseChannel): void {
|
||||
autoUpdater.allowPrerelease = releaseChannel === "beta";
|
||||
autoUpdater.channel = releaseChannel === "beta" ? "beta" : "latest";
|
||||
autoUpdater.allowDowngrade = false;
|
||||
autoUpdater.isUserWithinRollout = async (info) => {
|
||||
try {
|
||||
const parsed = rolloutManifestSchema.parse(info);
|
||||
const stagingUserId = await getStagingUserId();
|
||||
|
||||
return shouldAdmitToRollout({
|
||||
channel: releaseChannel,
|
||||
rolloutHours: parsed.rolloutHours,
|
||||
releaseDate: parsed.releaseDate,
|
||||
now: Date.now(),
|
||||
bucket: bucketFromStagingUserId(stagingUserId),
|
||||
});
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
if (configuredReleaseChannel !== releaseChannel) {
|
||||
cachedUpdateInfo = null;
|
||||
|
||||
Reference in New Issue
Block a user