mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
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:
21
.github/workflows/desktop-release.yml
vendored
21
.github/workflows/desktop-release.yml
vendored
@@ -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 }}"
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
Reference in New Issue
Block a user