feat(desktop): add time-based staged rollout for stable updates
Linear ramp from 0% at publish to 100% at releaseDate + rolloutHours (default 24h, configurable per release). Beta channel and rolloutHours=0 short-circuit to admit everyone. Per-machine bucket derives from a persistent UUID at <userData>/.updaterId and feeds electron-updater's isUserWithinRollout hook. Adds desktop-rollout.yml workflow for in-place rolloutHours edits on already-published releases (hotfix to 0 or extend the ramp), serialized against finalize-rollout in desktop-release.yml via a shared concurrency group keyed on the tag. Replaces the hand-rolled mac manifest merge with a js-yaml round-trip that preserves unknown fields.
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
import fs from "node:fs";
|
||||
import { dump, load } from "js-yaml";
|
||||
|
||||
export function mergeMacManifest(arm64Path, x64Path, outputPath) {
|
||||
const arm64 = load(fs.readFileSync(arm64Path, "utf8"));
|
||||
const x64 = load(fs.readFileSync(x64Path, "utf8"));
|
||||
const files = [...(arm64.files ?? []), ...(x64.files ?? [])].filter(
|
||||
(file, index, all) => all.findIndex((entry) => entry.url === file.url) === index,
|
||||
);
|
||||
const output = dump({ ...arm64, files }, { lineWidth: -1, noRefs: true });
|
||||
fs.writeFileSync(outputPath, output);
|
||||
return output;
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
const [, , arm64Path, x64Path, outputPath] = process.argv;
|
||||
if (!arm64Path || !x64Path || !outputPath) {
|
||||
throw new Error("Usage: node scripts/merge-mac-manifest.mjs <arm64.yml> <x64.yml> <out.yml>");
|
||||
}
|
||||
process.stdout.write(mergeMacManifest(arm64Path, x64Path, outputPath));
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { test } from "vitest";
|
||||
import { mergeMacManifest } from "./merge-mac-manifest.mjs";
|
||||
|
||||
test("preserves unknown fields while merging files by url", () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "paseo-merge-mac-manifest-"));
|
||||
try {
|
||||
const arm64Path = path.join(dir, "arm64.yml");
|
||||
const x64Path = path.join(dir, "x64.yml");
|
||||
const outputPath = path.join(dir, "latest-mac.yml");
|
||||
writeFileSync(
|
||||
arm64Path,
|
||||
"version: 1.2.3\nfiles:\n - url: app-arm64.zip\n sha512: arm\nstagingPercentage: 25\npath: app-arm64.zip\nsha512: arm\nreleaseDate: '2026-04-28T00:00:00.000Z'\n",
|
||||
);
|
||||
writeFileSync(
|
||||
x64Path,
|
||||
"version: 1.2.3\nfiles:\n - url: app-x64.zip\n sha512: x64\nstagingPercentage: 50\npath: app-x64.zip\nsha512: x64\nreleaseDate: '2026-04-29T00:00:00.000Z'\n",
|
||||
);
|
||||
mergeMacManifest(arm64Path, x64Path, outputPath);
|
||||
const output = readFileSync(outputPath, "utf8");
|
||||
assert.match(output, /stagingPercentage: 25/);
|
||||
assert.match(output, /url: app-arm64\.zip/);
|
||||
assert.match(output, /url: app-x64\.zip/);
|
||||
} finally {
|
||||
rmSync(dir, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import fs from "node:fs";
|
||||
import { dump, load } from "js-yaml";
|
||||
|
||||
export function stampRollout({ releaseDate, rolloutHours }, paths) {
|
||||
if (releaseDate === undefined && rolloutHours === undefined) {
|
||||
throw new Error("stampRollout requires at least one of releaseDate or rolloutHours");
|
||||
}
|
||||
for (const filePath of paths) {
|
||||
const manifest = load(fs.readFileSync(filePath, "utf8")) ?? {};
|
||||
const next = { ...manifest };
|
||||
if (releaseDate !== undefined) next.releaseDate = releaseDate;
|
||||
if (rolloutHours !== undefined) next.rolloutHours = rolloutHours;
|
||||
fs.writeFileSync(filePath, dump(next, { lineWidth: -1, noRefs: true }));
|
||||
}
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const opts = {};
|
||||
const paths = [];
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
if (arg === "--release-date") {
|
||||
opts.releaseDate = argv[++i];
|
||||
} else if (arg === "--rollout-hours") {
|
||||
const raw = argv[++i];
|
||||
const parsed = Number(raw);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||
throw new Error(`--rollout-hours must be a non-negative number, got ${raw}`);
|
||||
}
|
||||
opts.rolloutHours = parsed;
|
||||
} else {
|
||||
paths.push(arg);
|
||||
}
|
||||
}
|
||||
return { opts, paths };
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
const { opts, paths } = parseArgs(process.argv.slice(2));
|
||||
if (paths.length === 0 || (opts.releaseDate === undefined && opts.rolloutHours === undefined)) {
|
||||
throw new Error(
|
||||
"Usage: node scripts/stamp-rollout.mjs [--release-date <iso>] [--rollout-hours <n>] <yaml-path>...",
|
||||
);
|
||||
}
|
||||
stampRollout(opts, paths);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { test } from "vitest";
|
||||
import { stampRollout } from "./stamp-rollout.mjs";
|
||||
|
||||
test("rewrites rollout fields and preserves unrelated manifest data", () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "paseo-stamp-rollout-"));
|
||||
try {
|
||||
const firstPath = path.join(dir, "latest.yml");
|
||||
const secondPath = path.join(dir, "latest-mac.yml");
|
||||
writeFileSync(
|
||||
firstPath,
|
||||
"version: 1.2.3\nfiles:\n - url: app.zip\n sha512: abc\nreleaseDate: '2026-04-28T00:00:00.000Z'\nrolloutHours: 24\nstagingPercentage: 25\n",
|
||||
);
|
||||
writeFileSync(
|
||||
secondPath,
|
||||
"version: 1.2.3\nfiles:\n - url: app-arm64.zip\n sha512: def\nreleaseDate: '2026-04-28T00:00:00.000Z'\npath: app-arm64.zip\n",
|
||||
);
|
||||
stampRollout({ releaseDate: "2026-05-01T12:00:00.000Z", rolloutHours: 6 }, [
|
||||
firstPath,
|
||||
secondPath,
|
||||
]);
|
||||
|
||||
const first = readFileSync(firstPath, "utf8");
|
||||
const second = readFileSync(secondPath, "utf8");
|
||||
|
||||
assert.match(first, /releaseDate: '2026-05-01T12:00:00.000Z'/);
|
||||
assert.match(first, /rolloutHours: 6/);
|
||||
assert.match(first, /stagingPercentage: 25/);
|
||||
assert.match(second, /releaseDate: '2026-05-01T12:00:00.000Z'/);
|
||||
assert.match(second, /rolloutHours: 6/);
|
||||
assert.match(second, /path: app-arm64\.zip/);
|
||||
} finally {
|
||||
rmSync(dir, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("only updates rolloutHours when releaseDate is omitted", () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "paseo-stamp-rollout-"));
|
||||
try {
|
||||
const filePath = path.join(dir, "latest.yml");
|
||||
writeFileSync(
|
||||
filePath,
|
||||
"version: 1.2.3\nfiles:\n - url: app.zip\n sha512: abc\nreleaseDate: '2026-04-28T00:00:00.000Z'\nrolloutHours: 24\nstagingPercentage: 25\n",
|
||||
);
|
||||
stampRollout({ rolloutHours: 0 }, [filePath]);
|
||||
|
||||
const out = readFileSync(filePath, "utf8");
|
||||
assert.match(out, /releaseDate: '2026-04-28T00:00:00.000Z'/);
|
||||
assert.match(out, /rolloutHours: 0/);
|
||||
assert.match(out, /stagingPercentage: 25/);
|
||||
} finally {
|
||||
rmSync(dir, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("throws when neither releaseDate nor rolloutHours is provided", () => {
|
||||
assert.throws(() => stampRollout({}, ["/tmp/does-not-matter.yml"]));
|
||||
});
|
||||
Reference in New Issue
Block a user