Simpl-Resultat/scripts/build-latest-json.test.mjs
le king fu c859405deb
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 2m4s
fix(release): refuse an empty or multi-document latest.json, test the Publish step
Follow-ups of the review of PR #418.

- check-latest-json.jq wants exactly one document and release.yml calls it
  with -s: under jq 1.6, the release container's, an empty file passed the
  check (no output, status 0) and two documents were judged on the last one,
  so the job would have deleted the published manifest and uploaded the bad
  one. Two tests cover both cases.
- build-latest-json.test.mjs runs the text of release.yml's "Publish
  latest.json to package registry" step against a fake curl: DELETE then PUT
  on a valid manifest, exit 1 and no request on a refused one.
- check-frontend no longer ignores .claude/** and release.yml (decision of
  Max): the test reads verify-release.sh and runs the Publish step, so "on
  every pull request" holds. REVIEW.md, CLAUDE.md and architecture.md follow.
- release.yml: stale ".deb fallback" comment fixed.
- qa-update-cycle.md: a tauri dev build on Linux shows TargetsNotFound,
  expected since #418.

Refs #461

Resolves #419

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 13:00:40 -04:00

462 lines
17 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. 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([]);
});
});