ci(release): check latest.json with one jq filter that the tests also run

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>
This commit is contained in:
le king fu 2026-10-11 19:27:46 -04:00
parent e8306aa260
commit ba3db7c35d
6 changed files with 145 additions and 16 deletions

View file

@ -44,7 +44,7 @@ updated: 2026-10-10
bash .claude/skills/release/scripts/verify-release.sh vX.Y.Z bash .claude/skills/release/scripts/verify-release.sh vX.Y.Z
``` ```
Le script (token `~/.forgejo-token`) lit la release par tag, compte les 7 artefacts **par suffixe** (`.exe` NSIS, `.deb`, `.rpm`, leurs 3 signatures `.sig`, `latest.json` — les noms exacts varient selon la version de Tauri), puis télécharge `latest.json` (il pilote l'auto-update des installations existantes) et vérifie `version` (il affiche `pub_date` et la longueur des notes), puis exige **exactement quatre clés** (#320) : `linux-x86_64`, `linux-x86_64-deb`, `linux-x86_64-rpm` et `windows-x86_64`. Pour chacune : l'extension de l'URL (`.deb`, `.deb`, `.rpm`, `.exe`), une signature non vide dont le `file:` (commentaire de confiance minisign, signature décodée du base64) est le nom de fichier de l'URL, décodé, et la version dans l'URL. **Il sort en 1 sur tout écart** (`MANQUANT`, `VIDE`, `MAUVAISE VERSION`, `MAUVAISE EXTENSION`, `DIFFÉRENT`, `INATTENDUE`) et finit par `VERDICT : OK` ou `VERDICT : ÉCHEC` : pas d'annonce de la release ni d'étape 10 avant un verdict OK. Un second argument (`verify-release.sh vX.Y.Z fichier.json`) contrôle un `latest.json` local au lieu de l'asset, pour éprouver les contrôles sur un fichier fabriqué. Le script (token `~/.forgejo-token`) lit la release par tag, compte les 7 artefacts **par suffixe** (`.exe` NSIS, `.deb`, `.rpm`, leurs 3 signatures `.sig`, `latest.json` — les noms exacts varient selon la version de Tauri), puis télécharge `latest.json` (il pilote l'auto-update des installations existantes) et vérifie `version` (il affiche `pub_date` et la longueur des notes), puis exige **exactement quatre clés** (#320) : `linux-x86_64`, `linux-x86_64-deb`, `linux-x86_64-rpm` et `windows-x86_64`. Pour chacune : l'extension de l'URL (`.deb`, `.deb`, `.rpm`, `.exe`), une signature non vide dont le `file:` (commentaire de confiance minisign, signature décodée du base64) est le nom de fichier de l'URL, décodé, et la version dans l'URL. **Il sort en 1 sur tout écart** (`MANQUANT`, `VIDE`, `MAUVAISE VERSION`, `MAUVAISE EXTENSION`, `DIFFÉRENT`, `INATTENDUE`) et finit par `VERDICT : OK` ou `VERDICT : ÉCHEC` : pas d'annonce de la release ni d'étape 10 avant un verdict OK. Un second argument (`verify-release.sh vX.Y.Z fichier.json`) contrôle un `latest.json` local au lieu de l'asset, pour éprouver les contrôles sur un fichier fabriqué.
**Lire aussi l'étape « Generate latest.json » du journal du run** (même route web que ci-dessous) : `scripts/build-latest-json.sh` y nomme le paquet retenu pour chaque type (`build-latest-json: .rpm <- …`), puis le `latest.json` généré est affiché. Ce script, le délimiteur aléatoire des notes et l'assertion placée avant le `DELETE` du registre ne s'exercent qu'au tag : une assertion en échec arrête le job sur `ERROR: release-assets/latest.json failed its checks`, registre intact, et la release Forgejo reste créée sans mise à jour servie. **Lire aussi l'étape « Generate latest.json » du journal du run** (même route web que ci-dessous) : `scripts/build-latest-json.sh` y nomme le paquet retenu pour chaque type (`build-latest-json: .rpm <- …`), puis le `latest.json` généré est affiché. Le script et le filtre `scripts/check-latest-json.jq` passent par les tests de `check-frontend` à chaque PR ; leur appel par `release.yml` et le délimiteur aléatoire des notes ne s'exercent qu'au tag. Un manifeste refusé par le filtre arrête le job sur `ERROR: release-assets/latest.json failed scripts/check-latest-json.jq`, avant le `DELETE` : registre intact, et la release Forgejo reste créée sans mise à jour servie.
Durée observée du run : 22 à 30 min sur les 12 dernières releases — poser le `Monitor` puis passer à autre chose. Durée observée du run : 22 à 30 min sur les 12 dernières releases — poser le `Monitor` puis passer à autre chose.
**Lire aussi le Node.js du run.** `release.yml` n'est exercé qu'au tag : une montée de Node (Node 20 → 24 par #376) ne se prouve que dans ce journal. Il passe par la route web (l'API v1 des journaux répond 404). Le `run_number` vient de `/actions/tasks`, qui rend toute la liste, la plus récente en tête (le paramètre `limit` y est ignoré) : **Lire aussi le Node.js du run.** `release.yml` n'est exercé qu'au tag : une montée de Node (Node 20 → 24 par #376) ne se prouve que dans ce journal. Il passe par la route web (l'API v1 des journaux répond 404). Le `run_number` vient de `/actions/tasks`, qui rend toute la liste, la plus récente en tête (le paramètre `limit` y est ignoré) :
```bash ```bash

View file

@ -64,7 +64,9 @@ ok &= v == version
print(f"notes: {len(notes)} caractères — début: {notes[:120]!r}") print(f"notes: {len(notes)} caractères — début: {notes[:120]!r}")
# The four keys (#320): tauri-plugin-updater looks up "{os}-{arch}-{installer}", # The four keys (#320): tauri-plugin-updater looks up "{os}-{arch}-{installer}",
# then "{os}-{arch}". Each with the extension its URL must end with. # then "{os}-{arch}". Each with the extension its URL must end with. Same set
# as scripts/build-latest-json.sh writes and scripts/check-latest-json.jq
# checks: scripts/build-latest-json.test.mjs reads this line and compares.
EXPECTED = {"linux-x86_64": ".deb", "linux-x86_64-deb": ".deb", EXPECTED = {"linux-x86_64": ".deb", "linux-x86_64-deb": ".deb",
"linux-x86_64-rpm": ".rpm", "windows-x86_64": ".exe"} "linux-x86_64-rpm": ".rpm", "windows-x86_64": ".exe"}

View file

@ -194,17 +194,12 @@ jobs:
# Check the manifest before the DELETE below (#320): once the old # Check the manifest before the DELETE below (#320): once the old
# version is deleted, installed apps find no update until a valid # version is deleted, installed apps find no update until a valid
# one is uploaded. A failed check stops the job, registry untouched. # one is uploaded. A failed check stops the job, registry untouched.
# Same four keys as scripts/build-latest-json.sh writes. # The filter lives in scripts/check-latest-json.jq, which
if ! jq -e --arg version "${GITHUB_REF_NAME#v}" ' # scripts/build-latest-json.test.mjs also runs on every pull
.version == $version # request, so its key list cannot drift from the script's unseen.
and (.platforms | keys) == ["linux-x86_64", "linux-x86_64-deb", "linux-x86_64-rpm", "windows-x86_64"] if ! jq -e --arg version "${GITHUB_REF_NAME#v}" \
and all(.platforms[]; (.signature | type) == "string" and (.signature | length) > 0) -f scripts/check-latest-json.jq release-assets/latest.json > /dev/null; then
and (.platforms["linux-x86_64"].url | endswith(".deb")) echo "ERROR: release-assets/latest.json failed scripts/check-latest-json.jq, registry left as is:"
and (.platforms["linux-x86_64-deb"].url | endswith(".deb"))
and (.platforms["linux-x86_64-rpm"].url | endswith(".rpm"))
and (.platforms["windows-x86_64"].url | endswith(".exe"))
' release-assets/latest.json > /dev/null; then
echo "ERROR: release-assets/latest.json failed its checks, registry left as is:"
cat release-assets/latest.json cat release-assets/latest.json
exit 1 exit 1
fi fi

View file

@ -622,7 +622,7 @@ Déclenché par les tags `v*`. Un seul job, `build-and-release`, dans un contene
Fonctionnalités : Fonctionnalités :
- Signature des binaires (clés TAURI_SIGNING_PRIVATE_KEY) - Signature des binaires (clés TAURI_SIGNING_PRIVATE_KEY)
- JSON d'updater (`latest.json`) écrit par `scripts/build-latest-json.sh` à partir des assets ([#320](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/320)). `tauri-plugin-updater` cherche `{os}-{arch}-{installer}`, puis `{os}-{arch}` : le fichier porte `linux-x86_64-deb` et `linux-x86_64-rpm` (une clé par paquet Linux), `linux-x86_64` (le `.deb`, en repli : raison, lecteurs connus et condition de retrait dans `docs/qa-update-cycle.md`) et `windows-x86_64` (l'installeur NSIS). Le script échoue s'il ne trouve pas exactement un `.deb`, un `.rpm` et un `-setup.exe`, chacun avec un `.sig` non vide ; le JSON vient de `jq` seul (notes lues comme données par `--rawfile`), avec les noms de fichiers encodés par `@uri`, comme à l'envoi des assets. Testé par `scripts/build-latest-json.test.mjs` (vitest, lancé par `check-frontend`), qui l'exécute sous `bash -e -o pipefail` comme `release.yml`. - JSON d'updater (`latest.json`) écrit par `scripts/build-latest-json.sh` à partir des assets ([#320](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/320)). `tauri-plugin-updater` cherche `{os}-{arch}-{installer}`, puis `{os}-{arch}` : le fichier porte `linux-x86_64-deb` et `linux-x86_64-rpm` (une clé par paquet Linux), `linux-x86_64` (le `.deb`, en repli : raison, lecteurs connus et condition de retrait dans `docs/qa-update-cycle.md`) et `windows-x86_64` (l'installeur NSIS). Le script échoue s'il ne trouve pas exactement un `.deb`, un `.rpm` et un `-setup.exe`, chacun avec un `.sig` non vide ; le JSON vient de `jq` seul (notes lues comme données par `--rawfile`), avec les noms de fichiers encodés par `@uri`, comme à l'envoi des assets. Testé par `scripts/build-latest-json.test.mjs` (vitest, lancé par `check-frontend`), qui l'exécute sous `bash -e -o pipefail` comme `release.yml`.
- JSON publié sur `https://git.lacompagniemaximus.com/api/packages/maximus/generic/simpl-resultat/latest/latest.json` (registre de paquets, endpoint de `tauri.conf.json`). Avant le `DELETE` de l'ancienne version, une assertion `jq` (version égale au tag, exactement les quatre clés, signatures non vides, extension de chaque URL) arrête le job si le fichier ne la passe pas : le registre reste intact. - JSON publié sur `https://git.lacompagniemaximus.com/api/packages/maximus/generic/simpl-resultat/latest/latest.json` (registre de paquets, endpoint de `tauri.conf.json`). Avant le `DELETE` de l'ancienne version, le filtre `scripts/check-latest-json.jq` (version égale au tag, exactement les quatre clés, signatures non vides, extension de chaque URL) arrête le job si le fichier ne le passe pas : le registre reste intact. Le test du script applique ce même filtre à chaque PR et compare sa liste de clés à celles du script et de `verify-release.sh`, si bien qu'un écart se voit avant le tag.
- Release Forgejo automatique avec assets et release notes extraites du CHANGELOG.md, transmises par `GITHUB_OUTPUT` sous un délimiteur aléatoire (une ligne du CHANGELOG égale à un délimiteur fixe terminait la valeur) - Release Forgejo automatique avec assets et release notes extraites du CHANGELOG.md, transmises par `GITHUB_OUTPUT` sous un délimiteur aléatoire (une ligne du CHANGELOG égale à un délimiteur fixe terminait la valeur)
**Node.js 24** ([#376](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/376)), installé comme dans les trois autres workflows par le script NodeSource (`curl … | bash`). Ce script tourne en root dans le conteneur du job, et le Node.js qu'il installe lance ensuite `npx tauri build` avec `TAURI_SIGNING_PRIVATE_KEY` dans son environnement : le job confie à NodeSource (script et dépôt apt) la clé de signature de l'updater, et les jetons de publication des étapes suivantes. Risque accepté par Max le 2026-10-10, alternative écartée : un tarball Node.js épinglé. La chaîne Rust, installée de la même façon par `rustup`, est dans le même cas. Ce workflow ne tourne qu'au tag : la première release après #376 est la première sous Node 24, à vérifier par `node --version` dans le journal du run (étape 9 de `/release`). **Node.js 24** ([#376](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/376)), installé comme dans les trois autres workflows par le script NodeSource (`curl … | bash`). Ce script tourne en root dans le conteneur du job, et le Node.js qu'il installe lance ensuite `npx tauri build` avec `TAURI_SIGNING_PRIVATE_KEY` dans son environnement : le job confie à NodeSource (script et dépôt apt) la clé de signature de l'updater, et les jetons de publication des étapes suivantes. Risque accepté par Max le 2026-10-10, alternative écartée : un tarball Node.js épinglé. La chaîne Rust, installée de la même façon par `rustup`, est dans le même cas. Ce workflow ne tourne qu'au tag : la première release après #376 est la première sous Node 24, à vérifier par `node --version` dans le journal du run (étape 9 de `/release`).

View file

@ -13,6 +13,11 @@
* URLs must percent-encode. Expected values are literals, never rebuilt from * URLs must percent-encode. Expected values are literals, never rebuilt from
* the inputs. * 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 * 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; * look type-checked without being so. Needs bash and jq on PATH;
* check-frontend.yml installs jq for this file. * check-frontend.yml installs jq for this file.
@ -20,12 +25,15 @@
import { describe, it, expect, beforeAll, beforeEach, afterEach } from "vitest"; import { describe, it, expect, beforeAll, beforeEach, afterEach } from "vitest";
import { spawnSync } from "node:child_process"; import { spawnSync } from "node:child_process";
import { mkdtempSync, mkdirSync, rmSync, unlinkSync, writeFileSync } from "node:fs"; import { mkdtempSync, mkdirSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
const SCRIPT = join(dirname(fileURLToPath(import.meta.url)), "build-latest-json.sh"); 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 BASE = "https://git.example.test/owner/Repo/releases/download/v0.17.1";
const DEB = "Simpl Resultat_0.17.1_amd64.deb"; const DEB = "Simpl Resultat_0.17.1_amd64.deb";
@ -62,6 +70,22 @@ function expectFailure(result, message) {
expect(result.stderr).toContain(message); 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(() => { beforeAll(() => {
const jq = spawnSync("jq", ["--version"], { encoding: "utf8" }); const jq = spawnSync("jq", ["--version"], { encoding: "utf8" });
if (jq.status !== 0) { if (jq.status !== 0) {
@ -193,4 +217,85 @@ describe("build-latest-json.sh", () => {
expectFailure(run([assets, "0.17.1", notesFile]), "usage: build-latest-json.sh"); 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);
});
}); });

View file

@ -0,0 +1,27 @@
# Check of latest.json, the updater manifest, before release.yml deletes the
# published one from the package registry (#320). Once that version is
# deleted, installed apps find no update until a valid one is uploaded, so a
# manifest that fails this check stops the job and leaves the registry as is.
#
# Run as: jq -e --arg version <X.Y.Z> -f scripts/check-latest-json.jq latest.json
# -e turns false, null and any jq error (missing field, wrong type, invalid
# JSON) into a non-zero exit status: the check fails closed.
#
# The key set must stay the one scripts/build-latest-json.sh writes and
# .claude/skills/release/scripts/verify-release.sh expects.
# scripts/build-latest-json.test.mjs runs this file on the script's output and
# on broken manifests, and compares the three lists, on every pull request
# through check-frontend: a misaligned list shows before the tag, not at it.
# Written for jq 1.6, the version ubuntu:22.04 ships.
#
# Not checked here: that each signature belongs to its own file. The script's
# test checks it before the merge, and verify-release.sh after publication
# (the file: field of the minisign trusted comment).
.version == $version
and (.platforms | keys) == ["linux-x86_64", "linux-x86_64-deb", "linux-x86_64-rpm", "windows-x86_64"]
and all(.platforms[]; (.signature | type) == "string" and (.signature | length) > 0)
and (.platforms["linux-x86_64"].url | endswith(".deb"))
and (.platforms["linux-x86_64-deb"].url | endswith(".deb"))
and (.platforms["linux-x86_64-rpm"].url | endswith(".rpm"))
and (.platforms["windows-x86_64"].url | endswith(".exe"))