| 1 | #!/usr/bin/env node |
| 2 | |
| 3 | const assert = require("node:assert/strict"); |
| 4 | const { execFileSync } = 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 sha256(filePath) { |
| 28 | return crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex"); |
| 29 | } |
| 30 | |
| 31 | function makeIntermediateArtifacts(root) { |
| 32 | const generated = new Set(["codewhale.bat", CHECKSUM_MANIFEST]); |
| 33 | const copied = allReleaseAssetNames().filter((name) => !generated.has(name)); |
| 34 | for (const name of copied) { |
| 35 | if (name === BUNDLE_CHECKSUM_MANIFEST) { |
| 36 | continue; |
| 37 | } |
| 38 | const artifactDirectory = BUNDLE_ASSET_NAMES.includes(name) |
| 39 | ? path.join(root, "codewhale-bundles") |
| 40 | : path.join(root, name); |
| 41 | fs.mkdirSync(artifactDirectory, { recursive: true }); |
| 42 | fs.writeFileSync(path.join(artifactDirectory, name), `fixture:${name}\n`); |
| 43 | } |
| 44 | |
| 45 | const bundleManifestDirectory = path.join(root, "codewhale-bundles"); |
| 46 | fs.mkdirSync(bundleManifestDirectory, { recursive: true }); |
| 47 | const rows = BUNDLE_ASSET_NAMES.map((name) => { |
| 48 | const matches = fs |
| 49 | .readdirSync(root, { recursive: true }) |
| 50 | .map((entry) => path.join(root, entry)) |
| 51 | .filter((entry) => path.basename(entry) === name && fs.statSync(entry).isFile()); |
| 52 | assert.equal(matches.length, 1, `fixture should contain one ${name}`); |
| 53 | return `${sha256(matches[0])} ${name}`; |
| 54 | }).sort(); |
| 55 | fs.writeFileSync( |
| 56 | path.join(bundleManifestDirectory, BUNDLE_CHECKSUM_MANIFEST), |
| 57 | `${rows.join("\n")}\n`, |
| 58 | ); |
| 59 | } |
| 60 | |
| 61 | test("authoritative release inventory contains seven targets and 34 assets", () => { |
| 62 | const assets = allReleaseAssetNames(); |
| 63 | assert.equal(assets.length, 34); |
| 64 | assert.equal(checksummedReleaseAssetNames().length, 33); |
| 65 | for (const required of [ |
| 66 | "codewhale-android-arm64", |
| 67 | "codew-android-arm64", |
| 68 | "codewhale-windows-arm64.exe", |
| 69 | "codew-windows-arm64.exe", |
| 70 | "codewhale-windows-arm64.zip", |
| 71 | "CodeWhaleSetup.exe", |
| 72 | ]) { |
| 73 | assert.ok(assets.includes(required), `missing ${required}`); |
| 74 | } |
| 75 | }); |
| 76 | |
| 77 | test("assembly creates and verifies the exact release asset directory", async () => { |
| 78 | const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "codewhale-asset-assembly-")); |
| 79 | const input = path.join(tempRoot, "input"); |
| 80 | const output = path.join(tempRoot, "output"); |
| 81 | try { |
| 82 | fs.mkdirSync(input, { recursive: true }); |
| 83 | makeIntermediateArtifacts(input); |
| 84 | await assemble(input, output); |
| 85 | await assert.doesNotReject(() => verifyAssetDirectory(output)); |
| 86 | |
| 87 | assert.deepEqual( |
| 88 | fs.readdirSync(output).sort(), |
| 89 | [...allReleaseAssetNames()].sort(), |
| 90 | ); |
| 91 | assert.equal( |
| 92 | fs.readFileSync(path.join(output, "codewhale.bat"), "utf8"), |
| 93 | windowsLauncherContents(), |
| 94 | ); |
| 95 | |
| 96 | const checksums = parseChecksumManifest( |
| 97 | fs.readFileSync(path.join(output, CHECKSUM_MANIFEST), "utf8"), |
| 98 | CHECKSUM_MANIFEST, |
| 99 | ); |
| 100 | assert.deepEqual([...checksums.keys()].sort(), [...checksummedReleaseAssetNames()].sort()); |
| 101 | } finally { |
| 102 | fs.rmSync(tempRoot, { force: true, recursive: true }); |
| 103 | } |
| 104 | }); |
| 105 | |
| 106 | test("bundle helper creates the exact nine archives and checksum manifest", () => { |
| 107 | const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "codewhale-bundle-assembly-")); |
| 108 | const input = path.join(tempRoot, "input"); |
| 109 | const output = path.join(tempRoot, "output"); |
| 110 | const repeatedOutput = path.join(tempRoot, "output-repeated"); |
| 111 | try { |
| 112 | fs.mkdirSync(input, { recursive: true }); |
| 113 | for (const name of allReleaseAssetNames().filter((asset) => |
| 114 | /^(codewhale|codew|codewhale-tui)-(linux|android|macos|windows)-/.test(asset) && |
| 115 | !asset.endsWith(".tar.gz") && |
| 116 | !asset.endsWith(".zip"), |
| 117 | )) { |
| 118 | const artifactDirectory = path.join(input, name); |
| 119 | fs.mkdirSync(artifactDirectory, { recursive: true }); |
| 120 | // GitHub's artifact transport normalizes regular files to 0644. The |
| 121 | // bundler must restore executable modes for non-Windows archives. |
| 122 | fs.writeFileSync(path.join(artifactDirectory, name), `fixture:${name}\n`, { mode: 0o644 }); |
| 123 | } |
| 124 | |
| 125 | execFileSync( |
| 126 | "bash", |
| 127 | [path.join(repoRoot, "scripts/release/create-release-bundles.sh"), input, output], |
| 128 | { cwd: repoRoot, stdio: "pipe" }, |
| 129 | ); |
| 130 | execFileSync( |
| 131 | "bash", |
| 132 | [path.join(repoRoot, "scripts/release/create-release-bundles.sh"), input, repeatedOutput], |
| 133 | { cwd: repoRoot, stdio: "pipe" }, |
| 134 | ); |
| 135 | assert.deepEqual( |
| 136 | fs.readdirSync(output).sort(), |
| 137 | [...BUNDLE_ASSET_NAMES, BUNDLE_CHECKSUM_MANIFEST].sort(), |
| 138 | ); |
| 139 | const checksums = parseChecksumManifest( |
| 140 | fs.readFileSync(path.join(output, BUNDLE_CHECKSUM_MANIFEST), "utf8"), |
| 141 | BUNDLE_CHECKSUM_MANIFEST, |
| 142 | ); |
| 143 | for (const name of BUNDLE_ASSET_NAMES) { |
| 144 | assert.equal(checksums.get(name), sha256(path.join(output, name))); |
| 145 | assert.deepEqual( |
| 146 | fs.readFileSync(path.join(output, name)), |
| 147 | fs.readFileSync(path.join(repeatedOutput, name)), |
| 148 | `${name} should be byte-reproducible for identical inputs`, |
| 149 | ); |
| 150 | } |
| 151 | assert.deepEqual( |
| 152 | fs.readFileSync(path.join(output, BUNDLE_CHECKSUM_MANIFEST)), |
| 153 | fs.readFileSync(path.join(repeatedOutput, BUNDLE_CHECKSUM_MANIFEST)), |
| 154 | "bundle checksum manifest should be reproducible", |
| 155 | ); |
| 156 | |
| 157 | const linuxEntries = execFileSync( |
| 158 | "tar", |
| 159 | ["-tzf", path.join(output, "codewhale-linux-x64.tar.gz")], |
| 160 | { encoding: "utf8" }, |
| 161 | ); |
| 162 | for (const entry of ["codewhale", "codew", "codewhale-tui", "install.sh"]) { |
| 163 | assert.match(linuxEntries, new RegExp(`codewhale-linux-x64/${entry}\\n`)); |
| 164 | } |
| 165 | const extracted = path.join(tempRoot, "extracted"); |
| 166 | fs.mkdirSync(extracted); |
| 167 | execFileSync( |
| 168 | "tar", |
| 169 | ["-xzf", path.join(output, "codewhale-linux-x64.tar.gz"), "-C", extracted], |
| 170 | { stdio: "pipe" }, |
| 171 | ); |
| 172 | for (const entry of ["codewhale", "codew", "codewhale-tui", "install.sh"]) { |
| 173 | const mode = fs.statSync(path.join(extracted, "codewhale-linux-x64", entry)).mode & 0o777; |
| 174 | assert.equal(mode, 0o755, `${entry} should remain executable after artifact transport`); |
| 175 | } |
| 176 | const portableEntries = execFileSync( |
| 177 | "unzip", |
| 178 | ["-Z1", path.join(output, "codewhale-windows-arm64-portable.zip")], |
| 179 | { encoding: "utf8" }, |
| 180 | ); |
| 181 | assert.match(portableEntries, /codewhale-windows-arm64-portable\/codew\.exe\n/); |
| 182 | assert.doesNotMatch(portableEntries, /install\.bat/); |
| 183 | } finally { |
| 184 | fs.rmSync(tempRoot, { force: true, recursive: true }); |
| 185 | } |
| 186 | }); |
| 187 | |
| 188 | test("verification rejects modified and unexpected assets", async () => { |
| 189 | const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "codewhale-asset-tamper-")); |
| 190 | const input = path.join(tempRoot, "input"); |
| 191 | const output = path.join(tempRoot, "output"); |
| 192 | try { |
| 193 | fs.mkdirSync(input, { recursive: true }); |
| 194 | makeIntermediateArtifacts(input); |
| 195 | await assemble(input, output); |
| 196 | fs.appendFileSync(path.join(output, "codewhale-linux-x64"), "tampered\n"); |
| 197 | await assert.rejects( |
| 198 | () => verifyAssetDirectory(output), |
| 199 | /checksum mismatch for codewhale-linux-x64/, |
| 200 | ); |
| 201 | |
| 202 | fs.writeFileSync(path.join(output, "unexpected.txt"), "unexpected\n"); |
| 203 | await assert.rejects( |
| 204 | () => verifyAssetDirectory(output), |
| 205 | /unexpected: unexpected\.txt/, |
| 206 | ); |
| 207 | } finally { |
| 208 | fs.rmSync(tempRoot, { force: true, recursive: true }); |
| 209 | } |
| 210 | }); |
| 211 |