From e41fee0366f0b7324c2ae77ba4a0583eacbcec34 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 28 Apr 2026 16:58:54 +0700 Subject: [PATCH] fix(desktop): close two pre-ship rollout edge cases Bucket divisor now produces [0, 1) so the single UUID hashing to 0xFFFFFFFF is no longer permanently excluded by the strict-less-than admit condition. Mirrors the post-stamp manifest validation step from desktop-rollout.yml into desktop-release.yml's finalize-rollout job so a stamp-rollout.mjs regression can't silently ship a broken manifest. Adds boundary tests: bucket=0 stays blocked at exact release time, max-bucket admits at and past rollout end, unparseable releaseDate admits, bucketFromStagingUserId is strictly < 1 at the upper bound. --- .github/workflows/desktop-release.yml | 21 ++++++ .../desktop/src/features/auto-updater.test.ts | 70 ++++++++++++++++++- packages/desktop/src/features/auto-updater.ts | 4 +- 3 files changed, 92 insertions(+), 3 deletions(-) diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index 25d7ffd23..792fbacf5 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -446,4 +446,25 @@ jobs: fi timestamp="$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")" node ../scripts/stamp-rollout.mjs --release-date "$timestamp" --rollout-hours "$ROLLOUT_HOURS" "${files[@]}" + ROLLOUT_HOURS_EXPECTED="$ROLLOUT_HOURS" RELEASE_DATE_EXPECTED="$timestamp" node -e ' + const yaml = require("js-yaml"); + const fs = require("fs"); + const expectedHours = Number(process.env.ROLLOUT_HOURS_EXPECTED); + const expectedDate = process.env.RELEASE_DATE_EXPECTED; + if (!Number.isFinite(expectedHours) || expectedHours < 0) { + throw new Error(`expected non-negative rolloutHours, got ${process.env.ROLLOUT_HOURS_EXPECTED}`); + } + for (const f of process.argv.slice(1)) { + const m = yaml.load(fs.readFileSync(f, "utf8")) ?? {}; + if (m.rolloutHours !== expectedHours) { + throw new Error(`${f}: rolloutHours=${m.rolloutHours}, expected ${expectedHours}`); + } + if (m.releaseDate !== expectedDate) { + throw new Error(`${f}: releaseDate=${m.releaseDate}, expected ${expectedDate}`); + } + if (typeof m.version !== "string" || m.version.length === 0) { + throw new Error(`${f}: missing or invalid version`); + } + } + ' "${files[@]}" gh release upload "$RELEASE_TAG" "${files[@]}" --clobber --repo "${{ github.repository }}" diff --git a/packages/desktop/src/features/auto-updater.test.ts b/packages/desktop/src/features/auto-updater.test.ts index b8d8f122f..f810b8da3 100644 --- a/packages/desktop/src/features/auto-updater.test.ts +++ b/packages/desktop/src/features/auto-updater.test.ts @@ -14,7 +14,12 @@ vi.mock("electron-updater", () => ({ autoUpdater: {}, })); -import { resolveStagingUserId, rolloutManifestSchema, shouldAdmitToRollout } from "./auto-updater"; +import { + bucketFromStagingUserId, + resolveStagingUserId, + rolloutManifestSchema, + shouldAdmitToRollout, +} from "./auto-updater"; describe("shouldAdmitToRollout", () => { it("admits beta, missing rollout hours, zero-hour rollout, and missing release date", () => { @@ -86,6 +91,61 @@ describe("shouldAdmitToRollout", () => { ).toBe(false); }); + it("blocks the bucket-zero client at exact release time, admits as soon as time advances", () => { + expect( + shouldAdmitToRollout({ + channel: "stable", + rolloutHours: 24, + releaseDate: "2026-04-28T00:00:00.000Z", + now: Date.parse("2026-04-28T00: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-28T00:00:00.001Z"), + bucket: 0, + }), + ).toBe(true); + }); + + it("admits the highest-bucket client at and past the rollout end", () => { + const maxBucket = (0x100000000 - 1) / 0x100000000; + expect( + shouldAdmitToRollout({ + channel: "stable", + rolloutHours: 24, + releaseDate: "2026-04-28T00:00:00.000Z", + now: Date.parse("2026-04-29T00:00:00.000Z"), + bucket: maxBucket, + }), + ).toBe(true); + expect( + shouldAdmitToRollout({ + channel: "stable", + rolloutHours: 24, + releaseDate: "2026-04-28T00:00:00.000Z", + now: Date.parse("2027-04-28T00:00:00.000Z"), + bucket: maxBucket, + }), + ).toBe(true); + }); + + it("admits when releaseDate is unparseable", () => { + expect( + shouldAdmitToRollout({ + channel: "stable", + rolloutHours: 24, + releaseDate: "not a date", + now: Date.parse("2026-04-28T12:00:00.000Z"), + bucket: 0.99, + }), + ).toBe(true); + }); + it("treats garbage manifest rollout fields as missing and admits", () => { const parsed = rolloutManifestSchema.parse({ rolloutHours: "not a number", @@ -103,6 +163,14 @@ describe("shouldAdmitToRollout", () => { ).toBe(true); }); + it("maps the maximum 32-bit slot to a bucket strictly less than 1", () => { + const allOnes = "ffffffff-ffff-ffff-ffff-ffffffffffff"; + const allZeros = "00000000-0000-0000-0000-000000000000"; + expect(bucketFromStagingUserId(allOnes)).toBeLessThan(1); + expect(bucketFromStagingUserId(allOnes)).toBeGreaterThan(0.999); + expect(bucketFromStagingUserId(allZeros)).toBe(0); + }); + 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"); diff --git a/packages/desktop/src/features/auto-updater.ts b/packages/desktop/src/features/auto-updater.ts index f68867836..78949721f 100644 --- a/packages/desktop/src/features/auto-updater.ts +++ b/packages/desktop/src/features/auto-updater.ts @@ -69,8 +69,8 @@ export function shouldAdmitToRollout(args: { return args.bucket * 100 < pct; } -function bucketFromStagingUserId(stagingUserId: string): number { - return UUID.parse(stagingUserId).readUInt32BE(12) / 0xffffffff; +export function bucketFromStagingUserId(stagingUserId: string): number { + return UUID.parse(stagingUserId).readUInt32BE(12) / 0x100000000; } export async function resolveStagingUserId(filePath: string): Promise {