From ba3db7c35d6d5964ed6e3f7c4bd6611c2bf3872d Mon Sep 17 00:00:00 2001 From: le king fu Date: Sun, 11 Oct 2026 19:27:46 -0400 Subject: [PATCH] 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 --- .claude/skills/release/SKILL.md | 2 +- .../skills/release/scripts/verify-release.sh | 4 +- .forgejo/workflows/release.yml | 17 +-- docs/architecture.md | 2 +- scripts/build-latest-json.test.mjs | 109 +++++++++++++++++- scripts/check-latest-json.jq | 27 +++++ 6 files changed, 145 insertions(+), 16 deletions(-) create mode 100644 scripts/check-latest-json.jq diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md index 06ded2b..7c0d021 100644 --- a/.claude/skills/release/SKILL.md +++ b/.claude/skills/release/SKILL.md @@ -44,7 +44,7 @@ updated: 2026-10-10 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é. - **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. **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 diff --git a/.claude/skills/release/scripts/verify-release.sh b/.claude/skills/release/scripts/verify-release.sh index ca2a95d..870e5be 100755 --- a/.claude/skills/release/scripts/verify-release.sh +++ b/.claude/skills/release/scripts/verify-release.sh @@ -64,7 +64,9 @@ ok &= v == version print(f"notes: {len(notes)} caractères — début: {notes[:120]!r}") # 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", "linux-x86_64-rpm": ".rpm", "windows-x86_64": ".exe"} diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index 0db4012..7d14160 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -194,17 +194,12 @@ jobs: # Check the manifest before the DELETE below (#320): once the old # version is deleted, installed apps find no update until a valid # one is uploaded. A failed check stops the job, registry untouched. - # Same four keys as scripts/build-latest-json.sh writes. - if ! jq -e --arg version "${GITHUB_REF_NAME#v}" ' - .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")) - ' release-assets/latest.json > /dev/null; then - echo "ERROR: release-assets/latest.json failed its checks, registry left as is:" + # The filter lives in scripts/check-latest-json.jq, which + # scripts/build-latest-json.test.mjs also runs on every pull + # request, so its key list cannot drift from the script's unseen. + if ! jq -e --arg version "${GITHUB_REF_NAME#v}" \ + -f scripts/check-latest-json.jq release-assets/latest.json > /dev/null; then + echo "ERROR: release-assets/latest.json failed scripts/check-latest-json.jq, registry left as is:" cat release-assets/latest.json exit 1 fi diff --git a/docs/architecture.md b/docs/architecture.md index 36e0dec..f412aca 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -622,7 +622,7 @@ Déclenché par les tags `v*`. Un seul job, `build-and-release`, dans un contene Fonctionnalités : - 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 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) **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`). diff --git a/scripts/build-latest-json.test.mjs b/scripts/build-latest-json.test.mjs index 086f280..da5816e 100644 --- a/scripts/build-latest-json.test.mjs +++ b/scripts/build-latest-json.test.mjs @@ -13,6 +13,11 @@ * 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. @@ -20,12 +25,15 @@ import { describe, it, expect, beforeAll, beforeEach, afterEach } from "vitest"; 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 { dirname, join } from "node:path"; 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 DEB = "Simpl Resultat_0.17.1_amd64.deb"; @@ -62,6 +70,22 @@ function expectFailure(result, 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(() => { const jq = spawnSync("jq", ["--version"], { encoding: "utf8" }); 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"); }); + + // 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); + }); }); diff --git a/scripts/check-latest-json.jq b/scripts/check-latest-json.jq new file mode 100644 index 0000000..c09fb8d --- /dev/null +++ b/scripts/check-latest-json.jq @@ -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 -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"))