| 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 |