feat(desktop): add Windows preview distribution

This commit is contained in:
tianma-if committed 2026-08-30 10:24:03 +08:00
1 parent 6582a891d7
commit 383bafa83f
37 files changed
+1496 -107

No files matched your search

+29 -3
View File
@@ -8,6 +8,7 @@ export const nativeReleaseAssetsReady = ({
currentTag,
desktopVersion,
assetNames,
requireWindowsSignature = true,
}) => {
if (platform === "mobile") {
const apkNames = assetNames.filter((name) =>
@@ -47,6 +48,29 @@ export const nativeReleaseAssetsReady = ({
}
}
}
const windowsInstallerNames = assetNames.filter((name) =>
/^EdgeEver-(.+)-windows-x64\.exe$/.test(name)
);
if (windowsInstallerNames.length !== 1) return false;
const windowsVersion = /^EdgeEver-(.+)-windows-x64\.exe$/
.exec(windowsInstallerNames[0])?.[1];
if (!windowsVersion) return false;
versions.add(windowsVersion);
for (const name of [
"latest.yml",
"latest-windows.json",
"SHA256SUMS-windows.txt",
]) {
if (assetNames.filter((assetName) => assetName === name).length !== 1) {
return false;
}
}
const signatureCount = assetNames.filter(
(name) => name === "latest-windows.json.sig",
).length;
if (requireWindowsSignature ? signatureCount !== 1 : signatureCount > 1) {
return false;
}
return (
versions.size === 1 &&
(!rebuild || versions.has(desktopVersion)) &&
@@ -58,15 +82,16 @@ export const nativeReleaseAssetsReady = ({
};
const run = () => {
const [platform, rebuildValue, currentTag, desktopVersion = ""] =
const [platform, rebuildValue, currentTag, desktopVersion = "", signatureMode = "required"] =
process.argv.slice(2);
if (
!["mobile", "desktop"].includes(platform) ||
!["true", "false"].includes(rebuildValue) ||
!currentTag
!currentTag ||
!["required", "allow-missing-windows-signature"].includes(signatureMode)
) {
console.error(
"Usage: node scripts/check-native-release-assets.mjs <mobile|desktop> <true|false> <current-tag> [desktop-version]",
"Usage: node scripts/check-native-release-assets.mjs <mobile|desktop> <true|false> <current-tag> [desktop-version] [required|allow-missing-windows-signature]",
);
process.exit(1);
}
@@ -80,6 +105,7 @@ const run = () => {
currentTag,
desktopVersion,
assetNames,
requireWindowsSignature: signatureMode === "required",
}),
),
);
@@ -2,6 +2,14 @@ import { describe, expect, test } from "bun:test";
import { nativeReleaseAssetsReady } from "./check-native-release-assets.mjs";
describe("native release asset readiness", () => {
const windowsAssets = (version) => [
`EdgeEver-${version}-windows-x64.exe`,
"latest.yml",
"latest-windows.json",
"latest-windows.json.sig",
"SHA256SUMS-windows.txt",
];
test("accepts one reused Android APK but requires the current name after a rebuild", () => {
const reused = ["edgeever-android-v1.6.30-arm64-v8a.apk"];
expect(
@@ -53,6 +61,7 @@ describe("native release asset readiness", () => {
"EdgeEver-1.6.33-mac-x64.zip",
"EdgeEver-1.6.33-mac-x64.zip.blockmap",
"latest-mac.yml",
...windowsAssets("1.6.33"),
],
}),
).toBe(true);
@@ -69,6 +78,7 @@ describe("native release asset readiness", () => {
"EdgeEver-1.6.35-mac-x64.zip",
"EdgeEver-1.6.35-mac-x64.zip.blockmap",
"latest-mac.yml",
...windowsAssets("1.6.35"),
];
expect(
nativeReleaseAssetsReady({
@@ -103,8 +113,39 @@ describe("native release asset readiness", () => {
"EdgeEver-1.6.33-mac-arm64.zip",
"EdgeEver-1.6.33-mac-arm64.zip.blockmap",
"latest-mac.yml",
...windowsAssets("1.6.33"),
],
}),
).toBe(false);
});
test("allows the CI staging audit to omit only the offline signature", () => {
const assets = [
"EdgeEver-1.6.35-mac-arm64.dmg",
"EdgeEver-1.6.35-mac-arm64.dmg.blockmap",
"EdgeEver-1.6.35-mac-arm64.zip",
"EdgeEver-1.6.35-mac-arm64.zip.blockmap",
"EdgeEver-1.6.35-mac-x64.dmg",
"EdgeEver-1.6.35-mac-x64.dmg.blockmap",
"EdgeEver-1.6.35-mac-x64.zip",
"EdgeEver-1.6.35-mac-x64.zip.blockmap",
"latest-mac.yml",
...windowsAssets("1.6.35").filter((name) => name !== "latest-windows.json.sig"),
];
expect(nativeReleaseAssetsReady({
platform: "desktop",
rebuild: true,
currentTag: "v1.6.35",
desktopVersion: "1.6.35",
assetNames: assets,
requireWindowsSignature: false,
})).toBe(true);
expect(nativeReleaseAssetsReady({
platform: "desktop",
rebuild: true,
currentTag: "v1.6.35",
desktopVersion: "1.6.35",
assetNames: assets,
})).toBe(false);
});
});
@@ -0,0 +1,87 @@
import { createHash } from "node:crypto";
import { createReadStream, readFileSync, statSync, writeFileSync } from "node:fs";
import { basename, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import {
WINDOWS_UPDATE_KEY_ID,
WINDOWS_UPDATE_MANIFEST_NAME,
} from "../apps/desktop/src/main/windows-update-trust.mjs";
const STABLE_VERSION = /^\d+\.\d+\.\d+$/;
const hashFile = (path, algorithm, encoding) => new Promise((resolveHash, rejectHash) => {
const hash = createHash(algorithm);
const stream = createReadStream(path);
stream.on("error", rejectHash);
stream.on("data", (chunk) => hash.update(chunk));
stream.on("end", () => resolveHash(hash.digest(encoding)));
});
export const createWindowsUpdateMetadata = async ({ directory, version }) => {
if (!STABLE_VERSION.test(version)) {
throw new Error(`Windows update version must be stable X.Y.Z: ${version}`);
}
const installerName = `EdgeEver-${version}-windows-x64.exe`;
const installerPath = join(directory, installerName);
const stats = statSync(installerPath);
if (!stats.isFile() || stats.size <= 0) {
throw new Error(`Windows installer is missing or empty: ${installerPath}`);
}
const [sha512, sha256] = await Promise.all([
hashFile(installerPath, "sha512", "base64"),
hashFile(installerPath, "sha256", "hex"),
]);
const releaseDate = new Date().toISOString();
const manifest = {
schemaVersion: 1,
keyId: WINDOWS_UPDATE_KEY_ID,
version,
platform: "win32",
arch: "x64",
releaseDate,
file: {
name: installerName,
size: stats.size,
sha512,
sha256,
},
};
writeFileSync(
join(directory, WINDOWS_UPDATE_MANIFEST_NAME),
`${JSON.stringify(manifest, null, 2)}\n`,
);
writeFileSync(join(directory, "latest.yml"), [
`version: ${version}`,
"files:",
` - url: ${installerName}`,
` sha512: ${sha512}`,
` size: ${stats.size}`,
`path: ${installerName}`,
`sha512: ${sha512}`,
`releaseDate: '${releaseDate}'`,
"",
].join("\n"));
writeFileSync(
join(directory, "SHA256SUMS-windows.txt"),
`${sha256} ${installerName}\n`,
);
return manifest;
};
const run = async () => {
const [directoryValue, version] = process.argv.slice(2);
if (!directoryValue || !version) {
throw new Error("Usage: node scripts/create-windows-update-metadata.mjs <directory> <version>");
}
const directory = resolve(directoryValue);
const packageVersion = JSON.parse(readFileSync("apps/desktop/package.json", "utf8")).version;
if (version !== packageVersion) {
throw new Error(`Requested version ${version} does not match desktop package version ${packageVersion}`);
}
const manifest = await createWindowsUpdateMetadata({ directory, version });
process.stdout.write(`${basename(directory)}: ${manifest.file.name}\n`);
};
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
await run();
}
+11 -1
View File
@@ -61,7 +61,7 @@ describe("desktop release workflow", () => {
});
test("reports timings after builds without instrumenting native build steps", () => {
expect(workflow).toContain("name: Report macOS build timings");
expect(workflow).toContain("name: Report desktop build timings");
expect(workflow).toContain("--platform desktop");
expect(workflow).toContain("name: edgeever-desktop-build-timings");
expect(mobileWorkflow).toContain("name: Report Android build timings");
@@ -69,4 +69,14 @@ describe("desktop release workflow", () => {
expect(mobileWorkflow).toContain("name: edgeever-android-build-timings");
expect(mobileWorkflow).not.toContain("Build signed release APK for GitHub Release\n run: time");
});
test("builds an unsigned Windows x64 Preview and audits its signed update metadata", () => {
expect(workflow).toContain("name: Windows x64 unsigned Preview");
expect(workflow).toContain("EDGE_EVER_DESKTOP_TARGET: win");
expect(workflow).toContain("Get-AuthenticodeSignature");
expect(workflow).toContain("create-windows-update-metadata.mjs");
expect(workflow).toContain("allow-missing-windows-signature");
expect(workflow).toContain("name: Audit signed Windows update");
expect(workflow).toContain("verify-windows-update-release.mjs");
});
});
+3
View File
@@ -51,9 +51,12 @@ export const planNativeRelease = (platform, changedFiles) => {
".github/workflows/desktop-build.yml",
"bun.lock",
"scripts/create-mac-update-metadata.mjs",
"scripts/create-windows-update-metadata.mjs",
"scripts/desktop-icns.mjs",
"scripts/prepare-desktop-icons.mjs",
"scripts/run-desktop-builder.mjs",
"scripts/sign-windows-update-manifest.mjs",
"scripts/verify-windows-update-release.mjs",
"scripts/verify-desktop-package.mjs",
]);
+3
View File
@@ -71,9 +71,12 @@ describe("native release planning", () => {
const changedFiles = [
".github/workflows/desktop-build.yml",
"scripts/create-mac-update-metadata.mjs",
"scripts/create-windows-update-metadata.mjs",
"scripts/prepare-desktop-icons.mjs",
"scripts/desktop-icns.mjs",
"scripts/run-desktop-builder.mjs",
"scripts/sign-windows-update-manifest.mjs",
"scripts/verify-windows-update-release.mjs",
];
expect(planNativeRelease("desktop", changedFiles)).toEqual({
rebuild: true,
+77 -3
View File
@@ -16,6 +16,10 @@ import { join, resolve, sep } from "node:path";
import { spawn, spawnSync } from "node:child_process";
import { nativeReleaseAssetsReady } from "./check-native-release-assets.mjs";
import { planNativeRelease } from "./plan-native-release.mjs";
import {
assertWindowsUpdateSigningKey,
signWindowsUpdateManifest,
} from "./sign-windows-update-manifest.mjs";
const DEFAULT_REPOSITORY = "tianma-if/edgeever";
const VERSION_BUMPS = new Set(["patch", "minor", "major"]);
@@ -42,6 +46,7 @@ export const RELEASE_VALIDATIONS = [
"test",
"scripts/plan-native-release.test.mjs",
"scripts/check-native-release-assets.test.mjs",
"scripts/windows-update-metadata.test.mjs",
"scripts/release.test.mjs",
"scripts/validate-store-delivery.test.mjs",
"scripts/store-delivery.test.mjs",
@@ -360,7 +365,7 @@ export const buildIssueBody = ({ changesEn, changesZh, commitCoverageAudit }) =>
"## Acceptance criteria",
"",
"- Required type checks, Web build, and native release planning tests pass.",
"- The Draft Release contains audited macOS arm64 and x64 DMGs and a Play-signed Android arm64 APK.",
"- The Draft Release contains audited macOS arm64/x64 DMGs, an unsigned Windows x64 Preview with an independently signed update manifest, and a Play-signed Android arm64 APK.",
"- Post-publication native asset audits pass.",
].join("\n");
@@ -1010,10 +1015,17 @@ const assertDraftAssets = ({
.map((asset) => asset.name)
.filter((name) =>
/^EdgeEver-.*-mac-(?:arm64|x64)\.(?:dmg|zip)(?:\.blockmap)?$/.test(name) ||
name === "latest-mac.yml"
/^EdgeEver-.*-windows-x64\.exe$/.test(name) ||
[
"latest-mac.yml",
"latest.yml",
"latest-windows.json",
"latest-windows.json.sig",
"SHA256SUMS-windows.txt",
].includes(name)
);
if (
previousDesktopNames.length !== 9 ||
previousDesktopNames.length !== 14 ||
!previousDesktopNames.every((name) =>
reusedAssetMatches(previousAssets, assets, name)
)
@@ -1023,6 +1035,44 @@ const assertDraftAssets = ({
}
};
const signDraftWindowsUpdate = ({ repository, tag }) => {
const temporaryDirectory = mkdtempSync(join(tmpdir(), "edgeever-windows-update-signing-"));
const manifestPath = join(temporaryDirectory, "latest-windows.json");
const signaturePath = `${manifestPath}.sig`;
try {
run("gh", [
"release",
"download",
tag,
"--repo",
repository,
"--pattern",
"latest-windows.json",
"--dir",
temporaryDirectory,
]);
signWindowsUpdateManifest({
manifestPath,
signaturePath,
privateKeyPath: process.env.EDGE_EVER_WINDOWS_UPDATE_SIGNING_KEY,
});
run("gh", [
"release",
"upload",
tag,
signaturePath,
"--repo",
repository,
"--clobber",
]);
console.log(`[release] signed Windows update manifest for ${tag}`);
} finally {
if (temporaryDirectory.startsWith(`${tmpdir()}${sep}edgeever-windows-update-signing-`)) {
rmSync(temporaryDirectory, { recursive: true, force: true });
}
}
};
const sha256File = (path) => new Promise((resolveHash, rejectHash) => {
const hash = createHash("sha256");
const stream = createReadStream(path);
@@ -1221,6 +1271,13 @@ const releaseMain = async (options) => {
return;
}
if (desktopPlan.rebuild) {
assertWindowsUpdateSigningKey({
privateKeyPath: process.env.EDGE_EVER_WINDOWS_UPDATE_SIGNING_KEY,
});
console.log("[release] Windows update signing key matches the pinned desktop trust anchor");
}
let issueNumber;
let releaseSha;
if (resumedDraft) {
@@ -1372,6 +1429,23 @@ const releaseMain = async (options) => {
androidReleaseReady,
]);
if (desktopPlan.rebuild) {
signDraftWindowsUpdate({ repository: options.repository, tag });
}
const windowsUpdateAuditRunId = await dispatchReleaseWorkflow({
repository: options.repository,
workflow: RELEASE_WORKFLOWS.desktop,
tag,
headSha: releaseSha,
});
checkpoint.windowsUpdateAuditRunId = windowsUpdateAuditRunId;
persistCheckpoint();
await waitForRun({
repository: options.repository,
runId: windowsUpdateAuditRunId,
label: "Draft signed Windows update audit",
});
const draft = ghJson([
"release",
"view",
+20
View File
@@ -44,6 +44,25 @@ describe("release automation", () => {
expect(publication).toBeGreaterThan(signatureGate);
});
test("blocks publication until the offline-signed Windows update passes an independent audit", () => {
const releaseSource = readFileSync(new URL("./release.mjs", import.meta.url), "utf8");
const signing = releaseSource.indexOf("signDraftWindowsUpdate({");
const audit = releaseSource.indexOf('label: "Draft signed Windows update audit"');
const publication = releaseSource.indexOf('"--draft=false"');
expect(signing).toBeGreaterThanOrEqual(0);
expect(audit).toBeGreaterThan(signing);
expect(publication).toBeGreaterThan(audit);
});
test("checks the offline Windows key before creating release state", () => {
const releaseSource = readFileSync(new URL("./release.mjs", import.meta.url), "utf8");
const releaseMain = releaseSource.indexOf("const releaseMain = async");
const keyCheck = releaseSource.indexOf("assertWindowsUpdateSigningKey({", releaseMain);
const issueCreation = releaseSource.indexOf('"issue",\n "create"', releaseMain);
expect(keyCheck).toBeGreaterThan(releaseMain);
expect(issueCreation).toBeGreaterThan(keyCheck);
});
test("starts Play delivery as soon as Android preparation finishes", () => {
const releaseSource = readFileSync(new URL("./release.mjs", import.meta.url), "utf8");
const androidReady = releaseSource.lastIndexOf("const androidReleaseReady");
@@ -337,6 +356,7 @@ describe("release automation", () => {
expect(body).toContain("- 并行检查。");
expect(body).toContain("## Commit coverage audit");
expect(body).toContain("Play-signed Android arm64 APK");
expect(body).toContain("unsigned Windows x64 Preview");
expect(body).toContain("- Change 1: `aaaaaaaa`");
expect(body).toContain("- Excluded `bbbbbbbb`: test-only coverage");
});
+5
View File
@@ -12,6 +12,11 @@ const TARGETS = {
jobName: "macOS x64",
packageSteps: ["Package desktop installer"],
},
{
name: "Windows x64 Preview",
jobName: "Windows x64 unsigned Preview",
packageSteps: ["Package unsigned Windows installer"],
},
],
android: [
{
+13 -3
View File
@@ -22,7 +22,11 @@ function job(name, startedAt, completedAt, packageDurationSeconds) {
completed_at: packageStart,
},
{
name: name.startsWith("macOS") ? "Package desktop installer" : "Build signed release APK for GitHub Release",
name: name.startsWith("macOS")
? "Package desktop installer"
: name.startsWith("Windows")
? "Package unsigned Windows installer"
: "Build signed release APK for GitHub Release",
conclusion: "success",
started_at: packageStart,
completed_at: packageEnd,
@@ -38,18 +42,24 @@ describe("native build timing reports", () => {
expect(formatDuration(123_000)).toBe("2m 03s");
});
test("reports both desktop architectures from Actions timestamps", () => {
test("reports both macOS architectures and Windows x64 from Actions timestamps", () => {
const report = createTimingReport({
jobs: [
job("macOS arm64", "2026-08-13T00:00:00Z", "2026-08-13T00:06:00Z", 180),
job("macOS x64", "2026-08-13T00:00:00Z", "2026-08-13T00:09:00Z", 360),
job("Windows x64 unsigned Preview", "2026-08-13T00:00:00Z", "2026-08-13T00:07:00Z", 240),
],
}, "desktop", { runId: "123" });
expect(report.runId).toBe("123");
expect(report.targets.map((target) => target.name)).toEqual(["macOS arm64", "macOS x64"]);
expect(report.targets.map((target) => target.name)).toEqual([
"macOS arm64",
"macOS x64",
"Windows x64 Preview",
]);
expect(report.targets[1].packageDurationMs).toBe(360_000);
expect(renderTimingMarkdown(report)).toContain("| macOS x64 | success | 9m 00s | 1m 00s | 6m 00s | 2m 00s |");
expect(renderTimingMarkdown(report)).toContain("| Windows x64 Preview | success | 7m 00s | 1m 00s | 4m 00s | 2m 00s |");
});
test("selects the release Android package step", () => {
+14 -10
View File
@@ -43,28 +43,32 @@ function componentRow({ target, mode, candidate, duration, detail, url }) {
function nativeRows(payload, platform, requestedMode) {
if (platform === "desktop") {
const architectureJobs = ["macOS arm64", "macOS x64"]
.map((name) => findJob(payload, name))
.filter(Boolean);
const architectureJobs = [
{ name: "macOS arm64", packageStep: "Package desktop installer", detail: "package + notarize" },
{ name: "macOS x64", packageStep: "Package desktop installer", detail: "package + notarize" },
{ name: "Windows x64 unsigned Preview", packageStep: "Package unsigned Windows installer", detail: "package" },
]
.map((target) => ({ target, job: findJob(payload, target.name) }))
.filter(({ job }) => Boolean(job));
if (architectureJobs.length > 0) {
return architectureJobs.map((job) => {
const packageStep = findStep(job, "Package desktop installer");
return architectureJobs.map(({ target, job }) => {
const packageStep = findStep(job, target.packageStep);
return componentRow({
target: job.name,
target: target.name === "Windows x64 unsigned Preview" ? "Windows x64 Preview" : target.name,
mode: "rebuild",
candidate: job,
duration: jobDurationMs(job),
detail: `package + notarize ${formatDuration(stepDurationMs(packageStep))}`,
detail: `${target.detail} ${formatDuration(stepDurationMs(packageStep))}`,
url: job.html_url ?? payload.run.html_url,
});
});
}
const reuseJob = findJob(payload, "Reuse macOS release assets");
const reuseJob = findJob(payload, "Reuse desktop release assets");
if (reuseJob) {
return [
componentRow({
target: "macOS arm64 + x64",
target: "macOS arm64 + x64 + Windows x64",
mode: "reuse",
candidate: reuseJob,
duration: jobDurationMs(reuseJob),
@@ -77,7 +81,7 @@ function nativeRows(payload, platform, requestedMode) {
const planJob = findJob(payload, "Plan desktop release asset");
return [
componentRow({
target: "macOS arm64 + x64",
target: "macOS arm64 + x64 + Windows x64",
mode: "already prepared",
candidate: planJob ?? payload.run,
duration: jobDurationMs(planJob) ?? runDurationMs(payload.run),
+10 -3
View File
@@ -44,6 +44,9 @@ describe("release timing report", () => {
job("macOS x64", start, 600, [
step("Package desktop installer", start, 300),
]),
job("Windows x64 unsigned Preview", start, 420, [
step("Package unsigned Windows installer", start, 240),
]),
]),
desktopMode: "rebuild",
mobile: payload("Mobile", start, 540, [
@@ -83,6 +86,7 @@ describe("release timing report", () => {
"Docker TCR (amd64 + arm64)",
"macOS arm64",
"macOS x64",
"Windows x64 Preview",
"Android arm64",
"Google Play signed APK",
]);
@@ -96,6 +100,9 @@ describe("release timing report", () => {
expect(markdown).toContain(
"| macOS x64 | rebuild | success | 10m 00s | package + notarize 5m 00s |",
);
expect(markdown).toContain(
"| Windows x64 Preview | rebuild | success | 7m 00s | package 4m 00s |",
);
expect(markdown).toContain(
"| Google Play signed APK | build + deliver | success | 11m 00s | AAB build 3m 00s; Play upload 4m 00s |",
);
@@ -120,7 +127,7 @@ describe("release timing report", () => {
const report = createReleaseTimingReport({
...common,
desktop: payload("Desktop", start, 40, [
job("Reuse macOS release assets", start, 40),
job("Reuse desktop release assets", start, 40),
]),
desktopMode: "reuse",
mobile: payload("Mobile", start, 30, [
@@ -130,7 +137,7 @@ describe("release timing report", () => {
});
expect(report.rows.at(-2)).toMatchObject({
target: "macOS arm64 + x64",
target: "macOS arm64 + x64 + Windows x64",
mode: "reuse",
durationMs: 40_000,
});
@@ -146,7 +153,7 @@ describe("release timing report", () => {
const report = createReleaseTimingReport({
release: { tag: "v1.41.2", sha: "ghi", publishedAt: start },
desktop: payload("Desktop", start, 40, [
job("Reuse macOS release assets", start, 40),
job("Reuse desktop release assets", start, 40),
]),
desktopMode: "reuse",
mobile: payload("Mobile", start, 30, [
+7 -1
View File
@@ -4,10 +4,16 @@ const publishIndex = process.argv.indexOf("--publish");
const publishMode = publishIndex >= 0 ? process.argv[publishIndex + 1] ?? "never" : "never";
const environment = { ...process.env };
const requestedArch = environment.EDGE_EVER_DESKTOP_ARCH;
const requestedTarget = environment.EDGE_EVER_DESKTOP_TARGET || (
process.platform === "win32" ? "win" : process.platform === "darwin" ? "mac" : "linux"
);
if (requestedArch && !["arm64", "x64"].includes(requestedArch)) {
throw new Error(`EDGE_EVER_DESKTOP_ARCH must be arm64 or x64, received: ${requestedArch}`);
}
if (!["mac", "win", "linux"].includes(requestedTarget)) {
throw new Error(`EDGE_EVER_DESKTOP_TARGET must be mac, win, or linux, received: ${requestedTarget}`);
}
// electron-builder treats an explicitly present empty CSC_LINK/WIN_CSC_LINK
// as a path. Remove empty optional signing variables so unsigned CI builds
@@ -35,7 +41,7 @@ const builderArgs = [
publishMode,
];
if (requestedArch) {
builderArgs.push("--mac", `--${requestedArch}`);
builderArgs.push(`--${requestedTarget}`, `--${requestedArch}`);
}
const result = spawnSync(process.execPath, builderArgs, {
+91
View File
@@ -0,0 +1,91 @@
import { createPrivateKey, createPublicKey, sign } from "node:crypto";
import { readFileSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import {
WINDOWS_UPDATE_KEY_ID,
WINDOWS_UPDATE_PUBLIC_KEY_PEM,
WINDOWS_UPDATE_SIGNATURE_NAME,
verifyWindowsUpdateMetadata,
} from "../apps/desktop/src/main/windows-update-trust.mjs";
const normalizedPublicKey = (key) => (key?.type === "public" ? key : createPublicKey(key)).export({
type: "spki",
format: "pem",
}).trim();
export const assertWindowsUpdateSigningKey = ({
privateKeyPath,
expectedPublicKey = WINDOWS_UPDATE_PUBLIC_KEY_PEM,
}) => {
if (!privateKeyPath) {
throw new Error("EDGE_EVER_WINDOWS_UPDATE_SIGNING_KEY must point to the offline Ed25519 private key");
}
const privateKey = createPrivateKey(readFileSync(privateKeyPath, "utf8"));
if (privateKey.asymmetricKeyType !== "ed25519") {
throw new Error("Windows update signing key must be Ed25519");
}
const derivedPublicKey = normalizedPublicKey(createPublicKey(privateKey));
if (derivedPublicKey !== normalizedPublicKey(expectedPublicKey)) {
throw new Error("Windows update signing key does not match the public key pinned in the desktop client");
}
return privateKey;
};
export const signWindowsUpdateManifest = ({
manifestPath,
signaturePath,
privateKeyPath,
expectedPublicKey = WINDOWS_UPDATE_PUBLIC_KEY_PEM,
}) => {
const privateKey = assertWindowsUpdateSigningKey({ privateKeyPath, expectedPublicKey });
const manifestBytes = readFileSync(manifestPath);
const manifest = JSON.parse(manifestBytes.toString("utf8"));
if (manifest?.schemaVersion !== 1 || manifest.keyId !== WINDOWS_UPDATE_KEY_ID) {
throw new Error("Windows update manifest key metadata is invalid");
}
const envelope = {
schemaVersion: 1,
keyId: WINDOWS_UPDATE_KEY_ID,
signature: sign(null, manifestBytes, privateKey).toString("base64"),
};
const signatureBytes = Buffer.from(`${JSON.stringify(envelope)}\n`);
verifyWindowsUpdateMetadata({
manifestBytes,
signatureBytes,
expectedVersion: manifest.version,
updateInfo: {
version: manifest.version,
files: [{
url: manifest.file?.name,
size: manifest.file?.size,
sha512: manifest.file?.sha512,
}],
},
trustedPublicKeys: { [WINDOWS_UPDATE_KEY_ID]: expectedPublicKey },
});
writeFileSync(signaturePath, signatureBytes, { mode: 0o644 });
return envelope;
};
const run = () => {
const [manifestValue, signatureValue] = process.argv.slice(2);
if (!manifestValue) {
throw new Error("Usage: node scripts/sign-windows-update-manifest.mjs <manifest> [signature]");
}
const manifestPath = resolve(manifestValue);
const signaturePath = resolve(signatureValue || `${manifestPath}.sig`);
if (!signaturePath.endsWith(WINDOWS_UPDATE_SIGNATURE_NAME)) {
throw new Error(`Signature filename must end with ${WINDOWS_UPDATE_SIGNATURE_NAME}`);
}
signWindowsUpdateManifest({
manifestPath,
signaturePath,
privateKeyPath: process.env.EDGE_EVER_WINDOWS_UPDATE_SIGNING_KEY,
});
process.stdout.write(`${signaturePath}\n`);
};
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
run();
}
+35 -4
View File
@@ -1,6 +1,6 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { existsSync, readFileSync, readdirSync, rmSync, statSync } from "node:fs";
import { existsSync, openSync, closeSync, readFileSync, readSync, readdirSync, rmSync, statSync } from "node:fs";
import { basename, join } from "node:path";
import { listPackage } from "@electron/asar";
import { assertMacIcnsComplete } from "./desktop-icns.mjs";
@@ -33,6 +33,22 @@ const verifyMachOArch = (path, arch, label) => {
assert.deepEqual(result.stdout.trim().split(/\s+/), [expectedArch], `${label} must contain only ${expectedArch}`);
};
const verifyPeX64 = (path, label) => {
const descriptor = openSync(path, "r");
try {
const dosHeader = Buffer.alloc(64);
assert.equal(readSync(descriptor, dosHeader, 0, dosHeader.length, 0), dosHeader.length, `${label} DOS header is incomplete`);
assert.equal(dosHeader.toString("ascii", 0, 2), "MZ", `${label} is not a PE executable`);
const peOffset = dosHeader.readUInt32LE(0x3c);
const peHeader = Buffer.alloc(6);
assert.equal(readSync(descriptor, peHeader, 0, peHeader.length, peOffset), peHeader.length, `${label} PE header is incomplete`);
assert.equal(peHeader.toString("ascii", 0, 4), "PE\0\0", `${label} PE signature is invalid`);
assert.equal(peHeader.readUInt16LE(4), 0x8664, `${label} must target Windows x64`);
} finally {
closeSync(descriptor);
}
};
for (const sidecarPath of files.filter((path) => /[\\/]resources[\\/]sidecar[\\/]edgeever-sidecar(?:\.exe)?$/i.test(path))) {
const bundleRoot = sidecarPath.replace(/[\\/]resources[\\/]sidecar[\\/]edgeever-sidecar(?:\.exe)?$/i, "");
assert.ok(existsSync(join(bundleRoot, "resources", "web", "index.html")), `Desktop bundle is missing the Web renderer: ${bundleRoot}`);
@@ -69,9 +85,24 @@ if (requestedPlatform === "darwin") {
// disposable unpacked bundle once its contents have passed verification.
rmSync(unpackedApp, { recursive: true, force: true });
} else if (requestedPlatform === "win32") {
const installer = matchingPrefix(`EdgeEver-${version}-windows-`).some((path) => path.endsWith(".exe"));
const unpacked = existsSync(join(outputDirectory, "win-arm64-unpacked", "EdgeEver.exe"));
assert.ok(installer || unpacked, "Windows package must contain the current NSIS installer or an unpacked executable");
assert.equal(requestedArch, "x64", `Windows release package must target x64, received: ${requestedArch}`);
const installer = join(outputDirectory, `EdgeEver-${version}-windows-x64.exe`);
const unpackedDirectory = join(outputDirectory, "win-unpacked");
const executable = join(unpackedDirectory, "EdgeEver.exe");
const resources = join(unpackedDirectory, "resources");
const sidecar = join(resources, "sidecar", "edgeever-sidecar.exe");
assert.ok(existsSync(installer), `Windows package must contain the current x64 NSIS installer: ${installer}`);
assert.ok(existsSync(executable), `Windows package must contain the unpacked x64 executable: ${executable}`);
assert.ok(existsSync(sidecar), `Windows package must contain the x64 sidecar: ${sidecar}`);
assert.ok(existsSync(join(resources, "web", "index.html")), "Windows app bundle is missing the Web renderer");
assert.ok(existsSync(join(resources, "migrations")), "Windows app bundle is missing migrations");
verifyPeX64(executable, "Electron executable");
verifyPeX64(sidecar, "Rust sidecar");
const asarPath = join(resources, "app.asar");
assert.ok(existsSync(asarPath), `Windows app bundle is missing app.asar: ${asarPath}`);
const asarFiles = new Set(listPackage(asarPath));
assert.ok(asarFiles.has("/src/preload/index.cjs"), "Windows app bundle must contain the sandbox-compatible CommonJS preload");
assert.ok(!asarFiles.has("/src/preload/index.mjs"), "Windows app bundle must not contain the unsupported ESM preload");
} else if (requestedPlatform === "linux") {
assert.ok(matchingPrefix(`EdgeEver-${version}-linux-`).some((path) => path.endsWith(".AppImage")), "Linux package must contain the current AppImage");
const unpacked = files.find((path) => path.endsWith("/resources/sidecar/edgeever-sidecar") && path.includes("linux-") && path.includes("-unpacked"));
+77
View File
@@ -0,0 +1,77 @@
import { readFileSync, readdirSync } from "node:fs";
import { join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import {
WINDOWS_UPDATE_MANIFEST_NAME,
WINDOWS_UPDATE_SIGNATURE_NAME,
verifyDownloadedWindowsUpdate,
verifyWindowsUpdateMetadata,
} from "../apps/desktop/src/main/windows-update-trust.mjs";
export const verifyWindowsUpdateRelease = async (directory) => {
const manifestBytes = readFileSync(join(directory, WINDOWS_UPDATE_MANIFEST_NAME));
const signatureBytes = readFileSync(join(directory, WINDOWS_UPDATE_SIGNATURE_NAME));
const untrustedManifest = JSON.parse(manifestBytes.toString("utf8"));
const expectedNames = [
untrustedManifest.file?.name,
"latest.yml",
WINDOWS_UPDATE_MANIFEST_NAME,
WINDOWS_UPDATE_SIGNATURE_NAME,
"SHA256SUMS-windows.txt",
].sort();
const actualNames = readdirSync(directory).sort();
if (JSON.stringify(actualNames) !== JSON.stringify(expectedNames)) {
throw new Error(`Windows update audit expected exactly: ${expectedNames.join(", ")}`);
}
const manifest = verifyWindowsUpdateMetadata({
manifestBytes,
signatureBytes,
expectedVersion: untrustedManifest.version,
updateInfo: {
version: untrustedManifest.version,
files: [{
url: untrustedManifest.file?.name,
size: untrustedManifest.file?.size,
sha512: untrustedManifest.file?.sha512,
}],
},
});
await verifyDownloadedWindowsUpdate({
path: join(directory, manifest.file.name),
manifest,
});
const latestYml = readFileSync(join(directory, "latest.yml"), "utf8");
const expectedLatestYml = [
`version: ${manifest.version}`,
"files:",
` - url: ${manifest.file.name}`,
` sha512: ${manifest.file.sha512}`,
` size: ${manifest.file.size}`,
`path: ${manifest.file.name}`,
`sha512: ${manifest.file.sha512}`,
`releaseDate: '${manifest.releaseDate}'`,
"",
].join("\n");
if (latestYml !== expectedLatestYml) {
throw new Error("latest.yml does not exactly match the signed manifest");
}
const checksum = readFileSync(join(directory, "SHA256SUMS-windows.txt"), "utf8");
if (checksum !== `${manifest.file.sha256} ${manifest.file.name}\n`) {
throw new Error("SHA256SUMS-windows.txt does not match the signed manifest");
}
return manifest;
};
const run = async () => {
const [directoryValue] = process.argv.slice(2);
if (!directoryValue) {
throw new Error("Usage: node scripts/verify-windows-update-release.mjs <directory>");
}
const manifest = await verifyWindowsUpdateRelease(resolve(directoryValue));
process.stdout.write(`Verified ${manifest.file.name} with ${manifest.keyId}\n`);
};
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
await run();
}
+123
View File
@@ -0,0 +1,123 @@
import { generateKeyPairSync } from "node:crypto";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, test } from "bun:test";
import { createWindowsUpdateMetadata } from "./create-windows-update-metadata.mjs";
import {
assertWindowsUpdateSigningKey,
signWindowsUpdateManifest,
} from "./sign-windows-update-manifest.mjs";
import { verifyWindowsUpdateRelease } from "./verify-windows-update-release.mjs";
describe("Windows update release metadata", () => {
test("creates installer metadata consumed by electron-updater and the trust gate", async () => {
const directory = await mkdtemp(join(tmpdir(), "edgeever-windows-metadata-"));
try {
await writeFile(join(directory, "EdgeEver-1.49.0-windows-x64.exe"), "payload");
const manifest = await createWindowsUpdateMetadata({ directory, version: "1.49.0" });
expect(manifest.file).toMatchObject({
name: "EdgeEver-1.49.0-windows-x64.exe",
size: 7,
sha256: "239f59ed55e737c77147cf55ad0c1b030b6d7ee748a7426952f9b852d5a935e5",
});
expect(await readFile(join(directory, "latest.yml"), "utf8"))
.toContain(`sha512: ${manifest.file.sha512}`);
expect(await readFile(join(directory, "SHA256SUMS-windows.txt"), "utf8"))
.toBe(`${manifest.file.sha256} ${manifest.file.name}\n`);
} finally {
await rm(directory, { recursive: true, force: true });
}
});
test("signs only with the private key matching the expected public key", async () => {
const directory = await mkdtemp(join(tmpdir(), "edgeever-windows-signing-"));
const { privateKey, publicKey } = generateKeyPairSync("ed25519");
const { publicKey: otherPublicKey } = generateKeyPairSync("ed25519");
const manifestPath = join(directory, "latest-windows.json");
const signaturePath = `${manifestPath}.sig`;
const privateKeyPath = join(directory, "private.pem");
try {
await writeFile(manifestPath, `${JSON.stringify({
schemaVersion: 1,
keyId: "edgeever-windows-update-2026-01",
version: "1.49.0",
platform: "win32",
arch: "x64",
releaseDate: "2026-08-30T00:00:00.000Z",
file: {
name: "EdgeEver-1.49.0-windows-x64.exe",
size: 7,
sha512: "cLM86ckEfjD5F+fqE+Qvd2cAjD9PnJuvSeQ5D8YlVJ6WJe7jm5RUUHTooYJM8/I4RjsRvAPZc0jg/CmZyh//fw==",
sha256: "239f59ed55e737c77147cf55ad0c1b030b6d7ee748a7426952f9b852d5a935e5",
},
})}\n`);
await writeFile(privateKeyPath, privateKey.export({ type: "pkcs8", format: "pem" }));
expect(assertWindowsUpdateSigningKey({
privateKeyPath,
expectedPublicKey: publicKey,
}).asymmetricKeyType).toBe("ed25519");
expect(signWindowsUpdateManifest({
manifestPath,
signaturePath,
privateKeyPath,
expectedPublicKey: publicKey,
}).signature).toHaveLength(88);
expect(() => signWindowsUpdateManifest({
manifestPath,
signaturePath,
privateKeyPath,
expectedPublicKey: otherPublicKey,
})).toThrow("does not match");
} finally {
await rm(directory, { recursive: true, force: true });
}
});
test("audits the complete production release asset set", async () => {
const directory = await mkdtemp(join(tmpdir(), "edgeever-windows-release-audit-"));
const manifest = {
schemaVersion: 1,
keyId: "edgeever-windows-update-2026-01",
version: "1.49.0",
platform: "win32",
arch: "x64",
releaseDate: "2026-08-30T00:00:00.000Z",
file: {
name: "EdgeEver-1.49.0-windows-x64.exe",
size: 7,
sha512: "cLM86ckEfjD5F+fqE+Qvd2cAjD9PnJuvSeQ5D8YlVJ6WJe7jm5RUUHTooYJM8/I4RjsRvAPZc0jg/CmZyh//fw==",
sha256: "239f59ed55e737c77147cf55ad0c1b030b6d7ee748a7426952f9b852d5a935e5",
},
};
try {
await writeFile(join(directory, manifest.file.name), "payload");
await writeFile(join(directory, "latest-windows.json"), `${JSON.stringify(manifest, null, 2)}\n`);
await writeFile(join(directory, "latest-windows.json.sig"), `${JSON.stringify({
schemaVersion: 1,
keyId: manifest.keyId,
signature: "Itc3UD0G6Hn6BWdk9qlJ4SoqAUDn8efi1VRVUWDtA3Ip5+gwpAj7HxElxM6D/GXcAlHFxnVv19soEWxlfZLCDA==",
})}\n`);
await writeFile(join(directory, "latest.yml"), [
`version: ${manifest.version}`,
"files:",
` - url: ${manifest.file.name}`,
` sha512: ${manifest.file.sha512}`,
` size: ${manifest.file.size}`,
`path: ${manifest.file.name}`,
`sha512: ${manifest.file.sha512}`,
`releaseDate: '${manifest.releaseDate}'`,
"",
].join("\n"));
await writeFile(
join(directory, "SHA256SUMS-windows.txt"),
`${manifest.file.sha256} ${manifest.file.name}\n`,
);
await expect(verifyWindowsUpdateRelease(directory)).resolves.toEqual(manifest);
await writeFile(join(directory, manifest.file.name), "tampered");
await expect(verifyWindowsUpdateRelease(directory)).rejects.toThrow("size does not match");
} finally {
await rm(directory, { recursive: true, force: true });
}
});
});