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 {