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