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

View File

@@ -446,4 +446,25 @@ jobs:
fi fi
timestamp="$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")" timestamp="$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")"
node ../scripts/stamp-rollout.mjs --release-date "$timestamp" --rollout-hours "$ROLLOUT_HOURS" "${files[@]}" 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 }}" gh release upload "$RELEASE_TAG" "${files[@]}" --clobber --repo "${{ github.repository }}"

View File

@@ -14,7 +14,12 @@ vi.mock("electron-updater", () => ({
autoUpdater: {}, autoUpdater: {},
})); }));
import { resolveStagingUserId, rolloutManifestSchema, shouldAdmitToRollout } from "./auto-updater"; import {
bucketFromStagingUserId,
resolveStagingUserId,
rolloutManifestSchema,
shouldAdmitToRollout,
} from "./auto-updater";
describe("shouldAdmitToRollout", () => { describe("shouldAdmitToRollout", () => {
it("admits beta, missing rollout hours, zero-hour rollout, and missing release date", () => { it("admits beta, missing rollout hours, zero-hour rollout, and missing release date", () => {
@@ -86,6 +91,61 @@ describe("shouldAdmitToRollout", () => {
).toBe(false); ).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", () => { it("treats garbage manifest rollout fields as missing and admits", () => {
const parsed = rolloutManifestSchema.parse({ const parsed = rolloutManifestSchema.parse({
rolloutHours: "not a number", rolloutHours: "not a number",
@@ -103,6 +163,14 @@ describe("shouldAdmitToRollout", () => {
).toBe(true); ).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 () => { it("creates and then reuses the on-disk staging user id", async () => {
const tempDir = await mkdtemp(path.join(os.tmpdir(), "paseo-updater-id-")); const tempDir = await mkdtemp(path.join(os.tmpdir(), "paseo-updater-id-"));
const filePath = path.join(tempDir, ".updaterId"); const filePath = path.join(tempDir, ".updaterId");

View File

@@ -69,8 +69,8 @@ export function shouldAdmitToRollout(args: {
return args.bucket * 100 < pct; return args.bucket * 100 < pct;
} }
function bucketFromStagingUserId(stagingUserId: string): number { export function bucketFromStagingUserId(stagingUserId: string): number {
return UUID.parse(stagingUserId).readUInt32BE(12) / 0xffffffff; return UUID.parse(stagingUserId).readUInt32BE(12) / 0x100000000;
} }
export async function resolveStagingUserId(filePath: string): Promise<string> { export async function resolveStagingUserId(filePath: string): Promise<string> {