Simpl-Resultat/scripts/build-latest-json.test.mjs
le king fu 230ecd85ad fix(release): drop the generic linux-x86_64 key from latest.json
Max's decision on PR #416, after its review measured who still read the
key. tauri-plugin-updater looks up "{os}-{arch}-{installer}" first, and
every version that ships the updater (v0.2.1 and later, plugin 2.10.0,
tauri-utils 2.8.2 in each tag's Cargo.lock) carries its bundle-type
marker, so .deb and .rpm installs never reach the generic key. Its last
readers were the AppImage installs v0.4.3 to v0.4.5 (v0.4.2 targets a
route that answers 404, earlier versions read GitHub) and binaries run
outside a package; none of them can install the .deb it served, and the
updater wrote that .deb over the running file. They now get
TargetsNotFound when checking: an error on the Updates card, nothing
installed.

The key leaves the script, scripts/check-latest-json.jq and
verify-release.sh together; the test compares the three lists. Docs fix
the readers named by #416 (v0.4.2 is not one) and the CHANGELOG entry
says what changes for those AppImage installs.

Refs #320

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 19:30:34 -04:00

302 lines
11 KiB
JavaScript

/**
* 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);
});
});