返回 CodeWhale
assemble-release-assets.test.js
根目录 / scripts / release / assemble-release-assets.test.js
1 #!/usr/bin/env node
2
3 const assert = require("node:assert/strict");
4 const { execFileSync, spawnSync } = require("node:child_process");
5 const crypto = require("node:crypto");
6 const fs = require("node:fs");
7 const os = require("node:os");
8 const path = require("node:path");
9 const test = require("node:test");
10
11 const {
12 allReleaseAssetNames,
13 BUNDLE_ASSET_NAMES,
14 BUNDLE_CHECKSUM_MANIFEST,
15 CHECKSUM_MANIFEST,
16 checksummedReleaseAssetNames,
17 } = require("../../npm/codewhale/scripts/artifacts");
18 const {
19 assemble,
20 parseChecksumManifest,
21 verifyAssetDirectory,
22 windowsLauncherContents,
23 } = require("./assemble-release-assets");
24
25 const repoRoot = path.resolve(__dirname, "..", "..");
26
27 function toCrlf(text) {
28 return text.replace(/\r\n/g, "\n").replace(/\n/g, "\r\n");
29 }
30
31 function sha256(filePath) {
32 return crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex");
33 }
34
35 function createBundleInputs(input) {
36 fs.mkdirSync(input, { recursive: true });
37 for (const name of allReleaseAssetNames().filter((asset) =>
38 /^(codewhale|codew)-(linux|android|macos|windows)-/.test(asset) &&
39 !asset.endsWith(".tar.gz") &&
40 !asset.endsWith(".zip"),
41 )) {
42 const artifactDirectory = path.join(input, name);
43 fs.mkdirSync(artifactDirectory, { recursive: true });
44 // GitHub's artifact transport normalizes regular files to 0644. The
45 // bundler must restore executable modes for non-Windows archives.
46 fs.writeFileSync(path.join(artifactDirectory, name), `fixture:${name}\n`, { mode: 0o644 });
47 }
48 }
49
50 function runBundle(input, output, sourceDateEpoch) {
51 const env = { ...process.env };
52 if (sourceDateEpoch === undefined) {
53 delete env.SOURCE_DATE_EPOCH;
54 } else {
55 env.SOURCE_DATE_EPOCH = sourceDateEpoch;
56 }
57 return spawnSync(
58 "bash",
59 [path.join(repoRoot, "scripts/release/create-release-bundles.sh"), input, output],
60 { cwd: repoRoot, encoding: "utf8", env },
61 );
62 }
63
64 function makeIntermediateArtifacts(root) {
65 const generated = new Set(["codewhale.bat", CHECKSUM_MANIFEST]);
66 const copied = allReleaseAssetNames().filter((name) => !generated.has(name));
67 for (const name of copied) {
68 if (name === BUNDLE_CHECKSUM_MANIFEST) {
69 continue;
70 }
71 const artifactDirectory = BUNDLE_ASSET_NAMES.includes(name)
72 ? path.join(root, "codewhale-bundles")
73 : path.join(root, name);
74 fs.mkdirSync(artifactDirectory, { recursive: true });
75 fs.writeFileSync(path.join(artifactDirectory, name), `fixture:${name}\n`);
76 }
77
78 const bundleManifestDirectory = path.join(root, "codewhale-bundles");
79 fs.mkdirSync(bundleManifestDirectory, { recursive: true });
80 const rows = BUNDLE_ASSET_NAMES.map((name) => {
81 const matches = fs
82 .readdirSync(root, { recursive: true })
83 .map((entry) => path.join(root, entry))
84 .filter((entry) => path.basename(entry) === name && fs.statSync(entry).isFile());
85 assert.equal(matches.length, 1, `fixture should contain one ${name}`);
86 return `${sha256(matches[0])} ${name}`;
87 }).sort();
88 fs.writeFileSync(
89 path.join(bundleManifestDirectory, BUNDLE_CHECKSUM_MANIFEST),
90 `${rows.join("\n")}\n`,
91 );
92 }
93
94 test("authoritative release inventory contains seven targets and 34 bridge assets", () => {
95 const assets = allReleaseAssetNames();
96 assert.equal(assets.length, 34);
97 assert.equal(checksummedReleaseAssetNames().length, 33);
98 for (const required of [
99 "codewhale-android-arm64",
100 "codew-android-arm64",
101 "codewhale-windows-arm64.exe",
102 "codew-windows-arm64.exe",
103 "codewhale-windows-arm64.zip",
104 "codewhale-tui-android-arm64",
105 "codewhale-tui-windows-arm64.exe",
106 "CodeWhaleSetup.exe",
107 ]) {
108 assert.ok(assets.includes(required), `missing ${required}`);
109 }
110 });
111
112 test("NSIS installer ships the Windows Terminal launcher and Start Menu shortcut", () => {
113 const nsi = fs.readFileSync(path.join(repoRoot, "scripts/installer/codewhale.nsi"), "utf8");
114 const bat = fs.readFileSync(path.join(repoRoot, "scripts/installer/codewhale.bat"), "utf8");
115
116 assert.match(nsi, /^\s*File "codewhale\.bat"\s*$/m);
117 assert.match(
118 nsi,
119 /CreateShortCut "\$SMPROGRAMS\\\$\{PRODUCT_NAME\}\\\$\{PRODUCT_NAME\}\.lnk" "\$INSTDIR\\bin\\codewhale\.bat"/,
120 );
121 assert.match(nsi, /^\s*Delete "\$INSTDIR\\bin\\codewhale\.bat"\s*$/m);
122 assert.match(nsi, /^\s*Delete "\$SMPROGRAMS\\\$\{PRODUCT_NAME\}\\\$\{PRODUCT_NAME\}\.lnk"\s*$/m);
123 assert.match(nsi, /^\s*RMDir "\$SMPROGRAMS\\\$\{PRODUCT_NAME\}"\s*$/m);
124
125 const batNormalized = bat.replace(/\r\n/g, "\n");
126 assert.match(batNormalized, /where wt >nul 2>nul/);
127 assert.match(batNormalized, /wt --title Codewhale cmd \/k "%~dp0codewhale\.exe"/);
128 assert.match(batNormalized, /"%~dp0codewhale\.exe"/);
129 assert.doesNotMatch(batNormalized, /codewhale-windows-x64\.exe/);
130 assert.ok(
131 batNormalized.startsWith("@echo off\n"),
132 "installer launcher must start with @echo off",
133 );
134 assert.match(
135 windowsLauncherContents(),
136 /\r\n/,
137 "the GitHub/npm codewhale.bat asset must be generated with CRLF",
138 );
139 });
140
141 test("assembly creates and verifies the exact release asset directory", async () => {
142 const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "codewhale-asset-assembly-"));
143 const input = path.join(tempRoot, "input");
144 const output = path.join(tempRoot, "output");
145 try {
146 fs.mkdirSync(input, { recursive: true });
147 makeIntermediateArtifacts(input);
148 await assemble(input, output);
149 await assert.doesNotReject(() => verifyAssetDirectory(output));
150
151 assert.deepEqual(
152 fs.readdirSync(output).sort(),
153 [...allReleaseAssetNames()].sort(),
154 );
155 assert.equal(
156 fs.readFileSync(path.join(output, "codewhale.bat"), "utf8"),
157 windowsLauncherContents(),
158 );
159
160 const checksums = parseChecksumManifest(
161 fs.readFileSync(path.join(output, CHECKSUM_MANIFEST), "utf8"),
162 CHECKSUM_MANIFEST,
163 );
164 assert.deepEqual([...checksums.keys()].sort(), [...checksummedReleaseAssetNames()].sort());
165 } finally {
166 fs.rmSync(tempRoot, { force: true, recursive: true });
167 }
168 });
169
170 test("bundle helper emits reproducible timestamped tar and zip archives from paths with spaces", () => {
171 const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "codewhale-bundle-assembly-"));
172 const input = path.join(tempRoot, "input artifacts with spaces");
173 const output = path.join(tempRoot, "output bundles with spaces");
174 const repeatedOutput = path.join(tempRoot, "output bundles repeated with spaces");
175 const sourceDateEpoch = "1700000000";
176 try {
177 createBundleInputs(input);
178 assert.equal(runBundle(input, output, sourceDateEpoch).status, 0);
179 assert.equal(runBundle(input, repeatedOutput, sourceDateEpoch).status, 0);
180 assert.deepEqual(
181 fs.readdirSync(output).sort(),
182 [...BUNDLE_ASSET_NAMES, BUNDLE_CHECKSUM_MANIFEST].sort(),
183 );
184 const checksums = parseChecksumManifest(
185 fs.readFileSync(path.join(output, BUNDLE_CHECKSUM_MANIFEST), "utf8"),
186 BUNDLE_CHECKSUM_MANIFEST,
187 );
188 for (const name of BUNDLE_ASSET_NAMES) {
189 assert.equal(checksums.get(name), sha256(path.join(output, name)));
190 assert.deepEqual(
191 fs.readFileSync(path.join(output, name)),
192 fs.readFileSync(path.join(repeatedOutput, name)),
193 `${name} should be byte-reproducible for identical inputs`,
194 );
195 }
196 assert.deepEqual(
197 fs.readFileSync(path.join(output, BUNDLE_CHECKSUM_MANIFEST)),
198 fs.readFileSync(path.join(repeatedOutput, BUNDLE_CHECKSUM_MANIFEST)),
199 "bundle checksum manifest should be reproducible",
200 );
201
202 const linuxEntries = execFileSync(
203 "tar",
204 ["-tzf", path.join(output, "codewhale-linux-x64.tar.gz")],
205 { encoding: "utf8" },
206 ).trim().split("\n").sort();
207 assert.deepEqual(linuxEntries, [
208 "codewhale-linux-x64/",
209 "codewhale-linux-x64/codew",
210 "codewhale-linux-x64/codewhale",
211 "codewhale-linux-x64/install.sh",
212 ]);
213 const extracted = path.join(tempRoot, "tar extracted");
214 fs.mkdirSync(extracted);
215 execFileSync(
216 "tar",
217 ["-xzf", path.join(output, "codewhale-linux-x64.tar.gz"), "-C", extracted],
218 { stdio: "pipe" },
219 );
220 for (const entry of ["codewhale", "codew", "install.sh"]) {
221 const mode = fs.statSync(path.join(extracted, "codewhale-linux-x64", entry)).mode & 0o777;
222 assert.equal(mode, 0o755, `${entry} should remain executable after artifact transport`);
223 assert.equal(
224 Math.trunc(fs.statSync(path.join(extracted, "codewhale-linux-x64", entry)).mtimeMs / 1000),
225 Number(sourceDateEpoch),
226 `${entry} should retain the source commit timestamp`,
227 );
228 }
229 const expectedLauncher = toCrlf(
230 fs.readFileSync(path.join(repoRoot, "scripts/installer/codewhale.bat"), "utf8"),
231 );
232 const expectedInstallBat = toCrlf(
233 fs.readFileSync(path.join(repoRoot, "scripts/release/install.bat"), "utf8"),
234 );
235 const zipListing = (name) =>
236 execFileSync("unzip", ["-Z1", path.join(output, name)], { encoding: "utf8" })
237 .trim()
238 .split("\n")
239 .sort();
240 const zipFile = (archive, inner) =>
241 execFileSync("unzip", ["-p", path.join(output, archive), inner]);
242
243 assert.deepEqual(zipListing("codewhale-windows-x64.zip"), [
244 "codewhale-windows-x64/",
245 "codewhale-windows-x64/codew.exe",
246 "codewhale-windows-x64/codewhale.bat",
247 "codewhale-windows-x64/codewhale.exe",
248 "codewhale-windows-x64/install.bat",
249 ]);
250 assert.deepEqual(zipListing("codewhale-windows-arm64.zip"), [
251 "codewhale-windows-arm64/",
252 "codewhale-windows-arm64/codew.exe",
253 "codewhale-windows-arm64/codewhale.bat",
254 "codewhale-windows-arm64/codewhale.exe",
255 "codewhale-windows-arm64/install.bat",
256 ]);
257 const portableEntries = zipListing("codewhale-windows-arm64-portable.zip");
258 assert.deepEqual(portableEntries, [
259 "codewhale-windows-arm64-portable/",
260 "codewhale-windows-arm64-portable/codew.exe",
261 "codewhale-windows-arm64-portable/codewhale.bat",
262 "codewhale-windows-arm64-portable/codewhale.exe",
263 ]);
264 assert.deepEqual(zipListing("codewhale-windows-x64-portable.zip"), [
265 "codewhale-windows-x64-portable/",
266 "codewhale-windows-x64-portable/codew.exe",
267 "codewhale-windows-x64-portable/codewhale.bat",
268 "codewhale-windows-x64-portable/codewhale.exe",
269 ]);
270
271 for (const [archive, prefix, includeInstall] of [
272 ["codewhale-windows-x64.zip", "codewhale-windows-x64", true],
273 ["codewhale-windows-arm64.zip", "codewhale-windows-arm64", true],
274 ["codewhale-windows-x64-portable.zip", "codewhale-windows-x64-portable", false],
275 ["codewhale-windows-arm64-portable.zip", "codewhale-windows-arm64-portable", false],
276 ]) {
277 const launcher = zipFile(archive, `${prefix}/codewhale.bat`);
278 assert.deepEqual(
279 launcher,
280 Buffer.from(expectedLauncher, "utf8"),
281 `${archive} must ship the NSIS launcher with CRLF`,
282 );
283 assert.match(launcher.toString("utf8"), /where wt >nul 2>nul/);
284 assert.match(launcher.toString("utf8"), /codewhale\.exe/);
285 assert.doesNotMatch(launcher.toString("utf8"), /codewhale-windows-x64\.exe/);
286 if (includeInstall) {
287 const installBat = zipFile(archive, `${prefix}/install.bat`);
288 assert.deepEqual(
289 installBat,
290 Buffer.from(expectedInstallBat, "utf8"),
291 `${archive} install.bat must be staged with CRLF`,
292 );
293 assert.match(installBat.toString("utf8"), /codewhale\.bat/);
294 }
295 }
296
297 const zipExtracted = path.join(tempRoot, "zip extracted");
298 fs.mkdirSync(zipExtracted);
299 execFileSync(
300 "unzip",
301 ["-qq", path.join(output, "codewhale-windows-arm64-portable.zip"), "-d", zipExtracted],
302 { env: { ...process.env, TZ: "UTC" }, stdio: "pipe" },
303 );
304 for (const entry of ["codewhale.exe", "codew.exe", "codewhale.bat"]) {
305 assert.equal(
306 Math.trunc(
307 fs.statSync(path.join(zipExtracted, "codewhale-windows-arm64-portable", entry)).mtimeMs / 1000,
308 ),
309 Number(sourceDateEpoch),
310 `${entry} should retain the source commit timestamp`,
311 );
312 }
313 } finally {
314 fs.rmSync(tempRoot, { force: true, recursive: true });
315 }
316 });
317
318 test("bundle helper rejects missing, malformed, and ZIP-unrepresentable release epochs", () => {
319 const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "codewhale-bundle-input-validation-"));
320 const input = path.join(tempRoot, "input");
321 try {
322 createBundleInputs(input);
323
324 const missingEpoch = runBundle(input, path.join(tempRoot, "missing-epoch"));
325 assert.notEqual(missingEpoch.status, 0);
326 assert.match(missingEpoch.stderr, /SOURCE_DATE_EPOCH is required/);
327
328 const malformedEpoch = runBundle(input, path.join(tempRoot, "malformed-epoch"), "not-an-epoch");
329 assert.notEqual(malformedEpoch.status, 0);
330 assert.match(malformedEpoch.stderr, /SOURCE_DATE_EPOCH must be an integer Unix timestamp/);
331
332 const preZipEpoch = runBundle(input, path.join(tempRoot, "pre-zip-epoch"), "0");
333 assert.notEqual(preZipEpoch.status, 0);
334 assert.match(preZipEpoch.stderr, /SOURCE_DATE_EPOCH must be between 315532800/);
335 } finally {
336 fs.rmSync(tempRoot, { force: true, recursive: true });
337 }
338 });
339
340 test("bundle helper names a missing required release artifact", () => {
341 const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "codewhale-bundle-missing-artifact-"));
342 const input = path.join(tempRoot, "input");
343 try {
344 createBundleInputs(input);
345 fs.rmSync(path.join(input, "codew-linux-x64", "codew-linux-x64"));
346 const result = runBundle(input, path.join(tempRoot, "output"), "1700000000");
347 assert.notEqual(result.status, 0);
348 assert.match(result.stderr, /missing required release artifact for linux-x64: .*codew-linux-x64/);
349 } finally {
350 fs.rmSync(tempRoot, { force: true, recursive: true });
351 }
352 });
353
354 test("verification rejects modified and unexpected assets", async () => {
355 const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "codewhale-asset-tamper-"));
356 const input = path.join(tempRoot, "input");
357 const output = path.join(tempRoot, "output");
358 try {
359 fs.mkdirSync(input, { recursive: true });
360 makeIntermediateArtifacts(input);
361 await assemble(input, output);
362 fs.appendFileSync(path.join(output, "codewhale-linux-x64"), "tampered\n");
363 await assert.rejects(
364 () => verifyAssetDirectory(output),
365 /checksum mismatch for codewhale-linux-x64/,
366 );
367
368 fs.writeFileSync(path.join(output, "unexpected.txt"), "unexpected\n");
369 await assert.rejects(
370 () => verifyAssetDirectory(output),
371 /unexpected: unexpected\.txt/,
372 );
373 } finally {
374 fs.rmSync(tempRoot, { force: true, recursive: true });
375 }
376 });
377
377 lines JAVASCRIPT