/** * scripts/build-latest-json.sh — the updater manifest of a release (#320). * * release.yml runs this script to write latest.json, which every installed * app reads to find its update. tauri-plugin-updater looks up * "{os}-{arch}-{installer}" first, then "{os}-{arch}". Before #320 the * manifest only had linux-x86_64 (the .deb), so an .rpm install downloaded * the .deb and refused it. Since 2026-10-11 it has no linux-x86_64 key at * all: its last readers (old AppImage installs, binaries run outside a * package) could not install the .deb it served. * * Each test runs the script as release.yml does, under bash -e -o pipefail * (the shell of the runner's steps), on a temporary assets directory. The * bundle names are the real ones of v0.17.1: they contain a space, which the * URLs must percent-encode. Expected values are literals, never rebuilt from * the inputs. * * It also runs scripts/check-latest-json.jq, the filter release.yml applies * before deleting the published manifest, so that check is exercised on every * pull request instead of only at the tag, and compares the key lists of the * script, of that filter and of verify-release.sh. * * Plain .mjs on purpose: tsconfig only covers src/, so a .ts file here would * look type-checked without being so. Needs bash and jq on PATH; * check-frontend.yml installs jq for this file. */ import { describe, it, expect, beforeAll, beforeEach, afterEach } from "vitest"; import { spawnSync } from "node:child_process"; import { mkdtempSync, mkdirSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; const HERE = dirname(fileURLToPath(import.meta.url)); const SCRIPT = join(HERE, "build-latest-json.sh"); const CHECK = join(HERE, "check-latest-json.jq"); const VERIFY_RELEASE = join(HERE, "..", ".claude", "skills", "release", "scripts", "verify-release.sh"); const BASE = "https://git.example.test/owner/Repo/releases/download/v0.17.1"; const DEB = "Simpl Resultat_0.17.1_amd64.deb"; const RPM = "Simpl Resultat-0.17.1-1.x86_64.rpm"; const EXE = "Simpl Resultat_0.17.1_x64-setup.exe"; // One distinct signature per bundle, to tell which .sig each key carries. const SIG = { [DEB]: "RWRkZWItc2lnbmF0dXJl", [RPM]: "RWRycG0tc2lnbmF0dXJl", [EXE]: "RWRleGUtc2lnbmF0dXJl", }; let work; let assets; let notesFile; function writeAsset(name, sig = SIG[name]) { writeFileSync(join(assets, name), `bytes of ${name}\n`); if (sig !== null) writeFileSync(join(assets, `${name}.sig`), sig); } function writeAllAssets() { for (const name of [DEB, RPM, EXE]) writeAsset(name); } function run(args = [assets, "0.17.1", notesFile, BASE]) { return spawnSync("bash", ["-e", "-o", "pipefail", SCRIPT, ...args], { encoding: "utf8" }); } function expectFailure(result, message) { expect(result.status).not.toBe(0); expect(result.stdout).toBe(""); expect(result.stderr).toContain(message); } // The manifest the script writes for the three bundles of v0.17.1. function builtManifest() { writeAllAssets(); const result = run(); expect(result.status).toBe(0); return JSON.parse(result.stdout); } // Runs scripts/check-latest-json.jq as release.yml does, on a manifest written // to a file: an object is serialized, a string is written as is. function check(manifest, version = "0.17.1") { const file = join(work, "latest.json"); writeFileSync(file, typeof manifest === "string" ? manifest : JSON.stringify(manifest)); return spawnSync("jq", ["-e", "--arg", "version", version, "-f", CHECK, file], { encoding: "utf8" }); } beforeAll(() => { const jq = spawnSync("jq", ["--version"], { encoding: "utf8" }); if (jq.status !== 0) { throw new Error("jq not found: build-latest-json.sh needs it (check-frontend.yml installs it)"); } }); beforeEach(() => { work = mkdtempSync(join(tmpdir(), "build-latest-json-")); assets = join(work, "release-assets"); mkdirSync(assets); notesFile = join(work, "notes.md"); writeFileSync(notesFile, "### Fixed\n\n- Something (#320)."); }); afterEach(() => { rmSync(work, { recursive: true, force: true }); }); describe("build-latest-json.sh", () => { it("writes the three keys, each with its own URL and its own asset's signature", () => { writeAllAssets(); const result = run(); expect(result.status).toBe(0); const manifest = JSON.parse(result.stdout); expect(manifest.version).toBe("0.17.1"); expect(manifest.notes).toBe("### Fixed\n\n- Something (#320)."); expect(manifest.pub_date).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/); // No generic linux-x86_64: an install without a key of its own must fail // its check (TargetsNotFound) rather than receive the .deb. expect(manifest.platforms).toStrictEqual({ "linux-x86_64-deb": { signature: "RWRkZWItc2lnbmF0dXJl", url: "https://git.example.test/owner/Repo/releases/download/v0.17.1/Simpl%20Resultat_0.17.1_amd64.deb", }, "linux-x86_64-rpm": { signature: "RWRycG0tc2lnbmF0dXJl", url: "https://git.example.test/owner/Repo/releases/download/v0.17.1/Simpl%20Resultat-0.17.1-1.x86_64.rpm", }, "windows-x86_64": { signature: "RWRleGUtc2lnbmF0dXJl", url: "https://git.example.test/owner/Repo/releases/download/v0.17.1/Simpl%20Resultat_0.17.1_x64-setup.exe", }, }); // The step log names the bundle picked for each kind. expect(result.stderr).toContain("build-latest-json: .rpm <- Simpl Resultat-0.17.1-1.x86_64.rpm"); }); it.each([ [".deb", DEB], [".rpm", RPM], ["-setup.exe", EXE], ])("fails without a %s bundle, writing nothing on stdout", (kind, missing) => { for (const name of [DEB, RPM, EXE]) { if (name !== missing) writeAsset(name); } expectFailure(run(), `expected exactly one ${kind} in ${assets}, found 0`); }); it("fails on two .deb bundles", () => { writeAllAssets(); writeAsset("Simpl Resultat_0.17.0_amd64.deb", "RWRvbGQtZGViLXNpZ25hdHVyZQ=="); expectFailure(run(), `expected exactly one .deb in ${assets}, found 2`); }); it("serves Windows the NSIS -setup.exe only, not another .exe", () => { writeAllAssets(); writeAsset("simpl-result.exe", "RWRhcHAtZXhlLXNpZ25hdHVyZQ=="); const result = run(); expect(result.status).toBe(0); expect(JSON.parse(result.stdout).platforms["windows-x86_64"]).toStrictEqual({ signature: "RWRleGUtc2lnbmF0dXJl", url: "https://git.example.test/owner/Repo/releases/download/v0.17.1/Simpl%20Resultat_0.17.1_x64-setup.exe", }); }); it("fails on an empty .sig", () => { writeAllAssets(); writeFileSync(join(assets, `${RPM}.sig`), ""); expectFailure(run(), `missing or empty signature: ${assets}/${RPM}.sig`); }); it("fails on a .sig holding only line breaks", () => { writeAllAssets(); writeFileSync(join(assets, `${EXE}.sig`), "\n\n"); expectFailure(run(), `blank signature: ${assets}/${EXE}.sig`); }); it("fails on a missing .sig", () => { writeAllAssets(); unlinkSync(join(assets, `${DEB}.sig`)); expectFailure(run(), `missing or empty signature: ${assets}/${DEB}.sig`); }); it("keeps notes with quotes, backslashes and a CHANGELOG_EOF line intact", () => { writeAllAssets(); const notes = '### Fixed\n\n- A "quoted" word, a back\\slash and C:\\Users\\path.\n' + "CHANGELOG_EOF\n" + '"version": "9.9.9", "platforms": {}\n'; writeFileSync(notesFile, notes); const result = run(); expect(result.status).toBe(0); const manifest = JSON.parse(result.stdout); expect(manifest.notes).toBe( '### Fixed\n\n- A "quoted" word, a back\\slash and C:\\Users\\path.\n' + "CHANGELOG_EOF\n" + '"version": "9.9.9", "platforms": {}\n', ); expect(manifest.version).toBe("0.17.1"); expect(Object.keys(manifest.platforms)).toHaveLength(3); }); it("fails without its four arguments", () => { writeAllAssets(); expectFailure(run([assets, "0.17.1", notesFile]), "usage: build-latest-json.sh"); }); // The other tests pass 0.17.1, the version the bundle names also carry: a // script that ignored its argument would pass them all. it("takes the version from its argument, not from the bundle names", () => { writeAllAssets(); const result = run([assets, "0.18.0", notesFile, BASE]); expect(result.status).toBe(0); expect(JSON.parse(result.stdout).version).toBe("0.18.0"); }); it("fails on an empty version", () => { writeAllAssets(); expectFailure(run([assets, "", notesFile, BASE]), "empty version"); }); }); describe("check-latest-json.jq", () => { it("passes the script's output for the tag's version", () => { const result = check(builtManifest()); expect(result.status).toBe(0); expect(result.stdout).toBe("true\n"); }); it("refuses a manifest whose version is not the tag's", () => { expect(check(builtManifest(), "0.18.0").status).not.toBe(0); }); it.each([ ["a missing key", (m) => delete m.platforms["linux-x86_64-rpm"]], ["an extra key", (m) => (m.platforms["linux-x86_64-appimage"] = m.platforms["linux-x86_64-deb"])], ["the generic linux-x86_64 key, removed on 2026-10-11", (m) => (m.platforms["linux-x86_64"] = m.platforms["linux-x86_64-deb"])], ["the .rpm key on the .deb", (m) => (m.platforms["linux-x86_64-rpm"] = m.platforms["linux-x86_64-deb"])], ["the .deb key on the .rpm", (m) => (m.platforms["linux-x86_64-deb"] = m.platforms["linux-x86_64-rpm"])], ["the Windows key on a .sig", (m) => (m.platforms["windows-x86_64"].url += ".sig")], ["an empty signature", (m) => (m.platforms["linux-x86_64-deb"].signature = "")], ["a signature that is not a string", (m) => (m.platforms["windows-x86_64"].signature = 42)], ["a key without a URL", (m) => delete m.platforms["linux-x86_64-rpm"].url], ["a manifest without platforms", (m) => delete m.platforms], ])("refuses %s", (_, mutate) => { const manifest = builtManifest(); mutate(manifest); expect(check(manifest).status).not.toBe(0); }); it("refuses the shape published up to v0.17.1, without a key per Linux package", () => { const published = { version: "0.17.1", notes: "### Fixed", pub_date: "2026-10-09T01:44:40Z", platforms: { "linux-x86_64": { signature: "RWRkZWItc2lnbmF0dXJl", url: `${BASE}/Simpl%20Resultat_0.17.1_amd64.deb` }, "windows-x86_64": { signature: "RWRleGUtc2lnbmF0dXJl", url: `${BASE}/Simpl%20Resultat_0.17.1_x64-setup.exe` }, }, }; expect(check(published).status).not.toBe(0); }); it("refuses a file that is not JSON", () => { expect(check('{"version": "0.17.1", "platforms": ').status).not.toBe(0); }); }); describe("key lists", () => { it("are the same in the script, in check-latest-json.jq and in verify-release.sh", () => { const manifest = builtManifest(); const written = Object.keys(manifest.platforms).sort(); // The filter compares the exact key set: passing the script's output // proves its list is the script's. expect(check(manifest).status).toBe(0); // verify-release.sh keeps its own list, in a Python dict literal. const dict = readFileSync(VERIFY_RELEASE, "utf8").match(/^EXPECTED = \{([^}]*)\}/m); expect(dict).not.toBeNull(); const expected = [...dict[1].matchAll(/"([a-z0-9_-]+)":/g)].map((m) => m[1]).sort(); expect(expected).toStrictEqual(written); }); });