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.
This commit is contained in:
Mohamed Boudra
2026-04-28 16:58:54 +07:00
parent 25eb26db0f
commit e41fee0366
3 changed files with 92 additions and 3 deletions
+21
View File
@@ -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 }}"
@@ -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");
@@ -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<string> {