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