/** * 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. Last, it runs the text of * release.yml's "Publish latest.json to package registry" step against a fake * curl, so the way the step calls the filter is tested too (#419). * * check-frontend.yml does not ignore .claude/** nor release.yml for that * reason: a pull request that only touches verify-release.sh or release.yml * still runs this file. * * 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 { chmodSync, copyFileSync, existsSync, 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 RELEASE_YML = join(HERE, "..", ".forgejo", "workflows", "release.yml"); 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 with the options release.yml passes, on a // manifest written to a file: an object is serialized, a string is written as // is. The "release.yml Publish step" tests below run the step's own text. 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", "-s", "--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); }); // Without -s, jq 1.6 (the CI's and the release container's) produces no // output for an empty file, which -e lets through with status 0 (#419). it("refuses an empty file", () => { expect(check("").status).not.toBe(0); }); // Without -s, jq judges each document and its exit status follows the last // one, under 1.6 and 1.7 alike: a valid last document hid a broken first one. it("refuses a file with two documents, even when the last one is valid", () => { const valid = JSON.stringify(builtManifest()); expect(check(`{"version": "0.0.0"}\n${valid}\n`).status).not.toBe(0); expect(check(`${valid}\n${valid}\n`).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); }); }); // The text of release.yml's "Publish latest.json to package registry" step, // dedented. A whitespace-only line stays in the block; the block ends at the // first non-blank line indented no deeper than "run:", or at the end of file. function publishStepScript() { const lines = readFileSync(RELEASE_YML, "utf8").split("\n"); const step = lines.findIndex((l) => /^\s*- name: Publish latest\.json to package registry\s*$/.test(l)); expect(step).toBeGreaterThan(-1); const runAt = lines.findIndex((l, i) => i > step && /^\s*run: \|\s*$/.test(l)); expect(runAt).toBeGreaterThan(step); const runIndent = lines[runAt].search(/\S/); const block = []; for (const line of lines.slice(runAt + 1)) { if (line.trim() !== "" && line.search(/\S/) <= runIndent) break; block.push(line); } while (block.length > 0 && block[block.length - 1].trim() === "") block.pop(); const indent = block[0].search(/\S/); return block.map((l) => l.slice(indent)).join("\n") + "\n"; } describe("release.yml Publish step", () => { const SERVER = "https://git.example.test"; const DELETE_URL = `${SERVER}/api/v1/packages/owner/generic/simpl-resultat/latest`; const PUT_URL = `${SERVER}/api/packages/owner/generic/simpl-resultat/latest/latest.json`; let curlLog; // A curl that records "METHOD URL" (and the uploaded file for a PUT) and // prints the status code the step reads through -w: 204 to the DELETE, 201 // to the PUT. It never touches the network. function writeFakeCurl(bin) { const curl = join(bin, "curl"); writeFileSync( curl, `#!/usr/bin/env bash method=GET url= data= while [ $# -gt 0 ]; do case "$1" in -X) method="$2"; shift ;; -H|-o|-w) shift ;; --data-binary) data="$2"; shift ;; -*) ;; *) url="$1" ;; esac shift done if [ "$method" = PUT ]; then [ -f "\${data#@}" ] || { echo "fake curl: no file \${data#@}" >&2; exit 2; } echo "PUT $url \${data#@}" >> "$CURL_LOG" printf 201 else echo "$method $url" >> "$CURL_LOG" printf 204 fi `, ); chmodSync(curl, 0o755); } // Runs the step's text as the runner's default "shell: bash" does, in a // directory laid out like the checkout: scripts/check-latest-json.jq and // release-assets/latest.json. function publish(manifest, tag = "v0.17.1") { const bin = join(work, "bin"); mkdirSync(bin); writeFakeCurl(bin); mkdirSync(join(work, "scripts")); copyFileSync(CHECK, join(work, "scripts", "check-latest-json.jq")); writeFileSync( join(assets, "latest.json"), typeof manifest === "string" ? manifest : JSON.stringify(manifest), ); curlLog = join(work, "curl.log"); return spawnSync("bash", ["--noprofile", "--norc", "-e", "-o", "pipefail", "-c", publishStepScript()], { cwd: work, encoding: "utf8", env: { ...process.env, PATH: `${bin}:${process.env.PATH}`, CURL_LOG: curlLog, GITHUB_REF_NAME: tag, GITHUB_SERVER_URL: SERVER, GITHUB_REPOSITORY_OWNER: "owner", FORGEJO_TOKEN: "fake-token", }, }); } function curlCalls() { return existsSync(curlLog) ? readFileSync(curlLog, "utf8").trim().split("\n") : []; } it("is extracted whole, without workflow expressions", () => { const script = publishStepScript(); expect(script).toContain("-f scripts/check-latest-json.jq"); expect(script).toContain("-X DELETE"); expect(script).toContain("-X PUT"); // The runner would substitute them before bash runs; this test would not. expect(script).not.toContain("${{"); }); it("deletes the published manifest, then uploads the new one", () => { const result = publish(builtManifest()); expect(result.status).toBe(0); expect(curlCalls()).toStrictEqual([ `DELETE ${DELETE_URL}`, `PUT ${PUT_URL} release-assets/latest.json`, ]); }); it.each([ ["the generic linux-x86_64 key", (m) => (m.platforms["linux-x86_64"] = m.platforms["linux-x86_64-deb"])], ["a version that is not the tag's", (m) => (m.version = "0.18.0")], ])("stops before any request on %s", (_, mutate) => { const manifest = builtManifest(); mutate(manifest); const result = publish(manifest); expect(result.status).toBe(1); expect(result.stdout).toContain("failed scripts/check-latest-json.jq, registry left as is"); expect(curlCalls()).toStrictEqual([]); }); it.each([ ["an empty file", () => ""], ["two documents, the last one valid", () => `{"version": "0.0.0"}\n${JSON.stringify(builtManifest())}\n`], ])("stops before any request on %s", (_, manifest) => { const result = publish(manifest()); expect(result.status).toBe(1); expect(result.stdout).toContain("failed scripts/check-latest-json.jq, registry left as is"); expect(curlCalls()).toStrictEqual([]); }); });