返回 DeepSeek-Reasonix
desktop-release-artifacts.test.mjs
根目录 / scripts / desktop-release-artifacts.test.mjs
1 import assert from "node:assert/strict";
2 import { spawnSync } from "node:child_process";
3 import { cpSync, mkdtempSync, mkdirSync, readdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
4 import { tmpdir } from "node:os";
5 import path from "node:path";
6 import test from "node:test";
7 import { collect, pack, platforms, releaseIdentity, verifyBundle } from "./desktop-release-artifacts.mjs";
8
9 const env = {
10 RELEASE_SOURCE_SHA: "a".repeat(40), RELEASE_CONTROL_SHA: "b".repeat(40),
11 RELEASE_TAG: "desktop-v1.2.3", RELEASE_VERSION: "v1.2.3", RELEASE_CHANNEL: "stable",
12 RELEASE_SIGNING_FINGERPRINT: "contract-digest", RELEASE_ARTIFACT_PREFIX: "desktop-123-1-preflight",
13 GITHUB_RUN_ID: "123", GITHUB_RUN_ATTEMPT: "2",
14 };
15 const identity = releaseIdentity(env);
16
17 test("Windows signing finalization works in a fresh checkout for both architectures", t => {
18 const root = mkdtempSync(path.join(tmpdir(), "reasonix-signing-checkout-"));
19 t.after(() => rmSync(root, { recursive: true, force: true }));
20 const put = (name, content, mode) => {
21 const target = path.join(root, name);
22 mkdirSync(path.dirname(target), { recursive: true });
23 writeFileSync(target, content, { mode });
24 };
25 for (const name of ["scripts/finalize-windows-signed-candidate.sh", "scripts/package-windows-desktop.sh",
26 "scripts/verify-windows-portable.sh", "scripts/desktop-release-artifacts.mjs",
27 "desktop/packaging/lib.mjs", "desktop/packaging/signing-files.mjs",
28 "desktop/build/windows/installer/project.nsi", "desktop/build/windows/icon.ico"]) {
29 put(name, readFileSync(new URL(`../${name}`, import.meta.url)), 0o755);
30 }
31 put("desktop/packaging/size-report.mjs", "// Size measurement is outside this handoff fixture.\n");
32 put("commands/pwsh", "#!/usr/bin/env bash\nexit 0\n", 0o755);
33 put("commands/go", `#!/usr/bin/env bash
34 set -euo pipefail
35 case "$3" in
36 windows-payload) printf '{}' > "$4/reasonix-payload.json" ;;
37 sign) shift 3; for file in "$@"; do printf signature > "$file.minisig"; done ;;
38 verify) test -s "$4.minisig" ;;
39 *) exit 1 ;;
40 esac
41 `, 0o755);
42 put("commands/makensis", `#!/usr/bin/env bash
43 set -euo pipefail
44 test -s project.nsi
45 test -s ../icon.ico
46 test -d ../../bin
47 test -s reasonix_project.nsh
48 test -s reasonix-payload.json.minisig
49 test -s app/resources/app.asar
50 printf installer > ../../bin/fixture-installer.exe
51 `, 0o755);
52 const run = (command, args, extra = {}) => {
53 const result = spawnSync(command, args, {
54 cwd: root, encoding: "utf8",
55 env: { ...process.env, ...env, CERTUM_KEY_ID: "fixture", MINISIGN_PRIVATE_KEY: "fixture",
56 MINISIGN_PASSWORD: "fixture", PATH: `${path.join(root, "commands")}:${process.env.PATH}`, ...extra },
57 });
58 assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
59 };
60 for (const arch of ["amd64", "arm64"]) {
61 // A signing runner never built locally, and a second architecture must not
62 // consume the first architecture's generated installer or payload.
63 rmSync(path.join(root, "desktop/build/bin"), { recursive: true, force: true });
64 const payload = `payload-${arch}`;
65 for (const name of ["reasonix-desktop.exe", "reasonix-guard.exe", "reasonix-launcher.exe",
66 "reasonix-update-helper.exe", "reasonix-cli.exe", "reasonix-uninstall.exe",
67 "app/Reasonix.exe", "app/resources/bin/reasonix-cli-launcher.exe",
68 "app/resources/app.asar", "app/resources/build.json", "app/resources/app/index.html"]) {
69 put(`${payload}/${name}`, `${arch}:${name}`);
70 }
71 put(`signing-work-${arch}/desktop/build/windows/installer/reasonix_project.nsh`, '!define REASONIX_VERSION_TAG "v1.2.3"\n');
72 run(process.execPath, ["desktop/packaging/signing-files.mjs", payload]);
73 run("bash", ["scripts/finalize-windows-signed-candidate.sh", arch, `signing-work-${arch}`,
74 payload, `dist-${arch}`, `bundle-${arch}`, "v1.2.3"]);
75 assert.equal(readFileSync(path.join(root, "desktop/build/windows/installer/project.nsi"), "utf8"),
76 readFileSync(new URL("../desktop/build/windows/installer/project.nsi", import.meta.url), "utf8"));
77 assert.ok(readFileSync(path.join(root, `dist-${arch}/Reasonix-windows-${arch}.zip`)).length > 0);
78 assert.ok(readFileSync(path.join(root, `dist-${arch}/Reasonix-windows-${arch}-installer.exe.minisig`)).length > 0);
79 }
80 });
81 function fixture(t) {
82 const root = mkdtempSync(path.join(tmpdir(), "reasonix-signed-handoff-"));
83 t.after(() => rmSync(root, { recursive: true, force: true }));
84 const bundles = path.join(root, "bundles");
85 mkdirSync(bundles);
86 for (const platform of platforms) {
87 const source = path.join(root, platform);
88 mkdirSync(source);
89 writeFileSync(path.join(source, `${platform}.zip`), `signed:${platform}`);
90 writeFileSync(path.join(source, `${platform}.zip.minisig`), `signature:${platform}`);
91 pack(source, path.join(bundles, `${identity.prefix}-${platform}`), platform, identity);
92 }
93 return { bundles, target: path.join(root, "dist"), first: path.join(bundles, `${identity.prefix}-${platforms[0]}`) };
94 }
95
96 test("same-run failed-job retry preserves the exact signed bytes of all platforms", t => {
97 const f = fixture(t);
98 collect(f.bundles, f.target, identity);
99 for (const platform of platforms) {
100 assert.equal(readFileSync(path.join(f.target, `${platform}.zip`), "utf8"), `signed:${platform}`);
101 assert.equal(readFileSync(path.join(f.target, `${platform}.zip.minisig`), "utf8"), `signature:${platform}`);
102 }
103 });
104
105 test("publisher selects only complete platform bundles beside unsigned intermediates", t => {
106 const f = fixture(t);
107 for (const arch of ["amd64", "arm64"]) mkdirSync(path.join(f.bundles, `${identity.prefix}-unsigned-windows-${arch}`));
108 assert.throws(() => collect(f.bundles, f.target, identity), /unexpected platform bundle/);
109 const workflow = readFileSync(new URL("../.github/workflows/release-desktop.yml", import.meta.url), "utf8");
110 const pattern = workflow.match(/pattern: \$\{\{ inputs\.preflight_artifact_prefix \|\| needs\.resolve\.outputs\.artifact_prefix \}\}-(.+)/)[1];
111 const selected = readdirSync(f.bundles).filter(name => path.matchesGlob(name, `${identity.prefix}-${pattern}`));
112 assert.deepEqual(selected.sort(), platforms.map(platform => `${identity.prefix}-${platform}`).sort());
113 const downloaded = path.join(path.dirname(f.bundles), "downloaded");
114 mkdirSync(downloaded);
115 for (const name of selected) cpSync(path.join(f.bundles, name), path.join(downloaded, name), { recursive: true });
116 collect(downloaded, f.target, identity);
117 for (const platform of platforms) assert.equal(readFileSync(path.join(f.target, `${platform}.zip`), "utf8"), `signed:${platform}`);
118 });
119
120 test("another run, future attempt and missing identity cannot be reused", () => {
121 for (const change of [{ GITHUB_RUN_ID: "124" }, { RELEASE_ARTIFACT_PREFIX: "desktop-123-3-preflight" },
122 { RELEASE_SOURCE_SHA: "" }, { RELEASE_CONTROL_SHA: "main-v2" }, { GITHUB_RUN_ATTEMPT: "" }]) {
123 assert.throws(() => releaseIdentity({ ...env, ...change }));
124 }
125 });
126
127 test("a sealed candidate may be collected in a later publisher run", t => {
128 const f = fixture(t);
129 const reused = releaseIdentity({
130 ...env,
131 GITHUB_RUN_ID: "999",
132 GITHUB_RUN_ATTEMPT: "1",
133 RELEASE_PRODUCER_RUN_ID: "123",
134 RELEASE_PRODUCER_RUN_ATTEMPT: "2",
135 });
136 collect(f.bundles, f.target, reused, "2");
137 assert.equal(readFileSync(path.join(f.target, `${platforms[0]}.zip`), "utf8"), `signed:${platforms[0]}`);
138 });
139
140 for (const field of ["sourceSHA", "controlSHA", "tag", "version", "channel", "signingFingerprint", "prefix"]) {
141 test(`reject mismatched ${field} before staging publication`, t => {
142 const f = fixture(t);
143 assert.throws(() => collect(f.bundles, f.target, { ...identity, [field]: "different" }));
144 assert.throws(() => readFileSync(f.target), { code: "ENOENT" });
145 });
146 }
147
148 for (const mutation of ["payload", "signature", "missing-platform", "extra-platform", "symlink", "extra-file"]) {
149 test(`reject ${mutation} corruption`, t => {
150 const f = fixture(t);
151 const file = path.join(f.first, "files", `${platforms[0]}.zip`);
152 if (mutation === "payload") writeFileSync(file, "corrupted");
153 if (mutation === "signature") writeFileSync(`${file}.minisig`, "corrupted");
154 if (mutation === "missing-platform") rmSync(f.first, { recursive: true });
155 if (mutation === "extra-platform") mkdirSync(path.join(f.bundles, "unexpected"));
156 if (mutation === "extra-file") writeFileSync(path.join(f.first, "files", "extra"), "unbound");
157 if (mutation === "symlink") { rmSync(file); symlinkSync(`${file}.minisig`, file); }
158 assert.throws(() => collect(f.bundles, f.target, identity));
159 assert.throws(() => readFileSync(f.target), { code: "ENOENT" });
160 });
161 }
162
163 test("an unsigned source cannot become a bundle", t => {
164 const f = fixture(t);
165 const source = path.join(f.first, "files");
166 rmSync(path.join(source, `${platforms[0]}.zip.minisig`));
167 assert.throws(() => pack(source, f.target, platforms[0], identity), /missing payload or signature/);
168 });
169
170 test("a failed platform can be replaced on retry while successful platforms retain their bytes", t => {
171 const f = fixture(t);
172 const manifestFile = path.join(f.first, "identity.json");
173 const manifest = JSON.parse(readFileSync(manifestFile, "utf8"));
174 manifest.buildAttempt = "2";
175 writeFileSync(manifestFile, JSON.stringify(manifest));
176 assert.throws(() => collect(f.bundles, f.target, identity, "1"), /producer attempt/);
177 collect(f.bundles, f.target, identity, "2");
178 assert.equal(readFileSync(path.join(f.target, `${platforms[0]}.zip`), "utf8"), `signed:${platforms[0]}`);
179 });
180
181 test("a later attempt can verify and reuse one completed platform bundle", t => {
182 const f = fixture(t);
183 const bundle = path.join(f.bundles, `${identity.prefix}-windows-amd64`);
184 assert.equal(verifyBundle(bundle, "windows-amd64", identity, "2").manifest.buildAttempt, "1");
185 writeFileSync(path.join(bundle, "files", "windows-amd64.zip"), "tampered");
186 assert.throws(() => verifyBundle(bundle, "windows-amd64", identity, "2"), /digest mismatch/);
187 });
188
189 test("platform bundles cannot overwrite each other's filenames", t => {
190 const f = fixture(t);
191 const second = path.join(f.bundles, `${identity.prefix}-${platforms[1]}`);
192 rmSync(second, { recursive: true });
193 pack(path.join(f.first, "files"), second, platforms[1], identity);
194 assert.throws(() => collect(f.bundles, f.target, identity), /duplicate artifact/);
195 });
196
196 lines Plain Text