Review follow-ups of PR #416. The check that release.yml runs before deleting the published manifest moves from an inline jq program to scripts/check-latest-json.jq, which release.yml applies with `jq -e --arg version ... -f`. scripts/build-latest-json.test.mjs now runs that same filter on every pull request: on the script's output, on a wrong version, on nine broken manifests and on the shape published up to v0.17.1. It also compares the key lists of the script, of the filter and of verify-release.sh, so a list left behind shows before the tag. Two new cases pin the version argument: 0.18.0 must come out as 0.18.0 (the other fixtures pass 0.17.1, the version the bundle names carry, so a script that ignored its argument passed them all), and an empty version must fail. Both mutations survived the review's pass. No change to what the manifest holds: still four keys. Refs #320 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
301 lines
11 KiB
JavaScript
301 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.
|
|
*
|
|
* 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 four 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$/);
|
|
expect(manifest.platforms).toStrictEqual({
|
|
"linux-x86_64": {
|
|
signature: "RWRkZWItc2lnbmF0dXJl",
|
|
url: "https://git.example.test/owner/Repo/releases/download/v0.17.1/Simpl%20Resultat_0.17.1_amd64.deb",
|
|
},
|
|
"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(4);
|
|
});
|
|
|
|
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 .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);
|
|
});
|
|
});
|