fix(release): apply the review follow-ups of the release-deps stack, drop the linux-x86_64 key #418
6 changed files with 145 additions and 16 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`).
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
27
scripts/check-latest-json.jq
Normal file
27
scripts/check-latest-json.jq
Normal 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"))
|
||||
Loading…
Reference in a new issue