返回 DeepSeek-Reasonix
check-desktop-build-contract.mjs
根目录 / scripts / check-desktop-build-contract.mjs
1 import assert from "node:assert/strict";
2 import fs from "node:fs";
3 import path from "node:path";
4 import os from "node:os";
5 import { spawnSync } from "node:child_process";
6 import { fileURLToPath } from "node:url";
7
8 const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
9 const read = (relativePath) =>
10 fs.readFileSync(path.join(repoRoot, relativePath), "utf8");
11
12 const frontendPackage = JSON.parse(read("desktop/frontend/package.json"));
13 const ciWorkflow = read(".github/workflows/ci.yml");
14 const releaseWorkflow = read(".github/workflows/release-desktop.yml");
15 const readme = read("README.md");
16 const desktopReadme = read("desktop/README.md");
17 const desktopBuildScript = read("scripts/desktop-build.sh");
18
19 // Execute the production shell wrapper to check the identity passed to packaging.
20 const packageShell = desktopBuildScript.match(/^package_shell\(\) \{\n[\s\S]*?^\}/m)?.[0];
21 assert.ok(packageShell, "desktop builds must define package_shell");
22 const sourceSha = "a".repeat(40);
23 const identityProbe = spawnSync("bash", ["-c", `${packageShell}\nnode() { printf '%s' "$REASONIX_COMMIT"; }\npackage_shell`], {
24 encoding: "utf8",
25 env: { ...process.env, ROOT: "/fixture", PLATFORM: "windows/amd64", VERSION: "v0.0.0-ci", CHANNEL: "canary",
26 SOURCE_SHA: sourceSha, GIT_COMMIT: sourceSha.slice(0, 12), BUILD_TIME_UTC: "2026-01-01T00:00:00Z" },
27 });
28 assert.ifError(identityProbe.error);
29 assert.equal(identityProbe.status, 0, identityProbe.stderr);
30 assert.equal(identityProbe.stdout.trim().split("\n").at(-1), sourceSha, "packaged identity must retain the full source SHA");
31
32 const jobBody = (workflow, jobName) => {
33 const lines = workflow.split("\n");
34 const start = lines.findIndex((line) => line === ` ${jobName}:`);
35 assert.notEqual(start, -1, `workflow must define the ${jobName} job`);
36 const nextJob = lines
37 .slice(start + 1)
38 .findIndex((line) => /^ [a-zA-Z0-9_-]+:$/.test(line));
39 const end = nextJob === -1 ? lines.length : start + 1 + nextJob;
40 return lines.slice(start, end).join("\n");
41 };
42
43 const nodeVersions = (workflow) =>
44 [...workflow.matchAll(/node-version:\s*["']?(\d+)/g)].map(
45 (match) => match[1],
46 );
47
48 assert.equal(read("desktop/frontend/.nvmrc").trim(), "24");
49 assert.equal(frontendPackage.engines?.node, ">=24");
50 assert.equal(frontendPackage.engines?.pnpm, ">=10 <11");
51 assert.ok(
52 !fs.existsSync(path.join(repoRoot, "desktop/wails.json")),
53 "desktop/wails.json must be retired with the Wails shell",
54 );
55
56 for (const jobName of ["desktop-prepare", "desktop-go", "desktop-frontend", "desktop-browser-group", "desktop-macos", "desktop-windows"]) {
57 assert.deepEqual(nodeVersions(jobBody(ciWorkflow, jobName)), ["24"]);
58 }
59
60 const releaseNodeVersions = nodeVersions(releaseWorkflow);
61 assert.ok(releaseNodeVersions.length > 0, "release workflow must set up Node");
62 assert.deepEqual(new Set(releaseNodeVersions), new Set(["24"]));
63
64 for (const [name, workflow] of [
65 ["CI", ciWorkflow],
66 ["release", releaseWorkflow],
67 ]) {
68 const lines = workflow.split("\n");
69 const pnpmVersions = lines.flatMap((line, index) => {
70 if (!line.includes("pnpm/action-setup@")) return [];
71 const block = lines.slice(index, index + 5).join("\n");
72 return [block.match(/version:\s*(\d+)/)?.[1] ?? "missing"];
73 });
74 assert.ok(pnpmVersions.length > 0, `${name} workflow must set up pnpm`);
75 assert.deepEqual(new Set(pnpmVersions), new Set(["10"]));
76 }
77
78 for (const [name, content] of [
79 ["README.md", readme],
80 ["desktop/README.md", desktopReadme],
81 ]) {
82 assert.match(content, /npm i(?:nstall)? -g pnpm@10/);
83 assert.doesNotMatch(
84 content,
85 /wails/i,
86 `${name} must not reference the retired Wails toolchain`,
87 );
88 }
89
90 assert.match(readme, /#### CLI/);
91 assert.match(readme, /#### Desktop/);
92
93 // The desktop build is the Electron packaging entrypoint: it must regenerate
94 // the shell/service contract and fail on drift before compiling anything.
95 assert.match(
96 desktopBuildScript,
97 /go run \. -emit-contract frontend\/src\/generated/,
98 "desktop builds must regenerate the host contract",
99 );
100 // Verify the build guard's behavior, not its choice of git or diff syntax.
101 // A locally edited but current contract is valid; regeneration drift is not.
102 const guardStart = desktopBuildScript.indexOf('echo "==> desktop host contract drift check"');
103 const guardEnd = desktopBuildScript.indexOf("# The packaging script", guardStart);
104 assert.ok(guardStart >= 0 && guardEnd > guardStart, "contract guard must precede packaging");
105 const contractGuard = desktopBuildScript.slice(guardStart, guardEnd);
106 const fixture = fs.mkdtempSync(path.join(os.tmpdir(), "reasonix-contract-guard-"));
107 try {
108 for (const mode of ["current", "changed", "added", "removed", "generator-failed"]) {
109 const cwd = path.join(fixture, mode);
110 const generated = path.join(cwd, "frontend/src/generated");
111 fs.mkdirSync(generated, { recursive: true });
112 fs.writeFileSync(path.join(generated, "contract.json"), '{"version":10}\n');
113 const script = `set -euo pipefail
114 go() {
115 case "$GUARD_TEST_MODE" in
116 current) : ;;
117 changed) echo '{"version":11}' > frontend/src/generated/contract.json ;;
118 added) echo '{}' > frontend/src/generated/new.json ;;
119 removed) rm frontend/src/generated/contract.json ;;
120 generator-failed) return 42 ;;
121 esac
122 }
123 ${contractGuard}`;
124 const result = spawnSync("bash", ["-c", script], {
125 cwd, encoding: "utf8", env: { ...process.env, GUARD_TEST_MODE: mode, TMPDIR: fixture },
126 });
127 assert.ifError(result.error);
128 if (mode === "current") {
129 assert.equal(result.status, 0, `current uncommitted contract rejected: ${result.stderr}`);
130 } else {
131 assert.notEqual(result.status, 0, `contract guard accepted ${mode}`);
132 }
133 }
134 } finally {
135 fs.rmSync(fixture, { recursive: true, force: true });
136 }
137 // The release channel now rides in the Go service ldflags (the shell reads
138 // the same identity from resources/build.json written by package.mjs).
139 assert.match(
140 desktopBuildScript,
141 /service_ldflags="-X main\.version=\$VERSION -X main\.channel=\$CHANNEL/,
142 "desktop builds must link the release channel into the Go service",
143 );
144 assert.match(
145 desktopBuildScript,
146 /\[ "\$os" = "windows" \] && service_ldflags="\$service_ldflags -H windowsgui"/,
147 "Windows desktop builds must link the Go service as a GUI-subsystem image",
148 );
149 assert.match(
150 desktopBuildScript,
151 /GOOS="\$os" GOARCH="\$arch" go build -trimpath -ldflags="-s -w \$service_ldflags" -o "\$service_out"/,
152 "desktop service builds must consume the platform-specific linker flags",
153 );
154 const windowsJob = jobBody(ciWorkflow, "desktop-windows");
155 assert.match(
156 windowsJob,
157 /go build -trimpath -ldflags "-s -w -H windowsgui -X main\.version=v0\.0\.0-ci -X main\.channel=canary" -o build\/bin\/reasonix-desktop\.exe \./,
158 "Windows native startup CI must build the service as a GUI-subsystem image",
159 );
160 assert.match(
161 windowsJob,
162 /node \.\.\/scripts\/verify-windows-gui-subsystem\.mjs build\/bin\/reasonix-desktop\.exe/,
163 "Windows native startup CI must verify the service PE subsystem",
164 );
165 // The shell is packaged through the Electron packaging script, never wails build.
166 assert.match(
167 desktopBuildScript,
168 /node "\$ROOT\/desktop\/packaging\/package\.mjs" "\$PLATFORM" "\$VERSION" "\$CHANNEL"/,
169 "desktop builds must package the shell through desktop/packaging/package.mjs",
170 );
171 assert.match(
172 desktopBuildScript,
173 /darwin\) report_bundle="\$ROOT\/desktop\/build\/candidate\/darwin-\$\{arch\}\/\$\{APPNAME\}\.app"/,
174 "macOS size reports must inspect the retained candidate instead of the deleted staging app",
175 );
176 assert.doesNotMatch(desktopBuildScript, /wails build/);
177 assert.doesNotMatch(
178 desktopBuildScript,
179 /github\.com\/wailsapp\/wails\/v2\/cmd\/wails@/,
180 );
181 // darwin/universal still ships one fat binary per Go artifact.
182 assert.match(
183 desktopBuildScript,
184 /lipo -create "\$service_tmp\/amd64" "\$service_tmp\/arm64" -output "\$service_out"/,
185 "darwin universal builds must lipo the desktop service",
186 );
187 // Windows keeps one canonical SignPath payload: signing-files.txt enumerates
188 // every PE file, then package-windows-desktop.sh rebuilds from the payload.
189 assert.match(
190 desktopBuildScript,
191 /node "\$ROOT\/desktop\/packaging\/signing-files\.mjs" "\$payload_dir"/,
192 "windows builds must enumerate the signing payload",
193 );
194 assert.match(
195 desktopBuildScript,
196 /VERSION="\$VERSION" "\$ROOT\/scripts\/package-windows-desktop\.sh" "\$arch" "\$payload_dir"/,
197 "windows builds must package from the signing payload",
198 );
199
200 console.log("desktop build contract: PASS");
201
201 lines Plain Text